feat : M5 — Tickets de pesée (ERP-188 → ERP-193) (#144)
Auto Tag Develop / tag (push) Successful in 8s
Auto Tag Develop / tag (push) Successful in 8s
MR unique regroupant tout le module M5 « Tickets de pesée » (remplace les MR empilées #140/#141/#142/#143).
## Périmètre
- **ERP-188** — Page liste des tickets de pesée + export XLSX (colonnes Fournisseur/Client/Autre + Statut).
- **ERP-189** — Écran « Ajouter » (4 champs en haut, 2 blocs de pesée, pesée bascule/manuelle, date+heure horodatée à la validation).
- **ERP-190** — Écran « Modifier » + bouton Imprimer.
- **ERP-191** — i18n + libellés + branchement site courant.
- **ERP-192** — Bon de pesée PDF généré côté back (template Twig → Dompdf), endpoint `GET /api/weighing_tickets/{id}/print.pdf`.
- **ERP-193** — Cycle de vie brouillon/validé (status DRAFT/VALIDATED, numéro attribué à la validation), DSD saisi conservé en pesée manuelle, retours métier design.
## Vérifications
- Back : tests Logistique + architecture verts, php-cs-fixer propre, migrations appliquées (dev + test).
- Front : suite Vitest complète verte, ESLint propre.
Base : `develop` — contient les 16 commits du M5 (rien d'autre).
Reviewed-on: #144
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #144.
This commit is contained in:
@@ -12,6 +12,7 @@ use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Commercial\Domain\Entity\Client; // relation ORM partagee (§ 2.1)
|
||||
use App\Module\Commercial\Domain\Entity\Supplier; // relation ORM partagee (§ 2.1)
|
||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighingTicketProcessor;
|
||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketPrintProvider;
|
||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketProvider;
|
||||
use App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository;
|
||||
use App\Module\Sites\Domain\Entity\Site; // relation ORM partagee (§ 2.1)
|
||||
@@ -84,6 +85,18 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
]],
|
||||
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' => [
|
||||
@@ -95,6 +108,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
'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(
|
||||
@@ -108,6 +125,30 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
'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,
|
||||
),
|
||||
@@ -128,14 +169,20 @@ 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';
|
||||
|
||||
#[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)]
|
||||
/** 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;
|
||||
|
||||
@@ -145,9 +192,9 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
||||
#[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.')]
|
||||
/** 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: ['CLIENT', 'FOURNISSEUR', 'AUTRE'], message: 'Type de contrepartie invalide.')]
|
||||
#[Groups(['weighing_ticket:read', 'weighing_ticket:write'])]
|
||||
private ?string $counterpartyType = null;
|
||||
@@ -170,9 +217,9 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
||||
#[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')]
|
||||
/** 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;
|
||||
@@ -190,7 +237,12 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
||||
#[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). */
|
||||
/**
|
||||
* 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;
|
||||
@@ -205,12 +257,6 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
||||
#[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)]
|
||||
@@ -232,17 +278,21 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
||||
#[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;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -259,7 +309,7 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
||||
* (chk_wt_*_branch) et la normalisation du Processor (qui null-ifie les
|
||||
* champs hors-branche — ERP-185).
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
#[Assert\Callback(groups: ['finalize'])]
|
||||
public function validateCounterpartyConsistency(ExecutionContextInterface $context): void
|
||||
{
|
||||
switch ($this->counterpartyType) {
|
||||
@@ -295,6 +345,31 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -459,18 +534,6 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
||||
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;
|
||||
@@ -519,18 +582,6 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
||||
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;
|
||||
@@ -543,6 +594,23 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface
|
||||
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;
|
||||
|
||||
+29
-19
@@ -20,17 +20,16 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
*
|
||||
* - AUTO (`{ "mode": "AUTO" }`) → `{ weight, dsd, mode }` (stub : poids
|
||||
* aleatoire ∈ [10000,50000] kg + DSD du site, RG-5.04 / RG-5.06).
|
||||
* - MANUAL (`{ "mode": "MANUAL", "weight": <int>, "manualNumber": "<str>" }`)
|
||||
* → `{ weight, dsd, manualNumber, mode }` (DSD = dernier DSD du site + 1).
|
||||
* - MANUAL (`{ "mode": "MANUAL", "weight": <int>, "dsd": <int> }`)
|
||||
* → `{ weight, dsd, mode }`. Le DSD est SAISI par l'operateur (numero du pont
|
||||
* qu'il a reellement utilise) et conserve tel quel — plus d'auto-increment
|
||||
* (ERP-193). Pas d'unicite : un DSD peut se repeter.
|
||||
*
|
||||
* `read: false` : pas de chargement d'entite existante — le payload est
|
||||
* denormalise directement dans cette ressource, puis le Processor prend le relais.
|
||||
*
|
||||
* ⚠ Le `dsd` renvoye ici est PREVISIONNEL : l'attribution AUTORITAIRE du DSD
|
||||
* (et du numero de ticket) est refaite/verrouillee a la creation du ticket
|
||||
* (`POST /api/weighing_tickets`, ERP-185) pour eviter les collisions si deux
|
||||
* postes pesent en parallele. Le front affiche cette valeur, mais c'est le
|
||||
* ticket persiste qui fait foi.
|
||||
* ⚠ En AUTO, le `dsd` renvoye est fourni par le pont. En MANUAL, c'est la valeur
|
||||
* saisie. Le ticket persiste fait foi.
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'WeighbridgeReading',
|
||||
@@ -63,29 +62,40 @@ final class WeighbridgeReadingResource
|
||||
#[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])]
|
||||
public ?int $weight = null;
|
||||
|
||||
/** Numero de pesee papier saisi en MANUAL (distinct du DSD, RG-5.04). */
|
||||
#[Assert\Length(max: 50, maxMessage: 'Le numéro de pesée ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
/**
|
||||
* DSD de la pesee. En AUTO : fourni par le pont (lecture seule). En MANUAL :
|
||||
* SAISI par l'operateur et conserve tel quel (ERP-193). Positif s'il est present
|
||||
* (l'obligation en MANUAL est portee par le Callback ci-dessous).
|
||||
*/
|
||||
#[Assert\Positive(message: 'Le DSD doit être un entier positif.')]
|
||||
#[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])]
|
||||
public ?string $manualNumber = null;
|
||||
|
||||
/** DSD attribue par le serveur (lecture seule) — previsionnel (cf. docbloc classe). */
|
||||
#[Groups(['weighbridge_reading:read'])]
|
||||
public ?int $dsd = null;
|
||||
|
||||
/**
|
||||
* RG metier : en pesee MANUAL, le poids est saisi par l'operateur (le pont
|
||||
* n'est pas lu) → il est obligatoire. Porte par un Callback pour que le 422
|
||||
* cible le propertyPath `weight` (mapping inline front, ERP-101). En AUTO,
|
||||
* le poids fourni par le client est ignore (renseigne par le pont).
|
||||
* RG metier MANUAL : le pont n'est pas lu, l'operateur saisit le poids ET le DSD
|
||||
* → les deux sont obligatoires. Porte par un Callback pour que chaque 422 cible
|
||||
* son propertyPath (`weight` / `dsd`) et soit mappee inline (ERP-101). En AUTO,
|
||||
* poids et DSD sont fournis par le pont (saisie client ignoree).
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateManualWeight(ExecutionContextInterface $context): void
|
||||
public function validateManualFields(ExecutionContextInterface $context): void
|
||||
{
|
||||
if ('MANUAL' === $this->mode && null === $this->weight) {
|
||||
if ('MANUAL' !== $this->mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null === $this->weight) {
|
||||
$context->buildViolation('Le poids est obligatoire en pesée manuelle.')
|
||||
->atPath('weight')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
if (null === $this->dsd) {
|
||||
$context->buildViolation('Le DSD est obligatoire en pesée manuelle.')
|
||||
->atPath('dsd')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-10
@@ -6,7 +6,6 @@ namespace App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
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\Infrastructure\ApiPlatform\Resource\WeighbridgeReadingResource;
|
||||
@@ -23,7 +22,9 @@ use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
|
||||
* - AUTO : lit le pont (WeighbridgeReaderInterface) → poids + DSD. Si la
|
||||
* bascule est indisponible (WeighbridgeUnavailableException) → HTTP 503
|
||||
* « Pont bascule indisponible — passez en pesee manuelle » (RG-5.06).
|
||||
* - MANUAL : conserve le poids saisi et alloue le DSD (dernier + 1, RG-5.04).
|
||||
* - MANUAL : conserve le poids ET le DSD saisis par l'operateur tels quels — plus
|
||||
* d'auto-increment (ERP-193 : le DSD saisi est la valeur du pont reellement
|
||||
* utilisee, on ne la remplace pas).
|
||||
*
|
||||
* @implements ProcessorInterface<WeighbridgeReadingResource, WeighbridgeReadingResource>
|
||||
*/
|
||||
@@ -32,7 +33,6 @@ final class WeighbridgeReadingProcessor implements ProcessorInterface
|
||||
public function __construct(
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
private readonly WeighbridgeReaderInterface $weighbridgeReader,
|
||||
private readonly DsdAllocatorInterface $dsdAllocator,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): WeighbridgeReadingResource
|
||||
@@ -65,17 +65,15 @@ final class WeighbridgeReadingProcessor implements ProcessorInterface
|
||||
);
|
||||
}
|
||||
|
||||
$data->weight = $reading->weight;
|
||||
$data->dsd = $reading->dsd;
|
||||
$data->manualNumber = null; // pas de numero papier en mode bascule
|
||||
$data->weight = $reading->weight;
|
||||
$data->dsd = $reading->dsd;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
// MANUAL : le poids est saisi (validateManualWeight garantit sa presence),
|
||||
// seul le DSD est attribue serveur (dernier DSD du site + 1, RG-5.04).
|
||||
$data->dsd = $this->dsdAllocator->next($site);
|
||||
|
||||
// MANUAL : poids ET DSD sont saisis par l'operateur (validateManualFields
|
||||
// garantit leur presence) et conserves tels quels — aucun auto-increment
|
||||
// (ERP-193). Rien a recalculer cote serveur.
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
+75
-22
@@ -67,14 +67,14 @@ final class WeighingTicketProcessor implements ProcessorInterface
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
// Une entite non geree par l'ORM = creation (POST) : site + numero ne sont
|
||||
// attribues qu'a ce moment et restent immuables ensuite (RG-5.09).
|
||||
// Une entite non geree par l'ORM = creation (POST). On rattache le site
|
||||
// courant (cloisonnement + base de la numerotation), immuable ensuite
|
||||
// (RG-5.09). Le NUMERO n'est PLUS attribue ici : un ticket nait « brouillon »
|
||||
// (status DRAFT par defaut) et n'est numerote qu'a la validation (ERP-193).
|
||||
$isNew = !$this->em->contains($data);
|
||||
|
||||
if ($isNew) {
|
||||
$site = $this->resolveCurrentSite();
|
||||
$data->setSite($site);
|
||||
$data->setNumber($this->numberAllocator->allocate($site));
|
||||
$data->setSite($this->resolveCurrentSite());
|
||||
}
|
||||
|
||||
$this->applyCounterpartyExclusivity($data);
|
||||
@@ -84,11 +84,23 @@ final class WeighingTicketProcessor implements ProcessorInterface
|
||||
// depuis la base. Garde defensive si jamais il manque (ne devrait pas).
|
||||
$site = $data->getSite();
|
||||
if ($site instanceof Site) {
|
||||
$this->allocateAutoDsd($data, $site, $isNew);
|
||||
$this->allocateAutoDsd($data, $site);
|
||||
}
|
||||
|
||||
$this->computeNetWeight($data);
|
||||
|
||||
// Operation `validate` (« Valider », ERP-193) : transition brouillon -> valide.
|
||||
// La validation stricte (groupe finalize : contrepartie + immat + 2 pesees) a
|
||||
// deja joue en amont. On attribue le numero {siteCode}-TP-{NNNN} (compteur
|
||||
// verrouille, RG-5.02 ; uniquement s'il n'existe pas encore, immuable) puis on
|
||||
// passe le statut a VALIDATED.
|
||||
if ('weighing_ticket_validate' === $operation->getName()) {
|
||||
if (null === $data->getNumber() && $site instanceof Site) {
|
||||
$data->setNumber($this->numberAllocator->allocate($site));
|
||||
}
|
||||
$data->setStatus(WeighingTicket::STATUS_VALIDATED);
|
||||
}
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
@@ -109,36 +121,73 @@ final class WeighingTicketProcessor implements ProcessorInterface
|
||||
|
||||
/**
|
||||
* RG-5.03 : garantit l'exclusivite de la contrepartie en forcant a null les
|
||||
* champs hors-branche selon counterpartyType. La PRESENCE du champ requis est
|
||||
* deja validee en amont (Assert\Callback de l'entite) ; ici on evite qu'un
|
||||
* payload portant a la fois client_id ET supplier_id ne fasse echouer les CHECK
|
||||
* Postgres (500 generique au lieu d'une donnee coherente). otherLabel est
|
||||
* normalise (trim) dans la branche AUTRE.
|
||||
* champs hors-branche selon counterpartyType. La PRESENCE du champ requis n'est
|
||||
* validee qu'a la VALIDATION (Assert\Callback groupe finalize, ERP-193) : un
|
||||
* BROUILLON peut donc arriver ici avec un type choisi mais SANS son champ associe
|
||||
* (l'operateur a ouvert le menu avant de selectionner). On retire alors la
|
||||
* contrepartie entiere (clearCounterparty) au lieu de persister un etat
|
||||
* incoherent qui violerait les CHECK Postgres chk_wt_*_branch (500 generique).
|
||||
* Ne concerne que le brouillon : a la validation, le Callback finalize a deja
|
||||
* leve une 422 AVANT ce Processor. otherLabel est normalise (trim) en branche
|
||||
* AUTRE ; un libelle vide vaut « champ associe absent » -> contrepartie retiree.
|
||||
*/
|
||||
private function applyCounterpartyExclusivity(WeighingTicket $data): void
|
||||
{
|
||||
switch ($data->getCounterpartyType()) {
|
||||
case 'CLIENT':
|
||||
if (null === $data->getClient()) {
|
||||
$this->clearCounterparty($data);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$data->setSupplier(null);
|
||||
$data->setOtherLabel(null);
|
||||
|
||||
break;
|
||||
|
||||
case 'FOURNISSEUR':
|
||||
if (null === $data->getSupplier()) {
|
||||
$this->clearCounterparty($data);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$data->setClient(null);
|
||||
$data->setOtherLabel(null);
|
||||
|
||||
break;
|
||||
|
||||
case 'AUTRE':
|
||||
$label = $this->normalizer->normalizeOtherLabel($data->getOtherLabel());
|
||||
if (null === $label) {
|
||||
$this->clearCounterparty($data);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$data->setClient(null);
|
||||
$data->setSupplier(null);
|
||||
$data->setOtherLabel($this->normalizer->normalizeOtherLabel($data->getOtherLabel()));
|
||||
$data->setOtherLabel($label);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire toute la contrepartie d'un brouillon a la selection incomplete (type
|
||||
* sans champ associe) : on ne persiste pas une contrepartie a moitie (qui
|
||||
* violerait chk_wt_*_branch). Le brouillon reste enregistrable sans contrepartie
|
||||
* (ERP-193) ; la coherence est exigee a la validation.
|
||||
*/
|
||||
private function clearCounterparty(WeighingTicket $data): void
|
||||
{
|
||||
$data->setCounterpartyType(null);
|
||||
$data->setClient(null);
|
||||
$data->setSupplier(null);
|
||||
$data->setOtherLabel(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-5.01 / RG-5.10 : normalisation serveur de l'immatriculation (trim + UPPER
|
||||
* + masque XX-000-XX hors « Tout format »). Un format invalide est traduit en
|
||||
@@ -162,21 +211,25 @@ final class WeighingTicketProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-5.04 : (re)attribution AUTORITAIRE du DSD pour chaque pesee AUTO via
|
||||
* DsdAllocator (verrou FOR UPDATE). A la creation, le DSD prévisionnel envoye
|
||||
* par le client (issu de POST /api/weighbridge_readings) est ecrase. Sur PATCH,
|
||||
* on n'alloue que pour une pesee AUTO encore depourvue de DSD (ex. la pesee a
|
||||
* plein realisee apres coup) — sinon on churne le compteur a chaque edition.
|
||||
* Les pesees MANUELLES conservent leur DSD (deja alloue par l'endpoint de
|
||||
* pesee, « dernier + 1 »).
|
||||
* RG-5.04 : le DSD d'une pesee est attribue A LA PESEE (POST /api/weighbridge_readings)
|
||||
* et CONSERVE tel quel sur le ticket — on ne le reattribue PAS au save. Raison :
|
||||
* le DSD est l'index de pesee du pont, deja verrouille (FOR UPDATE) a l'emission ;
|
||||
* demain il proviendra directement du materiel (driver reel derriere
|
||||
* WeighbridgeReaderInterface) et devra etre persiste a l'identique. Reallouer ici
|
||||
* ecraserait cet index (double comptage aujourd'hui, perte de l'index reel demain)
|
||||
* et ferait diverger le DSD previsionnel affiche du DSD enregistre.
|
||||
*
|
||||
* On n'alloue donc qu'en FILET DE SECURITE : pesee AUTO sans DSD (ex. ticket cree
|
||||
* sans passer par l'endpoint de pesee). Les pesees MANUELLES conservent egalement
|
||||
* leur DSD (alloue « dernier + 1 » par l'endpoint de pesee).
|
||||
*/
|
||||
private function allocateAutoDsd(WeighingTicket $data, Site $site, bool $isNew): void
|
||||
private function allocateAutoDsd(WeighingTicket $data, Site $site): void
|
||||
{
|
||||
if ('AUTO' === $data->getEmptyMode() && ($isNew || null === $data->getEmptyDsd())) {
|
||||
if ('AUTO' === $data->getEmptyMode() && null === $data->getEmptyDsd()) {
|
||||
$data->setEmptyDsd($this->dsdAllocator->next($site));
|
||||
}
|
||||
|
||||
if ('AUTO' === $data->getFullMode() && ($isNew || null === $data->getFullDsd())) {
|
||||
if ('AUTO' === $data->getFullMode() && null === $data->getFullDsd()) {
|
||||
$data->setFullDsd($this->dsdAllocator->next($site));
|
||||
}
|
||||
}
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||
use App\Module\Logistique\Domain\Repository\WeighingTicketRepositoryInterface;
|
||||
use App\Module\Logistique\Infrastructure\Pdf\WeighingTicketPdfRenderer;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* Provider de l'operation `GET /api/weighing_tickets/{id}/print.pdf` : sert le bon
|
||||
* de pesee en PDF (M5, spec-back § 2.12 / § 4.6 — RG-5.08). Operation API Platform
|
||||
* dediee (pas de controller, decision spec § 4.6) ; le binaire est genere par
|
||||
* {@see WeighingTicketPdfRenderer} (template Twig -> Dompdf).
|
||||
*
|
||||
* Le provider retourne directement une {@see Response} : API Platform court-circuite
|
||||
* alors la serialisation Hydra (le SerializeListener/RespondListener detectent une
|
||||
* Response et la renvoient telle quelle). `Content-Type: application/pdf`,
|
||||
* disposition `inline` (le front ouvre l'apercu — RG-5.08).
|
||||
*
|
||||
* Securite & visibilite — miroir de {@see WeighingTicketProvider::provideItem()} :
|
||||
* - permission `logistique.weighing_tickets.view` portee par l'operation (403) ;
|
||||
* - 404 si ticket introuvable, soft-delete (non expose au M5 — § 2.13), ou hors
|
||||
* perimetre du site courant (anti-enumeration, § 2.3 / RG-5.09).
|
||||
*
|
||||
* @implements ProviderInterface<WeighingTicket>
|
||||
*/
|
||||
final class WeighingTicketPrintProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository')]
|
||||
private readonly WeighingTicketRepositoryInterface $repository,
|
||||
private readonly WeighingTicketPdfRenderer $renderer,
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||
{
|
||||
$ticket = $this->findVisibleTicket($uriVariables['id'] ?? null);
|
||||
if (null === $ticket) {
|
||||
throw new NotFoundHttpException('Ticket de pesée introuvable.');
|
||||
}
|
||||
|
||||
$pdf = $this->renderer->render($ticket);
|
||||
|
||||
$response = new Response($pdf);
|
||||
$response->headers->set('Content-Type', 'application/pdf');
|
||||
$response->headers->set(
|
||||
'Content-Disposition',
|
||||
sprintf('inline; filename="bon-pesee-%s.pdf"', $ticket->getNumber() ?? (string) $ticket->getId()),
|
||||
);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge le ticket visible par l'utilisateur courant, ou null (-> 404) :
|
||||
* introuvable, soft-delete, ou hors perimetre du site courant. Logique
|
||||
* identique a WeighingTicketProvider::provideItem() (cloisonnement § 2.3).
|
||||
*/
|
||||
private function findVisibleTicket(mixed $id): ?WeighingTicket
|
||||
{
|
||||
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ticket = $this->repository->findById((int) $id);
|
||||
if (null === $ticket || null !== $ticket->getDeletedAt()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$scopeSite = $this->currentScopeSite();
|
||||
if (null !== $scopeSite && $ticket->getSite()?->getId() !== $scopeSite->getId()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site servant a cloisonner, ou null si aucun cloisonnement ne s'applique
|
||||
* (user `sites.bypass_scope`, ou pas de site courant). Miroir de
|
||||
* WeighingTicketProvider::currentScopeSite().
|
||||
*/
|
||||
private function currentScopeSite(): ?Site
|
||||
{
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->currentSiteProvider->get();
|
||||
}
|
||||
}
|
||||
+22
-28
@@ -146,8 +146,11 @@ final class WeighingTicketExportController
|
||||
{
|
||||
return [
|
||||
'Numéro',
|
||||
'Type contrepartie',
|
||||
'Contrepartie',
|
||||
// Contrepartie eclatee en 3 colonnes mutuellement exclusives (miroir de
|
||||
// la liste / repertoire, ERP-193) plutot que « type + nom ».
|
||||
'Fournisseur',
|
||||
'Client',
|
||||
'Autre',
|
||||
'Date',
|
||||
'Immatriculation',
|
||||
'Poids vide (kg)',
|
||||
@@ -155,6 +158,7 @@ final class WeighingTicketExportController
|
||||
'Poids net (kg)',
|
||||
'DSD vide',
|
||||
'DSD plein',
|
||||
'Statut',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -166,10 +170,14 @@ final class WeighingTicketExportController
|
||||
private function buildRows(array $tickets): iterable
|
||||
{
|
||||
foreach ($tickets as $ticket) {
|
||||
$type = $ticket->getCounterpartyType();
|
||||
|
||||
yield [
|
||||
$ticket->getNumber(),
|
||||
$this->counterpartyTypeLabel($ticket->getCounterpartyType()),
|
||||
$this->counterpartyName($ticket),
|
||||
$ticket->getNumber() ?? '',
|
||||
// Une seule des 3 colonnes est renseignee selon le type (RG-5.03).
|
||||
'FOURNISSEUR' === $type ? ($ticket->getSupplier()?->getCompanyName() ?? '') : '',
|
||||
'CLIENT' === $type ? ($ticket->getClient()?->getCompanyName() ?? '') : '',
|
||||
'AUTRE' === $type ? ($ticket->getOtherLabel() ?? '') : '',
|
||||
$ticket->getDisplayDate()?->format('d/m/Y H:i') ?? '',
|
||||
$ticket->getImmatriculation() ?? '',
|
||||
$ticket->getEmptyWeight() ?? '',
|
||||
@@ -177,36 +185,22 @@ final class WeighingTicketExportController
|
||||
$ticket->getNetWeight() ?? '',
|
||||
$ticket->getEmptyDsd() ?? '',
|
||||
$ticket->getFullDsd() ?? '',
|
||||
$this->statusLabel($ticket->getStatus()),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Libelle FR du type de contrepartie (RG-5.03). Renvoie la valeur brute pour
|
||||
* une valeur inattendue (garde-fou : ne masque pas une donnee corrompue).
|
||||
* Libelle FR du statut du cycle de vie (ERP-193) : « En attente » (DRAFT) ou
|
||||
* « Terminée » (VALIDATED). Renvoie la valeur brute pour une valeur inattendue
|
||||
* (garde-fou : ne masque pas une donnee corrompue).
|
||||
*/
|
||||
private function counterpartyTypeLabel(?string $type): string
|
||||
private function statusLabel(string $status): string
|
||||
{
|
||||
return match ($type) {
|
||||
'CLIENT' => 'Client',
|
||||
'FOURNISSEUR' => 'Fournisseur',
|
||||
'AUTRE' => 'Autre',
|
||||
default => $type ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Nom de la contrepartie selon le type (RG-5.03) : raison sociale du client,
|
||||
* du fournisseur, ou libelle libre « Autre ». Client / Supplier sont
|
||||
* fetch-joines par le repository (anti N+1, § 4.0).
|
||||
*/
|
||||
private function counterpartyName(WeighingTicket $ticket): string
|
||||
{
|
||||
return match ($ticket->getCounterpartyType()) {
|
||||
'CLIENT' => $ticket->getClient()?->getCompanyName() ?? '',
|
||||
'FOURNISSEUR' => $ticket->getSupplier()?->getCompanyName() ?? '',
|
||||
'AUTRE' => $ticket->getOtherLabel() ?? '',
|
||||
default => '',
|
||||
return match ($status) {
|
||||
WeighingTicket::STATUS_DRAFT => 'En attente',
|
||||
WeighingTicket::STATUS_VALIDATED => 'Terminée',
|
||||
default => $status,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\Pdf;
|
||||
|
||||
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||
use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Rend le ticket de pesee (M5, spec-back § 2.12 / § 4.6 — RG-5.08) : hydrate le
|
||||
* template Twig `logistique/weighing_ticket_print.html.twig` avec le ticket, puis
|
||||
* convertit le HTML en PDF via Dompdf (pur PHP, aucune dependance systeme — choix
|
||||
* valide avec Matthieu, ERP-192).
|
||||
*
|
||||
* Le gabarit reproduit le modele fourni (ticket_pesee.pdf) : en-tete FIXE (logo +
|
||||
* identite societe), titre, les deux pesees (poids / N° pesee / DSD + date) et le
|
||||
* poids net. Le rendu ne depend PAS du site (decision Tristan, ERP-192) : le logo
|
||||
* et l'identite societe sont constants.
|
||||
*
|
||||
* Service technique d'infrastructure (pas de logique metier) : le contenu/affiche
|
||||
* est decide par le template ; ICI on ne fait que charger le logo et generer le
|
||||
* binaire.
|
||||
*/
|
||||
final class WeighingTicketPdfRenderer
|
||||
{
|
||||
/** Logo societe embarque dans l'en-tete (fixe, hors versioning par site). */
|
||||
private const string LOGO_PATH = __DIR__.'/assets/logo-lpc-liot.png';
|
||||
|
||||
public function __construct(
|
||||
private readonly Environment $twig,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Genere le binaire PDF du ticket de pesee pour un ticket donne.
|
||||
*
|
||||
* Dompdf : remote desactive (aucune ressource externe chargee — securite ; le
|
||||
* logo passe en data-URI), A4 portrait, police par defaut DejaVu Sans (UTF-8
|
||||
* -> accents FR et « ° » corrects).
|
||||
*/
|
||||
public function render(WeighingTicket $ticket): string
|
||||
{
|
||||
$html = $this->twig->render('logistique/weighing_ticket_print.html.twig', [
|
||||
'ticket' => $ticket,
|
||||
'logoSrc' => $this->logoDataUri(),
|
||||
]);
|
||||
|
||||
$options = new Options();
|
||||
$options->set('isRemoteEnabled', false);
|
||||
$options->set('defaultFont', 'DejaVu Sans');
|
||||
|
||||
$dompdf = new Dompdf($options);
|
||||
$dompdf->loadHtml($html, 'UTF-8');
|
||||
$dompdf->setPaper('A4', 'portrait');
|
||||
$dompdf->render();
|
||||
|
||||
return (string) $dompdf->output();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logo societe encode en data-URI base64, ou null s'il est introuvable (le
|
||||
* template degrade alors sans bloquer la generation du PDF).
|
||||
*/
|
||||
private function logoDataUri(): ?string
|
||||
{
|
||||
$binary = @file_get_contents(self::LOGO_PATH);
|
||||
if (false === $binary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'data:image/png;base64,'.base64_encode($binary);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
@@ -553,28 +553,27 @@ final class ColumnCommentsCatalog
|
||||
// -> app:apply-column-comments les rejoue depuis ce catalogue. Strings
|
||||
// identiques aux COMMENT de la migration Version20260617150000.
|
||||
'weighing_ticket' => [
|
||||
'_table' => 'Tickets de pesee (M5 Logistique) — pesee a vide + a plein au pont bascule, contrepartie Client/Fournisseur/Autre. Cloisonne par site courant.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'site_id' => 'Site du pont bascule (cloisonnement § 2.3). FK -> site.id, ON DELETE RESTRICT. Renseigne serveur depuis le site courant, immuable (RG-5.09).',
|
||||
'number' => 'Numero {siteCode}-TP-{NNNN}, unique par site (uq_weighing_ticket_number), immuable. Sequence weighing_ticket_counter (RG-5.02).',
|
||||
'counterparty_type' => 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (chk_wt_counterparty_type, RG-5.03). Pilote l obligation client_id / supplier_id / other_label.',
|
||||
'client_id' => 'Branche CLIENT (RG-5.03) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi counterparty_type = CLIENT, nul sinon (chk_wt_client_branch).',
|
||||
'supplier_id' => 'Branche FOURNISSEUR (RG-5.03) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi counterparty_type = FOURNISSEUR (chk_wt_supplier_branch).',
|
||||
'other_label' => 'Branche AUTRE (RG-5.03) : libelle libre de la contrepartie. Requis ssi counterparty_type = AUTRE, nul sinon (chk_wt_other_branch).',
|
||||
'immatriculation' => 'Plaque du vehicule, partagee entre pesee vide et plein. Masque XX-000-XX sauf si plate_free_format (RG-5.01). Normalisee serveur (trim/UPPER).',
|
||||
'plate_free_format' => '« Tout format » : desactive le masque XX-000-XX de l immatriculation (RG-5.01). Partage entre les 2 formulaires. Faux par defaut.',
|
||||
'empty_date' => 'Date/heure de la pesee a vide (tare). Defaut jour courant cote front (RG-5.07). Null tant que la pesee vide n est pas faite.',
|
||||
'empty_weight' => 'Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).',
|
||||
'empty_dsd' => 'Compteur DSD du pont a la pesee a vide. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).',
|
||||
'empty_mode' => 'Mode de la pesee a vide : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_empty_mode (RG-5.06).',
|
||||
'empty_manual_number' => 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a vide (RG-5.04).',
|
||||
'full_date' => 'Date/heure de la pesee a plein (brut). Null tant que la pesee plein n est pas faite.',
|
||||
'full_weight' => 'Poids a plein (brut) en kg — readonly UI, rempli par la pesee (RG-5.07).',
|
||||
'full_dsd' => 'Compteur DSD du pont a la pesee a plein. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).',
|
||||
'full_mode' => 'Mode de la pesee a plein : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_full_mode (RG-5.06).',
|
||||
'full_manual_number' => 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a plein (RG-5.04).',
|
||||
'net_weight' => 'Poids net = full_weight - empty_weight (kg), calcule serveur (RG-5.05). Null si une pesee manque. Colonne Poids de la liste.',
|
||||
'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.',
|
||||
'_table' => 'Tickets de pesee (M5 Logistique) — pesee a vide + a plein au pont bascule, contrepartie Client/Fournisseur/Autre. Cloisonne par site courant.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'site_id' => 'Site du pont bascule (cloisonnement § 2.3). FK -> site.id, ON DELETE RESTRICT. Renseigne serveur depuis le site courant, immuable (RG-5.09).',
|
||||
'number' => 'Numero {siteCode}-TP-{NNNN}, unique par site (uq_weighing_ticket_number), immuable. NULL tant que brouillon : attribue a la validation (RG-5.02, ERP-193).',
|
||||
'counterparty_type' => 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (chk_wt_counterparty_type, RG-5.03). NULL tant que brouillon, requise a la validation. Pilote l obligation client_id / supplier_id / other_label.',
|
||||
'client_id' => 'Branche CLIENT (RG-5.03) : client concerne. FK -> client.id, ON DELETE RESTRICT. Requis ssi counterparty_type = CLIENT, nul sinon (chk_wt_client_branch).',
|
||||
'supplier_id' => 'Branche FOURNISSEUR (RG-5.03) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi counterparty_type = FOURNISSEUR (chk_wt_supplier_branch).',
|
||||
'other_label' => 'Branche AUTRE (RG-5.03) : libelle libre de la contrepartie. Requis ssi counterparty_type = AUTRE, nul sinon (chk_wt_other_branch).',
|
||||
'immatriculation' => 'Plaque du vehicule, partagee entre pesee vide et plein. NULL tant que brouillon, requise a la validation. Masque XX-000-XX sauf si plate_free_format (RG-5.01). Normalisee serveur (trim/UPPER).',
|
||||
'plate_free_format' => '« Tout format » : desactive le masque XX-000-XX de l immatriculation (RG-5.01). Partage entre les 2 formulaires. Faux par defaut.',
|
||||
'empty_date' => 'Date/heure de la pesee a vide (tare). Defaut jour courant cote front (RG-5.07). Null tant que la pesee vide n est pas faite.',
|
||||
'empty_weight' => 'Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).',
|
||||
'empty_dsd' => 'Compteur DSD du pont a la pesee a vide. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).',
|
||||
'empty_mode' => 'Mode de la pesee a vide : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_empty_mode (RG-5.06).',
|
||||
'full_date' => 'Date/heure de la pesee a plein (brut). Null tant que la pesee plein n est pas faite.',
|
||||
'full_weight' => 'Poids a plein (brut) en kg — readonly UI, rempli par la pesee (RG-5.07).',
|
||||
'full_dsd' => 'Compteur DSD du pont a la pesee a plein. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).',
|
||||
'full_mode' => 'Mode de la pesee a plein : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_full_mode (RG-5.06).',
|
||||
'net_weight' => 'Poids net = full_weight - empty_weight (kg), calcule serveur (RG-5.05). Null si une pesee manque. Colonne Poids de la liste.',
|
||||
'status' => 'Cycle de vie (ERP-193) : DRAFT (« En attente », pesee enregistree sans contrepartie/immat) ou VALIDATED (« Terminée », valide avec numero). chk_wt_status. Defaut DRAFT.',
|
||||
'deleted_at' => 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.',
|
||||
] + self::timestampableBlamableComments(),
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user