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.
This commit is contained in:
Julien Fastré 2022-10-14 14:36:40 +02:00
parent 66f282e221
commit 59e21b6819
5 changed files with 293 additions and 27 deletions

View File

@ -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,8 +1247,12 @@ class AccompanyingPeriod implements
*/
public function setOpeningDate($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;

View File

@ -0,0 +1,115 @@
<?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\PersonBundle\Entity\AccompanyingPeriod;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table("chill_person_accompanying_period_step_history")
*/
class AccompanyingPeriodStepHistory implements TrackCreationInterface, TrackUpdateInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
/**
* @ORM\Column(type="date_immutable", nullable=true, options={"default": null})
*/
private ?DateTimeImmutable $endDate = null;
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private ?int $id = null;
/**
* @ORM\ManyToOne(targetEntity=AccompanyingPeriod::class)
*/
private AccompanyingPeriod $period;
/**
* @ORM\Column(type="date_immutable")
*/
private ?DateTimeImmutable $startDate = null;
/**
* @ORM\Column(type="text", nullable=false)
*/
private string $step;
public function getEndDate(): ?DateTimeImmutable
{
return $this->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;
}
}

View File

@ -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')

View File

@ -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');

View File

@ -0,0 +1,69 @@
<?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\Migrations\Person;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20221014115500 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->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\'
'
);
}
}