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, ), 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, ), // 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; #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['weighing_ticket:read'])] private ?int $id = null; /** Numero {siteCode}-TP-{NNNN} — attribue serveur, lecture seule, immuable (RG-5.02). */ #[ORM\Column(length: 20)] #[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) — pilote le champ associe obligatoire. */ #[ORM\Column(name: 'counterparty_type', length: 12)] #[Assert\NotBlank(message: 'La contrepartie (Client / Fournisseur / Autre) est obligatoire.')] #[Assert\Choice(choices: ['CLIENT', 'FOURNISSEUR', '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). Masque XX-000-XX sauf plateFreeFormat. */ #[ORM\Column(length: 20)] #[Assert\NotBlank(message: 'L\'immatriculation est obligatoire.', normalizer: 'trim')] #[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). * Obligatoire : un ticket est cree APRES la pesee a vide (POST). NotBlank ici * (et non sur empty_dsd, alloue serveur) rend la 422 « poids obligatoire » * coherente avec les autres champs requis (counterpartyType / immatriculation), * toutes renvoyees d'un coup -> mapping inline front (ERP-101). */ #[ORM\Column(name: 'empty_weight', nullable: true)] #[Assert\NotBlank(message: 'Le poids est obligatoire : effectuez une pesée.')] #[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; /** Numero de pesee saisi en manuelle (distinct du DSD) — RG-5.04. */ #[ORM\Column(name: 'empty_manual_number', length: 50, nullable: true)] #[Assert\Length(max: 50, maxMessage: 'Le numéro de pesée ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] private ?string $emptyManualNumber = 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; /** Numero de pesee saisi en manuelle (distinct du DSD) — RG-5.04. */ #[ORM\Column(name: 'full_manual_number', length: 50, nullable: true)] #[Assert\Length(max: 50, maxMessage: 'Le numéro de pesée ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['weighing_ticket:item:read', 'weighing_ticket:write'])] private ?string $fullManualNumber = 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; /** 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] public function validateCounterpartyConsistency(ExecutionContextInterface $context): void { switch ($this->counterpartyType) { case 'CLIENT': if (null === $this->client) { $context->buildViolation('Le client est obligatoire pour une contrepartie « Client ».') ->atPath('client') ->addViolation() ; } break; case 'FOURNISSEUR': if (null === $this->supplier) { $context->buildViolation('Le fournisseur est obligatoire pour une contrepartie « Fournisseur ».') ->atPath('supplier') ->addViolation() ; } break; case 'AUTRE': if (null === $this->otherLabel || '' === trim($this->otherLabel)) { $context->buildViolation('Le libellé est obligatoire pour une contrepartie « Autre ».') ->atPath('otherLabel') ->addViolation() ; } break; } } /** * 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; } 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 getEmptyManualNumber(): ?string { return $this->emptyManualNumber; } public function setEmptyManualNumber(?string $emptyManualNumber): static { $this->emptyManualNumber = $emptyManualNumber; 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 getFullManualNumber(): ?string { return $this->fullManualNumber; } public function setFullManualNumber(?string $fullManualNumber): static { $this->fullManualNumber = $fullManualNumber; return $this; } public function getNetWeight(): ?int { return $this->netWeight; } public function setNetWeight(?int $netWeight): static { $this->netWeight = $netWeight; return $this; } public function getDeletedAt(): ?DateTimeImmutable { return $this->deletedAt; } public function setDeletedAt(?DateTimeImmutable $deletedAt): static { $this->deletedAt = $deletedAt; return $this; } }