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 */ 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 */ 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 $collection * * @return null|array */ protected function memberById(array $collection, int $id): ?array { foreach ($collection['member'] ?? [] as $member) { if (($member['id'] ?? null) === $id) { return $member; } } return null; } }