From 59e21b68195540eefe5f789f373cfc9105db7fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 14 Oct 2022 14:36:40 +0200 Subject: [PATCH] Feature: [acp] record the step history of each accompanying period Each time a step is changed on an history, a record is stored in a dedicated table. When the acp's opening date is moved, the first row is adapted to match the new opening's date. This mechanisme does not work if the opening date is move beyon the first end date (if any), nor on the closing date. --- .../Entity/AccompanyingPeriod.php | 77 +++++++++++- .../AccompanyingPeriodStepHistory.php | 115 ++++++++++++++++++ .../PersonFilters/MaritalStatusFilter.php | 27 +--- .../Tests/Entity/AccompanyingPeriodTest.php | 32 +++++ .../migrations/Version20221014115500.php | 69 +++++++++++ 5 files changed, 293 insertions(+), 27 deletions(-) create mode 100644 src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod/AccompanyingPeriodStepHistory.php create mode 100644 src/Bundle/ChillPersonBundle/migrations/Version20221014115500.php diff --git a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php index e2114f05d..d3310fe15 100644 --- a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php +++ b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php @@ -22,6 +22,7 @@ use Chill\MainBundle\Entity\Scope; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\UserJob; use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodLocationHistory; +use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodStepHistory; use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork; use Chill\PersonBundle\Entity\AccompanyingPeriod\ClosingMotive; use Chill\PersonBundle\Entity\AccompanyingPeriod\Comment; @@ -336,6 +337,12 @@ class AccompanyingPeriod implements */ private string $step = self::STEP_DRAFT; + /** + * @ORM\OneToMany(targetEntity=AccompanyingPeriodStepHistory::class, + * mappedBy="period", cascade={"persist", "remove"}, orphanRemoval=true) + */ + private Collection $stepHistories; + /** * @ORM\Column(type="datetime", nullable=true, options={"default": NULL}) */ @@ -390,7 +397,6 @@ class AccompanyingPeriod implements */ public function __construct(?DateTime $dateOpening = null) { - $this->setOpeningDate($dateOpening ?? new DateTime('now')); $this->participations = new ArrayCollection(); $this->scopes = new ArrayCollection(); $this->socialIssues = new ArrayCollection(); @@ -399,6 +405,8 @@ class AccompanyingPeriod implements $this->resources = new ArrayCollection(); $this->userHistories = new ArrayCollection(); $this->locationHistories = new ArrayCollection(); + $this->stepHistories = new ArrayCollection(); + $this->setOpeningDate($dateOpening ?? new DateTime('now')); } /** @@ -966,6 +974,11 @@ class AccompanyingPeriod implements return $this->step; } + public function getStepHistories(): Collection + { + return $this->stepHistories; + } + public function getUser(): ?User { return $this->user; @@ -1234,7 +1247,11 @@ class AccompanyingPeriod implements */ public function setOpeningDate($openingDate) { - $this->openingDate = $openingDate; + if ($this->openingDate !== $openingDate) { + $this->openingDate = $openingDate; + + $this->ensureStepContinuity(); + } return $this; } @@ -1333,6 +1350,14 @@ class AccompanyingPeriod implements $this->bootPeriod(); } + if (self::STEP_DRAFT !== $this->step && $previous !== $step) { + // we create a new history + $history = new AccompanyingPeriodStepHistory(); + $history->setStep($this->step)->setStartDate(new DateTimeImmutable('now')); + + $this->addStepHistory($history); + } + return $this; } @@ -1373,6 +1398,17 @@ class AccompanyingPeriod implements return $this; } + private function addStepHistory(AccompanyingPeriodStepHistory $stepHistory): self + { + if (!$this->stepHistories->contains($stepHistory)) { + $this->stepHistories[] = $stepHistory; + $stepHistory->setPeriod($this); + $this->ensureStepContinuity(); + } + + return $this; + } + private function bootPeriod(): void { // first location history @@ -1384,6 +1420,43 @@ class AccompanyingPeriod implements $this->addLocationHistory($locationHistory); } + private function ensureStepContinuity(): void + { + // ensure continuity of histories + $criteria = new Criteria(); + $criteria->orderBy(['startDate' => Criteria::ASC, 'id' => Criteria::ASC]); + + /** @var Iterator $steps */ + $steps = $this->getStepHistories()->matching($criteria)->getIterator(); + $steps->rewind(); + + // we set the start date of the first step as the opening date, only if it is + // not greater than the end date + /** @var AccompanyingPeriodStepHistory $current */ + $current = $steps->current(); + + if (null === $current) { + return; + } + + if ($this->getOpeningDate()->format('Y-m-d') !== $current->getStartDate()->format('Y-m-d') + && ($this->getOpeningDate() <= $current->getEndDate() || null === $current->getEndDate())) { + $current->setStartDate(DateTimeImmutable::createFromMutable($this->getOpeningDate())); + } + + // then we set all the end date to the start date of the next one + do { + /** @var AccompanyingPeriodStepHistory $current */ + $current = $steps->current(); + $steps->next(); + + if ($steps->valid()) { + $next = $steps->current(); + $current->setEndDate($next->getStartDate()); + } + } while ($steps->valid()); + } + private function setRequestorPerson(?Person $requestorPerson = null): self { $this->requestorPerson = $requestorPerson; diff --git a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod/AccompanyingPeriodStepHistory.php b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod/AccompanyingPeriodStepHistory.php new file mode 100644 index 000000000..f9baffa35 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod/AccompanyingPeriodStepHistory.php @@ -0,0 +1,115 @@ +endDate; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getPeriod(): AccompanyingPeriod + { + return $this->period; + } + + public function getStartDate(): ?DateTimeImmutable + { + return $this->startDate; + } + + public function getStep(): string + { + return $this->step; + } + + public function setEndDate(?DateTimeImmutable $endDate): self + { + $this->endDate = $endDate; + + return $this; + } + + /** + * @internal use AccompanyingPeriod::addLocationHistory + */ + public function setPeriod(AccompanyingPeriod $period): self + { + $this->period = $period; + + return $this; + } + + public function setStartDate(?DateTimeImmutable $startDate): self + { + $this->startDate = $startDate; + + return $this; + } + + public function setStep(string $step): AccompanyingPeriodStepHistory + { + $this->step = $step; + + return $this; + } +} diff --git a/src/Bundle/ChillPersonBundle/Export/Filter/PersonFilters/MaritalStatusFilter.php b/src/Bundle/ChillPersonBundle/Export/Filter/PersonFilters/MaritalStatusFilter.php index a3d2260e3..aad98a394 100644 --- a/src/Bundle/ChillPersonBundle/Export/Filter/PersonFilters/MaritalStatusFilter.php +++ b/src/Bundle/ChillPersonBundle/Export/Filter/PersonFilters/MaritalStatusFilter.php @@ -12,12 +12,9 @@ declare(strict_types=1); namespace Chill\PersonBundle\Export\Filter\PersonFilters; use Chill\MainBundle\Export\FilterInterface; -use Chill\MainBundle\Form\Type\ChillDateType; use Chill\MainBundle\Templating\TranslatableStringHelper; use Chill\PersonBundle\Entity\MaritalStatus; use Chill\PersonBundle\Export\Declarations; -use DateTime; -use Doctrine\ORM\Query\Expr\Andx; use Symfony\Bridge\Doctrine\Form\Type\EntityType; class MaritalStatusFilter implements FilterInterface @@ -37,25 +34,10 @@ class MaritalStatusFilter implements FilterInterface public function alterQuery(\Doctrine\ORM\QueryBuilder $qb, $data) { - $where = $qb->getDQLPart('where'); - - $clause = $qb->expr()->andX( - $qb->expr()->in('person.maritalStatus', ':maritalStatus'), - $qb->expr()->orX( - $qb->expr()->eq('person.maritalStatusDate', ':calc_date'), - $qb->expr()->isNull('person.maritalStatusDate') - ) + $qb->andWhere( + $qb->expr()->in('person.maritalStatus', ':maritalStatus') ); - - if ($where instanceof Andx) { - $where->add($clause); - } else { - $where = $qb->expr()->andX($clause); - } - - $qb->add('where', $where); $qb->setParameter('maritalStatus', $data['maritalStatus']); - $qb->setParameter('calc_date', $data['calc_date']); } public function applyOn() @@ -75,11 +57,6 @@ class MaritalStatusFilter implements FilterInterface 'multiple' => true, 'expanded' => true, ]); - - $builder->add('calc_date', ChillDateType::class, [ - 'label' => 'Marital status at this time', - 'data' => new DateTime(), - ]); } public function describeAction($data, $format = 'string') diff --git a/src/Bundle/ChillPersonBundle/Tests/Entity/AccompanyingPeriodTest.php b/src/Bundle/ChillPersonBundle/Tests/Entity/AccompanyingPeriodTest.php index 4c6033ca9..2f38da244 100644 --- a/src/Bundle/ChillPersonBundle/Tests/Entity/AccompanyingPeriodTest.php +++ b/src/Bundle/ChillPersonBundle/Tests/Entity/AccompanyingPeriodTest.php @@ -29,6 +29,38 @@ use function count; */ final class AccompanyingPeriodTest extends \PHPUnit\Framework\TestCase { + public function testChangeStepKeepHistory() + { + $period = new AccompanyingPeriod(); + + $this->assertCount(0, $period->getStepHistories(), 'at initialization, period should not have any step history'); + + $period->setStep(AccompanyingPeriod::STEP_DRAFT); + + $this->assertCount(0, $period->getStepHistories(), 're applying a draft should not create a history'); + + $period->setStep(AccompanyingPeriod::STEP_CONFIRMED); + + $this->assertCount(1, $period->getStepHistories()); + $this->assertEquals(AccompanyingPeriod::STEP_CONFIRMED, $period->getStepHistories()->first()->getStep()); + + $period->setOpeningDate($aMonthAgo = new DateTime('1 month ago')); + + $this->assertCount(1, $period->getStepHistories()); + $this->assertEquals($aMonthAgo, $period->getStepHistories()->first()->getStartDate(), 'when changing the opening date, the start date of the first history should change'); + + $period->setOpeningDate($tenDaysAgo = new DateTime('10 days ago')); + + $this->assertCount(1, $period->getStepHistories()); + $this->assertEquals($tenDaysAgo, $period->getStepHistories()->first()->getStartDate(), 'when changing the opening date, the start date of the first history should change'); + + $period->setStep(AccompanyingPeriod::STEP_CLOSED); + $this->assertCount(2, $period->getStepHistories()); + + $period->setOpeningDate($tomorrow = new DateTime('tomorrow')); + $this->assertEquals($tenDaysAgo, $period->getStepHistories()->first()->getStartDate(), 'when changing the opening date to a later one and no history after, start date should change'); + } + public function testClosingEqualOpening() { $datetime = new DateTime('now'); diff --git a/src/Bundle/ChillPersonBundle/migrations/Version20221014115500.php b/src/Bundle/ChillPersonBundle/migrations/Version20221014115500.php new file mode 100644 index 000000000..fe7b920d9 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/migrations/Version20221014115500.php @@ -0,0 +1,69 @@ +addSql('DROP SEQUENCE chill_person_accompanying_period_step_history_id_seq CASCADE'); + $this->addSql('DROP TABLE chill_person_accompanying_period_step_history'); + } + + public function getDescription(): string + { + return 'Add step history on accompanying periods'; + } + + public function up(Schema $schema): void + { + $this->addSql('CREATE SEQUENCE chill_person_accompanying_period_step_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE chill_person_accompanying_period_step_history (id INT NOT NULL, period_id INT DEFAULT NULL, + endDate DATE DEFAULT NULL, startDate DATE NOT NULL, step TEXT NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, createdBy_id INT DEFAULT NULL, + updatedBy_id INT DEFAULT NULL, PRIMARY KEY(id)) + '); + $this->addSql('ALTER TABLE chill_person_accompanying_period_step_history ADD CHECK (startDate <= endDate)'); + + $this->addSql('ALTER TABLE chill_person_accompanying_period_step_history ADD CONSTRAINT ' . + 'chill_internal_acp_steps_not_overlaps EXCLUDE USING GIST( + -- extension btree_gist required to include comparaison with integer + period_id WITH =, + daterange(startDate, endDate, \'[)\') WITH && + ) + INITIALLY DEFERRED'); + + $this->addSql('CREATE INDEX IDX_84D514ACEC8B7ADE ON chill_person_accompanying_period_step_history (period_id)'); + $this->addSql('CREATE INDEX IDX_84D514AC3174800F ON chill_person_accompanying_period_step_history (createdBy_id)'); + $this->addSql('COMMENT ON COLUMN chill_person_accompanying_period_step_history.endDate IS \'(DC2Type:date_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_person_accompanying_period_step_history.startDate IS \'(DC2Type:date_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_person_accompanying_period_step_history.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_person_accompanying_period_step_history.updatedAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_person_accompanying_period_step_history ADD CONSTRAINT FK_84D514ACEC8B7ADE FOREIGN KEY (period_id) REFERENCES chill_person_accompanying_period (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_person_accompanying_period_step_history ADD CONSTRAINT FK_84D514AC3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_84D514AC65FF1AEC ON chill_person_accompanying_period_step_history (updatedBy_id)'); + + // fill the tables with current state + $this->addSql( + 'INSERT INTO chill_person_accompanying_period_step_history (id, period_id, startDate, endDate, step, createdAt, updatedAt) + SELECT nextval(\'chill_person_accompanying_period_step_history_id_seq\'), id, openingDate, null, step, NOW(), NOW() FROM chill_person_accompanying_period WHERE step = \'CONFIRMED\' + UNION + SELECT nextval(\'chill_person_accompanying_period_step_history_id_seq\'), id, openingDate, closingDate, \'CONFIRMED\', NOW(), NOW() FROM chill_person_accompanying_period WHERE step = \'CLOSED\' + UNION + SELECT nextval(\'chill_person_accompanying_period_step_history_id_seq\'), id, closingDate, null, \'CLOSED\', NOW(), NOW() FROM chill_person_accompanying_period WHERE step = \'CLOSED\' + ' + ); + } +}