Merge branch 'develop' into fix/erp-193-retours-metier
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled

This commit is contained in:
2026-06-19 13:08:14 +00:00
58 changed files with 5451 additions and 15 deletions
@@ -71,6 +71,10 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
'CarrierPrice::priceState' => 'Choice {EN_COURS,VALIDE,NON_VALIDE} borne deja les valeurs.',
// Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres.
'Site::color' => 'Regex code hex #RRGGBB borne deja la longueur.',
// Colonnes enum du ticket de pesee (M5) : le Choice borne deja les valeurs.
'WeighingTicket::counterpartyType' => 'Choice {CLIENT,FOURNISSEUR,AUTRE} borne deja les valeurs.',
'WeighingTicket::emptyMode' => 'Choice {AUTO,MANUAL} borne deja les valeurs.',
'WeighingTicket::fullMode' => 'Choice {AUTO,MANUAL} borne deja les valeurs.',
];
/**
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
use App\Module\Commercial\Domain\Entity\Supplier as SupplierEntity;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Base des tests fonctionnels du ticket de pesee (M5). Mutualise le seeding des
* dependances (Client cross-module, user manage/view rattache a un site courant),
* le payload POST de reference et la purge ciblee (pas de DAMA en local).
*
* Cloisonnement (§ 2.3) : le POST resout le site depuis le site courant de l'user
* (CurrentSiteProvider) ; on positionne donc toujours un site courant avant
* d'ecrire. Les Client de test sont prefixes pour une purge sans collision.
*
* @internal
*/
abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase
{
protected const string LD = 'application/ld+json';
protected const string MERGE = 'application/merge-patch+json';
/** Prefixe companyName des Client seedes par ces tests (purge ciblee). */
protected const string TEST_CLIENT_PREFIX = 'ZTESTWTAPI';
/** Prefixe companyName des Supplier seedes par ces tests (purge ciblee). */
protected const string TEST_SUPPLIER_PREFIX = 'ZTESTWTAPISUP';
protected function tearDown(): void
{
$em = $this->getEm();
// Tickets referencant un Client OU un Supplier de test d'abord (FK
// client_id / supplier_id RESTRICT) : purge DBAL brute pour liberer la
// contrepartie avant de la supprimer. Un ticket FOURNISSEUR a client_id
// NULL -> il faut bien purger aussi par supplier_id (sinon ticket orphelin).
$em->getConnection()->executeStatement(
'DELETE FROM weighing_ticket WHERE client_id IN (SELECT id FROM client WHERE company_name LIKE :p)',
['p' => self::TEST_CLIENT_PREFIX.'%'],
);
$em->getConnection()->executeStatement(
'DELETE FROM weighing_ticket WHERE supplier_id IN (SELECT id FROM supplier WHERE company_name LIKE :p)',
['p' => self::TEST_SUPPLIER_PREFIX.'%'],
);
$em->createQuery('DELETE FROM '.SupplierEntity::class.' s WHERE s.companyName LIKE :p')
->setParameter('p', self::TEST_SUPPLIER_PREFIX.'%')->execute()
;
$em->createQuery('DELETE FROM '.ClientEntity::class.' c WHERE c.companyName LIKE :p')
->setParameter('p', self::TEST_CLIENT_PREFIX.'%')->execute()
;
$em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :p')
->setParameter('p', 'testuser_%')->execute()
;
$em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :p')
->setParameter('p', 'test_%')->execute()
;
parent::tearDown();
}
/**
* Garde-fou ERP-101 (miroir M4) : une 422 doit porter une violation sur le
* `propertyPath` attendu, et pas seulement le bon code HTTP.
*/
protected static function assertViolationOnPath(object $response, string $path): void
{
/** @var ResponseInterface $response */
$paths = array_column($response->toArray(false)['violations'] ?? [], 'propertyPath');
self::assertContains(
$path,
$paths,
sprintf('Aucune violation sur "%s" (paths: %s).', $path, implode(', ', $paths)),
);
}
protected function firstSite(): Site
{
$site = $this->getEm()->getRepository(Site::class)->findAll()[0] ?? null;
self::assertInstanceOf(Site::class, $site, 'Un site fixture est requis (SitesFixtures).');
return $site;
}
protected function siteByCode(string $code): Site
{
$site = $this->getEm()->getRepository(Site::class)->findOneBy(['code' => $code]);
self::assertInstanceOf(Site::class, $site, sprintf('Le site de code "%s" doit etre seede.', $code));
return $site;
}
/**
* Cree un user non-admin portant view + manage, lui positionne $site comme site
* courant (cloisonnement + numerotation) et renvoie un client authentifie.
*/
protected function authManageOnSite(Site $site): Client
{
$creds = $this->createUserWithPermissions([
'logistique.weighing_tickets.view',
'logistique.weighing_tickets.manage',
]);
$this->setCurrentSite($creds['username'], $site);
return $this->authenticatedClient($creds['username'], $creds['password']);
}
/**
* Positionne le site courant d'un user (par username) — persiste en base, donc
* survit au reboot du kernel a l'authentification.
*/
protected function setCurrentSite(string $username, Site $site): void
{
$em = $this->getEm();
$user = $em->getRepository(User::class)->findOneBy(['username' => $username]);
self::assertInstanceOf(User::class, $user);
$user->setCurrentSite($em->getReference(Site::class, $site->getId()));
$em->flush();
}
/**
* Seede un Client minimal (companyName prefixe pour la purge). Sert de
* contrepartie aux tickets de test.
*/
protected function seedTestClient(string $label): ClientEntity
{
$em = $this->getEm();
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
$client = new ClientEntity();
$client->setCompanyName(mb_strtoupper(self::TEST_CLIENT_PREFIX.' '.$label.' '.$suffix, 'UTF-8'));
$em->persist($client);
$em->flush();
return $client;
}
protected function clientIri(ClientEntity $client): string
{
return '/api/clients/'.$client->getId();
}
/**
* Seede un Supplier minimal (companyName prefixe pour la purge). Sert de
* contrepartie aux tickets de test en branche FOURNISSEUR (RG-5.03).
*/
protected function seedTestSupplier(string $label): SupplierEntity
{
$em = $this->getEm();
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
$supplier = new SupplierEntity();
$supplier->setCompanyName(mb_strtoupper(self::TEST_SUPPLIER_PREFIX.' '.$label.' '.$suffix, 'UTF-8'));
$em->persist($supplier);
$em->flush();
return $supplier;
}
protected function supplierIri(SupplierEntity $supplier): string
{
return '/api/suppliers/'.$supplier->getId();
}
/**
* Payload POST de reference : contrepartie Client, pesee a vide + a plein en
* mode AUTO (le Processor (re)alloue les DSD et calcule le net = 14300 - 7150).
*
* @return array<string, mixed>
*/
protected function validClientTicketPayload(ClientEntity $client): array
{
return [
'counterpartyType' => 'CLIENT',
'client' => $this->clientIri($client),
'immatriculation' => 'AB-123-CD',
'plateFreeFormat' => false,
'emptyDate' => '2026-06-17T09:00:00+02:00',
'emptyWeight' => 7150,
'emptyMode' => 'AUTO',
'fullDate' => '2026-06-17T09:12:00+02:00',
'fullWeight' => 14300,
'fullMode' => 'AUTO',
];
}
/**
* Payload POST de reference en branche FOURNISSEUR (RG-5.03) — miroir de
* validClientTicketPayload, contrepartie Supplier. Sert a prouver l'embed
* symetrique de `supplier` (spec § 4.0.bis piege #1).
*
* @return array<string, mixed>
*/
protected function validSupplierTicketPayload(SupplierEntity $supplier): array
{
return [
'counterpartyType' => 'FOURNISSEUR',
'supplier' => $this->supplierIri($supplier),
'immatriculation' => 'AB-123-CD',
'plateFreeFormat' => false,
'emptyDate' => '2026-06-17T09:00:00+02:00',
'emptyWeight' => 7150,
'emptyMode' => 'AUTO',
'fullDate' => '2026-06-17T09:12:00+02:00',
'fullWeight' => 14300,
'fullMode' => 'AUTO',
];
}
/**
* POST un ticket et renvoie la reponse (assertions de statut a la charge de
* l'appelant).
*/
protected function postTicket(Client $http, array $payload): ResponseInterface
{
return $http->request('POST', '/api/weighing_tickets', [
'headers' => ['Content-Type' => self::LD],
'json' => $payload,
]);
}
/**
* Retrouve un membre d'une collection Hydra par son id.
*
* @param array<string, mixed> $collection
*
* @return null|array<string, mixed>
*/
protected function memberById(array $collection, int $id): ?array
{
foreach ($collection['member'] ?? [] as $member) {
if (($member['id'] ?? null) === $id) {
return $member;
}
}
return null;
}
}
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
/**
* Endpoint `POST /api/weighbridge_readings` (§ 4.2) — tests fonctionnels.
*
* Couvre le wiring securite/routage (que les tests unitaires ne voient pas) :
* - happy path AUTO / MANUAL avec site courant et permission `manage` ;
* - 403 sans la permission `manage` (RBAC § 5.2) ;
* - 422 si le mode est absent / invalide (validation de la ressource).
*
* Nettoyage manuel (pas de DAMA) : users/roles `test*` + compteurs DSD.
*
* @internal
*/
final class WeighbridgeReadingApiTest extends AbstractApiTestCase
{
protected function tearDown(): void
{
$em = $this->getEm();
$em->getConnection()->executeStatement('DELETE FROM weighbridge_dsd_counter');
$em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :p')
->setParameter('p', 'testuser_%')->execute()
;
$em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :p')
->setParameter('p', 'test_%')->execute()
;
parent::tearDown();
}
public function testAutoWeighingReturnsWeightInBoundsAndDsd(): void
{
$client = $this->manageClientWithCurrentSite();
$response = $client->request('POST', '/api/weighbridge_readings', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['mode' => 'AUTO'],
]);
self::assertResponseStatusCodeSame(200);
$data = $response->toArray();
self::assertSame('AUTO', $data['mode']);
self::assertIsInt($data['weight']);
self::assertGreaterThanOrEqual(10000, $data['weight']);
self::assertLessThanOrEqual(50000, $data['weight']);
self::assertIsInt($data['dsd']);
self::assertGreaterThanOrEqual(1, $data['dsd']);
// manualNumber est null en mode bascule (cle potentiellement omise si
// skip_null_values est actif — tolerant aux deux cas).
self::assertNull($data['manualNumber'] ?? null);
}
public function testManualWeighingKeepsWeightAndAllocatesDsd(): void
{
$client = $this->manageClientWithCurrentSite();
$response = $client->request('POST', '/api/weighbridge_readings', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['mode' => 'MANUAL', 'weight' => 23187, 'manualNumber' => 'PAP-555'],
]);
self::assertResponseStatusCodeSame(200);
$data = $response->toArray();
self::assertSame('MANUAL', $data['mode']);
self::assertSame(23187, $data['weight']);
self::assertSame('PAP-555', $data['manualNumber']);
self::assertGreaterThanOrEqual(1, $data['dsd']);
}
public function testManagePermissionIsRequired(): void
{
// Un user portant uniquement `view` ne peut pas declencher de pesee.
$credentials = $this->createUserWithPermission('logistique.weighing_tickets.view');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$client->request('POST', '/api/weighbridge_readings', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['mode' => 'AUTO'],
]);
self::assertResponseStatusCodeSame(403);
}
public function testInvalidModeIsRejected(): void
{
$client = $this->manageClientWithCurrentSite();
$response = $client->request('POST', '/api/weighbridge_readings', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['mode' => 'INVALID'],
]);
// Garde-fou ERP-101 : la 422 doit cibler `mode` (Assert\Choice), pas juste
// un bon code HTTP — sinon une violation sur le mauvais champ passerait.
self::assertResponseStatusCodeSame(422);
self::assertViolationOnPath($response, 'mode');
}
public function testManualWeighingRequiresWeight(): void
{
$client = $this->manageClientWithCurrentSite();
$response = $client->request('POST', '/api/weighbridge_readings', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['mode' => 'MANUAL'],
]);
// Garde-fou ERP-101 : la 422 doit cibler `weight` (Callback validateManualWeight).
self::assertResponseStatusCodeSame(422);
self::assertViolationOnPath($response, 'weight');
}
/**
* Garde-fou ERP-101 (miroir AbstractWeighingTicketApiTestCase) : une 422 doit
* porter une violation sur le `propertyPath` attendu, consommable inline par
* useFormErrors cote front, pas seulement le bon statut HTTP.
*/
private static function assertViolationOnPath(object $response, string $path): void
{
$paths = array_column($response->toArray(false)['violations'] ?? [], 'propertyPath');
self::assertContains(
$path,
$paths,
sprintf('Aucune violation sur "%s" (paths: %s).', $path, implode(', ', $paths)),
);
}
/**
* Cree un user non-admin portant `logistique.weighing_tickets.manage`, lui
* positionne un site courant (l'endpoint est cloisonne par site, § 2.3) et
* renvoie un client authentifie.
*/
private function manageClientWithCurrentSite(): Client
{
$credentials = $this->createUserWithPermission('logistique.weighing_tickets.manage');
$em = $this->getEm();
$user = $em->getRepository(User::class)->findOneBy(['username' => $credentials['username']]);
self::assertInstanceOf(User::class, $user);
$site = $em->getRepository(Site::class)->findAll()[0];
$user->setCurrentSite($site);
$em->flush();
return $this->authenticatedClient($credentials['username'], $credentials['password']);
}
}
@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use App\Module\Sites\Domain\Entity\Site;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* Tests fonctionnels de l'export XLSX des tickets de pesee (M5, § 4.5).
* Jumeau du {@see \App\Tests\Module\Transport\Api\CarrierExportControllerTest}.
*
* Couvre : reponse 200 (Content-Type + Content-Disposition + en-tetes), mapping
* des colonnes (numero, contrepartie, poids vide/plein/net, DSD vide/plein) avec
* net = plein - vide, cloisonnement par site courant (un non-admin n'exporte que
* les tickets de son site), 403 sans `logistique.weighing_tickets.view`, 401
* anonyme.
*
* Nettoyage manuel (pas de DAMA) : tickets/clients de test (prefixes dedies) +
* users/roles `test*`.
*
* @internal
*/
final class WeighingTicketExportControllerTest extends AbstractApiTestCase
{
private const string XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
private const string EXPORT_URL = '/api/weighing_tickets/export.xlsx';
private const string NUMBER_PREFIX = 'ZTEST-';
private const string CLIENT_PREFIX = 'ZTESTWT';
protected function tearDown(): void
{
$em = $this->getEm();
$em->createQuery('DELETE FROM '.WeighingTicket::class.' wt WHERE wt.number LIKE :p')
->setParameter('p', self::NUMBER_PREFIX.'%')->execute()
;
$em->createQuery('DELETE FROM '.ClientEntity::class.' c WHERE c.companyName LIKE :p')
->setParameter('p', self::CLIENT_PREFIX.'%')->execute()
;
$em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :p')
->setParameter('p', 'testuser_%')->execute()
;
$em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :p')
->setParameter('p', 'test_%')->execute()
;
parent::tearDown();
}
public function testExportReturnsXlsxResponseWithHeaders(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$this->seedTicketWithClient($this->firstSite(), 'Acme');
$response = $client->request('GET', self::EXPORT_URL);
self::assertResponseIsSuccessful();
$headers = $response->getHeaders(false);
self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? '');
$disposition = $headers['content-disposition'][0] ?? '';
self::assertMatchesRegularExpression('/filename="tickets-pesee-\d{8}\.xlsx"/', $disposition);
// 1re ligne = en-tetes attendus (ordre des colonnes § 4.5).
$header = $this->gridFromResponse($response->getContent())[0];
self::assertSame('Numéro', $header[0]);
self::assertContains('Type contrepartie', $header);
self::assertContains('Contrepartie', $header);
self::assertContains('Date', $header);
self::assertContains('Immatriculation', $header);
self::assertContains('Poids vide (kg)', $header);
self::assertContains('Poids plein (kg)', $header);
self::assertContains('Poids net (kg)', $header);
self::assertContains('DSD vide', $header);
self::assertContains('DSD plein', $header);
}
/**
* Mapping des colonnes : la ligne exportee porte les bonnes valeurs aux bons
* index, et le poids net = poids plein - poids vide (RG-5.05).
*/
public function testExportMapsColumnsAndComputesNetWeight(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$ticket = $this->seedTicketWithClient($this->firstSite(), 'Béton SA');
$grid = $this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent());
$header = $grid[0];
$row = $this->rowByNumber($grid, (string) $ticket->getNumber());
self::assertNotNull($row, 'La ligne du ticket seede doit etre presente dans l\'export.');
$cell = static fn (string $label) => $row[array_search($label, $header, true)] ?? null;
self::assertSame('Client', $cell('Type contrepartie'));
self::assertStringContainsString('BÉTON SA', (string) $cell('Contrepartie'));
self::assertSame('AB-123-CD', $cell('Immatriculation'));
self::assertSame(7150, (int) $cell('Poids vide (kg)'));
self::assertSame(14300, (int) $cell('Poids plein (kg)'));
self::assertSame(7150, (int) $cell('Poids net (kg)'));
self::assertSame(7150, (int) $cell('Poids plein (kg)') - (int) $cell('Poids vide (kg)'));
self::assertSame(41, (int) $cell('DSD vide'));
self::assertSame(42, (int) $cell('DSD plein'));
}
/**
* Cloisonnement par site (§ 2.3 / RG-5.09) : un non-admin (sans bypass)
* possedant un site courant n'exporte QUE les tickets de ce site.
*/
public function testExportIsScopedToCurrentSiteForNonAdmin(): void
{
$sites = $this->getEm()->getRepository(Site::class)->findAll();
self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites attendus (fixtures).');
$ticketHere = $this->seedTicketWithClient($sites[0], 'Ici');
$ticketOther = $this->seedTicketWithClient($sites[1], 'Ailleurs');
$client = $this->viewClientWithCurrentSite($sites[0]);
$numbers = $this->numbersFromResponse($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains($ticketHere->getNumber(), $numbers);
self::assertNotContains($ticketOther->getNumber(), $numbers);
}
public function testForbiddenWithoutViewPermission(): void
{
$creds = $this->createUserWithPermission('core.users.view');
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(403);
}
public function testUnauthorizedWhenAnonymous(): void
{
$client = self::createClient();
$client->request('GET', self::EXPORT_URL);
self::assertResponseStatusCodeSame(401);
}
private function firstSite(): Site
{
$site = $this->getEm()->getRepository(Site::class)->findAll()[0] ?? null;
self::assertInstanceOf(Site::class, $site, 'Un site fixture est requis.');
return $site;
}
/**
* Seede un ticket complet (contrepartie Client, pesee vide + plein) rattache au
* site donne. Numero unique prefixe pour la purge. Le net est pose
* explicitement (pas de Processor sur un persist direct) = plein - vide.
*/
private function seedTicketWithClient(Site $site, string $label): WeighingTicket
{
$em = $this->getEm();
$clientEntity = new ClientEntity();
$clientEntity->setCompanyName(mb_strtoupper(self::CLIENT_PREFIX.' '.$label, 'UTF-8'));
$em->persist($clientEntity);
$ticket = new WeighingTicket();
$ticket->setSite($em->getReference(Site::class, $site->getId()));
$ticket->setNumber(self::NUMBER_PREFIX.substr(bin2hex(random_bytes(5)), 0, 10));
$ticket->setCounterpartyType('CLIENT');
$ticket->setClient($clientEntity);
$ticket->setImmatriculation('AB-123-CD');
$ticket->setEmptyDate(new DateTimeImmutable('2026-06-17 09:00:00'));
$ticket->setEmptyWeight(7150);
$ticket->setEmptyDsd(41);
$ticket->setEmptyMode('AUTO');
$ticket->setFullDate(new DateTimeImmutable('2026-06-17 09:12:00'));
$ticket->setFullWeight(14300);
$ticket->setFullDsd(42);
$ticket->setFullMode('AUTO');
$ticket->setNetWeight(7150);
$em->persist($ticket);
$em->flush();
return $ticket;
}
/**
* Cree un non-admin portant `logistique.weighing_tickets.view`, lui positionne
* un site courant (cloisonnement § 2.3) et renvoie un client authentifie.
*/
private function viewClientWithCurrentSite(Site $site): Client
{
$creds = $this->createUserWithPermission('logistique.weighing_tickets.view');
$em = $this->getEm();
$user = $em->getRepository(User::class)->findOneBy(['username' => $creds['username']]);
self::assertInstanceOf(User::class, $user);
$user->setCurrentSite($em->getReference(Site::class, $site->getId()));
$em->flush();
return $this->authenticatedClient($creds['username'], $creds['password']);
}
/**
* Relit le binaire XLSX d'une reponse et renvoie la grille de cellules.
*
* @return array<int, array<int, mixed>>
*/
private function gridFromResponse(string $binary): array
{
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_wt_export_test_');
self::assertIsString($tmp);
file_put_contents($tmp, $binary);
try {
return IOFactory::load($tmp)->getActiveSheet()->toArray();
} finally {
@unlink($tmp);
}
}
/**
* Premiere ligne de donnees dont la colonne « Numéro » vaut $number, ou null.
*
* @param array<int, array<int, mixed>> $grid
*
* @return null|array<int, mixed>
*/
private function rowByNumber(array $grid, string $number): ?array
{
foreach (array_slice($grid, 1) as $row) {
if ((string) ($row[0] ?? '') === $number) {
return $row;
}
}
return null;
}
/**
* Colonne « Numéro » (1re colonne) des lignes de donnees.
*
* @return list<string>
*/
private function numbersFromResponse(string $binary): array
{
$rows = array_slice($this->gridFromResponse($binary), 1); // saute l'en-tete
return array_values(array_map(static fn (array $row): string => (string) ($row[0] ?? ''), $rows));
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Api;
/**
* Numerotation des tickets de pesee (RG-5.02 / § 2.5) — tests fonctionnels sur
* l'API reelle (compteur DBAL `weighing_ticket_counter`, verrou FOR UPDATE).
*
* Couvre : format {siteCode}-TP-{NNNN}, sequence incrementale et unique PAR site,
* independance des sequences entre sites, immuabilite du numero et du site au PATCH
* (RG-5.09 : aucun groupe d'ecriture sur ces champs).
*
* La serialisation concurrente (FOR UPDATE) est exercee a l'identique par le
* DsdAllocator (cf. DsdAllocatorTest) ; un vrai parallelisme n'est pas reproductible
* en PHPUnit mono-processus — on valide ici la sequence deterministe.
*
* @internal
*/
final class WeighingTicketNumberingTest extends AbstractWeighingTicketApiTestCase
{
public function testNumberFormatAndSequentialPerSite(): void
{
$site = $this->siteByCode('86');
$http = $this->authManageOnSite($site);
$client = $this->seedTestClient('Num');
$first = $this->postTicket($http, $this->validClientTicketPayload($client));
self::assertResponseStatusCodeSame(201);
$second = $this->postTicket($http, $this->validClientTicketPayload($client));
self::assertResponseStatusCodeSame(201);
$n1 = (string) $first->toArray()['number'];
$n2 = (string) $second->toArray()['number'];
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n1);
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n2);
self::assertNotSame($n1, $n2, 'Deux tickets du meme site portent des numeros distincts (unicite).');
// Sequence : le second numero = premier + 1 (compteur par site).
self::assertSame($this->suffix($n1) + 1, $this->suffix($n2));
}
public function testNumberingIsIsolatedPerSite(): void
{
$client = $this->seedTestClient('IsoSite');
$http86 = $this->authManageOnSite($this->siteByCode('86'));
$http17 = $this->authManageOnSite($this->siteByCode('17'));
$n86 = (string) $this->postTicket($http86, $this->validClientTicketPayload($client))->toArray()['number'];
$n17 = (string) $this->postTicket($http17, $this->validClientTicketPayload($client))->toArray()['number'];
// Chaque site encode son propre code dans le numero ; sequences disjointes.
self::assertStringStartsWith('86-TP-', $n86);
self::assertStringStartsWith('17-TP-', $n17);
}
public function testNumberAndSiteAreImmutableOnPatch(): void
{
$site = $this->siteByCode('86');
$http = $this->authManageOnSite($site);
$client = $this->seedTestClient('Immutable');
$created = $this->postTicket($http, $this->validClientTicketPayload($client))->toArray();
$id = (int) $created['id'];
$number = (string) $created['number'];
// Tentative de re-ecriture du numero et du site (aucun groupe d'ecriture) +
// changement legitime de la pesee a plein -> net recalcule.
$patched = $http->request('PATCH', '/api/weighing_tickets/'.$id, [
'headers' => ['Content-Type' => self::MERGE],
'json' => [
'number' => 'HACK-TP-9999',
'site' => '/api/sites/'.$this->siteByCode('17')->getId(),
'fullWeight' => 20000,
],
])->toArray();
self::assertSame($number, $patched['number'], 'Le numero est immuable (RG-5.02 / RG-5.09).');
self::assertSame('86', $patched['site']['code'], 'Le site est immuable (RG-5.09).');
// Net recalcule : 20000 - 7150 = 12850 (RG-5.05).
self::assertSame(12850, $patched['netWeight']);
}
/** Suffixe numerique {NNNN} d'un numero {siteCode}-TP-{NNNN}. */
private function suffix(string $number): int
{
return (int) substr($number, strrpos($number, '-') + 1);
}
}
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Api;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Matrice RBAC du ticket de pesee par role metier (spec-back M5 § 5.2). Jumeau de
* {@see \App\Tests\Module\Transport\Api\CarrierRBACMatrixTest}.
*
* Matrice § 5.2 (V0.2) :
* - admin / bureau / usine : view + manage (200 lecture, 201 creation)
* - compta / commerciale : AUCUN acces (403 sur view ET manage)
* - anonyme : 401
*
* La creation (POST -> 201) suppose un site courant (numerotation + cloisonnement,
* § 2.3) : on le positionne pour chaque role autorise a ecrire.
*
* @internal
*/
final class WeighingTicketRBACMatrixTest extends AbstractWeighingTicketApiTestCase
{
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
protected function setUp(): void
{
parent::setUp();
// Seed idempotent des roles metier + matrice § 5.2 + comptes demo (meme
// chemin qu'en recette).
self::bootKernel();
$application = new Application(self::$kernel);
$application->setAutoExit(false);
$exit = $application->run(
new ArrayInput([
'command' => 'app:seed-rbac',
'--with-demo-users' => true,
'--password' => self::PWD,
]),
new NullOutput(),
);
self::assertSame(
0,
$exit,
'app:seed-rbac a echoue : les permissions logistique.weighing_tickets.* sont-elles synchronisees (app:sync-permissions) ?',
);
self::ensureKernelShutdown();
}
public function testAdminCanViewAndManage(): void
{
$this->assertCanViewAndManage('admin', 'admin');
}
public function testBureauCanViewAndManage(): void
{
$this->assertCanViewAndManage('bureau', self::PWD);
}
public function testUsineCanViewAndManage(): void
{
$this->assertCanViewAndManage('usine', self::PWD);
}
public function testComptaHasNoAccess(): void
{
$this->assertHasNoAccess('compta');
}
public function testCommercialeHasNoAccess(): void
{
$this->assertHasNoAccess('commerciale');
}
public function testAnonymousIsUnauthorized(): void
{
$client = self::createClient();
$client->request('GET', '/api/weighing_tickets', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(401);
}
/**
* Role autorise : GET 200 (view) + POST 201 (manage). Le site courant est
* positionne avant le POST pour permettre la numerotation.
*/
private function assertCanViewAndManage(string $username, string $password): void
{
$site = $this->firstSite();
$this->setCurrentSite($username, $site);
$clientEntity = $this->seedTestClient('Rbac '.$username);
$http = $this->authenticatedClient($username, $password);
$http->request('GET', '/api/weighing_tickets', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200);
$this->postTicket($http, $this->validClientTicketPayload($clientEntity));
self::assertResponseStatusCodeSame(201);
}
/**
* Role sans acces : 403 en lecture (view absent) ET en ecriture (manage absent).
*/
private function assertHasNoAccess(string $username): void
{
$clientEntity = $this->seedTestClient('Rbac '.$username);
$http = $this->authenticatedClient($username, self::PWD);
$http->request('GET', '/api/weighing_tickets', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403);
$this->postTicket($http, $this->validClientTicketPayload($clientEntity));
self::assertResponseStatusCodeSame(403);
}
}
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Api;
/**
* Contrat de serialisation du ticket de pesee (M5, spec-back § 4.0 / § 4.0.bis).
* Jumeau du test de contrat M4 CarrierSerializationContractTest (module Transport,
* reference en prose pour ne pas materialiser d'import inter-module).
*
* Capture le JSON REEL (liste + detail) via un ticket cree par l'API (numerotation
* serveur reelle) et reverifie les 4 pieges du RETEX M1 transposes au M5 :
* #1 : `client` (et `supplier`) sortent en OBJET embarque, pas en IRI nu
* (read-groups client:read / supplier:read).
* #2 : booleen `plateFreeFormat` present dans le JSON (getter + SerializedName).
* #3 : `number` present, formate {siteCode}-TP-{NNNN}.
* #4 : `netWeight` coherent = full - empty (plein - vide, RG-5.05).
*
* REGLE D'OR : on asserte sur le CORPS JSON reel, jamais sur les annotations.
* DoD (§ 4.0.bis) : avec WEIGHING_TICKET_DOD_DUMP positionnee, ecrit les corps
* liste + detail sous /tmp pour les coller dans la spec avant les ecrans front.
*
* @internal
*/
final class WeighingTicketSerializationContractTest extends AbstractWeighingTicketApiTestCase
{
public function testListAndDetailSerializationContract(): void
{
$site = $this->siteByCode('86');
$http = $this->authManageOnSite($site);
$clientEntity = $this->seedTestClient('Negoce');
$created = $this->postTicket($http, $this->validClientTicketPayload($clientEntity));
self::assertResponseStatusCodeSame(201);
$createdBody = $created->toArray();
$id = (int) $createdBody['id'];
$number = (string) $createdBody['number'];
$detail = $http->request('GET', '/api/weighing_tickets/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
$list = $http->request('GET', '/api/weighing_tickets?search='.$number, ['headers' => ['Accept' => self::LD]])->toArray();
// Enveloppe Hydra AP4 (member/totalItems sans prefixe hydra:).
self::assertArrayHasKey('member', $list);
self::assertArrayNotHasKey('hydra:member', $list);
$row = $this->memberById($list, $id);
self::assertNotNull($row, 'Le ticket cree doit apparaitre dans la liste filtree.');
// === Piege #1 : relations embarquees en OBJET (pas IRI nu) ===
self::assertIsArray($row['client'], 'client doit etre un objet embarque (client:read), pas un IRI nu.');
self::assertArrayHasKey('companyName', $row['client']);
// supplier null sur une contrepartie Client (cle potentiellement omise par
// skip_null_values — tolerant aux deux cas, jamais un IRI nu).
self::assertNull($row['supplier'] ?? null);
// === Piege #2 : booleen plateFreeFormat present ===
self::assertArrayHasKey('plateFreeFormat', $row);
self::assertFalse($row['plateFreeFormat']);
// === Piege #3 : number formate {siteCode}-TP-{NNNN} ===
self::assertArrayHasKey('number', $row);
self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $row['number']);
// === Piege #4 : netWeight = full - empty (14300 - 7150) ===
self::assertSame(7150, $row['netWeight']);
// displayDate (date du ticket = fullDate ?? emptyDate) expose en liste.
self::assertArrayHasKey('displayDate', $row);
// === DETAIL : site embarque (avec code), immatriculation, les 2 pesees ===
self::assertIsArray($detail['site']);
self::assertSame('86', $detail['site']['code']);
self::assertSame('AB-123-CD', $detail['immatriculation']);
self::assertSame(7150, $detail['emptyWeight']);
self::assertSame(14300, $detail['fullWeight']);
self::assertSame(7150, $detail['netWeight']);
self::assertIsArray($detail['client']);
self::assertArrayHasKey('companyName', $detail['client']);
$this->dumpDodIfRequested($list, $detail);
}
/**
* Piege #1 symetrique (spec § 4.0.bis) : sur une contrepartie FOURNISSEUR,
* `supplier` doit sortir en OBJET embarque (supplier:read) et `client` etre
* null (jamais un IRI nu). Le cas Client est couvert ci-dessus ; ce test
* verrouille l'autre branche pour qu'un drift de read-group cote Supplier ne
* passe pas inapercu.
*/
public function testSupplierCounterpartyEmbedsSupplier(): void
{
$site = $this->siteByCode('86');
$http = $this->authManageOnSite($site);
$supplierEntity = $this->seedTestSupplier('Ferraille');
$created = $this->postTicket($http, $this->validSupplierTicketPayload($supplierEntity));
self::assertResponseStatusCodeSame(201);
$createdBody = $created->toArray();
$id = (int) $createdBody['id'];
$number = (string) $createdBody['number'];
$detail = $http->request('GET', '/api/weighing_tickets/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
$list = $http->request('GET', '/api/weighing_tickets?search='.$number, ['headers' => ['Accept' => self::LD]])->toArray();
$row = $this->memberById($list, $id);
self::assertNotNull($row, 'Le ticket fournisseur cree doit apparaitre dans la liste filtree.');
// Liste : supplier embarque en objet, client omis/null (skip_null_values).
self::assertIsArray($row['supplier'], 'supplier doit etre un objet embarque (supplier:read), pas un IRI nu.');
self::assertArrayHasKey('companyName', $row['supplier']);
self::assertNull($row['client'] ?? null);
self::assertSame('FOURNISSEUR', $row['counterpartyType']);
// Detail : meme contrat cote item.
self::assertIsArray($detail['supplier']);
self::assertArrayHasKey('companyName', $detail['supplier']);
self::assertNull($detail['client'] ?? null);
}
/**
* DoD (§ 4.0.bis) : ecrit les corps JSON reels sous /tmp si WEIGHING_TICKET_DOD_DUMP
* est positionnee (sinon no-op). A coller dans spec-back.md § 4.0.bis.
*
* @param array<string, mixed> $list
* @param array<string, mixed> $detail
*/
private function dumpDodIfRequested(array $list, array $detail): void
{
if (false === getenv('WEIGHING_TICKET_DOD_DUMP')) {
return;
}
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
file_put_contents('/tmp/weighing-ticket-dod-list.json', json_encode($list, $flags));
file_put_contents('/tmp/weighing-ticket-dod-detail.json', json_encode($detail, $flags));
}
}
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Application\Service;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
use App\Module\Logistique\Application\Service\WeighingTicketFieldNormalizer;
use App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use App\Module\Logistique\Domain\Exception\InvalidImmatriculationException;
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighingTicketProcessor;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* Normalisation de l'immatriculation (RG-5.01 / RG-5.10 / § 6 / § 2.10) — test
* unitaire en deux volets, sans BDD ni HTTP :
*
* 1. Le WeighingTicketFieldNormalizer applique trim + UPPER ; hors « Tout format »
* il ramene la saisie au masque SIV canonique XX-000-XX (separateurs/espaces
* ignores puis re-poses) et leve InvalidImmatriculationException si le squelette
* 2-3-2 n'est pas respecte. En « Tout format », seul trim + UPPER s'applique.
* 2. Le WeighingTicketProcessor traduit cette exception de domaine en 422 portant
* un propertyPath « immatriculation » (mapping inline useFormErrors, ERP-101).
*
* @internal
*/
final class ImmatriculationNormalizationTest extends TestCase
{
private WeighingTicketFieldNormalizer $normalizer;
protected function setUp(): void
{
$this->normalizer = new WeighingTicketFieldNormalizer();
}
// === Volet 1 : normalisation pure (masque + « Tout format ») ===
#[DataProvider('provideMaskedPlates')]
public function testMaskedPlateIsReformattedToCanonicalSiv(string $input): void
{
self::assertSame('AB-123-CD', $this->normalizer->normalizeImmatriculation($input, false));
}
/**
* @return iterable<string, array{string}>
*/
public static function provideMaskedPlates(): iterable
{
yield 'deja canonique' => ['AB-123-CD'];
yield 'minuscules nues' => ['ab123cd'];
yield 'espaces' => ['AB 123 CD'];
yield 'minuscules tirets'=> ['ab-123-cd'];
yield 'espaces de garde' => [' ab-123-cd '];
}
public function testInvalidPlateWithoutFreeFormatThrows(): void
{
$this->expectException(InvalidImmatriculationException::class);
$this->normalizer->normalizeImmatriculation('ABC-12-D', false);
}
public function testFreeFormatBypassesTheMask(): void
{
// Ancienne plaque / engin : aucune contrainte de masque, juste trim + UPPER.
self::assertSame('1234 WW 75', $this->normalizer->normalizeImmatriculation(' 1234 ww 75 ', true));
self::assertSame('ENGIN-XYZ', $this->normalizer->normalizeImmatriculation('engin-xyz', true));
}
public function testNullAndBlankAreNormalizedToNull(): void
{
self::assertNull($this->normalizer->normalizeImmatriculation(null, false));
self::assertNull($this->normalizer->normalizeImmatriculation(' ', false));
self::assertNull($this->normalizer->normalizeImmatriculation(' ', true));
}
public function testOtherLabelIsTrimmedAndBlankBecomesNull(): void
{
self::assertSame('Reprise interne', $this->normalizer->normalizeOtherLabel(' Reprise interne '));
self::assertNull($this->normalizer->normalizeOtherLabel(' '));
self::assertNull($this->normalizer->normalizeOtherLabel(null));
}
// === Volet 2 : mapping 422 par le Processor (RG-5.01, ERP-101) ===
public function testProcessorMapsInvalidPlateTo422OnImmatriculationPath(): void
{
$ticket = (new WeighingTicket())
->setCounterpartyType('AUTRE')
->setOtherLabel('Reprise')
->setImmatriculation('PLAQUE INVALIDE')
->setPlateFreeFormat(false)
;
try {
$this->makeProcessor()->process($ticket, new Post());
self::fail('Une ValidationException (422) etait attendue sur une immatriculation invalide.');
} catch (ValidationException $e) {
$paths = [];
foreach ($e->getConstraintViolationList() as $violation) {
$paths[] = $violation->getPropertyPath();
}
self::assertContains('immatriculation', $paths);
}
}
public function testProcessorReformatsValidPlateAndHonorsFreeFormat(): void
{
// Masque applique a la persistance (saisie nue -> canonique).
$masked = (new WeighingTicket())
->setCounterpartyType('AUTRE')
->setOtherLabel('Reprise')
->setImmatriculation('ab123cd')
->setPlateFreeFormat(false)
;
$this->makeProcessor()->process($masked, new Post());
self::assertSame('AB-123-CD', $masked->getImmatriculation());
// « Tout format » : la plaque libre passe (UPPER seulement), aucune 422.
$free = (new WeighingTicket())
->setCounterpartyType('AUTRE')
->setOtherLabel('Reprise')
->setImmatriculation('vieux 4321 zz')
->setPlateFreeFormat(true)
;
$this->makeProcessor()->process($free, new Post());
self::assertSame('VIEUX 4321 ZZ', $free->getImmatriculation());
}
private function makeProcessor(): WeighingTicketProcessor
{
$persist = $this->createStub(ProcessorInterface::class);
$persist->method('process')->willReturnArgument(0);
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
$siteProvider->method('get')->willReturn(
(new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233'))->setCode('86'),
);
$numberAllocator = $this->createStub(WeighingTicketNumberAllocatorInterface::class);
$numberAllocator->method('allocate')->willReturn('86-TP-0001');
$em = $this->createStub(EntityManagerInterface::class);
$em->method('contains')->willReturn(false);
return new WeighingTicketProcessor(
$persist,
$siteProvider,
$numberAllocator,
$this->createStub(DsdAllocatorInterface::class),
new WeighingTicketFieldNormalizer(),
$em,
);
}
}
@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
use App\Module\Logistique\Application\Service\WeighingTicketFieldNormalizer;
use App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighingTicketProcessor;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Coherence de la contrepartie (RG-5.03 / § 2.9) — test unitaire en deux volets,
* sans BDD ni HTTP :
*
* 1. PRESENCE (Assert\Callback de l'entite) : selon counterpartyType, le champ
* associe est obligatoire ; la violation porte le bon propertyPath
* (client / supplier / otherLabel) pour le mapping inline useFormErrors
* (ERP-101). Valide via le validateur Symfony sur l'entite (les attributs
* #[Assert\*] sont lus directement).
* 2. EXCLUSIVITE (WeighingTicketProcessor) : les champs hors-branche sont forces
* a null avant persistance (garde-fou des CHECK Postgres chk_wt_*_branch).
*
* @internal
*/
final class CounterpartyValidationTest extends TestCase
{
private ValidatorInterface $validator;
protected function setUp(): void
{
$this->validator = Validation::createValidatorBuilder()
->enableAttributeMapping()
->getValidator()
;
}
// === Volet 1 : presence du champ requis (Assert\Callback) ===
public function testClientBranchRequiresClient(): void
{
$ticket = $this->baseTicket('CLIENT');
// Sans client : violation attendue sur le path « client ».
self::assertContains('client', $this->violationPaths($ticket));
// Avec client : plus de violation sur « client ».
$ticket->setClient(new Client());
self::assertNotContains('client', $this->violationPaths($ticket));
}
public function testSupplierBranchRequiresSupplier(): void
{
$ticket = $this->baseTicket('FOURNISSEUR');
self::assertContains('supplier', $this->violationPaths($ticket));
$ticket->setSupplier(new Supplier());
self::assertNotContains('supplier', $this->violationPaths($ticket));
}
public function testOtherBranchRequiresOtherLabel(): void
{
$ticket = $this->baseTicket('AUTRE');
// Ni null ni chaine vide apres trim ne suffisent (RG-5.03).
self::assertContains('otherLabel', $this->violationPaths($ticket));
$ticket->setOtherLabel(' ');
self::assertContains('otherLabel', $this->violationPaths($ticket));
$ticket->setOtherLabel('Reprise interne');
self::assertNotContains('otherLabel', $this->violationPaths($ticket));
}
// === Volet 2 : exclusivite (le Processor null-ifie les champs hors-branche) ===
public function testClientBranchNullifiesSupplierAndOtherLabel(): void
{
$ticket = $this->baseTicket('CLIENT')
->setClient(new Client())
->setSupplier(new Supplier())
->setOtherLabel('parasite')
;
$this->makeProcessor()->process($ticket, new Post());
self::assertInstanceOf(Client::class, $ticket->getClient());
self::assertNull($ticket->getSupplier());
self::assertNull($ticket->getOtherLabel());
}
public function testSupplierBranchNullifiesClientAndOtherLabel(): void
{
$ticket = $this->baseTicket('FOURNISSEUR')
->setClient(new Client())
->setSupplier(new Supplier())
->setOtherLabel('parasite')
;
$this->makeProcessor()->process($ticket, new Post());
self::assertInstanceOf(Supplier::class, $ticket->getSupplier());
self::assertNull($ticket->getClient());
self::assertNull($ticket->getOtherLabel());
}
public function testOtherBranchNullifiesClientAndSupplierAndTrimsLabel(): void
{
$ticket = $this->baseTicket('AUTRE')
->setClient(new Client())
->setSupplier(new Supplier())
->setOtherLabel(' Reprise interne ')
;
$this->makeProcessor()->process($ticket, new Post());
self::assertNull($ticket->getClient());
self::assertNull($ticket->getSupplier());
self::assertSame('Reprise interne', $ticket->getOtherLabel());
}
/**
* Ticket minimal VALIDE hors contrepartie : counterpartyType + immatriculation
* renseignes, afin d'isoler la violation de contrepartie (et pas un NotBlank
* collateral) dans le volet 1.
*/
private function baseTicket(string $type): WeighingTicket
{
return (new WeighingTicket())
->setCounterpartyType($type)
->setImmatriculation('AB-123-CD')
;
}
/**
* Liste des propertyPath des violations de l'entite.
*
* @return list<string>
*/
private function violationPaths(WeighingTicket $ticket): array
{
$paths = [];
foreach ($this->validator->validate($ticket) as $violation) {
$paths[] = $violation->getPropertyPath();
}
return $paths;
}
private function makeProcessor(): WeighingTicketProcessor
{
$persist = $this->createStub(ProcessorInterface::class);
$persist->method('process')->willReturnArgument(0);
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
$siteProvider->method('get')->willReturn(
(new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233'))->setCode('86'),
);
$numberAllocator = $this->createStub(WeighingTicketNumberAllocatorInterface::class);
$numberAllocator->method('allocate')->willReturn('86-TP-0001');
$em = $this->createStub(EntityManagerInterface::class);
$em->method('contains')->willReturn(false);
return new WeighingTicketProcessor(
$persist,
$siteProvider,
$numberAllocator,
$this->createStub(DsdAllocatorInterface::class),
new WeighingTicketFieldNormalizer(),
$em,
);
}
}
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
use App\Module\Logistique\Application\Service\WeighingTicketFieldNormalizer;
use App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighingTicketProcessor;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
/**
* Poids net (RG-5.05 / § 2.8) — test unitaire du WeighingTicketProcessor, sans BDD
* ni HTTP (stubs purs, meme approche que WeighbridgeReadingProcessorTest).
*
* Verifie la regle metier seule : net_weight = full_weight - empty_weight des que
* les DEUX poids sont presents, null tant que l'une des deux pesees manque, et
* recalcul a l'edition (PATCH).
*
* @internal
*/
final class NetWeightTest extends TestCase
{
public function testNetIsFullMinusEmptyWhenBothPresent(): void
{
$ticket = new WeighingTicket()
->setEmptyWeight(7150)
->setFullWeight(14300)
;
$this->makeProcessor(isNew: true)->process($ticket, new Post());
// 14300 - 7150 = 7150 (exemple maquette § 2.8).
self::assertSame(7150, $ticket->getNetWeight());
}
public function testNetIsNullWhenFullWeightMissing(): void
{
$ticket = new WeighingTicket()->setEmptyWeight(7150);
$this->makeProcessor(isNew: true)->process($ticket, new Post());
self::assertNull($ticket->getNetWeight());
}
public function testNetIsNullWhenEmptyWeightMissing(): void
{
$ticket = new WeighingTicket()->setFullWeight(14300);
$this->makeProcessor(isNew: true)->process($ticket, new Post());
self::assertNull($ticket->getNetWeight());
}
public function testNetIsNullWhenNoWeighing(): void
{
$ticket = new WeighingTicket();
$this->makeProcessor(isNew: true)->process($ticket, new Post());
self::assertNull($ticket->getNetWeight());
}
/**
* RG-5.05 : a la modification (PATCH = entite deja geree par l'ORM), le net est
* recalcule a partir des poids courants — ici la pesee a plein renseignee apres
* coup complete le ticket.
*/
public function testNetIsRecomputedOnPatch(): void
{
$ticket = new WeighingTicket()
->setSite($this->site())
->setEmptyWeight(7150)
->setFullWeight(20000)
;
$this->makeProcessor(isNew: false)->process($ticket, new Patch());
self::assertSame(12850, $ticket->getNetWeight());
}
/**
* Construit le Processor avec des dependances stubbees. `isNew` porte le sens
* metier : true => creation (POST, attribution site/numero), false => entite
* geree (PATCH, ni site ni numero retouches). Il est INVERSE pour stubber
* EntityManager::contains() (qui renvoie true pour une entite deja persistee),
* d'ou `willReturn(!$isNew)` plus bas.
*/
private function makeProcessor(bool $isNew): WeighingTicketProcessor
{
$persist = $this->createStub(ProcessorInterface::class);
$persist->method('process')->willReturnArgument(0);
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
$siteProvider->method('get')->willReturn($this->site());
$numberAllocator = $this->createStub(WeighingTicketNumberAllocatorInterface::class);
$numberAllocator->method('allocate')->willReturn('86-TP-0001');
$dsdAllocator = $this->createStub(DsdAllocatorInterface::class);
$dsdAllocator->method('next')->willReturn(99);
$em = $this->createStub(EntityManagerInterface::class);
$em->method('contains')->willReturn(!$isNew);
return new WeighingTicketProcessor(
$persist,
$siteProvider,
$numberAllocator,
$dsdAllocator,
new WeighingTicketFieldNormalizer(),
$em,
);
}
private function site(): Site
{
// getId() reste null : numberAllocator et dsdAllocator sont stubbes, donc
// aucune requete reelle ne depend de l'id du site.
return new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233')
->setCode('86')
;
}
}
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Post;
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface;
use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException;
use App\Module\Logistique\Domain\Weighbridge\WeighbridgeReading;
use App\Module\Logistique\Infrastructure\ApiPlatform\Resource\WeighbridgeReadingResource;
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighbridgeReadingProcessor;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Sites\Domain\Entity\Site;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
/**
* Processor de l'action `POST /api/weighbridge_readings` (§ 4.2).
*
* Couvre les 4 chemins sans BDD ni HTTP (stubs purs) : AUTO (lecture pont),
* MANUAL (allocation DSD seule), indisponibilite → 503 (RG-5.06) et absence de
* site courant → 400.
*
* @internal
*/
final class WeighbridgeReadingProcessorTest extends TestCase
{
private function site(): Site
{
// getId() reste null (non persiste) — sans incidence : reader et allocator
// sont stubbes dans ces tests unitaires.
return new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233');
}
public function testAutoModeFillsWeightAndDsdFromReader(): void
{
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
$siteProvider->method('get')->willReturn($this->site());
$reader = $this->createStub(WeighbridgeReaderInterface::class);
$reader->method('read')->willReturn(new WeighbridgeReading(23000, 42));
$processor = new WeighbridgeReadingProcessor(
$siteProvider,
$reader,
$this->createStub(DsdAllocatorInterface::class),
);
$resource = new WeighbridgeReadingResource();
$resource->mode = 'AUTO';
$result = $processor->process($resource, new Post());
self::assertSame(23000, $result->weight);
self::assertSame(42, $result->dsd);
self::assertNull($result->manualNumber);
self::assertSame('AUTO', $result->mode);
}
public function testManualModeKeepsWeightAndAllocatesDsd(): void
{
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
$siteProvider->method('get')->willReturn($this->site());
$allocator = $this->createStub(DsdAllocatorInterface::class);
$allocator->method('next')->willReturn(43);
$processor = new WeighbridgeReadingProcessor(
$siteProvider,
$this->createStub(WeighbridgeReaderInterface::class),
$allocator,
);
$resource = new WeighbridgeReadingResource();
$resource->mode = 'MANUAL';
$resource->weight = 23187;
$resource->manualNumber = 'PAP-555';
$result = $processor->process($resource, new Post());
self::assertSame(23187, $result->weight, 'Le poids saisi est conserve en manuel.');
self::assertSame(43, $result->dsd);
self::assertSame('PAP-555', $result->manualNumber);
self::assertSame('MANUAL', $result->mode);
}
public function testWeighbridgeUnavailableIsMappedTo503(): void
{
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
$siteProvider->method('get')->willReturn($this->site());
$reader = $this->createStub(WeighbridgeReaderInterface::class);
$reader->method('read')->willThrowException(new WeighbridgeUnavailableException());
$processor = new WeighbridgeReadingProcessor(
$siteProvider,
$reader,
$this->createStub(DsdAllocatorInterface::class),
);
$resource = new WeighbridgeReadingResource();
$resource->mode = 'AUTO';
try {
$processor->process($resource, new Post());
self::fail('Une ServiceUnavailableHttpException (503) etait attendue.');
} catch (ServiceUnavailableHttpException $e) {
self::assertSame(503, $e->getStatusCode());
self::assertStringContainsString('pesée manuelle', $e->getMessage());
}
}
public function testMissingCurrentSiteIsRejected(): void
{
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
$siteProvider->method('get')->willReturn(null);
$processor = new WeighbridgeReadingProcessor(
$siteProvider,
$this->createStub(WeighbridgeReaderInterface::class),
$this->createStub(DsdAllocatorInterface::class),
);
$resource = new WeighbridgeReadingResource();
$resource->mode = 'AUTO';
$this->expectException(BadRequestHttpException::class);
$processor->process($resource, new Post());
}
}
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Infrastructure\Service;
use App\Module\Logistique\Infrastructure\Service\DsdAllocator;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Allocateur DSD (RG-5.04 / § 2.7) — test d'integration sur la table
* `weighbridge_dsd_counter` (DBAL brut, verrou FOR UPDATE).
*
* Verifie l'increment sequentiel et l'isolation PAR SITE (un pont par site).
* Les compteurs des sites touches sont remis a zero en debut de test et purges
* en tearDown (pas de DAMA en local — nettoyage manuel obligatoire).
*
* @internal
*/
final class DsdAllocatorTest extends KernelTestCase
{
private Connection $connection;
private DsdAllocator $allocator;
private EntityManagerInterface $em;
/** @var list<int> */
private array $touchedSiteIds = [];
protected function setUp(): void
{
self::bootKernel();
$container = self::getContainer();
$this->em = $container->get('doctrine')->getManager();
$this->connection = $this->em->getConnection();
$this->allocator = $container->get(DsdAllocator::class);
}
protected function tearDown(): void
{
if ([] !== $this->touchedSiteIds) {
$this->connection->executeStatement(
'DELETE FROM weighbridge_dsd_counter WHERE site_id IN (?)',
[$this->touchedSiteIds],
[ArrayParameterType::INTEGER],
);
}
parent::tearDown();
}
public function testNextIncrementsSequentiallyAndIsIsolatedPerSite(): void
{
$sites = $this->em->getRepository(Site::class)->findAll();
self::assertGreaterThanOrEqual(2, \count($sites), 'Au moins 2 sites doivent etre seedes (fixtures).');
$siteA = $sites[0];
$siteB = $sites[1];
$this->resetCounter($siteA);
$this->resetCounter($siteB);
// AUTO/MANUAL partagent le meme increment : la sequence demarre a 1.
self::assertSame(1, $this->allocator->next($siteA));
self::assertSame(2, $this->allocator->next($siteA));
self::assertSame(3, $this->allocator->next($siteA));
// Isolation par site : le compteur de B est independant de celui de A.
self::assertSame(1, $this->allocator->next($siteB));
self::assertSame(2, $this->allocator->next($siteB));
// La sequence de A reprend la ou elle en etait (4), non perturbee par B.
self::assertSame(4, $this->allocator->next($siteA));
}
public function testNextStartsAtOneWhenNoCounterRowExists(): void
{
$site = $this->em->getRepository(Site::class)->findAll()[0];
$this->resetCounter($site);
// Aucune ligne compteur pour ce site : le premier appel la cree (last=0)
// et renvoie 1 (dernier + 1).
self::assertSame(1, $this->allocator->next($site));
}
/**
* Supprime la ligne compteur du site pour repartir d'un etat connu, et
* enregistre l'id pour la purge de tearDown.
*/
private function resetCounter(Site $site): void
{
$siteId = $site->getId();
self::assertNotNull($siteId);
$this->connection->executeStatement(
'DELETE FROM weighbridge_dsd_counter WHERE site_id = :site',
['site' => $siteId],
);
if (!\in_array($siteId, $this->touchedSiteIds, true)) {
$this->touchedSiteIds[] = $siteId;
}
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Infrastructure\Weighbridge;
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
use App\Module\Logistique\Infrastructure\Weighbridge\RandomWeighbridgeReader;
use App\Shared\Domain\Contract\SiteInterface;
use PHPUnit\Framework\TestCase;
/**
* Stub du pont bascule (RG-5.06 / § 2.6).
*
* Verifie le contrat du stub livre au M5 : poids aleatoire borne a
* [10000, 50000] kg et DSD delegue a l'allocateur (le chemin d'erreur 503
* est couvert cote Processor — WeighbridgeReadingProcessorTest).
*
* @internal
*/
final class WeighbridgeReaderStubTest extends TestCase
{
/**
* RG-5.06 : sur un grand nombre de lectures, le poids reste toujours dans
* l'intervalle borne [10000, 50000] (random_int inclusif aux deux bornes).
*/
public function testReadReturnsWeightWithinBounds(): void
{
$allocator = $this->createStub(DsdAllocatorInterface::class);
$allocator->method('next')->willReturn(1);
$reader = new RandomWeighbridgeReader($allocator);
$site = $this->createStub(SiteInterface::class);
for ($i = 0; $i < 500; ++$i) {
$reading = $reader->read($site);
self::assertGreaterThanOrEqual(10000, $reading->weight);
self::assertLessThanOrEqual(50000, $reading->weight);
}
}
/**
* RG-5.04 : le DSD renvoye par la lecture est celui fourni par l'allocateur
* de site (le reader ne calcule pas le DSD lui-meme).
*/
public function testReadDelegatesDsdToAllocator(): void
{
$allocator = $this->createStub(DsdAllocatorInterface::class);
$allocator->method('next')->willReturn(42);
$reader = new RandomWeighbridgeReader($allocator);
$reading = $reader->read($this->createStub(SiteInterface::class));
self::assertSame(42, $reading->dsd);
}
}
+8 -6
View File
@@ -79,7 +79,9 @@ final class SiteApiTest extends AbstractApiTestCase
'name' => 'Test-New-Site',
'street' => '1 rue du Test',
'complement' => null,
'postalCode' => '86000',
// CP 75xxx -> code derive 75 : evite la collision uq_site_code
// avec la fixture Chatellerault (code 86) — RG-5.02 (ERP-183).
'postalCode' => '75000',
'city' => 'Poitiers',
'color' => '#AABBCC',
],
@@ -94,7 +96,7 @@ final class SiteApiTest extends AbstractApiTestCase
public function testAdminCanPatchSite(): void
{
$em = $this->getEm();
$site = new Site('Test-Patch-Site', '1 rue Test', null, '86000', 'Poitiers', '#000000');
$site = new Site('Test-Patch-Site', '1 rue Test', null, '75000', 'Poitiers', '#000000');
$em->persist($site);
$em->flush();
@@ -112,7 +114,7 @@ final class SiteApiTest extends AbstractApiTestCase
public function testAdminCanDeleteSite(): void
{
$em = $this->getEm();
$site = new Site('Test-Delete-Site', '1 rue Test', null, '86000', 'Poitiers', '#000000');
$site = new Site('Test-Delete-Site', '1 rue Test', null, '75000', 'Poitiers', '#000000');
$em->persist($site);
$em->flush();
$siteId = $site->getId();
@@ -129,7 +131,7 @@ final class SiteApiTest extends AbstractApiTestCase
public function testUserWithViewButNotManageCannotDelete(): void
{
$em = $this->getEm();
$site = new Site('Test-Protected', '1 rue Test', null, '86000', 'Poitiers', '#000000');
$site = new Site('Test-Protected', '1 rue Test', null, '75000', 'Poitiers', '#000000');
$em->persist($site);
$em->flush();
@@ -189,7 +191,7 @@ final class SiteApiTest extends AbstractApiTestCase
'json' => [
'name' => 'Test-FullAddress-Ignored',
'street' => '1 rue Test',
'postalCode' => '86000',
'postalCode' => '75000',
'city' => 'Poitiers',
'color' => '#000000',
'fullAddress' => 'Adresse arbitraire envoyee par le client',
@@ -200,7 +202,7 @@ final class SiteApiTest extends AbstractApiTestCase
$data = $response->toArray();
// Le getter computed prevaut sur ce qu'envoie le client : street
// determine la 1re ligne, jamais la valeur "Adresse arbitraire...".
self::assertSame("1 rue Test\n86000 Poitiers", $data['fullAddress']);
self::assertSame("1 rue Test\n75000 Poitiers", $data['fullAddress']);
}
public function testCreateSiteWithInvalidPostalCodeReturns422(): void