[person] Feature: add a person's center history

The association between Person and Center is now stored in a dedicated
Entity: `PersonCenterHistory`, which have a date interval (start date
and endDate). The SQL counterpart is a table, with a constraint which
ensure that no person might be associated with two center at the same time.

For ease, a view is created to get the current center associated with
the person.

The dedicated migration creates also:

* indexes for a rapid search for person at current date;
* and populate the table from current data, setting the startdate to the
  person's creation date and time if any, `NOW()` unless.

The `Person` entity is also updated to use the information from the
PersonCenterHistory classes, but this commit does not yet delete the
`Center` column.
This commit is contained in:
Julien Fastré 2022-09-26 21:11:01 +02:00
parent e3764f6f91
commit 49d2e98a1a
7 changed files with 468 additions and 3 deletions

View File

@ -27,6 +27,8 @@ use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Entity\Household\PersonHouseholdAddress;
use Chill\PersonBundle\Entity\Person\PersonCenterCurrent;
use Chill\PersonBundle\Entity\Person\PersonCenterHistory;
use Chill\PersonBundle\Entity\Person\PersonCurrentAddress;
use Chill\PersonBundle\Entity\Person\PersonResource;
use Chill\PersonBundle\Validator\Constraints\Household\HouseholdMembershipSequential;
@ -180,9 +182,21 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
* The person's center.
*
* @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\Center")
* @deprecated
*/
private ?Center $center = null;
/**
* @ORM\OneToMany(targetEntity=PersonCenterHistory::class, mappedBy="person")
* @var Collection|PersonCenterHistory[]
*/
private Collection $centerHistory;
/**
* @ORM\OneToOne(targetEntity=PersonCenterCurrent::class, mappedBy="person")
*/
private ?PersonCenterCurrent $centerCurrent = null;
/**
* Array where customfield's data are stored.
*
@ -523,6 +537,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
$this->budgetResources = new ArrayCollection();
$this->budgetCharges = new ArrayCollection();
$this->resources = new ArrayCollection();
$this->centerHistory = new ArrayCollection();
}
/**
@ -897,7 +912,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
public function getCenter(): ?Center
{
return $this->center;
return null === $this->centerCurrent ? null : $this->centerCurrent->getCenter();
}
public function getCFData(): ?array
@ -1510,17 +1525,60 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
return $this;
}
/**
* Associate the center with the person. The association start on 'now'.
*
* @param Center $center
* @return $this
*/
public function setCenter(Center $center): self
{
$this->center = $center;
$modification = new DateTimeImmutable('now');
foreach ($this->centerHistory as $centerHistory) {
if (null === $centerHistory->getEndDate()) {
$centerHistory->setEndDate($modification);
}
}
$this->centerHistory[] = $new = new PersonCenterHistory($this, $center, $modification);
$this->centerCurrent = new PersonCenterCurrent($new);
return $this;
}
/**
* @return Report
* @return Collection
*/
public function setCFData(?array $cFData)
public function getCenterHistory(): Collection
{
return $this->centerHistory;
}
/**
* @param Collection $centerHistory
* @return Person
*/
public function setCenterHistory(Collection $centerHistory): Person
{
$this->centerHistory = $centerHistory;
return $this;
}
/**
* @return PersonCenterCurrent|null
*/
public function getCenterCurrent(): ?PersonCenterCurrent
{
return $this->centerCurrent;
}
/**
* @return Person
*/
public function setCFData(?array $cFData): self
{
$this->cFData = $cFData;

View File

@ -0,0 +1,112 @@
<?php
namespace Chill\PersonBundle\Entity\Person;
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\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\Person;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
/**
* Associate a Person with the current center.
*
* The process of selecting the current center is done on database side,
* using a SQL view.
*
* @ORM\Entity(readOnly=true)
* @ORM\Table(name="view_chill_person_person_center_history_current")
* @psalm-internal Chill\PersonBundle\Entity
*/
class PersonCenterCurrent
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
*/
private ?int $id = null;
/**
* @ORM\ManyToOne(targetEntity=Person::class, inversedBy="centerCurrent")
*/
private Person $person;
/**
* @ORM\ManyToOne(targetEntity=Center::class)
*/
private Center $center;
/**
* @ORM\Column(type="date_immutable", nullable=false)
*/
private \DateTimeImmutable $startDate;
/**
* @ORM\Column(type="date_immutable", nullable=true, options={"default": null})
*/
private ?\DateTimeImmutable $endDate = null;
/**
* Populate the properties person, center, start and end date from history.
*
* The creator and updatedby are not filled.
*
* @internal Should not be instantied, unless inside Person entity
* @param PersonCenterHistory $history
*/
public function __construct(PersonCenterHistory $history)
{
$this->person = $history->getPerson();
$this->center = $history->getCenter();
$this->startDate = $history->getStartDate();
$this->endDate = $history->getEndDate();
$this->id = $history->getId();
}
/**
* The id will be the same as the current @link{PersonCenterHistory::class}
*
* @return int
*/
public function getId(): int
{
return $this->id;
}
/**
* @return Person
*/
public function getPerson(): Person
{
return $this->person;
}
/**
* @return Center
*/
public function getCenter(): Center
{
return $this->center;
}
/**
* @return \DateTimeImmutable
*/
public function getStartDate(): \DateTimeImmutable
{
return $this->startDate;
}
/**
* @return \DateTimeImmutable|null
*/
public function getEndDate(): ?\DateTimeImmutable
{
return $this->endDate;
}
}

View File

@ -0,0 +1,142 @@
<?php
namespace Chill\PersonBundle\Entity\Person;
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\MainBundle\Entity\Center;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\Mapping as ORM;
/**
* Associate a Person with a Center. The association may change on date intervals
*
* @ORM\Entity
* @ORM\Table(name="chill_person_person_center_history")
*/
class PersonCenterHistory implements TrackCreationInterface, TrackUpdateInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private ?int $id = null;
/**
* @ORM\ManyToOne(targetEntity=Person::class, inversedBy="centerHistory")
*/
private ?Person $person = null;
/**
* @ORM\ManyToOne(targetEntity=Center::class)
*/
private ?Center $center = null;
/**
* @ORM\Column(type="date_immutable", nullable=false)
*/
private ?\DateTimeImmutable $startDate = null;
/**
* @ORM\Column(type="date_immutable", nullable=true, options={"default": null})
*/
private ?\DateTimeImmutable $endDate = null;
/**
* @param Person|null $person
* @param Center|null $center
* @param \DateTimeImmutable|null $startDate
*/
public function __construct(?Person $person = null, ?Center $center = null, ?\DateTimeImmutable $startDate = null)
{
$this->person = $person;
$this->center = $center;
$this->startDate = $startDate;
}
/**
* @return int|null
*/
public function getId(): ?int
{
return $this->id;
}
/**
* @return Person|null
*/
public function getPerson(): ?Person
{
return $this->person;
}
/**
* @param Person|null $person
*/
public function setPerson(?Person $person): self
{
$this->person = $person;
return $this;
}
/**
* @return Center|null
*/
public function getCenter(): ?Center
{
return $this->center;
}
/**
* @param Center|null $center
*/
public function setCenter(?Center $center): self
{
$this->center = $center;
return $this;
}
/**
* @return \DateTimeImmutable|null
*/
public function getStartDate(): ?\DateTimeImmutable
{
return $this->startDate;
}
/**
* @param \DateTimeImmutable|null $startDate
*/
public function setStartDate(?\DateTimeImmutable $startDate): self
{
$this->startDate = $startDate;
return $this;
}
/**
* @return \DateTimeImmutable|null
*/
public function getEndDate(): ?\DateTimeImmutable
{
return $this->endDate;
}
/**
* @param \DateTimeImmutable|null $endDate
*/
public function setEndDate(?\DateTimeImmutable $endDate): self
{
$this->endDate = $endDate;
return $this;
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace Chill\PersonBundle\Repository\Person;
use Doctrine\Persistence\ObjectRepository;
interface PersonCenterHistoryInterface extends ObjectRepository
{
}

View File

@ -0,0 +1,48 @@
<?php
namespace Chill\PersonBundle\Repository\Person;
use Chill\PersonBundle\Entity\Person\PersonCenterHistory;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
class PersonCenterHistoryRepository implements PersonCenterHistoryInterface
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $em)
{
$this->repository = $em->getRepository($this->getClassName());
}
public function find($id): ?PersonCenterHistory
{
return $this->repository->find($id);
}
/**
* @return array|PersonCenterHistory[]
*/
public function findAll(): array
{
return $this->repository->findAll();
}
/**
* @return array|PersonCenterHistory[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findOneBy(array $criteria): ?PersonCenterHistory
{
return $this->repository->findOneBy($criteria);
}
public function getClassName(): string
{
return PersonCenterHistory::class;
}
}

View File

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Tests\Entity;
use Chill\MainBundle\Entity\Center;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
@ -191,4 +192,29 @@ final class PersonTest extends \PHPUnit\Framework\TestCase
$this->assertEquals($r['result'], Person::ERROR_ADDIND_PERIOD_AFTER_AN_OPEN_PERIOD);
}
public function testSetCenter()
{
$person = new Person();
$centerA = new Center();
$centerB = new Center();
$this->assertCount(0, $person->getCenterHistory());
$this->assertNull($person->getCenter());
$this->assertNull($person->getCenterCurrent());
$person->setCenter($centerA);
$this->assertCount(1, $person->getCenterHistory());
$this->assertSame($centerA, $person->getCenter());
$this->assertInstanceOf(Person\PersonCenterCurrent::class, $person->getCenterCurrent());
$this->assertSame($centerA, $person->getCenterCurrent()->getCenter());
$person->setCenter($centerB);
$this->assertCount(2, $person->getCenterHistory());
$this->assertSame($centerB, $person->getCenter());
$this->assertInstanceOf(Person\PersonCenterCurrent::class, $person->getCenterCurrent());
$this->assertSame($centerB, $person->getCenterCurrent()->getCenter());
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\Person;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20220926154347 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add a center history on person';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE SEQUENCE chill_person_person_center_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE chill_person_person_center_history (
id INT NOT NULL, person_id INT DEFAULT NULL, center_id INT DEFAULT NULL,
startDate DATE NOT NULL, endDate DATE DEFAULT 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('CREATE INDEX IDX_CACA67FA217BBB47 ON chill_person_person_center_history (person_id)');
$this->addSql('CREATE INDEX IDX_CACA67FA5932F377 ON chill_person_person_center_history (center_id)');
$this->addSql('CREATE INDEX IDX_CACA67FA3174800F ON chill_person_person_center_history (createdBy_id)');
$this->addSql('CREATE INDEX IDX_CACA67FA65FF1AEC ON chill_person_person_center_history (updatedBy_id)');
$this->addSql('COMMENT ON COLUMN chill_person_person_center_history.startDate IS \'(DC2Type:date_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_person_person_center_history.endDate IS \'(DC2Type:date_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_person_person_center_history.createdAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN chill_person_person_center_history.updatedAt IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE chill_person_person_center_history ADD CONSTRAINT FK_CACA67FA217BBB47 FOREIGN KEY (person_id) REFERENCES chill_person_person (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_person_person_center_history ADD CONSTRAINT FK_CACA67FA5932F377 FOREIGN KEY (center_id) REFERENCES centers (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_person_person_center_history ADD CONSTRAINT FK_CACA67FA3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE chill_person_person_center_history ADD CONSTRAINT FK_CACA67FA65FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
// check consistency of history on database side
$this->addSql('ALTER TABLE chill_person_person_center_history ADD CHECK (startdate <= enddate)');
$this->addSql('ALTER TABLE chill_person_person_center_history ADD CONSTRAINT ' .
'chill_internal_person_person_center_history_not_overlaps EXCLUDE USING GIST(
-- extension btree_gist required to include comparaison with integer
person_id WITH =,
daterange(startdate, enddate, \'[)\') WITH &&
)
INITIALLY DEFERRED');
// create index on search by person and date
$this->addSql('CREATE INDEX chill_internal_person_person_center_history_by_date ON chill_person_person_center_history (person_id DESC, startdate DESC, enddate DESC NULLS FIRST)');
// create a view to get the current center on a person
$this->addSql(
'CREATE VIEW view_chill_person_person_center_history_current AS
SELECT id, person_id, center_id, startDate, endDate, createdAt, updatedAt, createdBy_id, updatedBy_id
FROM chill_person_person_center_history WHERE startDate <= NOW() AND (enddate IS NULL OR enddate > NOW())'
);
$this->addSql('INSERT INTO chill_person_person_center_history (id, person_id, center_id, startdate)
SELECT nextval(\'chill_person_person_center_history_id_seq\'), id, center_id, COALESCE(createdat, NOW())
FROM chill_person_person WHERE center_id IS NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('DROP VIEW view_chill_person_person_center_history_current');
$this->addSql('DROP SEQUENCE chill_person_person_center_history_id_seq CASCADE');
$this->addSql('DROP TABLE chill_person_person_center_history');
}
}