[Ticket] Add documents to Motive

This commit is contained in:
Julien Fastré 2025-07-18 14:55:12 +00:00
parent 1b74c119dc
commit 1a66a9e864
14 changed files with 264 additions and 121 deletions

View File

@ -1,5 +1,5 @@
chill_doc_store: chill_doc_store:
use_driver: openstack use_driver: local_storage
local_storage: local_storage:
storage_path: '%kernel.project_dir%/var/storage' storage_path: '%kernel.project_dir%/var/storage'
openstack: openstack:

View File

@ -43,11 +43,17 @@ class StoredObjectVersionNormalizer implements NormalizerInterface, NormalizerAw
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, [...$context, UserNormalizer::AT_DATE => $object->getCreatedAt()]), 'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, [...$context, UserNormalizer::AT_DATE => $object->getCreatedAt()]),
]; ];
if (in_array(self::WITH_POINT_IN_TIMES_CONTEXT, $context[AbstractNormalizer::GROUPS] ?? [], true)) { $normalizationGroups = $context[AbstractNormalizer::GROUPS] ?? [];
if (is_string($normalizationGroups)) {
$normalizationGroups = [$normalizationGroups];
}
if (in_array(self::WITH_POINT_IN_TIMES_CONTEXT, $normalizationGroups, true)) {
$data['point-in-times'] = $this->normalizer->normalize($object->getPointInTimes(), $format, $context); $data['point-in-times'] = $this->normalizer->normalize($object->getPointInTimes(), $format, $context);
} }
if (in_array(self::WITH_RESTORED_CONTEXT, $context[AbstractNormalizer::GROUPS] ?? [], true)) { if (in_array(self::WITH_RESTORED_CONTEXT, $normalizationGroups, true)) {
$data['from-restored'] = $this->normalizer->normalize($object->getCreatedFrom(), $format, [AbstractNormalizer::GROUPS => ['read']]); $data['from-restored'] = $this->normalizer->normalize($object->getCreatedFrom(), $format, [AbstractNormalizer::GROUPS => ['read']]);
} }

View File

@ -18,12 +18,12 @@ use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Clock\ClockInterface; use Symfony\Component\Clock\ClockInterface;
final readonly class ReplaceMotiveCommandHandler class ReplaceMotiveCommandHandler
{ {
public function __construct( public function __construct(
private ClockInterface $clock, private readonly ClockInterface $clock,
private EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private ChangeEmergencyStateCommandHandler $changeEmergencyStateCommandHandler, private readonly ChangeEmergencyStateCommandHandler $changeEmergencyStateCommandHandler,
) {} ) {}
public function handle(Ticket $ticket, ReplaceMotiveCommand $command): void public function handle(Ticket $ticket, ReplaceMotiveCommand $command): void

View File

@ -60,7 +60,7 @@ final readonly class ReplaceMotiveController
$this->entityManager->flush(); $this->entityManager->flush();
return new JsonResponse( return new JsonResponse(
$this->serializer->serialize($ticket, 'json', ['groups' => 'read']), $this->serializer->serialize($ticket, 'json', ['groups' => ['read']]),
Response::HTTP_CREATED, Response::HTTP_CREATED,
[], [],
true true

View File

@ -11,6 +11,8 @@ declare(strict_types=1);
namespace Chill\TicketBundle\DataFixtures\ORM; namespace Chill\TicketBundle\DataFixtures\ORM;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\TicketBundle\Entity\EmergencyStatusEnum; use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\Motive;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
@ -19,6 +21,8 @@ use Doctrine\Persistence\ObjectManager;
final class LoadMotives extends Fixture implements FixtureGroupInterface final class LoadMotives extends Fixture implements FixtureGroupInterface
{ {
public function __construct(private readonly StoredObjectManagerInterface $storedObjectManager) {}
public static function getGroups(): array public static function getGroups(): array
{ {
return ['ticket']; return ['ticket'];
@ -26,6 +30,12 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface
public function load(ObjectManager $manager) public function load(ObjectManager $manager)
{ {
$docs = [
['label' => '🌙 De 21h à 07h du matin', 'path' => __DIR__.'/docs/peloton_1.pdf'],
['label' => '☀️ De 07h à 21h', 'path' => __DIR__.'/docs/peloton_2.pdf'],
['label' => 'Dimanche et jours fériés', 'path' => __DIR__.'/docs/schema_1.png'],
];
foreach (explode("\n", self::MOTIVES) as $row) { foreach (explode("\n", self::MOTIVES) as $row) {
if ('' === trim($row)) { if ('' === trim($row)) {
continue; continue;
@ -44,7 +54,26 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface
default => throw new \UnexpectedValueException('Unexpected value'), default => throw new \UnexpectedValueException('Unexpected value'),
}); });
foreach (array_slice($data, 2) as $supplementaryComment) { $numberOfDocs = (int) $data[2];
for ($i = 1; $i <= $numberOfDocs; ++$i) {
$doc = $docs[$i - 1];
$storedObject = new StoredObject();
$storedObject->setTitle($doc['label']);
$content = file_get_contents($doc['path']);
$contentType = match (substr($doc['path'], -3, 3)) {
'pdf' => 'application/pdf',
'png' => 'image/png',
default => throw new \UnexpectedValueException('Not supported content type here'),
};
$this->storedObjectManager->write($storedObject, $content, $contentType);
$motive->addStoredObject($storedObject);
$manager->persist($storedObject);
}
foreach (array_slice($data, 3) as $supplementaryComment) {
if ('' !== trim((string) $supplementaryComment)) { if ('' !== trim((string) $supplementaryComment)) {
$motive->addSupplementaryComment(['label' => trim((string) $supplementaryComment)]); $motive->addSupplementaryComment(['label' => trim((string) $supplementaryComment)]);
} }
@ -56,43 +85,43 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface
} }
private const MOTIVES = <<<'CSV' private const MOTIVES = <<<'CSV'
"Coordonnées",false,"Nouvelles coordonnées", "Coordonnées",false,"3","Nouvelles coordonnées",
"Horaire de passage",false, "Horaire de passage",false,"0",
"Retard de livraison",false, "Retard de livraison",false,"0",
"Erreur de livraison",false, "Erreur de livraison",false,"0",
"Colis incomplet",false, "Colis incomplet",false,"0",
"MATLOC",false, "MATLOC",false,"0",
"Retard DASRI",false, "Retard DASRI",false,"1",
"Planning d'astreintes",false, "Planning d'astreintes",false,"0",
"Planning des tournées",false, "Planning des tournées",false,"0",
"Contrôle pompe",true, "Contrôle pompe",true,"0",
"Changement de rendez-vous",false,"Date du nouveau rendez-vous","Lieu du nouveau rendez-vous", "Changement de rendez-vous",false,"0","Date du nouveau rendez-vous","Lieu du nouveau rendez-vous",
"Renseignement facturation/prestation",false, "Renseignement facturation/prestation",false,"0",
"Décès patient",false,"Date et heures du décès","Autorisation préalable du médecin pour le décès", "Décès patient",false,"0","Date et heures du décès","Autorisation préalable du médecin pour le décès",
"Demande de prise en charge",false, "Demande de prise en charge",false,"0",
"Information absence",false, "Information absence",false,"0",
"Demande bulletin de situation",false, "Demande bulletin de situation",false,"0",
"Difficultés accès logement",false, "Difficultés accès logement",false,"0",
"Déplacement inutile",false, "Déplacement inutile",false,"0",
"Problème de prélèvement/de commande",false, "Problème de prélèvement/de commande",false,"0",
"Parc auto",false, "Parc auto",false,"0",
"Demande d'admission",false, "Demande d'admission",false,"0",
"Retrait de matériel au domicile",false, "Retrait de matériel au domicile",false,"0",
"Comptes-rendus",false, "Comptes-rendus",false,"0",
"Démarchage commercial",false, "Démarchage commercial",false,"0",
"Demande de transport",false, "Demande de transport",false,"0",
"Demande laboratoire",false, "Demande laboratoire",false,"0",
"Demande admission",false, "Demande admission",false,"0",
"Suivi de prise en charge",false, "Suivi de prise en charge",false,"0",
"Mauvaise adresse",false, "Mauvaise adresse",false,"0",
"Patient absent",false, "Patient absent",false,"0",
"Annulation",false, "Annulation",false,"0",
"Colis perdu",false, "Colis perdu",false,"0",
"Changement de rendez-vous",false, "Changement de rendez-vous",false,"0",
"Coordination interservices",false, "Coordination interservices",false,"0",
"Problème de substitution produits",true, "Problème de substitution produits",true,"0",
"Problème ordonnance",false, "Problème ordonnance",false,"3",
"Réclamations facture",false,"Numéro de facture concerné", "Réclamations facture",false,"0","Numéro de facture concerné",
"Préparation urgente",true, "Préparation urgente",true,"3",
CSV; CSV;
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@ -11,6 +11,10 @@ declare(strict_types=1);
namespace Chill\TicketBundle\Entity; namespace Chill\TicketBundle\Entity;
use Chill\DocStoreBundle\Entity\StoredObject;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer; use Symfony\Component\Serializer\Annotation as Serializer;
@ -37,10 +41,40 @@ class Motive
#[Serializer\Groups(['read'])] #[Serializer\Groups(['read'])]
private ?EmergencyStatusEnum $makeTicketEmergency = null; private ?EmergencyStatusEnum $makeTicketEmergency = null;
/**
* @var Collection<int, StoredObject>
*/
#[ORM\ManyToMany(targetEntity: StoredObject::class)]
#[ORM\JoinTable(name: 'motive_stored_objects', schema: 'chill_ticket')]
private Collection $storedObjects;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['jsonb' => true, 'default' => '[]'])] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['jsonb' => true, 'default' => '[]'])]
#[Serializer\Groups(['read'])] #[Serializer\Groups(['read'])]
private array $supplementaryComments = []; private array $supplementaryComments = [];
public function __construct()
{
$this->storedObjects = new ArrayCollection();
}
public function addStoredObject(StoredObject $storedObject): void
{
if (!$this->storedObjects->contains($storedObject)) {
$this->storedObjects[] = $storedObject;
}
}
public function removeStoredObject(StoredObject $storedObject): void
{
$this->storedObjects->removeElement($storedObject);
}
#[Serializer\Groups(['read'])]
public function getStoredObjects(): ReadableCollection
{
return $this->storedObjects;
}
public function isActive(): bool public function isActive(): bool
{ {
return $this->active; return $this->active;

View File

@ -11,6 +11,8 @@ declare(strict_types=1);
namespace Chill\TicketBundle\Repository; namespace Chill\TicketBundle\Repository;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\Motive;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
@ -18,10 +20,22 @@ use Doctrine\Persistence\ManagerRegistry;
/** /**
* @template-extends ServiceEntityRepository<Motive> * @template-extends ServiceEntityRepository<Motive>
*/ */
class MotiveRepository extends ServiceEntityRepository class MotiveRepository extends ServiceEntityRepository implements AssociatedEntityToStoredObjectInterface
{ {
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)
{ {
parent::__construct($registry, Motive::class); parent::__construct($registry, Motive::class);
} }
public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?Motive
{
$query = $this->createQueryBuilder('m');
$query->select('m')
->where(':stored_object MEMBER OF m.storedObjects')
->setParameter('stored_object', $storedObject)
->setMaxResults(1)
;
return $query->getQuery()->getOneOrNullResult();
}
} }

View File

@ -0,0 +1,33 @@
<?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\TicketBundle\Security\Authorization\StoredObjectVoter;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface;
use Chill\TicketBundle\Repository\MotiveRepository;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
final readonly class MotiveStoredObjectVoter implements StoredObjectVoterInterface
{
public function __construct(private MotiveRepository $motiveRepository) {}
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool
{
return null !== $this->motiveRepository->findAssociatedEntityToStoredObject($subject);
}
public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool
{
return true;
}
}

View File

@ -20,6 +20,9 @@ services:
Chill\TicketBundle\Security\Voter\: Chill\TicketBundle\Security\Voter\:
resource: '../Security/Voter/' resource: '../Security/Voter/'
Chill\TicketBundle\Security\Authorization\:
resource: '../Security/Authorization/'
Chill\TicketBundle\Serializer\: Chill\TicketBundle\Serializer\:
resource: '../Serializer/' resource: '../Serializer/'

View File

@ -0,0 +1,51 @@
<?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\Ticket;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250718124651 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create join table "chill_ticket.motive_stored_objects"';
}
public function up(Schema $schema): void
{
$this->addSql(
<<<'SQL'
CREATE TABLE chill_ticket.motive_stored_objects (motive_id INT NOT NULL, storedobject_id INT NOT NULL, PRIMARY KEY(motive_id, storedobject_id))
SQL
);
$this->addSql('CREATE INDEX IDX_4247C4849658649C ON chill_ticket.motive_stored_objects (motive_id)');
$this->addSql('CREATE INDEX IDX_4247C484EE684399 ON chill_ticket.motive_stored_objects (storedobject_id)');
$this->addSql(
<<<'SQL'
ALTER TABLE chill_ticket.motive_stored_objects ADD CONSTRAINT FK_4247C4849658649C FOREIGN KEY (motive_id) REFERENCES chill_ticket.motive (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
SQL
);
$this->addSql(
<<<'SQL'
ALTER TABLE chill_ticket.motive_stored_objects ADD CONSTRAINT FK_4247C484EE684399 FOREIGN KEY (storedobject_id) REFERENCES chill_doc.stored_object (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
SQL
);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_ticket.motive_stored_objects DROP CONSTRAINT FK_4247C4849658649C');
$this->addSql('ALTER TABLE chill_ticket.motive_stored_objects DROP CONSTRAINT FK_4247C484EE684399');
$this->addSql('DROP TABLE chill_ticket.motive_stored_objects');
}
}

View File

@ -11,20 +11,19 @@ declare(strict_types=1);
namespace Chill\TicketBundle\Tests\Controller; namespace Chill\TicketBundle\Tests\Controller;
use Chill\TicketBundle\Action\Ticket\Handler\ChangeEmergencyStateCommandHandler;
use Chill\TicketBundle\Action\Ticket\Handler\ReplaceMotiveCommandHandler; use Chill\TicketBundle\Action\Ticket\Handler\ReplaceMotiveCommandHandler;
use Chill\TicketBundle\Controller\ReplaceMotiveController; use Chill\TicketBundle\Action\Ticket\ReplaceMotiveCommand;
use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\Motive;
use Chill\TicketBundle\Entity\MotiveHistory; use PHPUnit\Framework\TestCase;
use Chill\TicketBundle\Controller\ReplaceMotiveController;
use Chill\TicketBundle\Entity\Ticket; use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Validator\Validator\ValidatorInterface;
/** /**
@ -32,88 +31,62 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
* *
* @coversNothing * @coversNothing
*/ */
class ReplaceMotiveControllerTest extends KernelTestCase class ReplaceMotiveControllerTest extends TestCase
{ {
use ProphecyTrait; use ProphecyTrait;
private SerializerInterface $serializer; private function buildController(Ticket $ticket, string $body, Motive $motive): ReplaceMotiveController
private ValidatorInterface $validator;
protected function setUp(): void
{ {
self::bootKernel(); $command = new ReplaceMotiveCommand($motive);
$this->serializer = self::getContainer()->get(SerializerInterface::class);
$this->validator = self::getContainer()->get(ValidatorInterface::class); // Mock Security
$security = $this->prophesize(Security::class);
$security->isGranted('ROLE_USER')->willReturn(true);
// Mock EntityManager
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->flush()->shouldBeCalled();
$replaceMotiveCommandHandler = $this->prophesize(ReplaceMotiveCommandHandler::class);
$replaceMotiveCommandHandler->handle($ticket, $command)->shouldBeCalled();
// Mock Validator
$validator = $this->prophesize(ValidatorInterface::class);
$validator->validate($command)
->shouldBeCalled()
->willReturn(new ConstraintViolationList([]));
$serializer = $this->prophesize(SerializerInterface::class);
$serializer->deserialize($body, ReplaceMotiveCommand::class, 'json', [AbstractNormalizer::GROUPS => ['write']])
->willReturn($command);
$serializer->serialize($ticket, 'json', ['groups' => ['read']])
->shouldBeCalled()
->willReturn('{"type": "ticket", "id": 1}');
return new ReplaceMotiveController(
$security->reveal(),
$replaceMotiveCommandHandler->reveal(),
$serializer->reveal(),
$validator->reveal(),
$entityManager->reveal()
);
} }
protected function tearDown(): void public function testAddValidMotive(): void
{
self::ensureKernelShutdown();
}
/**
* @dataProvider generateMotiveId
*/
public function testAddValidMotive(int $motiveId): void
{ {
$ticket = new Ticket(); $ticket = new Ticket();
$payload = <<<JSON $motive = new Motive();
{"motive": {"type": "ticket_motive", "id": {$motiveId}}}
$payload = <<<'JSON'
{"motive": {"type": "ticket_motive", "id": 1}}
JSON; JSON;
$request = new Request(content: $payload); $request = new Request(content: $payload);
$controller = $this->buildController(); $controller = $this->buildController($ticket, $payload, $motive);
$response = $controller($ticket, $request); $response = $controller($ticket, $request);
self::assertEquals(201, $response->getStatusCode()); self::assertEquals(201, $response->getStatusCode());
} }
public static function generateMotiveId(): iterable
{
self::bootKernel();
$em = self::getContainer()->get(EntityManagerInterface::class);
$motive = $em->createQuery('SELECT m FROM '.Motive::class.' m ')
->setMaxResults(1)
->getOneOrNullResult();
if (null === $motive) {
throw new \RuntimeException('the motive table seems to be empty');
}
self::ensureKernelShutdown();
yield [$motive->getId()];
}
private function buildController(): ReplaceMotiveController
{
$security = $this->prophesize(Security::class);
$security->isGranted('ROLE_USER')->willReturn(true);
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::type(MotiveHistory::class))->shouldBeCalled();
$entityManager->flush()->shouldBeCalled();
$changeEmergencyCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class);
$changeEmergencyCommandHandler->__invoke(Argument::any(), Argument::any())->shouldBeCalled()
->will(fn (array $args) => $args[0]);
$handler = new ReplaceMotiveCommandHandler(
new MockClock(),
$entityManager->reveal(),
$changeEmergencyCommandHandler->reveal(),
);
return new ReplaceMotiveController(
$security->reveal(),
$handler,
$this->serializer,
$this->validator,
$entityManager->reveal(),
);
}
} }