atPath()) pour que * chaque 422 porte un propertyPath exploitable par useFormErrors (mapping inline, * pas un toast — ERP-101). L'exclusivite « les autres champs forces nuls » est * garantie par les CHECK Postgres (chk_wt_*_branch) + la normalisation du * Processor (ERP-185). Pas de Delete, pas d'archive au M5 (§ 2.13). * * @see WeighingTicketProvider Lecture (liste paginee filtree site courant + item) — ERP-185. * @see WeighingTicketProcessor Ecriture (numerotation, normalisation, net) — ERP-185. */ #[ApiResource( operations: [ new GetCollection( security: "is_granted('logistique.weighing_tickets.view')", normalizationContext: ['groups' => [ 'weighing_ticket:read', 'client:read', 'supplier:read', 'site:read', 'default:read', ]], provider: WeighingTicketProvider::class, ), new Get( security: "is_granted('logistique.weighing_tickets.view')", normalizationContext: ['groups' => [ 'weighing_ticket:read', 'weighing_ticket:item:read', 'client:read', 'supplier:read', 'site:read', 'default:read', ]], provider: WeighingTicketProvider::class, ), // Bon de pesee PDF (RG-5.08, spec § 2.12 / § 4.6) : operation dediee qui // sert un binaire (pas une representation Hydra). Le provider retourne une // Response -> la serialisation est court-circuitee. Pas de controller // (decision spec § 4.6). Pas de format API Platform negocie : `.pdf` est // litteral dans l'URI. new Get( uriTemplate: '/weighing_tickets/{id}/print.pdf', security: "is_granted('logistique.weighing_tickets.view')", provider: WeighingTicketPrintProvider::class, output: false, read: true, ), new Post( security: "is_granted('logistique.weighing_tickets.manage')", normalizationContext: ['groups' => [ 'weighing_ticket:read', 'weighing_ticket:item:read', 'client:read', 'supplier:read', 'site:read', 'default:read', ]], denormalizationContext: ['groups' => ['weighing_ticket:write']], // Erreurs de denormalisation (date non parsable, type/IRI invalide) // remontees en 422 avec propertyPath (et non 400 opaque) -> mapping // inline par champ cote front via useFormErrors (miroir M1 Client). collectDenormalizationErrors: true, processor: WeighingTicketProcessor::class, ), new Patch( security: "is_granted('logistique.weighing_tickets.manage')", normalizationContext: ['groups' => [ 'weighing_ticket:read', 'weighing_ticket:item:read', 'client:read', 'supplier:read', 'site:read', 'default:read', ]], denormalizationContext: ['groups' => ['weighing_ticket:write']], collectDenormalizationErrors: true, provider: WeighingTicketProvider::class, processor: WeighingTicketProcessor::class, ), // Validation (« Valider », ERP-193) : transition brouillon -> valide. Seule // operation qui exige le groupe `finalize` (contrepartie + immatriculation + // les 2 pesees, § 2.14) ; le Processor y attribue le numero et passe status // a VALIDATED. Le POST/PATCH standard restent « brouillon » (validation // Default relachee, on enregistre une pesee sans contrepartie/immat). new Patch( uriTemplate: '/weighing_tickets/{id}/validate', name: 'weighing_ticket_validate', security: "is_granted('logistique.weighing_tickets.manage')", normalizationContext: ['groups' => [ 'weighing_ticket:read', 'weighing_ticket:item:read', 'client:read', 'supplier:read', 'site:read', 'default:read', ]], denormalizationContext: ['groups' => ['weighing_ticket:write']], validationContext: ['groups' => ['Default', 'finalize']], collectDenormalizationErrors: true, provider: WeighingTicketProvider::class, processor: WeighingTicketProcessor::class, ), // Pas de Delete au M5 (HP-M5-05). Pas d'archive (hors docx). ], )] #[ORM\Entity(repositoryClass: DoctrineWeighingTicketRepository::class)] #[ORM\Table(name: 'weighing_ticket')] #[ORM\Index(name: 'idx_wt_site', columns: ['site_id'])] #[ORM\Index(name: 'idx_wt_client', columns: ['client_id'])] #[ORM\Index(name: 'idx_wt_supplier', columns: ['supplier_id'])] #[ORM\Index(name: 'idx_wt_deleted_at', columns: ['deleted_at'])] #[ORM\Index(name: 'idx_wt_created_by', columns: ['created_by'])] #[ORM\Index(name: 'idx_wt_updated_by', columns: ['updated_by'])] #[ORM\UniqueConstraint(name: 'uq_weighing_ticket_number', columns: ['site_id', 'number'])] #[Auditable] class WeighingTicket implements TimestampableInterface, BlamableInterface { use TimestampableBlamableTrait; /** Brouillon : pesee(s) enregistree(s), pas encore valide (« En attente »). */ public const string STATUS_DRAFT = 'DRAFT'; /** Valide : contrepartie + immatriculation + 2 pesees OK, numero attribue (« Terminée »). */ public const string STATUS_VALIDATED = 'VALIDATED'; /** Contrepartie « Client » (M1) — RG-5.03. */ public const string COUNTERPARTY_CLIENT = 'CLIENT'; /** Contrepartie « Fournisseur » (M2) — RG-5.03. */ public const string COUNTERPARTY_FOURNISSEUR = 'FOURNISSEUR'; /** Contrepartie « Autre » (libelle libre) — RG-5.03. */ public const string COUNTERPARTY_AUTRE = 'AUTRE'; #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['weighing_ticket:read'])] private ?int $id = null; /** Numero {siteCode}-TP-{NNNN} — attribue serveur a la VALIDATION, null tant que brouillon, immuable ensuite (RG-5.02, ERP-193). */ #[ORM\Column(length: 20, nullable: true)] #[Groups(['weighing_ticket:read'])] private ?string $number = null; /** Site du pont bascule — resolu serveur depuis le site courant, immuable (§ 2.3 / RG-5.09). */ #[ORM\ManyToOne(targetEntity: Site::class)] #[ORM\JoinColumn(name: 'site_id', nullable: false, onDelete: 'RESTRICT')] #[Groups(['weighing_ticket:item:read'])] private ?Site $site = null; /** CLIENT | FOURNISSEUR | AUTRE (RG-5.03) — null tant que brouillon, requis a la validation. Pilote le champ associe obligatoire. */ #[ORM\Column(name: 'counterparty_type', length: 12, nullable: true)] #[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.', groups: ['finalize'])] #[Assert\Choice(choices: [self::COUNTERPARTY_CLIENT, self::COUNTERPARTY_FOURNISSEUR, self::COUNTERPARTY_AUTRE], message: 'Type de contrepartie invalide.')] #[Groups(['weighing_ticket:read', 'weighing_ticket:write'])] private ?string $counterpartyType = null; /** Requis ssi counterpartyType = CLIENT (validateCounterpartyConsistency, RG-5.03). */ #[ORM\ManyToOne(targetEntity: Client::class)] #[ORM\JoinColumn(name: 'client_id', nullable: true, onDelete: 'RESTRICT')] #[Groups(['weighing_ticket:read', 'weighing_ticket:write'])] private ?Client $client = null; /** Requis ssi counterpartyType = FOURNISSEUR (RG-5.03). */ #[ORM\ManyToOne(targetEntity: Supplier::class)] #[ORM\JoinColumn(name: 'supplier_id', nullable: true, onDelete: 'RESTRICT')] #[Groups(['weighing_ticket:read', 'weighing_ticket:write'])] private ?Supplier $supplier = null; /** Libelle libre — requis ssi counterpartyType = AUTRE (RG-5.03). */ #[ORM\Column(name: 'other_label', length: 255, nullable: true)] #[Assert\Length(max: 255, maxMessage: 'Le libellé ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['weighing_ticket:read', 'weighing_ticket:write'])] private ?string $otherLabel = null; /** Plaque du vehicule, partagee entre les 2 formulaires (RG-5.01). Null tant que brouillon, requise a la validation. Masque XX-000-XX sauf plateFreeFormat. */ #[ORM\Column(length: 20, nullable: true)] #[Assert\NotBlank(message: 'L\'immatriculation est obligatoire.', normalizer: 'trim', groups: ['finalize'])] #[Assert\Length(max: 20, maxMessage: 'L\'immatriculation ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] private ?string $immatriculation = null; // « Tout format » : desactive le masque XX-000-XX (RG-5.01). Le groupe de // LECTURE est porte par le getter isPlateFreeFormat() (+ SerializedName, // piege booleen #3 M1) ; le groupe d'ECRITURE vit ici pour cibler le setter. #[ORM\Column(name: 'plate_free_format', options: ['default' => false])] #[Groups(['weighing_ticket:write'])] private bool $plateFreeFormat = false; // === Pesee a vide (§ 2.4) === #[ORM\Column(name: 'empty_date', type: 'datetime_immutable', nullable: true)] #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] private ?DateTimeImmutable $emptyDate = null; /** * Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07). * Nullable au brouillon (on peut enregistrer la seule pesee a plein d'abord, * ERP-193). L'obligation des DEUX pesees est portee par validateFinalization * (groupe `finalize`), jouee uniquement a la validation. */ #[ORM\Column(name: 'empty_weight', nullable: true)] #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] private ?int $emptyWeight = null; #[ORM\Column(name: 'empty_dsd', nullable: true)] #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] private ?int $emptyDsd = null; /** AUTO (pont bascule) | MANUAL (saisie) — chk_wt_empty_mode (RG-5.06). */ #[ORM\Column(name: 'empty_mode', length: 8, nullable: true)] #[Assert\Choice(choices: ['AUTO', 'MANUAL'], message: 'Mode de pesée invalide.')] #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] private ?string $emptyMode = null; // === Pesee a plein (§ 2.4) === #[ORM\Column(name: 'full_date', type: 'datetime_immutable', nullable: true)] #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] private ?DateTimeImmutable $fullDate = null; /** Poids a plein (brut) en kg — readonly UI, rempli par la pesee (RG-5.07). */ #[ORM\Column(name: 'full_weight', nullable: true)] #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] private ?int $fullWeight = null; #[ORM\Column(name: 'full_dsd', nullable: true)] #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] private ?int $fullDsd = null; /** AUTO (pont bascule) | MANUAL (saisie) — chk_wt_full_mode (RG-5.06). */ #[ORM\Column(name: 'full_mode', length: 8, nullable: true)] #[Assert\Choice(choices: ['AUTO', 'MANUAL'], message: 'Mode de pesée invalide.')] #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] private ?string $fullMode = null; /** Poids net derive plein - vide (kg) — calcule serveur (RG-5.05). Colonne Poids de la liste. */ #[ORM\Column(name: 'net_weight', nullable: true)] #[Groups(['weighing_ticket:read'])] private ?int $netWeight = null; /** * Cycle de vie (ERP-193) : DRAFT (« En attente » — pesee enregistree sans * contrepartie/immat) -> VALIDATED (« Terminée » — valide avec numero). Pose * serveur (DRAFT a la creation, VALIDATED par l'operation `validate`) ; pas de * groupe d'ecriture (jamais pilote par le client). */ #[ORM\Column(length: 12, options: ['default' => self::STATUS_DRAFT])] #[Groups(['weighing_ticket:read'])] private string $status = self::STATUS_DRAFT; /** Soft-delete technique prepare mais non expose au M5 (§ 2.13) — pas de groupe. */ #[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)] private ?DateTimeImmutable $deletedAt = null; /** * Coherence de la contrepartie (RG-5.03). Decision figee (miroir M4 * validateMainFormConsistency) : porte par une contrainte d'entite * (Assert\Callback + ->atPath()) pour que chaque 422 soit mappee inline sous * le champ par useFormErrors (pas un toast — ERP-101). Jouee par API Platform * AVANT le Processor, sur POST comme sur PATCH. * * Ne valide ICI que la PRESENCE du champ associe au type. L'exclusivite * « les autres champs forces nuls » est garantie par les CHECK Postgres * (chk_wt_*_branch) et la normalisation du Processor (qui null-ifie les * champs hors-branche — ERP-185). */ #[Assert\Callback(groups: ['finalize'])] public function validateCounterpartyConsistency(ExecutionContextInterface $context): void { switch ($this->counterpartyType) { case self::COUNTERPARTY_CLIENT: if (null === $this->client) { $context->buildViolation('Le client est obligatoire pour une contrepartie « Client ».') ->atPath('client') ->addViolation() ; } break; case self::COUNTERPARTY_FOURNISSEUR: if (null === $this->supplier) { $context->buildViolation('Le fournisseur est obligatoire pour une contrepartie « Fournisseur ».') ->atPath('supplier') ->addViolation() ; } break; case self::COUNTERPARTY_AUTRE: if (null === $this->otherLabel || '' === trim($this->otherLabel)) { $context->buildViolation('Le libellé est obligatoire pour une contrepartie « Autre ».') ->atPath('otherLabel') ->addViolation() ; } break; } } /** * Validation finale (ERP-193, § 2.14) : un ticket ne peut etre VALIDE qu'avec * ses DEUX pesees renseignees (le poids net plein - vide n'a de sens que * complet). Jouee uniquement dans le groupe `finalize` (operation `validate`) ; * un brouillon peut ne porter qu'une seule pesee. Violations posees sur les * champs poids -> mapping inline front (useFormErrors, ERP-101). */ #[Assert\Callback(groups: ['finalize'])] public function validateFinalization(ExecutionContextInterface $context): void { if (null === $this->emptyWeight) { $context->buildViolation('La pesée à vide est obligatoire pour valider le ticket.') ->atPath('emptyWeight') ->addViolation() ; } if (null === $this->fullWeight) { $context->buildViolation('La pesée à plein est obligatoire pour valider le ticket.') ->atPath('fullWeight') ->addViolation() ; } } /** * Date du ticket affichee en LISTE (§ 4.0) : date de la pesee a plein si * disponible, sinon date de la pesee a vide. Getter calcule (jamais * persiste), expose en lecture seule. */ #[Groups(['weighing_ticket:read'])] public function getDisplayDate(): ?DateTimeImmutable { return $this->fullDate ?? $this->emptyDate; } public function getId(): ?int { return $this->id; } public function getNumber(): ?string { return $this->number; } public function setNumber(?string $number): static { $this->number = $number; return $this; } public function getSite(): ?Site { return $this->site; } public function setSite(?Site $site): static { $this->site = $site; return $this; } public function getCounterpartyType(): ?string { return $this->counterpartyType; } public function setCounterpartyType(?string $counterpartyType): static { $this->counterpartyType = $counterpartyType; return $this; } public function getClient(): ?Client { return $this->client; } public function setClient(?Client $client): static { $this->client = $client; return $this; } public function getSupplier(): ?Supplier { return $this->supplier; } public function setSupplier(?Supplier $supplier): static { $this->supplier = $supplier; return $this; } public function getOtherLabel(): ?string { return $this->otherLabel; } public function setOtherLabel(?string $otherLabel): static { $this->otherLabel = $otherLabel; return $this; } /** * Nom du tiers à afficher (cartouche du bon de pesée PDF, ERP-208) : raison * sociale du client/fournisseur ou libellé libre selon le type de contrepartie * (RG-5.03). Null si aucune contrepartie cohérente (brouillon). */ public function getCounterpartyName(): ?string { return match ($this->counterpartyType) { self::COUNTERPARTY_CLIENT => $this->client?->getCompanyName(), self::COUNTERPARTY_FOURNISSEUR => $this->supplier?->getCompanyName(), self::COUNTERPARTY_AUTRE => $this->otherLabel, default => null, }; } public function getImmatriculation(): ?string { return $this->immatriculation; } public function setImmatriculation(?string $immatriculation): static { $this->immatriculation = $immatriculation; return $this; } // Piege booleen (RETEX M1 #3) : #[Groups] + #[SerializedName] sur le getter, // sinon Symfony strip le prefixe « is » et drope la cle plateFreeFormat du JSON. #[Groups(['weighing_ticket:read'])] #[SerializedName('plateFreeFormat')] public function isPlateFreeFormat(): bool { return $this->plateFreeFormat; } public function setPlateFreeFormat(bool $plateFreeFormat): static { $this->plateFreeFormat = $plateFreeFormat; return $this; } public function getEmptyDate(): ?DateTimeImmutable { return $this->emptyDate; } public function setEmptyDate(?DateTimeImmutable $emptyDate): static { $this->emptyDate = $emptyDate; return $this; } public function getEmptyWeight(): ?int { return $this->emptyWeight; } public function setEmptyWeight(?int $emptyWeight): static { $this->emptyWeight = $emptyWeight; return $this; } public function getEmptyDsd(): ?int { return $this->emptyDsd; } public function setEmptyDsd(?int $emptyDsd): static { $this->emptyDsd = $emptyDsd; return $this; } public function getEmptyMode(): ?string { return $this->emptyMode; } public function setEmptyMode(?string $emptyMode): static { $this->emptyMode = $emptyMode; return $this; } public function getFullDate(): ?DateTimeImmutable { return $this->fullDate; } public function setFullDate(?DateTimeImmutable $fullDate): static { $this->fullDate = $fullDate; return $this; } public function getFullWeight(): ?int { return $this->fullWeight; } public function setFullWeight(?int $fullWeight): static { $this->fullWeight = $fullWeight; return $this; } public function getFullDsd(): ?int { return $this->fullDsd; } public function setFullDsd(?int $fullDsd): static { $this->fullDsd = $fullDsd; return $this; } public function getFullMode(): ?string { return $this->fullMode; } public function setFullMode(?string $fullMode): static { $this->fullMode = $fullMode; return $this; } public function getNetWeight(): ?int { return $this->netWeight; } public function setNetWeight(?int $netWeight): static { $this->netWeight = $netWeight; return $this; } public function getStatus(): string { return $this->status; } public function setStatus(string $status): static { $this->status = $status; return $this; } public function isValidated(): bool { return self::STATUS_VALIDATED === $this->status; } public function getDeletedAt(): ?DateTimeImmutable { return $this->deletedAt; } public function setDeletedAt(?DateTimeImmutable $deletedAt): static { $this->deletedAt = $deletedAt; return $this; } }