Merge branch 'ticket/add-files-to-motives' into 'ticket-app-master'

[Ticket]  Add documents to Motive

See merge request Chill-Projet/chill-bundles!862
This commit is contained in:
Julien Fastré 2025-07-18 14:55:12 +00:00
commit 6594d4f6a6
14 changed files with 264 additions and 121 deletions

View File

@ -1,5 +1,5 @@
chill_doc_store:
use_driver: openstack
use_driver: local_storage
local_storage:
storage_path: '%kernel.project_dir%/var/storage'
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()]),
];
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);
}
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']]);
}

View File

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

View File

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

View File

@ -11,6 +11,8 @@ declare(strict_types=1);
namespace Chill\TicketBundle\DataFixtures\ORM;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\Motive;
use Doctrine\Bundle\FixturesBundle\Fixture;
@ -19,6 +21,8 @@ use Doctrine\Persistence\ObjectManager;
final class LoadMotives extends Fixture implements FixtureGroupInterface
{
public function __construct(private readonly StoredObjectManagerInterface $storedObjectManager) {}
public static function getGroups(): array
{
return ['ticket'];
@ -26,6 +30,12 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface
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) {
if ('' === trim($row)) {
continue;
@ -44,7 +54,26 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface
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)) {
$motive->addSupplementaryComment(['label' => trim((string) $supplementaryComment)]);
}
@ -56,43 +85,43 @@ final class LoadMotives extends Fixture implements FixtureGroupInterface
}
private const MOTIVES = <<<'CSV'
"Coordonnées",false,"Nouvelles coordonnées",
"Horaire de passage",false,
"Retard de livraison",false,
"Erreur de livraison",false,
"Colis incomplet",false,
"MATLOC",false,
"Retard DASRI",false,
"Planning d'astreintes",false,
"Planning des tournées",false,
"Contrôle pompe",true,
"Changement de rendez-vous",false,"Date du nouveau rendez-vous","Lieu du nouveau rendez-vous",
"Renseignement facturation/prestation",false,
"Décès patient",false,"Date et heures du décès","Autorisation préalable du médecin pour le décès",
"Demande de prise en charge",false,
"Information absence",false,
"Demande bulletin de situation",false,
"Difficultés accès logement",false,
"Déplacement inutile",false,
"Problème de prélèvement/de commande",false,
"Parc auto",false,
"Demande d'admission",false,
"Retrait de matériel au domicile",false,
"Comptes-rendus",false,
"Démarchage commercial",false,
"Demande de transport",false,
"Demande laboratoire",false,
"Demande admission",false,
"Suivi de prise en charge",false,
"Mauvaise adresse",false,
"Patient absent",false,
"Annulation",false,
"Colis perdu",false,
"Changement de rendez-vous",false,
"Coordination interservices",false,
"Problème de substitution produits",true,
"Problème ordonnance",false,
"Réclamations facture",false,"Numéro de facture concerné",
"Préparation urgente",true,
"Coordonnées",false,"3","Nouvelles coordonnées",
"Horaire de passage",false,"0",
"Retard de livraison",false,"0",
"Erreur de livraison",false,"0",
"Colis incomplet",false,"0",
"MATLOC",false,"0",
"Retard DASRI",false,"1",
"Planning d'astreintes",false,"0",
"Planning des tournées",false,"0",
"Contrôle pompe",true,"0",
"Changement de rendez-vous",false,"0","Date du nouveau rendez-vous","Lieu du nouveau rendez-vous",
"Renseignement facturation/prestation",false,"0",
"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,"0",
"Information absence",false,"0",
"Demande bulletin de situation",false,"0",
"Difficultés accès logement",false,"0",
"Déplacement inutile",false,"0",
"Problème de prélèvement/de commande",false,"0",
"Parc auto",false,"0",
"Demande d'admission",false,"0",
"Retrait de matériel au domicile",false,"0",
"Comptes-rendus",false,"0",
"Démarchage commercial",false,"0",
"Demande de transport",false,"0",
"Demande laboratoire",false,"0",
"Demande admission",false,"0",
"Suivi de prise en charge",false,"0",
"Mauvaise adresse",false,"0",
"Patient absent",false,"0",
"Annulation",false,"0",
"Colis perdu",false,"0",
"Changement de rendez-vous",false,"0",
"Coordination interservices",false,"0",
"Problème de substitution produits",true,"0",
"Problème ordonnance",false,"3",
"Réclamations facture",false,"0","Numéro de facture concerné",
"Préparation urgente",true,"3",
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;
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 Symfony\Component\Serializer\Annotation as Serializer;
@ -37,10 +41,40 @@ class Motive
#[Serializer\Groups(['read'])]
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' => '[]'])]
#[Serializer\Groups(['read'])]
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
{
return $this->active;

View File

@ -11,6 +11,8 @@ declare(strict_types=1);
namespace Chill\TicketBundle\Repository;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface;
use Chill\TicketBundle\Entity\Motive;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@ -18,10 +20,22 @@ use Doctrine\Persistence\ManagerRegistry;
/**
* @template-extends ServiceEntityRepository<Motive>
*/
class MotiveRepository extends ServiceEntityRepository
class MotiveRepository extends ServiceEntityRepository implements AssociatedEntityToStoredObjectInterface
{
public function __construct(ManagerRegistry $registry)
{
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\:
resource: '../Security/Voter/'
Chill\TicketBundle\Security\Authorization\:
resource: '../Security/Authorization/'
Chill\TicketBundle\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;
use Chill\TicketBundle\Action\Ticket\Handler\ChangeEmergencyStateCommandHandler;
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\MotiveHistory;
use PHPUnit\Framework\TestCase;
use Chill\TicketBundle\Controller\ReplaceMotiveController;
use Chill\TicketBundle\Entity\Ticket;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
@ -32,88 +31,62 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
*
* @coversNothing
*/
class ReplaceMotiveControllerTest extends KernelTestCase
class ReplaceMotiveControllerTest extends TestCase
{
use ProphecyTrait;
private SerializerInterface $serializer;
private ValidatorInterface $validator;
protected function setUp(): void
private function buildController(Ticket $ticket, string $body, Motive $motive): ReplaceMotiveController
{
self::bootKernel();
$this->serializer = self::getContainer()->get(SerializerInterface::class);
$this->validator = self::getContainer()->get(ValidatorInterface::class);
$command = new ReplaceMotiveCommand($motive);
// 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
{
self::ensureKernelShutdown();
}
/**
* @dataProvider generateMotiveId
*/
public function testAddValidMotive(int $motiveId): void
public function testAddValidMotive(): void
{
$ticket = new Ticket();
$payload = <<<JSON
{"motive": {"type": "ticket_motive", "id": {$motiveId}}}
$motive = new Motive();
$payload = <<<'JSON'
{"motive": {"type": "ticket_motive", "id": 1}}
JSON;
$request = new Request(content: $payload);
$controller = $this->buildController();
$controller = $this->buildController($ticket, $payload, $motive);
$response = $controller($ticket, $request);
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(),
);
}
}