diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d9ee0eb2..ecb4ea358 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ and this project adheres to * [homepage_widget]: If no sender then display as 'notification automatique' (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/435) * [parcours]: Order social activities and only display most recent three in parcours resumé (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/481) * [3party]: 3party: redirect to parent when contact (child) is opened in view page +* [parcours / addresses]: launch an event when a person change address (either through changing household or because the household is associated to a new address). If the person is localising a course, the course location go back to a temporarily address. +* Creation of PickCivilityType, and implementation in PersonType and ThirdpartyType ## Test releases diff --git a/phpstan-critical.neon b/phpstan-critical.neon index 632356aa6..262d3012f 100644 --- a/phpstan-critical.neon +++ b/phpstan-critical.neon @@ -25,11 +25,6 @@ parameters: count: 1 path: src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepository.php - - - message: "#^Access to an undefined property Chill\\\\PersonBundle\\\\Household\\\\MembersEditorFactory\\:\\:\\$validator\\.$#" - count: 2 - path: src/Bundle/ChillPersonBundle/Household/MembersEditorFactory.php - - message: "#^Variable variables are not allowed\\.$#" count: 4 diff --git a/src/Bundle/ChillMainBundle/Entity/Address.php b/src/Bundle/ChillMainBundle/Entity/Address.php index 4ccafa8c2..8eac6668b 100644 --- a/src/Bundle/ChillMainBundle/Entity/Address.php +++ b/src/Bundle/ChillMainBundle/Entity/Address.php @@ -142,7 +142,7 @@ class Address * @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\PostalCode") * @Groups({"write"}) */ - private ?PostalCode $postcode; + private ?PostalCode $postcode = null; /** * @var string|null @@ -304,10 +304,8 @@ class Address /** * Get postcode. - * - * @return PostalCode */ - public function getPostcode() + public function getPostcode(): ?PostalCode { return $this->postcode; } diff --git a/src/Bundle/ChillMainBundle/Form/Type/PickCivilityType.php b/src/Bundle/ChillMainBundle/Form/Type/PickCivilityType.php new file mode 100644 index 000000000..592c14242 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/Type/PickCivilityType.php @@ -0,0 +1,57 @@ +translatableStringHelper = $translatableStringHelper; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver + ->setDefault('label', 'Civility') + ->setDefault( + 'choice_label', + function (Civility $civility): string { + return $this->translatableStringHelper->localize($civility->getName()); + } + ) + ->setDefault( + 'query_builder', + static function (EntityRepository $er): QueryBuilder { + return $er->createQueryBuilder('c') + ->where('c.active = true') + ->orderBy('c.order'); + }, + ) + ->setDefault('placeholder', 'choose civility') + ->setDefault('class', Civility::class); + } + + public function getParent() + { + return EntityType::class; + } +} diff --git a/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Events/PersonAddressMoveEventSubscriber.php b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Events/PersonAddressMoveEventSubscriber.php new file mode 100644 index 000000000..3eff58dc7 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/AccompanyingPeriod/Events/PersonAddressMoveEventSubscriber.php @@ -0,0 +1,95 @@ +engine = $engine; + $this->entityManager = $entityManager; + $this->security = $security; + $this->translator = $translator; + } + + public static function getSubscribedEvents(): array + { + return [ + PersonAddressMoveEvent::class => 'resetPeriodLocation', + ]; + } + + public function resetPeriodLocation(PersonAddressMoveEvent $event) + { + if ($event->getPreviousAddress() !== $event->getNextAddress() + && null !== $event->getPreviousAddress() + ) { + $person = $event->getPerson(); + + foreach ($person->getCurrentAccompanyingPeriods() as $period) { + if ($period->getStep() === AccompanyingPeriod::STEP_DRAFT) { + continue; + } + + if ( + $period->getPersonLocation() === $person + && ( + $event->getMoveDate() >= $period->getLastLocationHistory()->getStartDate() + || $event->willChangeBeActiveAt(new DateTimeImmutable('now')) + ) + && null !== $period->getUser() + && $period->getUser() !== $this->security->getUser() + ) { + // reset the location, back to an address + $period->setPersonLocation(null); + $period->setAddressLocation(Address::createFromAddress($event->getPreviousAddress())); + + $notification = new Notification(); + $notification + ->addAddressee($period->getUser()) + ->setTitle($this->translator->trans('period_notification.Person locating period has moved')) + ->setRelatedEntityClass(AccompanyingPeriod::class) + ->setRelatedEntityId($period->getId()) + ->setMessage($this->engine->render('@ChillPerson/AccompanyingPeriod/notification_location_user_on_period_has_moved.fr.txt.twig', [ + 'oldPersonLocation' => $person, + 'period' => $period, + ])); + + $this->entityManager->persist($notification); + } + } + } + } +} diff --git a/src/Bundle/ChillPersonBundle/Controller/HouseholdApiController.php b/src/Bundle/ChillPersonBundle/Controller/HouseholdApiController.php index 1ebde2b57..470094507 100644 --- a/src/Bundle/ChillPersonBundle/Controller/HouseholdApiController.php +++ b/src/Bundle/ChillPersonBundle/Controller/HouseholdApiController.php @@ -16,28 +16,37 @@ use Chill\MainBundle\Entity\Address; use Chill\MainBundle\Entity\AddressReference; use Chill\MainBundle\Serializer\Model\Collection; use Chill\PersonBundle\Entity\Household\Household; +use Chill\PersonBundle\Entity\Household\HouseholdMember; use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\Event\Person\PersonAddressMoveEvent; use Chill\PersonBundle\Repository\Household\HouseholdACLAwareRepositoryInterface; use Chill\PersonBundle\Repository\Household\HouseholdRepository; +use Chill\PersonBundle\Security\Authorization\HouseholdVoter; +use DateTimeImmutable; use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; -use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use function array_filter; use function array_values; class HouseholdApiController extends ApiController { + private EventDispatcherInterface $eventDispatcher; + private HouseholdACLAwareRepositoryInterface $householdACLAwareRepository; private HouseholdRepository $householdRepository; public function __construct( + EventDispatcherInterface $eventDispatcher, HouseholdRepository $householdRepository, HouseholdACLAwareRepositoryInterface $householdACLAwareRepository ) { + $this->eventDispatcher = $eventDispatcher; $this->householdRepository = $householdRepository; $this->householdACLAwareRepository = $householdACLAwareRepository; } @@ -66,9 +75,51 @@ class HouseholdApiController extends ApiController ]); } - public function householdAddressApi($id, Request $request, string $_format): Response + /** + * Add an address to a household. + * + * @Route("/api/1.0/person/household/{id}/address.{_format}", name="chill_api_single_household_address", + * methods={"POST"}, requirements={"_format": "json"}) + */ + public function householdAddressApi(Household $household, Request $request, string $_format): Response { - return $this->addRemoveSomething('address', $id, $request, $_format, 'address', Address::class, ['groups' => ['read']]); + $this->denyAccessUnlessGranted(HouseholdVoter::EDIT, $household); + + /** @var Address $address */ + $address = $this->getSerializer()->deserialize($request->getContent(), Address::class, $_format, [ + AbstractNormalizer::GROUPS => ['write'], + ]); + + $household->addAddress($address); + + foreach ($household->getMembersOnRange( + DateTimeImmutable::createFromMutable($address->getValidFrom()), + null === $address->getValidTo() ? null : + DateTimeImmutable::createFromMutable($address->getValidTo()) + ) as $member) { + /** @var HouseholdMember $member */ + $event = new PersonAddressMoveEvent($member->getPerson()); + $event + ->setPreviousAddress($household->getPreviousAddressOf($address)) + ->setNextAddress($address); + dump($event); + $this->eventDispatcher->dispatch($event); + } + + $errors = $this->getValidator()->validate($household); + + if ($errors->count() > 0) { + return $this->json($errors, 422); + } + + $this->getDoctrine()->getManager()->flush(); + + return $this->json( + $address, + Response::HTTP_OK, + [], + [AbstractNormalizer::GROUPS => ['read']] + ); } /** diff --git a/src/Bundle/ChillPersonBundle/Controller/HouseholdMemberController.php b/src/Bundle/ChillPersonBundle/Controller/HouseholdMemberController.php index 85fbfda32..2ec72a786 100644 --- a/src/Bundle/ChillPersonBundle/Controller/HouseholdMemberController.php +++ b/src/Bundle/ChillPersonBundle/Controller/HouseholdMemberController.php @@ -180,6 +180,7 @@ class HouseholdMemberController extends ApiController public function move(Request $request, $_format): Response { try { + /** @var MembersEditor $editor */ $editor = $this->getSerializer() ->deserialize( $request->getContent(), @@ -199,6 +200,9 @@ class HouseholdMemberController extends ApiController return $this->json($errors, 422); } + // launch events on post move + $editor->postMove(); + $em = $this->getDoctrine()->getManager(); // if new household, persist it diff --git a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php index 01df460e3..716ef9ee8 100644 --- a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php +++ b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php @@ -527,15 +527,6 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac Request::METHOD_HEAD => true, ], ], - 'address' => [ - 'methods' => [ - Request::METHOD_POST => true, - Request::METHOD_DELETE => true, - Request::METHOD_GET => false, - Request::METHOD_HEAD => false, - ], - 'controller_action' => 'householdAddressApi', - ], 'suggestHouseholdByAccompanyingPeriodParticipation' => [ 'path' => '/suggest/by-person/{person_id}/through-accompanying-period-participation.{_format}', 'methods' => [ diff --git a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php index 1e7444af6..0012f341d 100644 --- a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php +++ b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod.php @@ -21,6 +21,7 @@ use Chill\MainBundle\Entity\Location; 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\AccompanyingPeriodWork; use Chill\PersonBundle\Entity\AccompanyingPeriod\ClosingMotive; use Chill\PersonBundle\Entity\AccompanyingPeriod\Comment; @@ -37,17 +38,19 @@ use DateTimeImmutable; use DateTimeInterface; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\Mapping as ORM; +use Iterator; use LogicException; use Symfony\Component\Serializer\Annotation\DiscriminatorMap; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\GroupSequenceProviderInterface; + use UnexpectedValueException; use function in_array; - use const SORT_REGULAR; /** @@ -212,6 +215,12 @@ class AccompanyingPeriod implements */ private ?UserJob $job = null; + /** + * @ORM\OneToMany(targetEntity=AccompanyingPeriodLocationHistory::class, + * mappedBy="period", cascade={"persist", "remove"}, orphanRemoval=true) + */ + private Collection $locationHistories; + /** * @var DateTime * @@ -384,6 +393,7 @@ class AccompanyingPeriod implements $this->works = new ArrayCollection(); $this->resources = new ArrayCollection(); $this->userHistories = new ArrayCollection(); + $this->locationHistories = new ArrayCollection(); } /** @@ -434,6 +444,39 @@ class AccompanyingPeriod implements return $this; } + public function addLocationHistory(AccompanyingPeriodLocationHistory $history): self + { + if ($this->getStep() === self::STEP_DRAFT) { + return $this; + } + + if (!$this->locationHistories->contains($history)) { + $this->locationHistories[] = $history; + $history->setPeriod($this); + } + + // ensure continuity of histories + $criteria = new Criteria(); + $criteria->orderBy(['startDate' => Criteria::ASC, 'id' => Criteria::ASC]); + + /** @var Iterator $locations */ + $locations = $this->getLocationHistories()->matching($criteria)->getIterator(); + $locations->rewind(); + + do { + /** @var AccompanyingPeriodLocationHistory $current */ + $current = $locations->current(); + $locations->next(); + + if ($locations->valid()) { + $next = $locations->current(); + $current->setEndDate($next->getStartDate()); + } + } while ($locations->valid()); + + return $this; + } + public function addPerson(?Person $person = null): self { if (null !== $person) { @@ -666,6 +709,17 @@ class AccompanyingPeriod implements return $this->job; } + public function getLastLocationHistory(): ?AccompanyingPeriodLocationHistory + { + foreach ($this->getLocationHistories() as $locationHistory) { + if (null === $locationHistory->getEndDate()) { + return $locationHistory; + } + } + + return null; + } + /** * Get the location, taking precedence into account. * @@ -680,6 +734,14 @@ class AccompanyingPeriod implements return $this->getAddressLocation(); } + /** + * @return Collection|AccompanyingPeriodLocationHistory[] + */ + public function getLocationHistories(): Collection + { + return $this->locationHistories; + } + /** * Get where the location is. * @@ -982,6 +1044,15 @@ class AccompanyingPeriod implements $this->comments->removeElement($comment); } + public function removeLocationHistory(AccompanyingPeriodLocationHistory $history): self + { + if ($this->locationHistories->removeElement($history)) { + $history->setPeriod(null); + } + + return $this; + } + /** * Remove Participation. */ @@ -1036,7 +1107,18 @@ class AccompanyingPeriod implements */ public function setAddressLocation(?Address $addressLocation = null): self { - $this->addressLocation = $addressLocation; + if ($this->addressLocation !== $addressLocation) { + $this->addressLocation = $addressLocation; + + if (null !== $addressLocation) { + $locationHistory = new AccompanyingPeriodLocationHistory(); + $locationHistory + ->setStartDate(new DateTimeImmutable('now')) + ->setAddressLocation($addressLocation); + + $this->addLocationHistory($locationHistory); + } + } return $this; } @@ -1139,7 +1221,18 @@ class AccompanyingPeriod implements */ public function setPersonLocation(?Person $person = null): self { - $this->personLocation = $person; + if ($this->personLocation !== $person) { + $this->personLocation = $person; + + if (null !== $person) { + $locationHistory = new AccompanyingPeriodLocationHistory(); + $locationHistory + ->setStartDate(new DateTimeImmutable('now')) + ->setPersonLocation($person); + + $this->addLocationHistory($locationHistory); + } + } return $this; } @@ -1206,8 +1299,14 @@ class AccompanyingPeriod implements public function setStep(string $step): self { + $previous = $this->step; + $this->step = $step; + if (self::STEP_DRAFT === $previous && self::STEP_DRAFT !== $step) { + $this->bootPeriod(); + } + return $this; } @@ -1246,6 +1345,17 @@ class AccompanyingPeriod implements return $this; } + private function bootPeriod(): void + { + // first location history + $locationHistory = new AccompanyingPeriodLocationHistory(); + $locationHistory + ->setStartDate(new DateTimeImmutable('now')) + ->setPersonLocation($this->getPersonLocation()) + ->setAddressLocation($this->getAddressLocation()); + $this->addLocationHistory($locationHistory); + } + private function setRequestorPerson(?Person $requestorPerson = null): self { $this->requestorPerson = $requestorPerson; diff --git a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod/AccompanyingPeriodLocationHistory.php b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod/AccompanyingPeriodLocationHistory.php new file mode 100644 index 000000000..1dcb5a1bf --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriod/AccompanyingPeriodLocationHistory.php @@ -0,0 +1,129 @@ +addressLocation; + } + + public function getEndDate(): ?DateTimeImmutable + { + return $this->endDate; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getPeriod(): AccompanyingPeriod + { + return $this->period; + } + + public function getPersonLocation(): ?Person + { + return $this->personLocation; + } + + public function getStartDate(): ?DateTimeImmutable + { + return $this->startDate; + } + + public function setAddressLocation(?Address $addressLocation): AccompanyingPeriodLocationHistory + { + $this->addressLocation = $addressLocation; + + return $this; + } + + public function setEndDate(?DateTimeImmutable $endDate): AccompanyingPeriodLocationHistory + { + $this->endDate = $endDate; + + return $this; + } + + /** + * @internal use AccompanyingPeriod::addLocationHistory + */ + public function setPeriod(AccompanyingPeriod $period): AccompanyingPeriodLocationHistory + { + $this->period = $period; + + return $this; + } + + public function setPersonLocation(?Person $personLocation): AccompanyingPeriodLocationHistory + { + $this->personLocation = $personLocation; + + return $this; + } + + public function setStartDate(?DateTimeImmutable $startDate): AccompanyingPeriodLocationHistory + { + $this->startDate = $startDate; + + return $this; + } +} diff --git a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriodParticipation.php b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriodParticipation.php index 42e57185e..161f5c316 100644 --- a/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriodParticipation.php +++ b/src/Bundle/ChillPersonBundle/Entity/AccompanyingPeriodParticipation.php @@ -66,6 +66,7 @@ class AccompanyingPeriodParticipation $this->startDate = new DateTime('now'); $this->accompanyingPeriod = $accompanyingPeriod; $this->person = $person; + $person->getAccompanyingPeriodParticipations()->add($this); } public function getAccompanyingPeriod(): ?AccompanyingPeriod diff --git a/src/Bundle/ChillPersonBundle/Entity/Household/Household.php b/src/Bundle/ChillPersonBundle/Entity/Household/Household.php index ef718bc2e..b922ad19c 100644 --- a/src/Bundle/ChillPersonBundle/Entity/Household/Household.php +++ b/src/Bundle/ChillPersonBundle/Entity/Household/Household.php @@ -106,19 +106,13 @@ class Household $this->compositions = new ArrayCollection(); } - /** - * @return $this - */ - public function addAddress(Address $address) + public function addAddress(Address $address): self { - foreach ($this->getAddresses() as $a) { - if ($a->getValidFrom() <= $address->getValidFrom() && $a->getValidTo() === null) { - $a->setValidTo($address->getValidFrom()); - } + if (!$this->addresses->contains($address)) { + $this->addresses[] = $address; + $this->makeAddressConsistent(); } - $this->addresses[] = $address; - return $this; } @@ -157,6 +151,31 @@ class Household return $this->addresses; } + public function getAddressesOrdered(): array + { + $addresses = $this->getAddresses()->toArray(); + usort($addresses, static function (Address $a, Address $b) { + $validFromA = $a->getValidFrom()->format('Y-m-d'); + $validFromB = $b->getValidFrom()->format('Y-m-d'); + + if ($a === $b) { + if (null === $a->getId()) { + return 1; + } + + if (null === $b->getId()) { + return -1; + } + + return $a->getId() <=> $b->getId(); + } + + return $validFromA <=> $validFromB; + }); + + return $addresses; + } + public function getCommentMembers(): CommentEmbeddable { return $this->commentMembers; @@ -358,24 +377,25 @@ class Household public function getMembersOnRange(DateTimeImmutable $from, ?DateTimeImmutable $to): Collection { - $criteria = new Criteria(); - $expr = Criteria::expr(); + return $this->getMembers()->filter(static function (HouseholdMember $m) use ($from, $to) { + if (null === $m->getEndDate() && null !== $to) { + return $m->getStartDate() <= $to; + } - $criteria->where( - $expr->gte('startDate', $from) - ); + if (null === $to) { + return $m->getStartDate() >= $from || null === $m->getEndDate(); + } - if (null !== $to) { - $criteria->andWhere( - $expr->orX( - $expr->lte('endDate', $to), - $expr->eq('endDate', null) - ), - ); - } + if (null !== $m->getEndDate() && $m->getEndDate() < $from) { + return false; + } - return $this->getMembers() - ->matching($criteria); + if ($m->getStartDate() <= $to) { + return true; + } + + return false; + }); } public function getNonCurrentMembers(?DateTimeImmutable $now = null): Collection @@ -418,6 +438,25 @@ class Household return $this->getNonCurrentMembers($now)->matching($criteria); } + public function getPreviousAddressOf(Address $address): ?Address + { + $iterator = new ArrayIterator($this->getAddressesOrdered()); + $iterator->rewind(); + + while ($iterator->valid()) { + $current = $iterator->current(); + $iterator->next(); + + if ($iterator->valid()) { + if ($iterator->current() === $address) { + return $current; + } + } + } + + return null; + } + public function getWaitingForBirth(): bool { return $this->waitingForBirth; @@ -462,6 +501,23 @@ class Household } while ($iterator->valid()); } + public function makeAddressConsistent(): void + { + $iterator = new ArrayIterator($this->getAddressesOrdered()); + + $iterator->rewind(); + + while ($iterator->valid()) { + $current = $iterator->current(); + + $iterator->next(); + + if ($iterator->valid()) { + $current->setValidTo($iterator->current()->getValidFrom()); + } + } + } + public function removeAddress(Address $address) { $this->addresses->removeElement($address); diff --git a/src/Bundle/ChillPersonBundle/Entity/Person.php b/src/Bundle/ChillPersonBundle/Entity/Person.php index e908dc489..49f7ae297 100644 --- a/src/Bundle/ChillPersonBundle/Entity/Person.php +++ b/src/Bundle/ChillPersonBundle/Entity/Person.php @@ -930,6 +930,8 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI /** * Get current accompanyingPeriods array. + * + * @return AccompanyingPeriod[]|array */ public function getCurrentAccompanyingPeriods(): array { diff --git a/src/Bundle/ChillPersonBundle/Event/Person/PersonAddressMoveEvent.php b/src/Bundle/ChillPersonBundle/Event/Person/PersonAddressMoveEvent.php new file mode 100644 index 000000000..2f4a1a92e --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Event/Person/PersonAddressMoveEvent.php @@ -0,0 +1,209 @@ +person = $person; + } + + /** + * Get the date of the move. + * + * It might be either: + * + * * the date of the new membership; + * * or the date of the move + * * or the date when the household leaving take place (the end date of the previous membership) + */ + public function getMoveDate(): DateTimeImmutable + { + if ($this->personLeaveWithoutHousehold()) { + return $this->getPreviousMembership()->getEndDate(); + } + + if ($this->personChangeHousehold()) { + return $this->getNextMembership()->getStartDate(); + } + + // person is changing address without household + return DateTimeImmutable::createFromMutable($this->getNextAddress()->getValidFrom()); + } + + public function getNextAddress(): ?Address + { + if (null !== $this->getNextMembership()) { + return $this->getNextMembership()->getHousehold() + ->getCurrentAddress( + $this->getMoveDate() === null ? null : + DateTime::createFromImmutable($this->getMoveDate()) + ); + } + + return $this->nextAddress; + } + + public function getNextHousehold(): ?Household + { + if (null !== $nextMembership = $this->getNextMembership()) { + return $nextMembership->getHousehold(); + } + + return null; + } + + public function getNextMembership(): ?HouseholdMember + { + return $this->nextMembership; + } + + public function getPerson(): Person + { + return $this->person; + } + + public function getPreviousAddress(): ?Address + { + if (null !== $this->getPreviousMembership()) { + return $this->getPreviousMembership()->getHousehold() + ->getCurrentAddress( + null === $this->getMoveDate() ? null : + DateTime::createFromImmutable($this->getMoveDate()) + ); + } + + return $this->previousAddress; + } + + public function getPreviousHousehold(): ?Household + { + if (null !== $previousMembership = $this->getPreviousMembership()) { + return $previousMembership->getHousehold(); + } + + return null; + } + + public function getPreviousMembership(): ?HouseholdMember + { + return $this->previousMembership; + } + + public function personChangeAddress(): bool + { + return $this->getPreviousAddress() !== $this->getNextAddress(); + } + + /** + * Return true if the user change household (this include the fact that a person + * leave household without a new one). + */ + public function personChangeHousehold(): bool + { + return $this->getPreviousHousehold() !== $this->getNextHousehold(); + } + + public function personLeaveWithoutHousehold(): bool + { + return null === $this->getNextMembership() + && null === $this->getNextAddress(); + } + + public function setNextAddress(?Address $nextAddress): PersonAddressMoveEvent + { + $this->nextAddress = $nextAddress; + + return $this; + } + + public function setNextMembership(?HouseholdMember $nextMembership): PersonAddressMoveEvent + { + $this->nextMembership = $nextMembership; + + return $this; + } + + public function setPreviousAddress(?Address $previousAddress): PersonAddressMoveEvent + { + $this->previousAddress = $previousAddress; + + return $this; + } + + public function setPreviousMembership(?HouseholdMember $previousMembership): PersonAddressMoveEvent + { + $this->previousMembership = $previousMembership; + + return $this; + } + + /** + * Will the change affect this date ? + */ + public function willChangeBeActiveAt(DateTimeImmutable $date): bool + { + if ($this->getMoveDate() < $date && $this->personLeaveWithoutHousehold()) { + return true; + } + + if ($this->personChangeHousehold()) { + if ($this->getMoveDate() > $date) { + return false; + } + + if (null === $this->getNextMembership()->getEndDate()) { + return true; + } + + if ($this->getNextMembership()->getEndDate() > $date) { + return true; + } + } else { + if ($this->getNextAddress()->getValidFrom() > $date) { + return false; + } + + if (null === $this->getNextAddress()->getValidTo()) { + return true; + } + + if ($this->getNextAddress()->getValidTo() > $date) { + return true; + } + } + + return false; + } +} diff --git a/src/Bundle/ChillPersonBundle/Form/PersonType.php b/src/Bundle/ChillPersonBundle/Form/PersonType.php index bafe21277..3e809ad49 100644 --- a/src/Bundle/ChillPersonBundle/Form/PersonType.php +++ b/src/Bundle/ChillPersonBundle/Form/PersonType.php @@ -12,11 +12,11 @@ declare(strict_types=1); namespace Chill\PersonBundle\Form; use Chill\CustomFieldsBundle\Form\Type\CustomFieldType; -use Chill\MainBundle\Entity\Civility; use Chill\MainBundle\Form\Type\ChillCollectionType; use Chill\MainBundle\Form\Type\ChillDateType; use Chill\MainBundle\Form\Type\ChillTextareaType; use Chill\MainBundle\Form\Type\CommentType; +use Chill\MainBundle\Form\Type\PickCivilityType; use Chill\MainBundle\Form\Type\Select2CountryType; use Chill\MainBundle\Form\Type\Select2LanguageType; use Chill\MainBundle\Templating\TranslatableStringHelper; @@ -27,9 +27,6 @@ use Chill\PersonBundle\Form\Type\GenderType; use Chill\PersonBundle\Form\Type\PersonAltNameType; use Chill\PersonBundle\Form\Type\PersonPhoneType; use Chill\PersonBundle\Form\Type\Select2MaritalStatusType; -use Doctrine\ORM\EntityRepository; -use Doctrine\ORM\QueryBuilder; -use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\CallbackTransformer; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; @@ -190,19 +187,10 @@ class PersonType extends AbstractType if ('visible' === $this->config['civility']) { $builder - ->add('civility', EntityType::class, [ - 'label' => 'Civility', - 'class' => Civility::class, - 'choice_label' => function (Civility $civility): string { - return $this->translatableStringHelper->localize($civility->getName()); - }, - 'query_builder' => static function (EntityRepository $er): QueryBuilder { - return $er->createQueryBuilder('c') - ->where('c.active = true') - ->orderBy('c.order'); - }, - 'placeholder' => 'choose civility', + ->add('civility', PickCivilityType::class, [ 'required' => false, + 'label' => 'Civility', + 'placeholder' => 'choose civility', ]); } diff --git a/src/Bundle/ChillPersonBundle/Household/MembersEditor.php b/src/Bundle/ChillPersonBundle/Household/MembersEditor.php index adc97a4ac..e327efbab 100644 --- a/src/Bundle/ChillPersonBundle/Household/MembersEditor.php +++ b/src/Bundle/ChillPersonBundle/Household/MembersEditor.php @@ -15,13 +15,14 @@ use Chill\PersonBundle\Entity\Household\Household; use Chill\PersonBundle\Entity\Household\HouseholdMember; use Chill\PersonBundle\Entity\Household\Position; use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\Event\Person\PersonAddressMoveEvent; use DateTimeImmutable; use Doctrine\Common\Collections\Criteria; use LogicException; use Symfony\Component\Validator\ConstraintViolationList; use Symfony\Component\Validator\ConstraintViolationListInterface; use Symfony\Component\Validator\Validator\ValidatorInterface; - +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use function in_array; use function spl_object_hash; @@ -33,6 +34,10 @@ class MembersEditor public const VALIDATION_GROUP_CREATED = 'household_memberships_created'; + private EventDispatcherInterface $eventDispatcher; + + private array $events = []; + private ?Household $household = null; private array $membershipsAffected = []; @@ -43,10 +48,11 @@ class MembersEditor private ValidatorInterface $validator; - public function __construct(ValidatorInterface $validator, ?Household $household) + public function __construct(ValidatorInterface $validator, ?Household $household, EventDispatcherInterface $eventDispatcher) { $this->validator = $validator; $this->household = $household; + $this->eventDispatcher = $eventDispatcher; } public function addMovement(DateTimeImmutable $date, Person $person, Position $position, ?bool $holder = false, ?string $comment = null): self @@ -55,6 +61,8 @@ class MembersEditor throw new LogicException('You must define a household first'); } + $event = new PersonAddressMoveEvent($person); + $membership = (new HouseholdMember()) ->setStartDate($date) ->setPerson($person) @@ -62,6 +70,7 @@ class MembersEditor ->setHolder($holder) ->setComment($comment); $this->household->addMember($membership); + $event->setNextMembership($membership); if ($position->getShareHousehold()) { foreach ($person->getHouseholdParticipationsShareHousehold() as $participation) { @@ -74,6 +83,7 @@ class MembersEditor } if ($participation->getEndDate() === null || $participation->getEndDate() > $date) { + $event->setPreviousMembership($participation); $participation->setEndDate($date); $this->membershipsAffected[] = $participation; $this->oldMembershipsHashes[] = spl_object_hash($participation); @@ -92,6 +102,7 @@ class MembersEditor $this->membershipsAffected[] = $membership; $this->persistables[] = $membership; + $this->events[] = $event; return $this; } @@ -129,6 +140,8 @@ class MembersEditor ->matching($criteria); foreach ($participations as $participation) { + $this->events[] = $event = new PersonAddressMoveEvent($person); + $event->setPreviousMembership($participation); $participation->setEndDate($date); $this->membershipsAffected[] = $participation; } @@ -136,6 +149,13 @@ class MembersEditor return $this; } + public function postMove(): void + { + foreach ($this->events as $event) { + $this->eventDispatcher->dispatch($event); + } + } + public function validate(): ConstraintViolationListInterface { if ($this->hasHousehold()) { diff --git a/src/Bundle/ChillPersonBundle/Household/MembersEditorFactory.php b/src/Bundle/ChillPersonBundle/Household/MembersEditorFactory.php index a26841064..7d1b19d51 100644 --- a/src/Bundle/ChillPersonBundle/Household/MembersEditorFactory.php +++ b/src/Bundle/ChillPersonBundle/Household/MembersEditorFactory.php @@ -13,16 +13,24 @@ namespace Chill\PersonBundle\Household; use Chill\PersonBundle\Entity\Household\Household; use Symfony\Component\Validator\Validator\ValidatorInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; class MembersEditorFactory { - public function __construct(ValidatorInterface $validator) - { + private EventDispatcherInterface $eventDispatcher; + + private ValidatorInterface $validator; + + public function __construct( + EventDispatcherInterface $eventDispatcher, + ValidatorInterface $validator + ) { $this->validator = $validator; + $this->eventDispatcher = $eventDispatcher; } public function createEditor(?Household $household = null): MembersEditor { - return new MembersEditor($this->validator, $household); + return new MembersEditor($this->validator, $household, $this->eventDispatcher); } } diff --git a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingPeriod/notification_location_user_on_period_has_moved.fr.txt.twig b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingPeriod/notification_location_user_on_period_has_moved.fr.txt.twig new file mode 100644 index 000000000..e4509d78a --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingPeriod/notification_location_user_on_period_has_moved.fr.txt.twig @@ -0,0 +1,22 @@ +{# + content of the notification if the person move and the person "localize" the period +#}{{ period.user.label }}, + +L'usager {{ oldPersonLocation|chill_entity_render_string }} a déménagé. + +Son adresse était utilisée pour localiser le parcours n°{{ period.id }}, dont vous êtes +le référent. + +En conséquence de ce déménage, le parcours est toujours localisé à cette adresse, mais à l'aide d'une +adresse temporaire. + +Si vous continuez à suivre le parcours, vous pouvez le localiser à nouveau auprès de l'adresse de +l'usager {{ oldPersonLocation|chill_entity_render_string }}. + +Sinon, veillez à vous assurer de la continuité du suivi par vos collègues. + +Pour visualiser le parcours, cliquez ici: + +{{ absolute_url(path('chill_person_accompanying_course_index', {'accompanying_period_id': period.id})) }} + +Cordialement, diff --git a/src/Bundle/ChillPersonBundle/Tests/AccompanyingPeriod/Events/PersonMoveEventSubscriberTest.php b/src/Bundle/ChillPersonBundle/Tests/AccompanyingPeriod/Events/PersonMoveEventSubscriberTest.php new file mode 100644 index 000000000..321d69aac --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/AccompanyingPeriod/Events/PersonMoveEventSubscriberTest.php @@ -0,0 +1,290 @@ +setStep(AccompanyingPeriod::STEP_CONFIRMED) + ->setPersonLocation($person) + ->addPerson($person); + $this->forceIdToPeriod($period); + + $previousHousehold = (new Household())->addAddress( + (new Address())->setValidFrom(new DateTime('1 year ago')) + ); + $previousMembership = new HouseholdMember(); + $previousMembership + ->setPerson($person) + ->setHousehold($previousHousehold) + ->setStartDate(new DateTimeImmutable('1 year ago')) + ->setEndDate(new DateTimeImmutable('tomorrow')); + + $nextHousehold = (new Household())->addAddress( + (new Address())->setValidFrom(new DateTime('tomorrow')) + ); + $nextMembership = new HouseholdMember(); + $nextMembership + ->setPerson($person) + ->setHousehold($nextHousehold) + ->setStartDate(new DateTimeImmutable('tomorrow')); + + $event = new PersonAddressMoveEvent($person); + $event + ->setPreviousMembership($previousMembership) + ->setNextMembership($nextMembership); + + $em = $this->prophesize(EntityManagerInterface::class); + $em->persist(Argument::type(Notification::class))->shouldNotBeCalled(); + $eventSubscriber = $this->buildSubscriber(null, $em->reveal(), null, null); + + $eventSubscriber->resetPeriodLocation($event); + } + + public function testEventChangeHouseholdNotification() + { + $person = new Person(); + $period = new AccompanyingPeriod(); + $period + ->setStep(AccompanyingPeriod::STEP_CONFIRMED) + ->setPersonLocation($person) + ->addPerson($person) + ->setUser(new User()); + $this->forceIdToPeriod($period); + + $previousHousehold = (new Household())->addAddress( + ($previousAddress = new Address())->setValidFrom(new DateTime('1 year ago')) + ); + $previousMembership = new HouseholdMember(); + $previousMembership + ->setPerson($person) + ->setHousehold($previousHousehold) + ->setStartDate(new DateTimeImmutable('1 year ago')) + ->setEndDate(new DateTimeImmutable('tomorrow')); + + $nextHousehold = (new Household())->addAddress( + (new Address())->setValidFrom(new DateTime('tomorrow')) + ); + $nextMembership = new HouseholdMember(); + $nextMembership + ->setPerson($person) + ->setHousehold($nextHousehold) + ->setStartDate(new DateTimeImmutable('tomorrow')); + + $event = new PersonAddressMoveEvent($person); + $event + ->setPreviousMembership($previousMembership) + ->setNextMembership($nextMembership); + + $em = $this->prophesize(EntityManagerInterface::class); + $em->persist(Argument::type(Notification::class))->shouldBeCalledTimes(1); + $eventSubscriber = $this->buildSubscriber(null, $em->reveal(), null, null); + + $eventSubscriber->resetPeriodLocation($event); + + $this->assertNotNull($period->getAddressLocation()); + $this->assertNull($period->getPersonLocation()); + } + + public function testEventChangeHouseholdNotificationForPeriodChangeLocationOfPersonAnteriorToCurrentLocationHistory() + { + $person = new Person(); + $period = new AccompanyingPeriod(); + $period + ->setStep(AccompanyingPeriod::STEP_CONFIRMED) + ->setPersonLocation($person) + ->setUser(new User()) + ->addPerson($person); + $this->forceIdToPeriod($period); + + $previousHousehold = (new Household())->addAddress( + ($previousAddress = new Address())->setValidFrom(new DateTime('1 year ago')) + ); + $previousMembership = new HouseholdMember(); + $previousMembership + ->setPerson($person) + ->setHousehold($previousHousehold) + ->setStartDate(new DateTimeImmutable('1 year ago')) + ->setEndDate(new DateTimeImmutable('tomorrow')); + + $nextHousehold = (new Household())->addAddress( + (new Address())->setValidFrom(new DateTime('1 month ago')) + ); + $nextMembership = new HouseholdMember(); + $nextMembership + ->setPerson($person) + ->setHousehold($nextHousehold) + ->setStartDate(new DateTimeImmutable('1 month ago')); + + $event = new PersonAddressMoveEvent($person); + $event + ->setPreviousMembership($previousMembership) + ->setNextMembership($nextMembership); + + $em = $this->prophesize(EntityManagerInterface::class); + $em->persist(Argument::type(Notification::class))->shouldBeCalled(1); + $eventSubscriber = $this->buildSubscriber(null, $em->reveal(), null, null); + + $eventSubscriber->resetPeriodLocation($event); + + $this->assertNotNull($period->getAddressLocation()); + $this->assertNull($period->getPersonLocation()); + } + + public function testEventLeaveNotification() + { + $person = new Person(); + $period = new AccompanyingPeriod(); + $period + ->setStep(AccompanyingPeriod::STEP_CONFIRMED) + ->setPersonLocation($person) + ->addPerson($person) + ->setUser(new User()); + $this->forceIdToPeriod($period); + + $previousHousehold = (new Household())->addAddress( + ($previousAddress = new Address())->setValidFrom(new DateTime('1 year ago')) + ); + $previousMembership = new HouseholdMember(); + $previousMembership + ->setPerson($person) + ->setHousehold($previousHousehold) + ->setStartDate(new DateTimeImmutable('1 year ago')) + ->setEndDate(new DateTimeImmutable('tomorrow')); + + $event = new PersonAddressMoveEvent($person); + $event + ->setPreviousMembership($previousMembership); + + $em = $this->prophesize(EntityManagerInterface::class); + $em->persist(Argument::type(Notification::class))->shouldBeCalledTimes(1); + $eventSubscriber = $this->buildSubscriber(null, $em->reveal(), null, null); + + $eventSubscriber->resetPeriodLocation($event); + + $this->assertNotNull($period->getAddressLocation()); + $this->assertNull($period->getPersonLocation()); + } + + public function testEventPersonChangeAddressInThePast() + { + $person = new Person(); + $period = new AccompanyingPeriod(); + $period + ->setStep(AccompanyingPeriod::STEP_CONFIRMED) + ->setPersonLocation($person) + ->addPerson($person) + ->setUser(new User()); + $this->forceIdToPeriod($period); + + $membership = new HouseholdMember(); + $membership + ->setPerson($person) + ->setHousehold($household = new Household()) + ->setStartDate(new DateTimeImmutable('1 year ago')); + + $previousAddress = new Address(); + $previousAddress->setValidFrom(new DateTime('6 months ago')); + $household->addAddress($previousAddress); + + $newAddress = new Address(); + $newAddress->setValidFrom(new DateTime('tomorrow')); + $household->addAddress($newAddress); + + $event = new PersonAddressMoveEvent($person); + $event + ->setPreviousAddress($household->getPreviousAddressOf($newAddress)) + ->setNextAddress($newAddress); + + $em = $this->prophesize(EntityManagerInterface::class); + $em->persist(Argument::type(Notification::class))->shouldBeCalledTimes(1); + $eventSubscriber = $this->buildSubscriber(null, $em->reveal(), null, null); + + $eventSubscriber->resetPeriodLocation($event); + + $this->assertNotNull($period->getAddressLocation()); + $this->assertNull($period->getPersonLocation()); + } + + private function buildSubscriber( + ?EngineInterface $engine = null, + ?EntityManagerInterface $entityManager = null, + ?Security $security = null, + ?TranslatorInterface $translator = null + ): PersonAddressMoveEventSubscriber { + if (null === $translator) { + $double = $this->prophesize(TranslatorInterface::class); + $translator = $double->reveal(); + } + + if (null === $security) { + $double = $this->prophesize(Security::class); + $double->getUser()->willReturn(new User()); + $security = $double->reveal(); + } + + if (null === $engine) { + $double = $this->prophesize(EngineInterface::class); + $engine = $double->reveal(); + } + + if (null === $entityManager) { + $double = $this->prophesize(EntityManagerInterface::class); + $entityManager = $double->reveal(); + } + + return new PersonAddressMoveEventSubscriber( + $engine, + $entityManager, + $security, + $translator + ); + } + + private function forceIdToPeriod(AccompanyingPeriod $period): void + { + $reflectionClass = new ReflectionClass($period); + $property = $reflectionClass->getProperty('id'); + $property->setAccessible(true); + $property->setValue($period, 0); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/Entity/AccompanyingPeriodTest.php b/src/Bundle/ChillPersonBundle/Tests/Entity/AccompanyingPeriodTest.php index 6c78d817d..1db356018 100644 --- a/src/Bundle/ChillPersonBundle/Tests/Entity/AccompanyingPeriodTest.php +++ b/src/Bundle/ChillPersonBundle/Tests/Entity/AccompanyingPeriodTest.php @@ -11,6 +11,8 @@ declare(strict_types=1); namespace Chill\PersonBundle\Tests\Entity; +use ArrayIterator; +use Chill\MainBundle\Entity\Address; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\AccompanyingPeriod\Comment; use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation; @@ -60,6 +62,62 @@ final class AccompanyingPeriodTest extends \PHPUnit\Framework\TestCase $this->assertFalse($period->isClosingAfterOpening()); } + public function testHistoryLocation() + { + $period = new AccompanyingPeriod(); + $person = new Person(); + $address = new Address(); + + $period->setPersonLocation($person); + + $this->assertCount(0, $period->getLocationHistories()); + + $period->setAddressLocation($address); + $period->setPersonLocation(null); + + $this->assertCount(0, $period->getLocationHistories()); + + $period->setStep(AccompanyingPeriod::STEP_CONFIRMED); + + $this->assertCount(1, $period->getLocationHistories()); + + $this->assertSame($address, $period->getLocationHistories()->first()->getAddressLocation()); + + $period->setPersonLocation($person); + $period->setAddressLocation(null); + + $this->assertCount(2, $period->getLocationHistories()); + $this->assertSame($person, $period->getLocationHistories()->last()->getPersonLocation()); + + $period->setAddressLocation($address); + $period->setPersonLocation(null); + + $this->assertCount(3, $period->getLocationHistories()); + + $locations = $period->getLocationHistories()->toArray(); + + usort($locations, static function (AccompanyingPeriod\AccompanyingPeriodLocationHistory $a, AccompanyingPeriod\AccompanyingPeriodLocationHistory $b) { + return $a->getStartDate() <=> $b->getStartDate(); + }); + + $iterator = new ArrayIterator($locations); + $iterator->rewind(); + + do { + $current = $iterator->current(); + + $iterator->next(); + + if ($iterator->valid()) { + $next = $iterator->current(); + $this->assertNotNull($current->getEndDate()); + $this->assertEquals($current->getEndDate(), $next->getStartDate()); + } else { + $this->assertNull($current->getEndDate()); + } + } while ($iterator->valid()); + } + public function testIsClosed() { $period = new AccompanyingPeriod(new DateTime()); diff --git a/src/Bundle/ChillPersonBundle/Tests/Entity/Household/HouseholdTest.php b/src/Bundle/ChillPersonBundle/Tests/Entity/Household/HouseholdTest.php index f63c12a2c..6799f882b 100644 --- a/src/Bundle/ChillPersonBundle/Tests/Entity/Household/HouseholdTest.php +++ b/src/Bundle/ChillPersonBundle/Tests/Entity/Household/HouseholdTest.php @@ -11,8 +11,12 @@ declare(strict_types=1); namespace Entity\Household; +use Chill\MainBundle\Entity\Address; use Chill\PersonBundle\Entity\Household\Household; use Chill\PersonBundle\Entity\Household\HouseholdComposition; +use Chill\PersonBundle\Entity\Household\HouseholdMember; +use Chill\PersonBundle\Entity\Person; +use DateTime; use DateTimeImmutable; use PHPUnit\Framework\TestCase; @@ -22,6 +26,90 @@ use PHPUnit\Framework\TestCase; */ final class HouseholdTest extends TestCase { + public function testGetMembersOnRange() + { + $household = new Household(); + + $household->addMember($householdMemberA = (new HouseholdMember()) + ->setStartDate(new DateTimeImmutable('2020-01-01')) + ->setEndDate(new DateTimeImmutable('2020-12-31')) + ->setPerson(new Person())); + $household->addMember($householdMemberB = (new HouseholdMember()) + ->setStartDate(new DateTimeImmutable('2020-06-01')) + ->setEndDate(new DateTimeImmutable('2021-06-31')) + ->setPerson(new Person())); + $household->addMember($householdMemberC = (new HouseholdMember()) + ->setStartDate(new DateTimeImmutable('2021-01-01')) + ->setEndDate(null) + ->setPerson(new Person())); + + $members = $household->getMembersOnRange(new DateTimeImmutable('2019-01-01'), null); + + $this->assertCount(3, $members); + $this->assertContains($householdMemberA, $members); + $this->assertContains($householdMemberB, $members); + $this->assertContains($householdMemberC, $members); + + $members = $household->getMembersOnRange(new DateTimeImmutable('2020-01-01'), new DateTimeImmutable('2020-07-01')); + $this->assertCount(2, $members); + $this->assertContains($householdMemberA, $members); + $this->assertContains($householdMemberB, $members); + $this->assertNotContains($householdMemberC, $members); + + $members = $household->getMembersOnRange(new DateTimeImmutable('2020-01-01'), new DateTimeImmutable('2022-12-31')); + $this->assertCount(3, $members); + $this->assertContains($householdMemberA, $members); + $this->assertContains($householdMemberB, $members); + $this->assertContains($householdMemberC, $members); + + $members = $household->getMembersOnRange(new DateTimeImmutable('2021-01-01'), new DateTimeImmutable('2022-12-31')); + $this->assertCount(2, $members); + $this->assertNotContains($householdMemberA, $members); + $this->assertContains($householdMemberB, $members); + $this->assertContains($householdMemberC, $members); + } + + public function testHouseholdAddressConsistent() + { + $household = new Household(); + + $lastAddress = new Address(); + $lastAddress->setValidFrom($yesterday = new DateTime('yesterday')); + $household->addAddress($lastAddress); + + $this->assertNull($lastAddress->getValidTo()); + $this->assertEquals($yesterday, $lastAddress->getValidFrom()); + + $previousAddress = new Address(); + $previousAddress->setValidFrom($oneMonthAgo = new DateTime('1 month ago')); + $household->addAddress($previousAddress); + + $addresses = $household->getAddressesOrdered(); + $this->assertSame($previousAddress, $addresses[0]); + $this->assertSame($lastAddress, $addresses[1]); + + $this->assertEquals($oneMonthAgo, $previousAddress->getValidFrom()); + $this->assertEquals($yesterday, $previousAddress->getValidTo()); + $this->assertEquals($yesterday, $lastAddress->getValidFrom()); + $this->assertNull($lastAddress->getValidTo()); + + $futureAddress = new Address(); + $futureAddress->setValidFrom($tomorrow = new DateTime('tomorrow')); + $household->addAddress($futureAddress); + + $addresses = $household->getAddressesOrdered(); + $this->assertSame($previousAddress, $addresses[0]); + $this->assertSame($lastAddress, $addresses[1]); + $this->assertSame($futureAddress, $addresses[2]); + + $this->assertEquals($oneMonthAgo, $previousAddress->getValidFrom()); + $this->assertEquals($yesterday, $previousAddress->getValidTo()); + $this->assertEquals($yesterday, $lastAddress->getValidFrom()); + $this->assertEquals($tomorrow, $lastAddress->getValidTo()); + $this->assertEquals($tomorrow, $futureAddress->getValidFrom()); + $this->assertNull($futureAddress->getValidTo()); + } + public function testHouseholdComposition() { $household = new Household(); diff --git a/src/Bundle/ChillPersonBundle/Tests/Event/Person/PersonAddressMoveEventTest.php b/src/Bundle/ChillPersonBundle/Tests/Event/Person/PersonAddressMoveEventTest.php new file mode 100644 index 000000000..65d10f68a --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Event/Person/PersonAddressMoveEventTest.php @@ -0,0 +1,118 @@ +addAddress( + ($previousAddress = new Address())->setValidFrom(new DateTime('1 year ago')) + ); + $household->addAddress( + ($nextAddress = new Address())->setValidFrom(new DateTime('1 month ago')) + ); + $member = new HouseholdMember(); + $member + ->setPerson($person) + ->setHousehold($household) + ->setStartDate(new DateTimeImmutable('1 year ago')) + ->setEndDate(new DateTimeImmutable('tomorrow')); + + $event = new PersonAddressMoveEvent($person); + $event + ->setPreviousAddress($previousAddress) + ->setNextAddress($nextAddress); + + $this->assertSame($previousAddress, $event->getPreviousAddress()); + $this->assertSame($nextAddress, $event->getNextAddress()); + $this->assertEquals((new DateTime('1 month ago'))->format('Y-m-d'), $nextAddress->getValidFrom()->format('Y-m-d')); + $this->assertEquals((new DateTime('1 month ago'))->format('Y-m-d'), $event->getMoveDate()->format('Y-m-d')); + } + + public function testPersonChangeHousehold() + { + $person = new Person(); + + $previousHousehold = (new Household())->addAddress( + ($previousAddress = new Address())->setValidFrom(new DateTime('1 year ago')) + ); + $previousMembership = new HouseholdMember(); + $previousMembership + ->setPerson($person) + ->setHousehold($previousHousehold) + ->setStartDate(new DateTimeImmutable('1 year ago')) + ->setEndDate(new DateTimeImmutable('tomorrow')); + + $nextHousehold = (new Household())->addAddress( + ($nextAddress = new Address())->setValidFrom(new DateTime('tomorrow')) + ); + $nextMembership = new HouseholdMember(); + $nextMembership + ->setPerson($person) + ->setHousehold($nextHousehold) + ->setStartDate(new DateTimeImmutable('tomorrow')); + + $event = new PersonAddressMoveEvent($person); + $event + ->setPreviousMembership($previousMembership) + ->setNextMembership($nextMembership); + + $this->assertTrue($event->personChangeHousehold()); + $this->assertSame($previousAddress, $event->getPreviousAddress()); + $this->assertSame($nextAddress, $event->getNextAddress()); + $this->assertTrue($event->personChangeAddress()); + $this->assertFalse($event->personLeaveWithoutHousehold()); + $this->assertEquals(new DateTimeImmutable('tomorrow'), $event->getMoveDate()); + } + + public function testPersonLeaveHousehold() + { + $person = new Person(); + + $previousHousehold = (new Household())->addAddress( + ($previousAddress = new Address())->setValidFrom(new DateTime('1 year ago')) + ); + $previousMembership = new HouseholdMember(); + $previousMembership + ->setPerson($person) + ->setHousehold($previousHousehold) + ->setStartDate(new DateTimeImmutable('1 year ago')) + ->setEndDate(new DateTimeImmutable('tomorrow')); + + $event = new PersonAddressMoveEvent($person); + $event + ->setPreviousMembership($previousMembership); + + $this->assertTrue($event->personChangeHousehold()); + $this->assertSame($previousAddress, $event->getPreviousAddress()); + $this->assertNull($event->getNextAddress()); + $this->assertTrue($event->personChangeAddress()); + $this->assertTrue($event->personLeaveWithoutHousehold()); + $this->assertEquals(new DateTimeImmutable('tomorrow'), $event->getMoveDate()); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/Household/MembersEditorTest.php b/src/Bundle/ChillPersonBundle/Tests/Household/MembersEditorTest.php index f3e1424fb..65199dabf 100644 --- a/src/Bundle/ChillPersonBundle/Tests/Household/MembersEditorTest.php +++ b/src/Bundle/ChillPersonBundle/Tests/Household/MembersEditorTest.php @@ -14,10 +14,14 @@ namespace Chill\PersonBundle\Tests\Household; use Chill\PersonBundle\Entity\Household\Household; use Chill\PersonBundle\Entity\Household\Position; use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\Event\Person\PersonAddressMoveEvent; use Chill\PersonBundle\Household\MembersEditorFactory; use DateTimeImmutable; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\Validator\Validator\ValidatorInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use function count; /** @@ -26,13 +30,13 @@ use function count; */ final class MembersEditorTest extends TestCase { + use ProphecyTrait; + private MembersEditorFactory $factory; protected function setUp(): void { - $validator = $this->createMock(ValidatorInterface::class); - - $this->factory = new MembersEditorFactory($validator); + $this->factory = $this->buildMembersEditorFactory(); } public function testMovePersonWithoutSharedHousehold() @@ -121,4 +125,49 @@ final class MembersEditorTest extends TestCase ); $this->assertEquals($date, $membership1->getEndDate()); } + + public function testPostMove() + { + $person = new Person(); + $position = (new Position()) + ->setShareHousehold(false); + $household1 = new Household(); + $household2 = new Household(); + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher + ->dispatch(Argument::type(PersonAddressMoveEvent::class)) + ->shouldBeCalled(); + $factory = $this->buildMembersEditorFactory( + $eventDispatcher->reveal(), + null + ); + $editor = $factory->createEditor($household1); + + $editor->addMovement(new DateTimeImmutable('now'), $person, $position); + + $editor->postMove(); + } + + private function buildMembersEditorFactory( + ?EventDispatcherInterface $eventDispatcher = null, + ?ValidatorInterface $validator = null + ) { + if (null === $eventDispatcher) { + $double = $this->getProphet()->prophesize(); + $double->willImplement(EventDispatcherInterface::class); + $double->dispatch(Argument::type(PersonAddressMoveEvent::class)); + $eventDispatcher = $double->reveal(); + } + + if (null === $validator) { + $double = $this->getProphet()->prophesize(); + $double->willImplement(ValidatorInterface::class); + $validator = $double->reveal(); + } + + return new MembersEditorFactory( + $eventDispatcher, + $validator + ); + } } diff --git a/src/Bundle/ChillPersonBundle/migrations/Version20220214200327.php b/src/Bundle/ChillPersonBundle/migrations/Version20220214200327.php new file mode 100644 index 000000000..995cb30c7 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/migrations/Version20220214200327.php @@ -0,0 +1,49 @@ +addSql('DROP SEQUENCE chill_person_accompanying_period_location_history_id_seq CASCADE'); + $this->addSql('DROP TABLE chill_person_accompanying_period_location_history'); + } + + public function getDescription(): string + { + return 'Add location history to period'; + } + + public function up(Schema $schema): void + { + $this->addSql('CREATE SEQUENCE chill_person_accompanying_period_location_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE chill_person_accompanying_period_location_history (id INT NOT NULL, period_id INT DEFAULT NULL, startDate DATE NOT NULL, endDate DATE DEFAULT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, personLocation_id INT DEFAULT NULL, addressLocation_id INT DEFAULT NULL, createdBy_id INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_61E4E688EC8B7ADE ON chill_person_accompanying_period_location_history (period_id)'); + $this->addSql('CREATE INDEX IDX_61E4E688D5213D34 ON chill_person_accompanying_period_location_history (personLocation_id)'); + $this->addSql('CREATE INDEX IDX_61E4E6889B07D6BF ON chill_person_accompanying_period_location_history (addressLocation_id)'); + $this->addSql('CREATE INDEX IDX_61E4E6883174800F ON chill_person_accompanying_period_location_history (createdBy_id)'); + $this->addSql('COMMENT ON COLUMN chill_person_accompanying_period_location_history.startDate IS \'(DC2Type:date_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_person_accompanying_period_location_history.endDate IS \'(DC2Type:date_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_person_accompanying_period_location_history.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_person_accompanying_period_location_history ADD CONSTRAINT FK_61E4E688EC8B7ADE FOREIGN KEY (period_id) REFERENCES chill_person_accompanying_period (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_person_accompanying_period_location_history ADD CONSTRAINT FK_61E4E688D5213D34 FOREIGN KEY (personLocation_id) REFERENCES chill_person_person (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_person_accompanying_period_location_history ADD CONSTRAINT FK_61E4E6889B07D6BF FOREIGN KEY (addressLocation_id) REFERENCES chill_main_address (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_person_accompanying_period_location_history ADD CONSTRAINT FK_61E4E6883174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('INSERT INTO chill_person_accompanying_period_location_history (id, period_id, startDate, createdAt, personLocation_id, addresslocation_id) + SELECT nextval(\'chill_person_accompanying_period_location_history_id_seq\'), id, NOW(), NOW(), personlocation_id, addresslocation_id FROM chill_person_accompanying_period WHERE step != \'DRAFT\' + '); + } +} diff --git a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml index 1098729ca..3a45da7e1 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml @@ -548,6 +548,7 @@ period_notification: Persons are: Les usagers concernés sont les suivants Social issues are: Les problématiques sociales renseignées sont les suivantes See it online: Visualisez le parcours en ligne + Person locating period has moved: L'usager qui localise un parcours a déménagé You are getting a notification for a period which does not exists any more: Cette notification ne correspond pas à une période d'accompagnement valide. You are getting a notification for a period you are not allowed to see: La notification fait référence à une période d'accompagnement à laquelle vous n'avez pas accès. diff --git a/src/Bundle/ChillThirdPartyBundle/Entity/ThirdParty.php b/src/Bundle/ChillThirdPartyBundle/Entity/ThirdParty.php index 390427d74..e8f7e292f 100644 --- a/src/Bundle/ChillThirdPartyBundle/Entity/ThirdParty.php +++ b/src/Bundle/ChillThirdPartyBundle/Entity/ThirdParty.php @@ -45,6 +45,40 @@ use function spl_object_hash; * all users with the right 'CHILL_3PARTY_3PARTY_SEE', 'CHILL_3PARTY_3 to see, select and edit parties for this * center. * + * A ThirdParty may have: + * + * * 0, one or more categories; + * * 0, one or more type + * * 1 kind. + * + * ## Kind + * + * The kind indicate if a thirdparty is a: + * + * * company ("personne morale"); + * * a contact ("personne morale") + * * a child inside a company ("contact" in French). Only companies may have childs + * + * **take care** the french translation may lead to misinterpretation, as the string "contact" is the translation of + * kind "child". + * + * ## Categories and types + * + * ThirdParty may have zero, one or more categories and/or type. + * + * Categories are managed in the database. The Chill administrator may create or desactivate categories. + * + * Types are also a way to categorize the thirdparties, but this is done **into the code**, through + * @link{Chill\ThirdPartyBundle\ThirdPartyType\ThirdPartyTypeProviderInterface}. The type is stored into the + * database by a Php array, mapped by a jsonb into the database. This has one advantage: it is easily searchable. + * + * As the list of type is hardcoded into database, it is more easily searchable. (for chill 2.0, the + * @link{Chill\ThirdPartyBundle\Form\Type\PickThirdPartyDynamicType} does not support it yet, but + * the legacy @link{Chill\ThirdPartyBundle\Form\Type\PickThirdPartyType} does. + * + * The difference between categories and types is transparent for user: they choose the same fields into the UI, without + * noticing a difference. + * * @ORM\Entity * @ORM\Table(name="chill_3party.third_party") * @DiscriminatorMap(typeProperty="type", mapping={ diff --git a/src/Bundle/ChillThirdPartyBundle/Form/ThirdPartyType.php b/src/Bundle/ChillThirdPartyBundle/Form/ThirdPartyType.php index b4ab8655d..7ca5188b1 100644 --- a/src/Bundle/ChillThirdPartyBundle/Form/ThirdPartyType.php +++ b/src/Bundle/ChillThirdPartyBundle/Form/ThirdPartyType.php @@ -11,11 +11,11 @@ declare(strict_types=1); namespace Chill\ThirdPartyBundle\Form; -use Chill\MainBundle\Entity\Civility; use Chill\MainBundle\Form\Type\ChillCollectionType; use Chill\MainBundle\Form\Type\ChillTextareaType; use Chill\MainBundle\Form\Type\PickAddressType; use Chill\MainBundle\Form\Type\PickCenterType; +use Chill\MainBundle\Form\Type\PickCivilityType; use Chill\MainBundle\Security\Authorization\AuthorizationHelper; use Chill\MainBundle\Templating\TranslatableStringHelper; use Chill\ThirdPartyBundle\Entity\ThirdParty; @@ -101,16 +101,8 @@ class ThirdPartyType extends AbstractType // Contact Person ThirdParty (child) if (ThirdParty::KIND_CONTACT === $options['kind'] || ThirdParty::KIND_CHILD === $options['kind']) { $builder - ->add('civility', EntityType::class, [ + ->add('civility', PickCivilityType::class, [ 'label' => 'thirdparty.Civility', - 'class' => Civility::class, - 'choice_label' => function (Civility $civility): string { - return $this->translatableStringHelper->localize($civility->getName()); - }, - 'query_builder' => static function (EntityRepository $er): QueryBuilder { - return $er->createQueryBuilder('c') - ->where('c.active = true'); - }, 'placeholder' => 'thirdparty.choose civility', 'required' => false, ])