From f6d39cb187e1834cd001b150e2897d1baa6ad4e0 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Wed, 17 Jun 2026 16:32:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(logistique)=20:=20migration=20sch=C3=A9ma?= =?UTF-8?q?=20M5=20tickets=20de=20pes=C3=A9e=20(ERP-182)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crée le schéma BDD du module Logistique (M5) au namespace racine DoctrineMigrations (FK cross-module user/client/supplier/site, règle n°11) : - site.code VARCHAR(8) (préfixe de numérotation {siteCode}-TP, RG-5.02) + backfill depuis le code postal + index unique uq_site_code. Colonne NULLABLE à ce ticket (l'entité Site ne mappe pas encore code) ; mapping ORM, peuplement et SET NOT NULL portés par le ticket entité. - weighing_ticket_counter / weighbridge_dsd_counter : compteurs par site (numéro RG-5.02 / DSD pont RG-5.04), gérés en DBAL brut FOR UPDATE, hors ORM → exclus du schema_filter (sinon schema:update les droppe) + catalogués. - weighing_ticket : table principale (contrepartie Client/Fournisseur/Autre avec CHECK 3 branches RG-5.03, immatriculation partagée, pesées vide/plein en colonnes plates, net_weight dérivé, soft-delete + Timestampable/Blamable). Index unique (site_id, number) + index FK. ON DELETE site/client/supplier = RESTRICT, created_by/updated_by = SET NULL. COMMENT ON COLUMN sur chaque colonne créée (règle n°12). make test + ColumnsHaveSqlCommentTest verts, db-reset OK. --- config/packages/doctrine.yaml | 9 +- migrations/Version20260617150000.php | 264 ++++++++++++++++++ .../Database/ColumnCommentsCatalog.php | 15 + 3 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 migrations/Version20260617150000.php diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index ee2f59d..2f1af79 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -24,13 +24,18 @@ doctrine: # is_active, TIMESTAMP(6)) -> schema:update reste un no-op. # - `idtf_product` / `idtf_sync_log` : referentiel codes IDTF # synchronise en DBAL brut par `app:idtf:sync`, hors ORM. + # - `weighing_ticket_counter` / `weighbridge_dsd_counter` : compteurs + # par site (numero de ticket de pesee RG-5.02 / DSD du pont RG-5.04, + # M5 Logistique), incrementes en DBAL brut sous verrou `FOR UPDATE` + # par l'allocateur — jamais mappes en ORM (cf. spec M5 § 2.5 / § 2.7). # Sans ce filtre, schema:update les considere comme "orphelines" et # genere un `DROP TABLE` qui casse la base de test apres chaque # `make test-db-setup` (la migration les a creees, schema:update les # supprime juste apres). Creation / suppression restent pilotees par # les migrations (audit_log : Version20260420202749 ; qualimat : - # Version20260612150000 ; idtf : Version20260612160000). - schema_filter: '~^(?!(?:audit_log|qualimat_sync_log|idtf_product|idtf_sync_log)$).+~' + # Version20260612150000 ; idtf : Version20260612160000 ; compteurs M5 : + # Version20260617150000). + schema_filter: '~^(?!(?:audit_log|qualimat_sync_log|idtf_product|idtf_sync_log|weighing_ticket_counter|weighbridge_dsd_counter)$).+~' audit: url: '%env(resolve:DATABASE_URL)%' orm: diff --git a/migrations/Version20260617150000.php b/migrations/Version20260617150000.php new file mode 100644 index 0000000..fa4b883 --- /dev/null +++ b/migrations/Version20260617150000.php @@ -0,0 +1,264 @@ + exclues du + * `schema_filter` (config/packages/doctrine.yaml) pour que schema:update ne + * les drope pas. Leurs descriptions sont aussi catalogue-es dans + * ColumnCommentsCatalog (rejeu par `app:apply-column-comments`). + * - weighing_ticket et la colonne site.code seront mappes en ORM au ticket + * suivant (entite WeighingTicket + propriete Site::code) ; d'ici la, + * schema:update les drope sur la base de TEST uniquement (sans impact : aucun + * test ne les reference encore, et dev/prod ne lancent jamais schema:update). + */ +final class Version20260617150000 extends AbstractMigration +{ + public function getDescription(): string + { + return 'ERP-182 (M5) : site.code + compteurs (numero ticket / DSD) + table weighing_ticket (tickets de pesee).'; + } + + public function up(Schema $schema): void + { + $this->addSiteCode(); + $this->createWeighingTicketCounter(); + $this->createWeighbridgeDsdCounter(); + $this->createWeighingTicket(); + } + + public function down(Schema $schema): void + { + // Ordre inverse : table principale puis compteurs, enfin la colonne site.code. + $this->addSql('DROP TABLE IF EXISTS weighing_ticket'); + $this->addSql('DROP TABLE IF EXISTS weighbridge_dsd_counter'); + $this->addSql('DROP TABLE IF EXISTS weighing_ticket_counter'); + $this->addSql('DROP INDEX IF EXISTS uq_site_code'); + $this->addSql('ALTER TABLE site DROP COLUMN IF EXISTS code'); + } + + // ================================================================= + // site.code — prefixe de numerotation des tickets (§ 2.5) + // ================================================================= + + private function addSiteCode(): void + { + // Colonne NULLABLE a ce ticket : l'entite Site ne mappe pas encore `code`, + // donc tout persist ORM (fixtures, tests) l'omettrait -> un NOT NULL casserait + // `make db-reset`. Le mapping ORM Site::code, son peuplement (86/17/82) et le + // passage `SET NOT NULL` sont portes par le ticket suivant (entite WeighingTicket + // + Site::code), via une 2e migration. L'index unique est pose des maintenant + // (Postgres tolere plusieurs NULL) : il garantit l'unicite des qu'ils seront peuples. + $this->addSql('ALTER TABLE site ADD COLUMN code VARCHAR(8) DEFAULT NULL'); + // Backfill : 2 premiers chiffres du code postal (departement) par defaut, + // editable ensuite cote admin Sites. No-op sur base fraiche (aucun site encore). + $this->addSql('UPDATE site SET code = LEFT(postal_code, 2) WHERE code IS NULL'); + $this->addSql('CREATE UNIQUE INDEX uq_site_code ON site (code)'); + + $this->comment('site', 'code', 'Code court du site (ex. 86/17/82) — prefixe de numerotation des tickets de pesee (RG-5.02). Unique (uq_site_code). Backfill = 2 premiers chiffres du CP. NOT NULL pose au ticket entite.'); + } + + // ================================================================= + // Compteur du numero de ticket (sequence par site) — RG-5.02 + // ================================================================= + + private function createWeighingTicketCounter(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE weighing_ticket_counter ( + site_id INT NOT NULL, + last_value INT DEFAULT 0 NOT NULL, + PRIMARY KEY (site_id), + CONSTRAINT fk_wt_counter_site + FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE + ) + SQL); + + $this->comment('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.'); + $this->comment('weighing_ticket_counter', 'site_id', 'Site proprietaire de la sequence (1 ligne par site). PK + FK -> site.id, ON DELETE CASCADE.'); + $this->comment('weighing_ticket_counter', 'last_value', 'Dernier numero de ticket attribue pour le site. Increment verrouille FOR UPDATE (RG-5.02).'); + } + + // ================================================================= + // Compteur DSD (pesee du pont, par site) — RG-5.04 + // ================================================================= + + private function createWeighbridgeDsdCounter(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE weighbridge_dsd_counter ( + site_id INT NOT NULL, + last_value INT DEFAULT 0 NOT NULL, + PRIMARY KEY (site_id), + CONSTRAINT fk_dsd_counter_site + FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE + ) + SQL); + + $this->comment('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.'); + $this->comment('weighbridge_dsd_counter', 'site_id', 'Site proprietaire du compteur (1 pont par site). PK + FK -> site.id, ON DELETE CASCADE.'); + $this->comment('weighbridge_dsd_counter', 'last_value', 'Derniere valeur DSD attribuee pour le site (pont bascule). Increment verrouille FOR UPDATE (RG-5.04).'); + } + + // ================================================================= + // Table principale `weighing_ticket` + // ================================================================= + + private function createWeighingTicket(): void + { + $this->addSql(<<<'SQL' + CREATE TABLE weighing_ticket ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + site_id INT NOT NULL, + number VARCHAR(20) NOT NULL, + counterparty_type VARCHAR(12) NOT NULL, + client_id INT DEFAULT NULL, + supplier_id INT DEFAULT NULL, + other_label VARCHAR(255) DEFAULT NULL, + immatriculation VARCHAR(20) NOT NULL, + plate_free_format BOOLEAN DEFAULT FALSE NOT NULL, + empty_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + empty_weight INT DEFAULT NULL, + empty_dsd INT DEFAULT NULL, + empty_mode VARCHAR(8) DEFAULT NULL, + empty_manual_number VARCHAR(50) DEFAULT NULL, + full_date TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + full_weight INT DEFAULT NULL, + full_dsd INT DEFAULT NULL, + full_mode VARCHAR(8) DEFAULT NULL, + full_manual_number VARCHAR(50) DEFAULT NULL, + net_weight INT DEFAULT NULL, + deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + PRIMARY KEY (id), + CONSTRAINT chk_wt_counterparty_type + CHECK (counterparty_type IN ('CLIENT', 'FOURNISSEUR', 'AUTRE')), + CONSTRAINT chk_wt_empty_mode + CHECK (empty_mode IS NULL OR empty_mode IN ('AUTO', 'MANUAL')), + CONSTRAINT chk_wt_full_mode + CHECK (full_mode IS NULL OR full_mode IN ('AUTO', 'MANUAL')), + CONSTRAINT chk_wt_client_branch + CHECK (counterparty_type <> 'CLIENT' OR (client_id IS NOT NULL AND supplier_id IS NULL AND other_label IS NULL)), + CONSTRAINT chk_wt_supplier_branch + CHECK (counterparty_type <> 'FOURNISSEUR' OR (supplier_id IS NOT NULL AND client_id IS NULL AND other_label IS NULL)), + CONSTRAINT chk_wt_other_branch + CHECK (counterparty_type <> 'AUTRE' OR (other_label IS NOT NULL AND client_id IS NULL AND supplier_id IS NULL)), + CONSTRAINT fk_wt_site + FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT, + CONSTRAINT fk_wt_client + FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE RESTRICT, + CONSTRAINT fk_wt_supplier + FOREIGN KEY (supplier_id) REFERENCES supplier (id) ON DELETE RESTRICT, + CONSTRAINT fk_wt_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, + CONSTRAINT fk_wt_updated_by + FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + + $this->addSql('CREATE UNIQUE INDEX uq_weighing_ticket_number ON weighing_ticket (site_id, number)'); + $this->addSql('CREATE INDEX idx_wt_site ON weighing_ticket (site_id)'); + $this->addSql('CREATE INDEX idx_wt_client ON weighing_ticket (client_id)'); + $this->addSql('CREATE INDEX idx_wt_supplier ON weighing_ticket (supplier_id)'); + $this->addSql('CREATE INDEX idx_wt_deleted_at ON weighing_ticket (deleted_at)'); + $this->addSql('CREATE INDEX idx_wt_created_by ON weighing_ticket (created_by)'); + $this->addSql('CREATE INDEX idx_wt_updated_by ON weighing_ticket (updated_by)'); + + $this->comment('weighing_ticket', '_table', 'Tickets de pesee (M5 Logistique) — pesee a vide + a plein au pont bascule, contrepartie Client/Fournisseur/Autre. Cloisonne par site courant.'); + $this->comment('weighing_ticket', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('weighing_ticket', '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).'); + $this->comment('weighing_ticket', 'number', 'Numero {siteCode}-TP-{NNNN}, unique par site (uq_weighing_ticket_number), immuable. Sequence weighing_ticket_counter (RG-5.02).'); + $this->comment('weighing_ticket', 'counterparty_type', 'Contrepartie : CLIENT, FOURNISSEUR ou AUTRE (chk_wt_counterparty_type, RG-5.03). Pilote l obligation client_id / supplier_id / other_label.'); + $this->comment('weighing_ticket', '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).'); + $this->comment('weighing_ticket', 'supplier_id', 'Branche FOURNISSEUR (RG-5.03) : fournisseur concerne. FK -> supplier.id, ON DELETE RESTRICT. Requis ssi counterparty_type = FOURNISSEUR (chk_wt_supplier_branch).'); + $this->comment('weighing_ticket', 'other_label', 'Branche AUTRE (RG-5.03) : libelle libre de la contrepartie. Requis ssi counterparty_type = AUTRE, nul sinon (chk_wt_other_branch).'); + $this->comment('weighing_ticket', '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).'); + $this->comment('weighing_ticket', '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.'); + $this->comment('weighing_ticket', '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.'); + $this->comment('weighing_ticket', 'empty_weight', 'Poids a vide (tare) en kg — readonly UI, rempli par la pesee (RG-5.07).'); + $this->comment('weighing_ticket', 'empty_dsd', 'Compteur DSD du pont a la pesee a vide. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).'); + $this->comment('weighing_ticket', 'empty_mode', 'Mode de la pesee a vide : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_empty_mode (RG-5.06).'); + $this->comment('weighing_ticket', 'empty_manual_number', 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a vide (RG-5.04).'); + $this->comment('weighing_ticket', 'full_date', 'Date/heure de la pesee a plein (brut). Null tant que la pesee plein n est pas faite.'); + $this->comment('weighing_ticket', 'full_weight', 'Poids a plein (brut) en kg — readonly UI, rempli par la pesee (RG-5.07).'); + $this->comment('weighing_ticket', 'full_dsd', 'Compteur DSD du pont a la pesee a plein. AUTO = valeur du pont ; MANUAL = dernier dsd du site + 1 (RG-5.04).'); + $this->comment('weighing_ticket', 'full_mode', 'Mode de la pesee a plein : AUTO (pont bascule) ou MANUAL (saisie) — chk_wt_full_mode (RG-5.06).'); + $this->comment('weighing_ticket', 'full_manual_number', 'Numero de pesee saisi en pesee manuelle (distinct du DSD) — formulaire a plein (RG-5.04).'); + $this->comment('weighing_ticket', 'net_weight', 'Poids net = full_weight - empty_weight (kg), calcule serveur (RG-5.05). Null si une pesee manque. Colonne Poids de la liste.'); + $this->comment('weighing_ticket', 'deleted_at', 'Horodatage du soft-delete technique — prepare mais non expose par l API au M5 (§ 2.13). Null = ligne active.'); + $this->addTimestampableBlamableComments('weighing_ticket'); + } + + // ================================================================= + // Helpers (identiques au M4 Version20260615150000) + // ================================================================= + + /** + * Pose les 4 commentaires standardises Timestampable/Blamable sur une table, + * en reutilisant le catalogue partage (source unique, ERP-67). + */ + private function addTimestampableBlamableComments(string $table): void + { + foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) { + $this->comment($table, $column, $description); + } + } + + /** + * Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN` + * en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement d apostrophe. + */ + private function comment(string $table, string $column, string $description): void + { + $quotedTable = '"'.str_replace('"', '""', $table).'"'; + + if ('_table' === $column) { + $this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description)); + + return; + } + + $this->addSql(sprintf( + 'COMMENT ON COLUMN %s.%s IS $_$%s$_$', + $quotedTable, + '"'.str_replace('"', '""', $column).'"', + $description, + )); + } +} diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index 6cf7e2d..f727239 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -538,6 +538,21 @@ 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).', + ], ]; }