Merge branch 'develop' into fix/erp-193-retours-metier
This commit is contained in:
@@ -80,6 +80,9 @@ final class RbacSeeder
|
||||
// Transporteurs (M4 § 5.2, ERP-153) : view + manage (PAS archive -> admin seul).
|
||||
'transport.carriers.view',
|
||||
'transport.carriers.manage',
|
||||
// Tickets de pesee (M5 § 5.2, ERP-181) : view + manage (« Tout »).
|
||||
'logistique.weighing_tickets.view',
|
||||
'logistique.weighing_tickets.manage',
|
||||
// Visibilite multi-site des prestataires (M3 § 2.13) : voit tous les sites.
|
||||
'sites.bypass_scope',
|
||||
// Lecture des referentiels transverses pour les selects client (ERP-102).
|
||||
@@ -137,9 +140,14 @@ final class RbacSeeder
|
||||
'label' => 'Usine',
|
||||
// Prestataires (M3 § 2.9 + § 2.13, ERP-138) : view en lecture seule,
|
||||
// SANS `sites.bypass_scope` -> cloisonne aux prestataires de son site
|
||||
// courant. Aucun autre acces metier.
|
||||
// courant.
|
||||
'permissions' => [
|
||||
'technique.providers.view',
|
||||
// Tickets de pesee (M5 § 5.2, ERP-181) : view + manage. L'Usine
|
||||
// pese sur site -> reste cloisonnee a son site courant (pas de
|
||||
// bypass_scope ; les tickets sont filtres par SiteScopedQueryExtension).
|
||||
'logistique.weighing_tickets.view',
|
||||
'logistique.weighing_tickets.manage',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -217,6 +217,10 @@ final class SeedE2ECommand extends Command
|
||||
'transport.carriers.view',
|
||||
'transport.carriers.manage',
|
||||
'transport.carriers.archive',
|
||||
// Logistique — Tickets de pesee (M5, ERP-181). Meme logique :
|
||||
// mappe sur le persona "tout". Miroir de personas.ts.
|
||||
'logistique.weighing_tickets.view',
|
||||
'logistique.weighing_tickets.manage',
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Application\Service;
|
||||
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
|
||||
/**
|
||||
* Allocateur du compteur DSD du pont bascule (RG-5.04, § 2.7).
|
||||
*
|
||||
* Le DSD est un index sequentiel de pesee, propre a CHAQUE site (un pont par
|
||||
* site). Chaque pesee — bascule (AUTO) ou manuelle (MANUAL) — consomme une
|
||||
* valeur : la suivante = dernier DSD du site + 1.
|
||||
*
|
||||
* Port (interface en couche Application) ; l'implementation (DsdAllocator,
|
||||
* Infrastructure) incremente le compteur sous verrou ligne `SELECT ... FOR
|
||||
* UPDATE` pour garantir l'unicite en concurrence.
|
||||
*/
|
||||
interface DsdAllocatorInterface
|
||||
{
|
||||
/**
|
||||
* Attribue et renvoie la prochaine valeur DSD pour le site (dernier + 1),
|
||||
* en persistant l'increment de maniere atomique (verrou ligne).
|
||||
*/
|
||||
public function next(SiteInterface $site): int;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Application\Service;
|
||||
|
||||
use App\Module\Logistique\Domain\Exception\InvalidImmatriculationException;
|
||||
|
||||
/**
|
||||
* Normalisation serveur des champs texte d'un WeighingTicket, appliquee par le
|
||||
* WeighingTicketProcessor AVANT persistance. Cf. spec-back M5 § 6 + RG-5.01 /
|
||||
* RG-5.10. Jumeau leger de CarrierFieldNormalizer (M4).
|
||||
*
|
||||
* - immatriculation (RG-5.01 / RG-5.10) : trim + UPPER. Si « Tout format » N'EST
|
||||
* PAS coche (freeFormat = false), la saisie est ramenee au masque SIV
|
||||
* canonique XX-000-XX (separateurs/espaces ignores a la saisie, re-poses) ; une
|
||||
* plaque qui ne s'y conforme pas leve InvalidImmatriculationException (-> 422
|
||||
* par le Processor). En « Tout format » (anciennes plaques, etranger, engins),
|
||||
* seul le trim + UPPER s'applique.
|
||||
* - otherLabel (RG-5.03) : trim ; une chaine vide apres trim devient null (evite
|
||||
* de persister "" dans une colonne nullable).
|
||||
*
|
||||
* Methodes null-safe : une entree null ressort null (l'obligation eventuelle est
|
||||
* portee par les Assert de l'entite / la coherence contrepartie, pas ici).
|
||||
*/
|
||||
final class WeighingTicketFieldNormalizer
|
||||
{
|
||||
/**
|
||||
* Plaque SIV « nue » (sans separateurs) : 2 lettres, 3 chiffres, 2 lettres.
|
||||
* Les lettres interdites du SIV (I, O, U + SS) ne sont pas filtrees ici : le
|
||||
* masque de saisie reste volontairement simple (le metier accepte ces cas via
|
||||
* « Tout format » si besoin).
|
||||
*/
|
||||
private const string SIV_BARE_PATTERN = '/^[A-Z]{2}[0-9]{3}[A-Z]{2}$/';
|
||||
|
||||
/**
|
||||
* Normalise l'immatriculation (RG-5.01 / RG-5.10).
|
||||
*
|
||||
* @param bool $freeFormat « Tout format » coche -> masque SIV desactive
|
||||
*
|
||||
* @throws InvalidImmatriculationException si !freeFormat et la plaque ne
|
||||
* respecte pas le masque XX-000-XX
|
||||
*/
|
||||
public function normalizeImmatriculation(?string $value, bool $freeFormat): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = mb_strtoupper(trim($value), 'UTF-8');
|
||||
if ('' === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// « Tout format » : aucune contrainte de masque (RG-5.01).
|
||||
if ($freeFormat) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Masque SIV : on ignore tout ce qui n'est pas alphanumerique (l'operateur
|
||||
// peut saisir « ab123cd », « AB 123 CD » ou « AB-123-CD ») puis on valide
|
||||
// le squelette 2-3-2 et on repose les separateurs canoniques.
|
||||
$bare = preg_replace('/[^A-Z0-9]/', '', $value) ?? '';
|
||||
|
||||
if (1 !== preg_match(self::SIV_BARE_PATTERN, $bare)) {
|
||||
throw new InvalidImmatriculationException(
|
||||
'Format d\'immatriculation invalide : attendu XX-000-XX (cochez « Tout format » pour une plaque libre).',
|
||||
);
|
||||
}
|
||||
|
||||
return sprintf('%s-%s-%s', substr($bare, 0, 2), substr($bare, 2, 3), substr($bare, 5, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim du libelle « Autre » (RG-5.03). Une chaine vide apres trim devient null.
|
||||
*/
|
||||
public function normalizeOtherLabel(?string $value): ?string
|
||||
{
|
||||
if (null === $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return '' === $value ? null : $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Application\Service;
|
||||
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
|
||||
/**
|
||||
* Allocateur du numero de ticket de pesee (RG-5.02, § 2.5).
|
||||
*
|
||||
* Le numero a le format {siteCode}-TP-{NNNN} (ex. 86-TP-0001), UNIQUE PAR SITE
|
||||
* et immuable. Chaque site porte sa propre sequence : 86-TP-0001 et 17-TP-0001
|
||||
* coexistent.
|
||||
*
|
||||
* Le code du site (prefixe) vit sur l'entite Site (site.code, ERP-183) — d'ou le
|
||||
* type-hint sur Site concret (et non SiteInterface qui n'expose pas getCode()) ;
|
||||
* c'est la meme reference ORM partagee que celle consommee par WeighingTicket
|
||||
* (§ 2.1, pas de logique inter-module).
|
||||
*
|
||||
* Port (couche Application) ; l'implementation (WeighingTicketNumberAllocator,
|
||||
* Infrastructure) incremente le compteur weighing_ticket_counter sous verrou ligne
|
||||
* `SELECT ... FOR UPDATE` pour garantir l'unicite meme en concurrence.
|
||||
*/
|
||||
interface WeighingTicketNumberAllocatorInterface
|
||||
{
|
||||
/**
|
||||
* Attribue et renvoie le prochain numero formate {siteCode}-TP-{NNNN} pour le
|
||||
* site, en persistant l'increment de maniere atomique (verrou ligne).
|
||||
*/
|
||||
public function allocate(Site $site): string;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Domain\Contract;
|
||||
|
||||
use App\Module\Logistique\Domain\Exception\WeighbridgeUnavailableException;
|
||||
use App\Module\Logistique\Domain\Weighbridge\WeighbridgeReading;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
|
||||
/**
|
||||
* Contrat de lecture du pont bascule (§ 2.6).
|
||||
*
|
||||
* Abstraction posee au M5 pour decoupler l'API du materiel : l'implementation
|
||||
* livree est un stub (RandomWeighbridgeReader, poids aleatoire ∈ [10000,50000]
|
||||
* kg). Le driver materiel reel (protocole serie/TCP de l'indicateur de pesage)
|
||||
* est hors perimetre M5 (HP-M5-02) : le jour venu on substitue l'implementation
|
||||
* derriere cette interface — zero impact sur les ecrans / l'API.
|
||||
*/
|
||||
interface WeighbridgeReaderInterface
|
||||
{
|
||||
/**
|
||||
* Effectue une pesee « bascule » (AUTO) pour le site donne : renvoie le poids
|
||||
* lu et le DSD (index de pesee du pont) attribue pour ce site (RG-5.04).
|
||||
*
|
||||
* @throws WeighbridgeUnavailableException si la bascule ne repond pas
|
||||
* (le Processor traduit en HTTP 503 →
|
||||
* bascule manuelle, RG-5.06)
|
||||
*/
|
||||
public function read(SiteInterface $site): WeighbridgeReading;
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
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\WeighingTicketProvider;
|
||||
use App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository;
|
||||
use App\Module\Sites\Domain\Entity\Site; // relation ORM partagee (§ 2.1)
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* Ticket de pesee (M5 Logistique) — entite racine du module, jumelle de
|
||||
* Carrier (M4) / Supplier (M2) cote pattern (#[Auditable], TimestampableBlamable,
|
||||
* contrat de serialisation 3 maillons). Porte EXACTEMENT deux pesees modelisees
|
||||
* en colonnes plates (vide + plein, § 2.4), une contrepartie Client/Fournisseur/
|
||||
* Autre (RG-5.03) et l'immatriculation partagee entre les deux formulaires
|
||||
* (RG-5.01).
|
||||
*
|
||||
* Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) :
|
||||
* - LISTE (weighing_ticket:read + client:read + supplier:read + site:read +
|
||||
* default:read) : number, counterpartyType, client/supplier embarques,
|
||||
* otherLabel, displayDate (= fullDate ?? emptyDate), netWeight,
|
||||
* plateFreeFormat, createdAt/updatedAt (via default:read).
|
||||
* - DETAIL (+ weighing_ticket:item:read) : ajoute site embarque, immatriculation
|
||||
* et les deux pesees (empty* / full*).
|
||||
*
|
||||
* Champs renseignes SERVEUR (lecture seule cote API, sans groupe d'ecriture) :
|
||||
* - number : numero {siteCode}-TP-{NNNN} attribue par le WeighingTicketProcessor
|
||||
* (RG-5.02, immuable) ;
|
||||
* - site : resolu depuis le site courant a la creation (CurrentSiteProvider,
|
||||
* § 2.3), immuable (RG-5.09) ;
|
||||
* - netWeight : poids net derive plein - vide, recalcule serveur (RG-5.05).
|
||||
*
|
||||
* Les RG inter-champs (RG-5.03 : champ associe a counterpartyType obligatoire)
|
||||
* passent par une contrainte d'entite (Assert\Callback + ->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']],
|
||||
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']],
|
||||
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). */
|
||||
#[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;
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Levee quand une immatriculation ne respecte pas le masque SIV XX-000-XX alors
|
||||
* que « Tout format » n'est PAS coche (plateFreeFormat = false, RG-5.01).
|
||||
*
|
||||
* Exception de DOMAINE (pure, sans dependance HTTP) levee par le
|
||||
* WeighingTicketFieldNormalizer : c'est le WeighingTicketProcessor qui la traduit
|
||||
* en 422 portant un propertyPath « immatriculation » (mapping inline useFormErrors,
|
||||
* convention ERP-101) plutot qu'un toast.
|
||||
*/
|
||||
final class InvalidImmatriculationException extends RuntimeException {}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Domain\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Levee lorsque le pont bascule ne repond pas / est indisponible (RG-5.06).
|
||||
*
|
||||
* Exception de DOMAINE (pure, sans dependance HTTP) : c'est le Processor de
|
||||
* l'endpoint de pesee qui la traduit en reponse HTTP 503 « Pont bascule
|
||||
* indisponible — passez en pesee manuelle » (cf. WeighbridgeReadingProcessor).
|
||||
*
|
||||
* Au M5, le stub (RandomWeighbridgeReader) ne la leve jamais, mais le chemin
|
||||
* d'erreur est implemente et teste pour le jour ou un driver materiel reel
|
||||
* (HP-M5-02) sera branche derriere WeighbridgeReaderInterface.
|
||||
*/
|
||||
final class WeighbridgeUnavailableException extends RuntimeException {}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Domain\Repository;
|
||||
|
||||
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
/**
|
||||
* Contrat du repository des tickets de pesee (M5). Implementation Doctrine :
|
||||
* App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository.
|
||||
*/
|
||||
interface WeighingTicketRepositoryInterface
|
||||
{
|
||||
public function findById(int $id): ?WeighingTicket;
|
||||
|
||||
public function save(WeighingTicket $ticket): void;
|
||||
|
||||
/**
|
||||
* QueryBuilder de SELECTION (recherche + tri + fetch-join client/supplier/site)
|
||||
* pour la liste, exploite par le WeighingTicketProvider (ERP-185) qui le wrappe
|
||||
* dans un Paginator (règle ABSOLUE n°13). Exclut les soft-deletes (deleted_at
|
||||
* IS NOT NULL). Tri par defaut number DESC (plus recents en tete, § 4.1).
|
||||
*
|
||||
* Le cloisonnement par site courant n'est PAS applique ici : un provider custom
|
||||
* court-circuite le SiteScopedQueryExtension (qui n'agit que dans le provider
|
||||
* ORM standard), donc le WeighingTicketProvider l'applique lui-meme (§ 2.3).
|
||||
*
|
||||
* @param null|string $search recherche fuzzy sur number, nom client/fournisseur,
|
||||
* other_label et immatriculation (§ 4.1)
|
||||
*/
|
||||
public function createListQueryBuilder(?string $search = null): QueryBuilder;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Domain\Weighbridge;
|
||||
|
||||
/**
|
||||
* Resultat immuable d'une lecture du pont bascule (§ 2.6 / RG-5.06).
|
||||
*
|
||||
* Porte le couple {poids, DSD} renvoye par une pesee « bascule » (AUTO) :
|
||||
* - weight : poids brut lu, en kilogrammes ;
|
||||
* - dsd : index de pesee du pont (compteur par site, RG-5.04).
|
||||
*
|
||||
* Au M5 le pont est un stub (RandomWeighbridgeReader) ; un driver materiel reel
|
||||
* (HP-M5-02) produira le meme objet derriere WeighbridgeReaderInterface, sans
|
||||
* impact sur l'API.
|
||||
*/
|
||||
final readonly class WeighbridgeReading
|
||||
{
|
||||
public function __construct(
|
||||
public int $weight,
|
||||
public int $dsd,
|
||||
) {}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\ApiPlatform\Resource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighbridgeReadingProcessor;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* Ressource API Platform virtuelle (non mappee Doctrine) portant l'action de
|
||||
* pesee au pont bascule : `POST /api/weighbridge_readings` (§ 4.2).
|
||||
*
|
||||
* Action AUTONOME : declenchee depuis le formulaire AVANT que le ticket existe.
|
||||
* Le site est resolu serveur (site courant) — jamais envoye par le client.
|
||||
*
|
||||
* - 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).
|
||||
*
|
||||
* `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.
|
||||
*/
|
||||
#[ApiResource(
|
||||
shortName: 'WeighbridgeReading',
|
||||
operations: [
|
||||
new Post(
|
||||
uriTemplate: '/weighbridge_readings',
|
||||
// Action de lecture du pont (pas une creation de ressource) : 200, pas 201.
|
||||
status: 200,
|
||||
security: "is_granted('logistique.weighing_tickets.manage')",
|
||||
normalizationContext: ['groups' => ['weighbridge_reading:read']],
|
||||
denormalizationContext: ['groups' => ['weighbridge_reading:write']],
|
||||
processor: WeighbridgeReadingProcessor::class,
|
||||
read: false,
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class WeighbridgeReadingResource
|
||||
{
|
||||
/** AUTO (pesee bascule) | MANUAL (pesee manuelle) — pilote le comportement (§ 4.2). */
|
||||
#[Assert\NotBlank(message: 'Le mode de pesée est obligatoire.')]
|
||||
#[Assert\Choice(choices: ['AUTO', 'MANUAL'], message: 'Mode de pesée invalide (AUTO ou MANUAL).')]
|
||||
#[Groups(['weighbridge_reading:write', 'weighbridge_reading:read'])]
|
||||
public ?string $mode = null;
|
||||
|
||||
/**
|
||||
* Poids en kg. En entree : requis et saisi en MANUAL, ignore en AUTO (le pont
|
||||
* fournit le poids). En sortie : poids effectif de la pesee.
|
||||
*/
|
||||
#[Assert\Positive(message: 'Le poids doit être un entier positif (kg).')]
|
||||
#[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')]
|
||||
#[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).
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateManualWeight(ExecutionContextInterface $context): void
|
||||
{
|
||||
if ('MANUAL' === $this->mode && null === $this->weight) {
|
||||
$context->buildViolation('Le poids est obligatoire en pesée manuelle.')
|
||||
->atPath('weight')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use LogicException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
|
||||
|
||||
/**
|
||||
* Processor de l'action `POST /api/weighbridge_readings` (§ 4.2).
|
||||
*
|
||||
* Resout le site courant (CurrentSiteProviderInterface — contrat Sites, seule
|
||||
* logique cross-module autorisee, regle ABSOLUE n°1) puis :
|
||||
* - 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).
|
||||
*
|
||||
* @implements ProcessorInterface<WeighbridgeReadingResource, WeighbridgeReadingResource>
|
||||
*/
|
||||
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
|
||||
{
|
||||
if (!$data instanceof WeighbridgeReadingResource) {
|
||||
throw new LogicException(sprintf(
|
||||
'WeighbridgeReadingProcessor attend une instance de %s, %s recu.',
|
||||
WeighbridgeReadingResource::class,
|
||||
get_debug_type($data),
|
||||
));
|
||||
}
|
||||
|
||||
// Site courant resolu serveur (jamais envoye par le client). Absent =
|
||||
// aucun site selectionne dans le sélecteur → on ne peut pas peser.
|
||||
$site = $this->currentSiteProvider->get();
|
||||
if (null === $site) {
|
||||
throw new BadRequestHttpException('Aucun site courant sélectionné — sélectionnez un site avant de peser.');
|
||||
}
|
||||
|
||||
if ('AUTO' === $data->mode) {
|
||||
try {
|
||||
$reading = $this->weighbridgeReader->read($site);
|
||||
} catch (WeighbridgeUnavailableException $e) {
|
||||
// RG-5.06 : le pont ne repond pas → 503 explicite, le front bascule
|
||||
// en pesee manuelle. (Le stub M5 ne leve jamais — chemin teste.)
|
||||
throw new ServiceUnavailableHttpException(
|
||||
null,
|
||||
'Pont bascule indisponible — passez en pesée manuelle.',
|
||||
$e,
|
||||
);
|
||||
}
|
||||
|
||||
$data->weight = $reading->weight;
|
||||
$data->dsd = $reading->dsd;
|
||||
$data->manualNumber = null; // pas de numero papier en mode bascule
|
||||
|
||||
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);
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
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\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
/**
|
||||
* Processor d'ecriture du ticket de pesee (M5). Cf. spec-back M5 § 4.3 / § 4.4 +
|
||||
* RG-5.01 / RG-5.02 / RG-5.03 / RG-5.04 / RG-5.05 / RG-5.09 / RG-5.10. Jumeau des
|
||||
* processors M2/M3/M4, recentre sur les regles specifiques du ticket de pesee.
|
||||
*
|
||||
* Sequence (POST / PATCH) — l'entite arrive deja VALIDEE (les Assert + le Callback
|
||||
* RG-5.03 ont joue en amont) :
|
||||
* 1. CREATION uniquement (RG-5.09, immuables) : resolution du site courant
|
||||
* (CurrentSiteProviderInterface — seule logique cross-module autorisee, regle
|
||||
* ABSOLUE n°1) puis attribution du numero {siteCode}-TP-{NNNN} (compteur
|
||||
* verrouille, RG-5.02). Le PATCH ne retouche ni site ni numero.
|
||||
* 2. Coherence contrepartie (RG-5.03) : null-ification des champs hors-branche
|
||||
* selon counterpartyType (la PRESENCE du champ requis est deja validee par le
|
||||
* Callback de l'entite ; ici on garantit l'EXCLUSIVITE — sinon les CHECK
|
||||
* Postgres chk_wt_*_branch leveraient une 500 generique).
|
||||
* 3. Normalisation immatriculation (RG-5.01 / RG-5.10) : trim + UPPER + masque
|
||||
* XX-000-XX si !plateFreeFormat. Format invalide -> 422 sur « immatriculation »
|
||||
* (mapping inline useFormErrors, ERP-101).
|
||||
* 4. DSD autoritaire (RG-5.04) : pour chaque pesee AUTO, (re)attribution du DSD
|
||||
* via DsdAllocator (verrou FOR UPDATE). Le DSD renvoye par
|
||||
* POST /api/weighbridge_readings est PREVISIONNEL ; l'attribution autoritaire
|
||||
* est faite ici. Une pesee MANUELLE conserve le DSD deja alloue par l'endpoint
|
||||
* de pesee (« dernier + 1 », round-trip par le client, deja consomme).
|
||||
* 5. Poids net (RG-5.05) : net_weight = full_weight - empty_weight si les deux
|
||||
* poids sont presents, sinon null.
|
||||
* 6. Persistance via le persist_processor Doctrine.
|
||||
*
|
||||
* @implements ProcessorInterface<WeighingTicket, WeighingTicket>
|
||||
*/
|
||||
final class WeighingTicketProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private readonly ProcessorInterface $persistProcessor,
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
private readonly WeighingTicketNumberAllocatorInterface $numberAllocator,
|
||||
private readonly DsdAllocatorInterface $dsdAllocator,
|
||||
private readonly WeighingTicketFieldNormalizer $normalizer,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if (!$data instanceof WeighingTicket) {
|
||||
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).
|
||||
$isNew = !$this->em->contains($data);
|
||||
|
||||
if ($isNew) {
|
||||
$site = $this->resolveCurrentSite();
|
||||
$data->setSite($site);
|
||||
$data->setNumber($this->numberAllocator->allocate($site));
|
||||
}
|
||||
|
||||
$this->applyCounterpartyExclusivity($data);
|
||||
$this->normalizeImmatriculation($data);
|
||||
|
||||
// Le site est toujours present apres creation ; sur PATCH il est charge
|
||||
// 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->computeNetWeight($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resout le site courant (sélecteur de site). Absent = aucun site selectionne
|
||||
* -> 400 : on ne peut pas numeroter ni rattacher un ticket sans site (site_id
|
||||
* NOT NULL, § 2.3).
|
||||
*/
|
||||
private function resolveCurrentSite(): Site
|
||||
{
|
||||
$site = $this->currentSiteProvider->get();
|
||||
if (!$site instanceof Site) {
|
||||
throw new BadRequestHttpException('Aucun site courant sélectionné — sélectionnez un site avant de créer un ticket de pesée.');
|
||||
}
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private function applyCounterpartyExclusivity(WeighingTicket $data): void
|
||||
{
|
||||
switch ($data->getCounterpartyType()) {
|
||||
case 'CLIENT':
|
||||
$data->setSupplier(null);
|
||||
$data->setOtherLabel(null);
|
||||
|
||||
break;
|
||||
|
||||
case 'FOURNISSEUR':
|
||||
$data->setClient(null);
|
||||
$data->setOtherLabel(null);
|
||||
|
||||
break;
|
||||
|
||||
case 'AUTRE':
|
||||
$data->setClient(null);
|
||||
$data->setSupplier(null);
|
||||
$data->setOtherLabel($this->normalizer->normalizeOtherLabel($data->getOtherLabel()));
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 422 portant un propertyPath « immatriculation » consommable inline par
|
||||
* useFormErrors (ERP-101), plutot qu'un toast.
|
||||
*/
|
||||
private function normalizeImmatriculation(WeighingTicket $data): void
|
||||
{
|
||||
$current = $data->getImmatriculation();
|
||||
if (null === $current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$data->setImmatriculation(
|
||||
$this->normalizer->normalizeImmatriculation($current, $data->isPlateFreeFormat()),
|
||||
);
|
||||
} catch (InvalidImmatriculationException $e) {
|
||||
$this->throwFieldViolation($data, 'immatriculation', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 »).
|
||||
*/
|
||||
private function allocateAutoDsd(WeighingTicket $data, Site $site, bool $isNew): void
|
||||
{
|
||||
if ('AUTO' === $data->getEmptyMode() && ($isNew || null === $data->getEmptyDsd())) {
|
||||
$data->setEmptyDsd($this->dsdAllocator->next($site));
|
||||
}
|
||||
|
||||
if ('AUTO' === $data->getFullMode() && ($isNew || null === $data->getFullDsd())) {
|
||||
$data->setFullDsd($this->dsdAllocator->next($site));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-5.05 : poids net = poids plein - poids vide (kg), recalcule a chaque
|
||||
* ecriture. Null tant que l'une des deux pesees manque.
|
||||
*/
|
||||
private function computeNetWeight(WeighingTicket $data): void
|
||||
{
|
||||
$empty = $data->getEmptyWeight();
|
||||
$full = $data->getFullWeight();
|
||||
|
||||
$data->setNetWeight(null !== $empty && null !== $full ? $full - $empty : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leve une 422 portant une violation unique sur un champ — meme rendu Hydra que
|
||||
* les contraintes Symfony, consommable inline par useFormErrors (ERP-101).
|
||||
*
|
||||
* @return never
|
||||
*/
|
||||
private function throwFieldViolation(WeighingTicket $root, string $propertyPath, string $message): void
|
||||
{
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation($message, null, [], $root, $propertyPath, null));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\Pagination\Pagination;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||
use App\Module\Logistique\Domain\Repository\WeighingTicketRepositoryInterface;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Provider de lecture des tickets de pesee (M5). Cf. spec-back M5 § 4.0 / § 4.1 +
|
||||
* RG-5.09. Jumeau du SupplierProvider (M2), augmente du cloisonnement par site.
|
||||
*
|
||||
* Collection (GET /api/weighing_tickets) :
|
||||
* - exclut les soft-deletes (deleted_at IS NOT NULL, prepares mais non exposes au
|
||||
* M5 — § 2.13), via le repository ;
|
||||
* - filtre ?search=... (fuzzy sur number, nom client/fournisseur, other_label,
|
||||
* immatriculation — § 4.1) ;
|
||||
* - tri ?order[displayDate]=asc|desc (date du ticket = COALESCE full/empty),
|
||||
* defaut number DESC (plus recents en tete) ;
|
||||
* - pagination obligatoire (regle ABSOLUE n°13) : Paginator ORM ; echappatoire
|
||||
* ?pagination=false ;
|
||||
* - fetch-join client / supplier / site (ManyToOne surs) pour eviter le N+1 a la
|
||||
* serialisation (§ 4.0).
|
||||
*
|
||||
* Cloisonnement par site (§ 2.3 / RG-5.09) — applique ICI : un provider custom
|
||||
* REMPLACE le provider Doctrine, donc le SiteScopedQueryExtension ne s'execute pas
|
||||
* automatiquement (il n'agit que dans le provider ORM standard). On replique sa
|
||||
* logique a l'identique :
|
||||
* - user `sites.bypass_scope` (Admin auto, consolidation) -> aucun filtre ;
|
||||
* - site courant null (module Sites off / user sans site) -> no-op (l'user voit
|
||||
* tout, decision site-aware.md § 5) ;
|
||||
* - sinon -> liste restreinte aux tickets du site courant, AVANT pagination
|
||||
* (totalItems reflete le perimetre).
|
||||
*
|
||||
* Item (GET /api/weighing_tickets/{id} + provider de PATCH) :
|
||||
* - 404 si introuvable OU soft-delete (deleted_at non null) ;
|
||||
* - 404 si hors perimetre site (ne pas reveler l'existence d'une ligne d'un autre
|
||||
* site — anti-enumeration).
|
||||
*
|
||||
* @implements ProviderInterface<WeighingTicket>
|
||||
*/
|
||||
final class WeighingTicketProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository')]
|
||||
private readonly WeighingTicketRepositoryInterface $repository,
|
||||
private readonly Pagination $pagination,
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|WeighingTicket|null
|
||||
{
|
||||
if ($operation instanceof CollectionOperationInterface) {
|
||||
return $this->provideCollection($operation, $context);
|
||||
}
|
||||
|
||||
return $this->provideItem($uriVariables);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*
|
||||
* @return list<WeighingTicket>|Paginator<WeighingTicket>
|
||||
*/
|
||||
private function provideCollection(Operation $operation, array $context): array|Paginator
|
||||
{
|
||||
$filters = $context['filters'] ?? [];
|
||||
$search = $filters['search'] ?? null;
|
||||
|
||||
$qb = $this->repository->createListQueryBuilder(is_string($search) ? $search : null);
|
||||
|
||||
$this->applyDisplayDateOrder($qb, $filters);
|
||||
$this->applySiteScope($qb);
|
||||
|
||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||
// (regle n°13 — utile pour alimenter un <select> cote front).
|
||||
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||
// @var list<WeighingTicket> $tickets
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
$limit = $this->pagination->getLimit($operation, $context);
|
||||
$page = max(1, $this->pagination->getPage($context));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$qb->setFirstResult($offset)->setMaxResults($limit);
|
||||
|
||||
// Les fetch-joins du repository sont tous ManyToOne (client/supplier/site) :
|
||||
// pas de demultiplication de lignes -> fetchJoinCollection: false (COUNT
|
||||
// simple, page correcte).
|
||||
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $uriVariables
|
||||
*/
|
||||
private function provideItem(array $uriVariables): ?WeighingTicket
|
||||
{
|
||||
$id = $uriVariables['id'] ?? null;
|
||||
if (!is_int($id) && !(is_string($id) && ctype_digit($id))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ticket = $this->repository->findById((int) $id);
|
||||
if (null === $ticket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Soft-delete : jamais expose au M5 (§ 2.13) -> 404 via retour null.
|
||||
if (null !== $ticket->getDeletedAt()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cloisonnement par site : un ticket hors perimetre -> 404 (anti-enumeration).
|
||||
$scopeSite = $this->currentScopeSite();
|
||||
if (null !== $scopeSite && $ticket->getSite()?->getId() !== $scopeSite->getId()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tri par date du ticket (§ 4.1) : displayDate = full_date ?? empty_date, donc
|
||||
* un getter calcule (pas une colonne) -> on trie sur l'expression DQL
|
||||
* COALESCE(full_date, empty_date). Absent du payload -> on garde le tri par
|
||||
* defaut du repository (number DESC).
|
||||
*
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
private function applyDisplayDateOrder(QueryBuilder $qb, array $filters): void
|
||||
{
|
||||
$order = $filters['order'] ?? null;
|
||||
if (!is_array($order) || !isset($order['displayDate'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$direction = 'asc' === strtolower((string) $order['displayDate']) ? 'ASC' : 'DESC';
|
||||
$rootAlias = $qb->getRootAliases()[0];
|
||||
|
||||
$qb->orderBy(sprintf('COALESCE(%1$s.fullDate, %1$s.emptyDate)', $rootAlias), $direction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restreint la liste au site courant si l'user n'a pas le bypass et qu'un site
|
||||
* est selectionne (cf. docblock de classe). No-op sinon.
|
||||
*/
|
||||
private function applySiteScope(QueryBuilder $qb): void
|
||||
{
|
||||
$scopeSite = $this->currentScopeSite();
|
||||
if (null === $scopeSite) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rootAlias = $qb->getRootAliases()[0];
|
||||
$qb->andWhere(sprintf('%s.site = :scopeSite', $rootAlias))
|
||||
->setParameter('scopeSite', $scopeSite)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site servant a cloisonner, ou null si aucun cloisonnement ne s'applique
|
||||
* (bypass_scope, ou pas de site courant). Replique les conditions de
|
||||
* SiteScopedQueryExtension.
|
||||
*/
|
||||
private function currentScopeSite(): ?Site
|
||||
{
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->currentSiteProvider->get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||
use App\Module\Logistique\Domain\Repository\WeighingTicketRepositoryInterface;
|
||||
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Provider\WeighingTicketProvider;
|
||||
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Export XLSX de la liste des tickets de pesee (M5, spec-back § 4.5 — bouton
|
||||
* « Exporter » : « Exporte toute la liste des tickets de pesée »). Jumeau des
|
||||
* controllers d'export SupplierExportController (M2) / ProviderExportController
|
||||
* (M3) — references en prose volontairement (un {@see} inter-module violerait la
|
||||
* regle ABSOLUE n°1).
|
||||
*
|
||||
* Controller Symfony custom (et non operation API Platform) car il produit un
|
||||
* binaire de fichier, pas une representation Hydra. `priority: 1` est OBLIGATOIRE
|
||||
* sur la route : sans cela API Platform capterait `/api/weighing_tickets/export.xlsx`
|
||||
* comme l'item `GET /api/weighing_tickets/{id}.{_format}` (id="export",
|
||||
* _format="xlsx") — cf. CLAUDE.md « controller custom sous /api ».
|
||||
*
|
||||
* Separation des responsabilites :
|
||||
* - le COMMENT (generation du fichier) est delegue au service Shared
|
||||
* {@see SpreadsheetExporterInterface} — generique, reutilisable, sans metier ;
|
||||
* - le QUOI vit ICI : selection des tickets (MEMES criteres que la liste
|
||||
* `GET /api/weighing_tickets`, mais SANS pagination — export complet § 4.5) et
|
||||
* mapping metier des colonnes.
|
||||
*
|
||||
* Filtrage : on rejoue EXACTEMENT la selection du {@see WeighingTicketProvider}
|
||||
* pour que l'export reflete ce que l'utilisateur voit a l'ecran :
|
||||
* - recherche fuzzy ?search (number, nom client/fournisseur, other_label, immat) ;
|
||||
* - tri ?order[displayDate]=asc|desc (defaut number DESC) ;
|
||||
* - cloisonnement par site courant (§ 2.3 / RG-5.09) : un user sans
|
||||
* `sites.bypass_scope` possedant un site courant n'exporte que les tickets de
|
||||
* ce site. La decision est prise ICI (l'user), le filtre DQL sur wt.site est
|
||||
* pose sur le QueryBuilder. No-op pour bypass_scope ou site courant null.
|
||||
*/
|
||||
#[AsController]
|
||||
final class WeighingTicketExportController
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(service: 'App\Module\Logistique\Infrastructure\Doctrine\DoctrineWeighingTicketRepository')]
|
||||
private readonly WeighingTicketRepositoryInterface $repository,
|
||||
private readonly SpreadsheetExporterInterface $exporter,
|
||||
private readonly Security $security,
|
||||
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||
) {}
|
||||
|
||||
#[Route('/api/weighing_tickets/export.xlsx', name: 'logistique_weighing_tickets_export_xlsx', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('logistique.weighing_tickets.view')]
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$search = $request->query->getString('search') ?: null;
|
||||
|
||||
$qb = $this->repository->createListQueryBuilder($search);
|
||||
|
||||
$this->applyDisplayDateOrder($qb, $request->query->all());
|
||||
$this->applySiteScope($qb);
|
||||
|
||||
// Export complet : pas de pagination (§ 4.5). On materialise toute la
|
||||
// selection filtree (cloisonnee par site) AVANT le mapping des colonnes.
|
||||
/** @var list<WeighingTicket> $tickets */
|
||||
$tickets = $qb->getQuery()->getResult();
|
||||
|
||||
$binary = $this->exporter->export(
|
||||
'Tickets de pesée',
|
||||
$this->buildHeaders(),
|
||||
$this->buildRows($tickets),
|
||||
);
|
||||
|
||||
return $this->buildResponse($binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tri par date du ticket (§ 4.1), miroir de WeighingTicketProvider :
|
||||
* displayDate = COALESCE(full_date, empty_date) (getter calcule, pas une
|
||||
* colonne). Absent du payload -> tri par defaut du repository (number DESC).
|
||||
*
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
private function applyDisplayDateOrder(QueryBuilder $qb, array $query): void
|
||||
{
|
||||
$order = $query['order'] ?? null;
|
||||
if (!is_array($order) || !isset($order['displayDate'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$direction = 'asc' === strtolower((string) $order['displayDate']) ? 'ASC' : 'DESC';
|
||||
$rootAlias = $qb->getRootAliases()[0];
|
||||
|
||||
$qb->orderBy(sprintf('COALESCE(%1$s.fullDate, %1$s.emptyDate)', $rootAlias), $direction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloisonnement par site courant (§ 2.3 / RG-5.09), miroir de
|
||||
* WeighingTicketProvider::applySiteScope() : restreint la selection au site
|
||||
* courant si l'user n'a pas le bypass et qu'un site est resolu. No-op sinon.
|
||||
*/
|
||||
private function applySiteScope(QueryBuilder $qb): void
|
||||
{
|
||||
$scopeSite = $this->siteScopeOrNull();
|
||||
if (null === $scopeSite) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rootAlias = $qb->getRootAliases()[0];
|
||||
$qb->andWhere(sprintf('%s.site = :scopeSite', $rootAlias))
|
||||
->setParameter('scopeSite', $scopeSite)
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site servant a cloisonner, ou null si aucun cloisonnement ne s'applique
|
||||
* (user `sites.bypass_scope`, ou pas de site courant — module Sites off /
|
||||
* user sans currentSite). Miroir de WeighingTicketProvider::currentScopeSite().
|
||||
*/
|
||||
private function siteScopeOrNull(): ?SiteInterface
|
||||
{
|
||||
if ($this->security->isGranted('sites.bypass_scope')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->currentSiteProvider->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Colonnes de l'export (spec § 4.5).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Numéro',
|
||||
'Type contrepartie',
|
||||
'Contrepartie',
|
||||
'Date',
|
||||
'Immatriculation',
|
||||
'Poids vide (kg)',
|
||||
'Poids plein (kg)',
|
||||
'Poids net (kg)',
|
||||
'DSD vide',
|
||||
'DSD plein',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<WeighingTicket> $tickets
|
||||
*
|
||||
* @return iterable<list<null|scalar>>
|
||||
*/
|
||||
private function buildRows(array $tickets): iterable
|
||||
{
|
||||
foreach ($tickets as $ticket) {
|
||||
yield [
|
||||
$ticket->getNumber(),
|
||||
$this->counterpartyTypeLabel($ticket->getCounterpartyType()),
|
||||
$this->counterpartyName($ticket),
|
||||
$ticket->getDisplayDate()?->format('d/m/Y H:i') ?? '',
|
||||
$ticket->getImmatriculation() ?? '',
|
||||
$ticket->getEmptyWeight() ?? '',
|
||||
$ticket->getFullWeight() ?? '',
|
||||
$ticket->getNetWeight() ?? '',
|
||||
$ticket->getEmptyDsd() ?? '',
|
||||
$ticket->getFullDsd() ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
private function counterpartyTypeLabel(?string $type): 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 => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function buildResponse(string $binary): Response
|
||||
{
|
||||
$filename = sprintf('tickets-pesee-%s.xlsx', new DateTimeImmutable()->format('Ymd'));
|
||||
|
||||
$response = new Response($binary);
|
||||
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
$response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename));
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Logistique\Domain\Entity\WeighingTicket;
|
||||
use App\Module\Logistique\Domain\Repository\WeighingTicketRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<WeighingTicket>
|
||||
*/
|
||||
class DoctrineWeighingTicketRepository extends ServiceEntityRepository implements WeighingTicketRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, WeighingTicket::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?WeighingTicket
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function save(WeighingTicket $ticket): void
|
||||
{
|
||||
$this->getEntityManager()->persist($ticket);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function createListQueryBuilder(?string $search = null): QueryBuilder
|
||||
{
|
||||
// Fetch-join (addSelect) des relations ManyToOne client / supplier / site :
|
||||
// sert a la fois la recherche par nom et l'anti-N+1 a la serialisation
|
||||
// (§ 4.0 / § 4.1) — aucune demultiplication de lignes (cardinalite to-one).
|
||||
// Le cloisonnement par site courant n'est PAS pose ici : un provider custom
|
||||
// court-circuite le SiteScopedQueryExtension, le WeighingTicketProvider
|
||||
// l'applique donc lui-meme (§ 2.3). Tri par defaut number DESC.
|
||||
$qb = $this->createQueryBuilder('wt')
|
||||
->leftJoin('wt.client', 'c')->addSelect('c')
|
||||
->leftJoin('wt.supplier', 's')->addSelect('s')
|
||||
->leftJoin('wt.site', 'st')->addSelect('st')
|
||||
->andWhere('wt.deletedAt IS NULL')
|
||||
->orderBy('wt.number', 'DESC')
|
||||
;
|
||||
|
||||
$this->applySearch($qb, $search);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche fuzzy insensible a la casse sur le numero, le nom du client /
|
||||
* fournisseur, le libelle « Autre » et l'immatriculation (§ 4.1).
|
||||
* Metacaracteres LIKE (%, _, \) echappes pour rester litteraux.
|
||||
*/
|
||||
private function applySearch(QueryBuilder $qb, ?string $search): void
|
||||
{
|
||||
if (null === $search || '' === trim($search)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search));
|
||||
$pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%';
|
||||
|
||||
$qb->andWhere(
|
||||
'LOWER(wt.number) LIKE :search '
|
||||
.'OR LOWER(c.companyName) LIKE :search '
|
||||
.'OR LOWER(s.companyName) LIKE :search '
|
||||
.'OR LOWER(wt.otherLabel) LIKE :search '
|
||||
.'OR LOWER(wt.immatriculation) LIKE :search',
|
||||
)->setParameter('search', $pattern);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\Service;
|
||||
|
||||
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
* Implementation DBAL de l'allocateur DSD (RG-5.04, § 2.7).
|
||||
*
|
||||
* Le compteur vit dans la table `weighbridge_dsd_counter (site_id PK,
|
||||
* last_value)` — jamais mappee en ORM (DBAL brut, exclue du schema_filter).
|
||||
* L'increment est realise dans une transaction avec verrou ligne
|
||||
* `SELECT ... FOR UPDATE` : deux postes pesant en parallele sur le meme site
|
||||
* sont serialises, ce qui garantit des DSD distincts (pas de collision).
|
||||
*
|
||||
* AUTO comme MANUAL passent par le meme increment (« dernier DSD du site + 1 ») :
|
||||
* la seule difference fonctionnelle est l'origine du poids (lu par le pont en
|
||||
* AUTO, saisi en MANUAL), pas la sequence DSD.
|
||||
*
|
||||
* La ligne compteur n'est pas seedee a la creation du site : on la cree a la
|
||||
* volee (INSERT ... ON CONFLICT DO NOTHING) avant de prendre le verrou.
|
||||
*/
|
||||
final class DsdAllocator implements DsdAllocatorInterface
|
||||
{
|
||||
public function __construct(private readonly Connection $connection) {}
|
||||
|
||||
public function next(SiteInterface $site): int
|
||||
{
|
||||
$siteId = $site->getId();
|
||||
if (null === $siteId) {
|
||||
// Garde defensive : un site non persiste n'a pas de compteur (et la FK
|
||||
// weighbridge_dsd_counter.site_id -> site(id) rejetterait l'INSERT).
|
||||
throw new LogicException('Impossible d\'allouer un DSD pour un site non persiste (id null).');
|
||||
}
|
||||
|
||||
return $this->connection->transactional(function (Connection $conn) use ($siteId): int {
|
||||
// Garantit l'existence de la ligne compteur du site sans ecraser une
|
||||
// valeur deja presente (idempotent, concurrence-safe).
|
||||
$conn->executeStatement(
|
||||
'INSERT INTO weighbridge_dsd_counter (site_id, last_value) VALUES (:site, 0) ON CONFLICT (site_id) DO NOTHING',
|
||||
['site' => $siteId],
|
||||
);
|
||||
|
||||
// Verrou ligne : serialise les pesees concurrentes du meme site.
|
||||
$current = (int) $conn->fetchOne(
|
||||
'SELECT last_value FROM weighbridge_dsd_counter WHERE site_id = :site FOR UPDATE',
|
||||
['site' => $siteId],
|
||||
);
|
||||
|
||||
$next = $current + 1;
|
||||
|
||||
$conn->executeStatement(
|
||||
'UPDATE weighbridge_dsd_counter SET last_value = :value WHERE site_id = :site',
|
||||
['value' => $next, 'site' => $siteId],
|
||||
);
|
||||
|
||||
return $next;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\Service;
|
||||
|
||||
use App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
* Implementation DBAL de l'allocateur de numero de ticket (RG-5.02, § 2.5).
|
||||
*
|
||||
* Le compteur vit dans la table `weighing_ticket_counter (site_id PK,
|
||||
* last_value)` — jamais mappee en ORM (DBAL brut, exclue du schema_filter), meme
|
||||
* pattern que DsdAllocator. L'increment est realise dans une transaction avec
|
||||
* verrou ligne `SELECT ... FOR UPDATE` : deux postes creant un ticket en parallele
|
||||
* sur le meme site sont serialises -> numeros distincts, pas de collision sur
|
||||
* l'index unique uq_weighing_ticket_number (site_id, number).
|
||||
*
|
||||
* La ligne compteur n'est pas seedee a la creation du site : on la cree a la
|
||||
* volee (INSERT ... ON CONFLICT DO NOTHING) avant de prendre le verrou.
|
||||
*
|
||||
* Le numero est formate `{siteCode}-TP-%04d` (zero-padding 4 chiffres, debordement
|
||||
* naturel au-dela de 9999).
|
||||
*/
|
||||
final class WeighingTicketNumberAllocator implements WeighingTicketNumberAllocatorInterface
|
||||
{
|
||||
public function __construct(private readonly Connection $connection) {}
|
||||
|
||||
public function allocate(Site $site): string
|
||||
{
|
||||
$siteId = $site->getId();
|
||||
if (null === $siteId) {
|
||||
// Garde defensive : un site non persiste n'a pas de compteur (et la FK
|
||||
// weighing_ticket_counter.site_id -> site(id) rejetterait l'INSERT).
|
||||
throw new LogicException('Impossible d\'allouer un numero de ticket pour un site non persiste (id null).');
|
||||
}
|
||||
|
||||
$code = $site->getCode();
|
||||
if (null === $code || '' === trim($code)) {
|
||||
// site.code est NOT NULL (ERP-183) ; garde defensive pour les contextes
|
||||
// hors-flux (fixtures incompletes, site cree sans code).
|
||||
throw new LogicException(sprintf('Le site #%d n\'a pas de code de numerotation (site.code).', $siteId));
|
||||
}
|
||||
|
||||
$next = $this->connection->transactional(function (Connection $conn) use ($siteId): int {
|
||||
// Garantit l'existence de la ligne compteur du site sans ecraser une
|
||||
// valeur deja presente (idempotent, concurrence-safe).
|
||||
$conn->executeStatement(
|
||||
'INSERT INTO weighing_ticket_counter (site_id, last_value) VALUES (:site, 0) ON CONFLICT (site_id) DO NOTHING',
|
||||
['site' => $siteId],
|
||||
);
|
||||
|
||||
// Verrou ligne : serialise les creations concurrentes du meme site.
|
||||
$current = (int) $conn->fetchOne(
|
||||
'SELECT last_value FROM weighing_ticket_counter WHERE site_id = :site FOR UPDATE',
|
||||
['site' => $siteId],
|
||||
);
|
||||
|
||||
$nextValue = $current + 1;
|
||||
|
||||
$conn->executeStatement(
|
||||
'UPDATE weighing_ticket_counter SET last_value = :value WHERE site_id = :site',
|
||||
['value' => $nextValue, 'site' => $siteId],
|
||||
);
|
||||
|
||||
return $nextValue;
|
||||
});
|
||||
|
||||
return sprintf('%s-TP-%04d', $code, $next);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique\Infrastructure\Weighbridge;
|
||||
|
||||
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
|
||||
use App\Module\Logistique\Domain\Contract\WeighbridgeReaderInterface;
|
||||
use App\Module\Logistique\Domain\Weighbridge\WeighbridgeReading;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
|
||||
/**
|
||||
* Stub du pont bascule livre au M5 (DECISION Matthieu 17/06, § 2.6 / RG-5.06).
|
||||
*
|
||||
* Aucune liaison materielle : la pesee « bascule » est simulee par un poids
|
||||
* aleatoire ∈ [10000, 50000] kg, et le DSD est attribue par l'allocateur de
|
||||
* site (DsdAllocator, RG-5.04). Le driver materiel reel (HP-M5-02) remplacera
|
||||
* cette classe derriere WeighbridgeReaderInterface sans impact sur l'API.
|
||||
*
|
||||
* Ce stub ne leve jamais WeighbridgeUnavailableException ; le chemin d'erreur
|
||||
* (→ 503) reste implemente et teste cote Processor.
|
||||
*/
|
||||
final class RandomWeighbridgeReader implements WeighbridgeReaderInterface
|
||||
{
|
||||
public function __construct(private readonly DsdAllocatorInterface $dsdAllocator) {}
|
||||
|
||||
public function read(SiteInterface $site): WeighbridgeReading
|
||||
{
|
||||
return new WeighbridgeReading(
|
||||
weight: random_int(10000, 50000),
|
||||
dsd: $this->dsdAllocator->next($site),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Logistique;
|
||||
|
||||
final class LogistiqueModule
|
||||
{
|
||||
public const string ID = 'logistique';
|
||||
public const string LABEL = 'Logistique';
|
||||
public const bool REQUIRED = false;
|
||||
|
||||
/**
|
||||
* Liste declarative des permissions RBAC exposees par le module Logistique.
|
||||
*
|
||||
* Socle des tickets de pesee (M5 § 5.1, ERP-181) :
|
||||
* - `view` : consultation de la liste / fiche d'un ticket de pesee ;
|
||||
* - `manage` : creation / modification d'un ticket de pesee.
|
||||
*
|
||||
* Consommee par `app:sync-permissions`. Matrice role -> permissions dans
|
||||
* `RbacSeeder::MATRIX` (§ 5.2 : Bureau / Usine = view + manage ; Compta /
|
||||
* Commerciale = aucun acces).
|
||||
*
|
||||
* @return array<int, array{code: string, label: string}>
|
||||
*/
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'logistique.weighing_tickets.view', 'label' => 'Voir les tickets de pesée'],
|
||||
['code' => 'logistique.weighing_tickets.manage', 'label' => 'Créer / modifier les tickets de pesée'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Table(name: 'site')]
|
||||
#[Auditable]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]
|
||||
#[ORM\UniqueConstraint(name: 'uq_site_code', columns: ['code'])]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[UniqueEntity(fields: ['name'], message: 'Un site avec ce nom existe deja.')]
|
||||
class Site implements SiteInterface
|
||||
@@ -88,6 +89,16 @@ class Site implements SiteInterface
|
||||
#[Groups(['site:read', 'site:write', 'me:read'])]
|
||||
private string $name;
|
||||
|
||||
// Code court du site (86/17/82) — prefixe de numerotation des tickets de
|
||||
// pesee (RG-5.02, M5 Logistique). Auto-derive des 2 premiers chiffres du
|
||||
// code postal (departement) a la creation s'il n'est pas fourni
|
||||
// explicitement (onPrePersist, § 2.5), editable ensuite cote admin Sites.
|
||||
// Unique en base (uq_site_code).
|
||||
#[ORM\Column(length: 8)]
|
||||
#[Assert\Length(max: 8, maxMessage: 'Le code du site ne peut pas depasser {{ limit }} caracteres.')]
|
||||
#[Groups(['site:read', 'site:write', 'me:read'])]
|
||||
private ?string $code = null;
|
||||
|
||||
// Premiere ligne d'adresse : numero + voie. Requise.
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Assert\NotBlank(message: 'La rue est requise.')]
|
||||
@@ -188,6 +199,20 @@ class Site implements SiteInterface
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
/**
|
||||
* A la creation, derive le code court du site des 2 premiers chiffres du
|
||||
* code postal (departement) si aucun code n'a ete fourni explicitement
|
||||
* (RG-5.02, § 2.5). Le code reste editable ensuite ; son unicite est
|
||||
* garantie en base par uq_site_code.
|
||||
*/
|
||||
#[ORM\PrePersist]
|
||||
public function onPrePersist(): void
|
||||
{
|
||||
if (null === $this->code || '' === trim($this->code)) {
|
||||
$this->code = substr($this->postalCode, 0, 2);
|
||||
}
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -205,6 +230,18 @@ class Site implements SiteInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCode(): ?string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
public function setCode(?string $code): static
|
||||
{
|
||||
$this->code = $code;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStreet(): string
|
||||
{
|
||||
return $this->street;
|
||||
|
||||
@@ -36,7 +36,7 @@ class SitesFixtures extends Fixture
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// Chatellerault : bleu Starseed.
|
||||
// Chatellerault : bleu Starseed. Code 86 (prefixe TP — RG-5.02).
|
||||
$this->ensureSite(
|
||||
$manager,
|
||||
name: 'Chatellerault',
|
||||
@@ -45,11 +45,12 @@ class SitesFixtures extends Fixture
|
||||
postalCode: '86100',
|
||||
city: 'Châtellerault',
|
||||
color: '#056CF2',
|
||||
code: '86',
|
||||
);
|
||||
|
||||
// Saint-Jean : jaune vif. Le nom du site (identifier) ne reflete
|
||||
// pas la ville reelle (Fontenet) — c'est une nomenclature interne
|
||||
// client.
|
||||
// client. Code 17 (prefixe TP — RG-5.02).
|
||||
$this->ensureSite(
|
||||
$manager,
|
||||
name: 'Saint-Jean',
|
||||
@@ -58,9 +59,10 @@ class SitesFixtures extends Fixture
|
||||
postalCode: '17400',
|
||||
city: 'Fontenet',
|
||||
color: '#F3CB00',
|
||||
code: '17',
|
||||
);
|
||||
|
||||
// Pommevic : vert clair.
|
||||
// Pommevic : vert clair. Code 82 (prefixe TP — RG-5.02).
|
||||
$this->ensureSite(
|
||||
$manager,
|
||||
name: 'Pommevic',
|
||||
@@ -69,6 +71,7 @@ class SitesFixtures extends Fixture
|
||||
postalCode: '82400',
|
||||
city: 'Pommevic',
|
||||
color: '#74BF04',
|
||||
code: '82',
|
||||
);
|
||||
|
||||
$manager->flush();
|
||||
@@ -91,11 +94,13 @@ class SitesFixtures extends Fixture
|
||||
string $postalCode,
|
||||
string $city,
|
||||
string $color,
|
||||
string $code,
|
||||
): Site {
|
||||
$site = $this->siteRepository->findByName($name);
|
||||
|
||||
if (null === $site) {
|
||||
$site = new Site($name, $street, $complement, $postalCode, $city, $color);
|
||||
$site->setCode($code);
|
||||
$manager->persist($site);
|
||||
|
||||
return $site;
|
||||
@@ -106,6 +111,7 @@ class SitesFixtures extends Fixture
|
||||
$site->setPostalCode($postalCode);
|
||||
$site->setCity($city);
|
||||
$site->setColor($color);
|
||||
$site->setCode($code);
|
||||
|
||||
return $site;
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ final class ColumnCommentsCatalog
|
||||
'_table' => 'Sites geographiques — perimetre de scoping multi-site, attribues aux utilisateurs via user_site.',
|
||||
'id' => 'Identifiant interne auto-incremente.',
|
||||
'name' => 'Nom du site (≤ 100 caracteres).',
|
||||
'code' => 'Code court du site (ex. 86/17/82) — prefixe de numerotation des tickets de pesee (RG-5.02). Auto-derive des 2 premiers chiffres du CP a la creation, editable ensuite. Unique (uq_site_code).',
|
||||
'city' => 'Ville du site (≤ 100 caracteres).',
|
||||
'postal_code' => 'Code postal (chaine ≤ 20 caracteres) — VARCHAR pour gerer les zeros initiaux et les formats internationaux.',
|
||||
'color' => "Code couleur hexadecimal (#RRGGBB) — differenciation visuelle dans l'UI.",
|
||||
@@ -537,6 +538,50 @@ final class ColumnCommentsCatalog
|
||||
'price_state' => 'Etat du prix : EN_COURS, VALIDE ou NON_VALIDE (chk_carrier_price_state). Affiche dans le tableau Prix.',
|
||||
'position' => 'Ordre d affichage du prix dans la liste du transporteur (croissant).',
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
// M5 Logistique (ERP-182) — compteurs par site, hors ORM (DBAL brut
|
||||
// FOR UPDATE) donc exclus du schema_filter ; catalogues ici pour que
|
||||
// `app:apply-column-comments` rejoue leurs descriptions au besoin.
|
||||
'weighing_ticket_counter' => [
|
||||
'_table' => 'Sequence du numero de ticket de pesee par site (RG-5.02, M5 Logistique) — incrementee en DBAL brut sous verrou FOR UPDATE, hors ORM.',
|
||||
'site_id' => 'Site proprietaire de la sequence (1 ligne par site). PK + FK -> site.id, ON DELETE CASCADE.',
|
||||
'last_value' => 'Dernier numero de ticket attribue pour le site. Increment verrouille FOR UPDATE (RG-5.02).',
|
||||
],
|
||||
|
||||
'weighbridge_dsd_counter' => [
|
||||
'_table' => 'Compteur DSD du pont bascule par site (RG-5.04, M5 Logistique) — chaque pesee consomme une valeur. Incremente en DBAL brut sous verrou FOR UPDATE, hors ORM.',
|
||||
'site_id' => 'Site proprietaire du compteur (1 pont par site). PK + FK -> site.id, ON DELETE CASCADE.',
|
||||
'last_value' => 'Derniere valeur DSD attribuee pour le site (pont bascule). Increment verrouille FOR UPDATE (RG-5.04).',
|
||||
],
|
||||
|
||||
// M5 Logistique (ERP-183) — table principale, desormais mappee par
|
||||
// l'entite WeighingTicket : schema:update (test) la recree sans COMMENT
|
||||
// -> 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.',
|
||||
] + self::timestampableBlamableComments(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user