Partage d'export enregistré et génération asynchrone des exports

This commit is contained in:
2025-07-08 13:53:25 +00:00
parent c4cc0baa8e
commit 8bc16dadb0
447 changed files with 14134 additions and 3854 deletions

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Service\Regroupement;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Regroupment;
class CenterRegroupementResolver
{
/**
* Resolves and returns a unique list of centers by merging those from the provided
* groups and the additional centers while eliminating duplicates.
*
* @param list<Regroupment> $groups
* @param list<Center> $centers
*
* @return list<Center>
*/
public function resolveCenters(array $groups, array $centers = []): array
{
$centersByHash = [];
foreach ($groups as $group) {
foreach ($group->getCenters() as $center) {
$centersByHash[spl_object_hash($center)] = $center;
}
}
foreach ($centers as $center) {
$centersByHash[spl_object_hash($center)] = $center;
}
return array_values($centersByHash);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Service\Regroupement;
use Chill\MainBundle\Entity\Regroupment;
/**
* Class RegroupementFiltering.
*
* Provides methods to filter and manage groups based on specific criteria.
*/
class RegroupementFiltering
{
/**
* Filters the provided groups and returns only those that contain at least one of the specified centers.
*
* @param array $groups an array of groups to filter
* @param array $centers an array of centers to check against the groups
*
* @return array an array of filtered groups containing at least one of the specified centers
*/
public function filterContainsAtLeastOneCenter(array $groups, array $centers): array
{
return array_values(
array_filter($groups, static fn (Regroupment $group) => $group->containsAtLeastOneCenter($centers)),
);
}
}

View File

@@ -11,6 +11,8 @@ declare(strict_types=1);
namespace Chill\MainBundle\Service\RollingDate;
use Doctrine\Instantiator\Exception\UnexpectedValueException;
class RollingDate
{
final public const ALL_T = [
@@ -61,6 +63,8 @@ class RollingDate
final public const T_YEAR_PREVIOUS_START = 'year_previous_start';
private const NORMALIZATION_FORMAT = 'Y-m-d-H:i:s.u e';
/**
* @param string|self::T_* $roll
* @param \DateTimeImmutable|null $fixedDate Only to insert if $roll equals @see{self::T_FIXED_DATE}
@@ -68,7 +72,7 @@ class RollingDate
public function __construct(
private readonly string $roll,
private readonly ?\DateTimeImmutable $fixedDate = null,
private readonly \DateTimeImmutable $pivotDate = new \DateTimeImmutable('now'),
private readonly ?\DateTimeImmutable $pivotDate = null,
) {}
public function getFixedDate(): ?\DateTimeImmutable
@@ -76,7 +80,7 @@ class RollingDate
return $this->fixedDate;
}
public function getPivotDate(): \DateTimeImmutable
public function getPivotDate(): ?\DateTimeImmutable
{
return $this->pivotDate;
}
@@ -85,4 +89,31 @@ class RollingDate
{
return $this->roll;
}
public function normalize(): array
{
return [
'roll' => $this->getRoll(),
'fixed_date' => $this->getFixedDate()?->format(self::NORMALIZATION_FORMAT),
'pivot_date' => $this->getPivotDate()?->format(self::NORMALIZATION_FORMAT),
'v' => 1,
];
}
public static function fromNormalized(?array $data): ?self
{
if (null === $data) {
return null;
}
if (1 === $data['v']) {
return new self(
$data['roll'],
null !== $data['fixed_date'] ? \DateTimeImmutable::createFromFormat(self::NORMALIZATION_FORMAT, (string) $data['fixed_date']) : null,
null !== $data['pivot_date'] ? \DateTimeImmutable::createFromFormat(self::NORMALIZATION_FORMAT, (string) $data['pivot_date']) : null,
);
}
throw new UnexpectedValueException('Format of the rolling date unknow, no version information');
}
}

View File

@@ -11,8 +11,12 @@ declare(strict_types=1);
namespace Chill\MainBundle\Service\RollingDate;
class RollingDateConverter implements RollingDateConverterInterface
use Symfony\Component\Clock\ClockInterface;
final readonly class RollingDateConverter implements RollingDateConverterInterface
{
public function __construct(private readonly ClockInterface $clock) {}
public function convert(?RollingDate $rollingDate): ?\DateTimeImmutable
{
if (null === $rollingDate) {
@@ -21,43 +25,43 @@ class RollingDateConverter implements RollingDateConverterInterface
switch ($rollingDate->getRoll()) {
case RollingDate::T_MONTH_CURRENT_START:
return $this->toBeginOfMonth($rollingDate->getPivotDate());
return $this->toBeginOfMonth($rollingDate->getPivotDate() ?? $this->clock->now());
case RollingDate::T_MONTH_NEXT_START:
return $this->toBeginOfMonth($rollingDate->getPivotDate()->add(new \DateInterval('P1M')));
return $this->toBeginOfMonth(($rollingDate->getPivotDate() ?? $this->clock->now())->add(new \DateInterval('P1M')));
case RollingDate::T_MONTH_PREVIOUS_START:
return $this->toBeginOfMonth($rollingDate->getPivotDate()->sub(new \DateInterval('P1M')));
return $this->toBeginOfMonth(($rollingDate->getPivotDate() ?? $this->clock->now())->sub(new \DateInterval('P1M')));
case RollingDate::T_QUARTER_CURRENT_START:
return $this->toBeginOfQuarter($rollingDate->getPivotDate());
return $this->toBeginOfQuarter($rollingDate->getPivotDate() ?? $this->clock->now());
case RollingDate::T_QUARTER_NEXT_START:
return $this->toBeginOfQuarter($rollingDate->getPivotDate()->add(new \DateInterval('P3M')));
return $this->toBeginOfQuarter(($rollingDate->getPivotDate() ?? $this->clock->now())->add(new \DateInterval('P3M')));
case RollingDate::T_QUARTER_PREVIOUS_START:
return $this->toBeginOfQuarter($rollingDate->getPivotDate()->sub(new \DateInterval('P3M')));
return $this->toBeginOfQuarter(($rollingDate->getPivotDate() ?? $this->clock->now())->sub(new \DateInterval('P3M')));
case RollingDate::T_WEEK_CURRENT_START:
return $this->toBeginOfWeek($rollingDate->getPivotDate());
return $this->toBeginOfWeek($rollingDate->getPivotDate() ?? $this->clock->now());
case RollingDate::T_WEEK_NEXT_START:
return $this->toBeginOfWeek($rollingDate->getPivotDate()->add(new \DateInterval('P1W')));
return $this->toBeginOfWeek(($rollingDate->getPivotDate() ?? $this->clock->now())->add(new \DateInterval('P1W')));
case RollingDate::T_WEEK_PREVIOUS_START:
return $this->toBeginOfWeek($rollingDate->getPivotDate()->sub(new \DateInterval('P1W')));
return $this->toBeginOfWeek(($rollingDate->getPivotDate() ?? $this->clock->now())->sub(new \DateInterval('P1W')));
case RollingDate::T_YEAR_CURRENT_START:
return $this->toBeginOfYear($rollingDate->getPivotDate());
return $this->toBeginOfYear($rollingDate->getPivotDate() ?? $this->clock->now());
case RollingDate::T_YEAR_PREVIOUS_START:
return $this->toBeginOfYear($rollingDate->getPivotDate()->sub(new \DateInterval('P1Y')));
return $this->toBeginOfYear(($rollingDate->getPivotDate() ?? $this->clock->now())->sub(new \DateInterval('P1Y')));
case RollingDate::T_YEAR_NEXT_START:
return $this->toBeginOfYear($rollingDate->getPivotDate()->add(new \DateInterval('P1Y')));
return $this->toBeginOfYear(($rollingDate->getPivotDate() ?? $this->clock->now())->add(new \DateInterval('P1Y')));
case RollingDate::T_TODAY:
return $rollingDate->getPivotDate();
return $rollingDate->getPivotDate() ?? $this->clock->now();
case RollingDate::T_FIXED_DATE:
if (null === $rollingDate->getFixedDate()) {
@@ -75,7 +79,7 @@ class RollingDateConverter implements RollingDateConverterInterface
{
return \DateTimeImmutable::createFromFormat(
'Y-m-d His',
sprintf('%s-%s-01 000000', $date->format('Y'), $date->format('m'))
sprintf('%s-%s-01 000000', $date->format('Y'), $date->format('m')),
);
}
@@ -90,7 +94,7 @@ class RollingDateConverter implements RollingDateConverterInterface
return \DateTimeImmutable::createFromFormat(
'Y-m-d His',
sprintf('%s-%s-01 000000', $date->format('Y'), $month)
sprintf('%s-%s-01 000000', $date->format('Y'), $month),
);
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Service\UserGroup;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Repository\UserJobRepositoryInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final readonly class UserGroupRelatedToUserJobSync implements UserGroupRelatedToUserJobSyncInterface
{
private const LOG_PREFIX = '[UserGroupRelatedToUserJobSync] ';
public function __construct(
private UserJobRepositoryInterface $userJobRepository,
private EntityManagerInterface $entityManager,
private TranslatorInterface $translator,
private TranslatableStringHelperInterface $translatableStringHelper,
private LoggerInterface $logger,
) {}
public function __invoke(): array
{
$created = $this->createNotExistingUserGroups();
$connection = $this->entityManager->getConnection();
$stats = $connection->transactional(function (Connection $connection) {
$removed = $this->removeUserNotRelatedToJob($connection);
$created = $this->createNewAssociations($connection);
return ['association_removed' => $removed, 'association_created' => $created];
});
$fullStats = ['userjob_created' => $created, ...$stats];
$this->logger->info(self::LOG_PREFIX.'Executed synchronisation', $fullStats);
return $fullStats;
}
private function createNotExistingUserGroups(): int
{
$jobs = $this->userJobRepository->findAllNotAssociatedWithUserGroup();
$counter = 0;
foreach ($jobs as $job) {
$userGroup = new UserGroup();
$userGroup->setUserJob($job);
$userGroup->setLabel(
[
$this->translator->getLocale() => $this->translator->trans(
'user_group.label_related_to_user_job',
['job' => $this->translatableStringHelper->localize($job->getLabel())]
)]
);
$userGroup->setBackgroundColor('#e5a50a')->setForegroundColor('#f6f5f4');
$this->entityManager->persist($userGroup);
$this->logger->info(self::LOG_PREFIX.'Will create user group', ['job' => $this->translatableStringHelper->localize($userGroup->getLabel())]);
++$counter;
}
$this->entityManager->flush();
return $counter;
}
private function removeUserNotRelatedToJob(Connection $connection): int
{
$sql = <<<'SQL'
DELETE FROM chill_main_user_group_user
USING users AS u, chill_main_user_group ug
WHERE
chill_main_user_group_user.usergroup_id = ug.id
AND chill_main_user_group_user.user_id = u.id
-- only where user_group.userjob_id is set (we ignore groups not automatically created)
AND ug.userjob_id IS NOT NULL
AND (
-- Case 1: User has no job history records matching the time period
NOT EXISTS (
SELECT 1 FROM chill_main_user_job_history jh
WHERE jh.user_id = u.id
AND tsrange(jh.startdate, jh.enddate) @> localtimestamp
)
OR
-- Case 2: User has job history but with different job_id or user is disabled
EXISTS (
SELECT 1 FROM chill_main_user_job_history jh
WHERE jh.user_id = u.id
AND tsrange(jh.startdate, jh.enddate) @> localtimestamp
AND (jh.job_id <> ug.userjob_id OR u.enabled IS FALSE OR jh.job_id IS NULL)
)
)
SQL;
$result = $connection->executeQuery($sql);
return $result->rowCount();
}
private function createNewAssociations(Connection $connection): int
{
$sql = <<<'SQL'
INSERT INTO chill_main_user_group_user (usergroup_id, user_id)
SELECT cmug.id, jh.user_id
FROM chill_main_user_group cmug
JOIN chill_main_user_job_history jh ON jh.job_id = cmug.userjob_id AND tsrange(jh.startdate, jh.enddate) @> localtimestamp
JOIN users u ON u.id = jh.user_id
WHERE cmug.userjob_id IS NOT NULL AND u.enabled IS TRUE
ON CONFLICT DO NOTHING
SQL;
$result = $connection->executeQuery($sql);
return $result->rowCount();
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Service\UserGroup;
use Chill\MainBundle\Cron\CronJobInterface;
use Chill\MainBundle\Entity\CronJobExecution;
use Symfony\Component\Clock\ClockInterface;
final readonly class UserGroupRelatedToUserJobSyncCronJob implements CronJobInterface
{
private const KEY = 'user-group-related-to-user-job-sync';
public function __construct(private ClockInterface $clock, private UserGroupRelatedToUserJobSyncInterface $userJobSync) {}
public function canRun(?CronJobExecution $cronJobExecution): bool
{
if (null === $cronJobExecution) {
return true;
}
return $cronJobExecution->getLastStart() < $this->clock->now()->sub(new \DateInterval('P1D'));
}
public function getKey(): string
{
return self::KEY;
}
public function run(array $lastExecutionData): ?array
{
return ($this->userJobSync)();
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Service\UserGroup;
interface UserGroupRelatedToUserJobSyncInterface
{
/**
* @return array{userjob_created: int, association_removed: int, association_created: int}
*/
public function __invoke(): array;
}