Impression

Module impression — rôle imprimeur, cycle de vie des ordres d'impression, routes API et accès workspace /printer.

Impression

Le workspace imprimeur (/printer) et les routes associées permettent à une organisation prestataire d'impression de consulter les ordres d'impression générés par la plateforme, de télécharger les fichiers à imprimer et de mettre à jour le statut de fabrication.


Acteur et rôle

Rôle printer

  • Rôle applicatif non-admin, distinct de industrial, distributor et admin.
  • Scoped exclusivement au périmètre d'impression : aucun accès aux workspaces industriel, distributeur ou admin.
  • Provisionnement explicite par un administrateur plateforme — aucun parcours d'auto-onboarding.

Type d'organisation printer

  • actorType = 'printer' sur l'enregistrement d'organisation.
  • Convention d'identifiant : printer:<siren> lorsqu'une organisation est scoped sur un SIREN imprimeur.
  • Les handlers API vérifient systématiquement que organizationId.startsWith('printer:') avant d'exécuter toute action.

Route cible post-login

  • Après authentification, un utilisateur avec le rôle printer est redirigé vers /printer.
  • Le middleware de routage résout actor_workspace = printer et bloque l'accès à /industrial/**, /distributor/** et /admin/**.
  • Symétriquement, les utilisateurs non-printer sont bloqués sur /printer/**.

Modèle de domaine — ordres d'impression

Un ordre d'impression (print_order) est créé par la plateforme à partir d'une réservation approuvée. Il regroupe une ou plusieurs lignes de fabrication, chacune associée à un fichier visuel (asset).

Quantité d'une ligne

PrintOrderLine.quantity porte le nombre de panneaux à fabriquer pour cette ligne. C'est un instantané figé au moment de la commande, au même titre que le prix : corriger la quantité d'une configuration après coup ne réécrit pas une commande déjà partie chez l'imprimeur.

La quantité vient de PromoFurnitureTypeFace.quantity (voir Magasins et espaces) et se répartit selon le mode de dépôt choisi par l'industriel :

Mode de dépôtLignes crééesquantity par ligne
« même visuel »1 ligne pour toute la zonequantité de la face
« un visuel par face »1 ligne par face déposée1
qualifiers est une liste d'étiquettes de position (« avant », « arrière »), pas un compteur. Elle peut être plus courte que quantity — les faces sans quad n'ont pas d'étiquette. Ne jamais dériver une quantité de qualifiers.length : les helpers resolveLineQuantity / countPanels / sumSurfaceM2 de #shared/utils/print-quantity sont la seule voie.

qualifiers sert au repérage de l'industriel sur la photo du meuble. Il n'est donc pas affiché dans l'espace imprimeur ni dans le récapitulatif de commande de l'industriel : une étiquette de position n'aide pas à fabriquer, et la montrer à côté d'une quantité invite à confondre les deux. Il reste exposé dans l'espace admin, comme trace de diagnostic.

Les métriques d'un ordre sont calculées par summarizePrintOrderLines (campaigns/application/reservations/print-order-metrics.ts) :

  • filesCount — nombre de fichiers distincts (= nombre de lignes) ;
  • panelCount — nombre de panneaux fabriqués (= somme des quantity) ;
  • totalSurfaceM2 — surface cumulée, chaque ligne comptée quantity fois.
Ces deux compteurs alimentent des cartes voisines dans l'espace imprimeur — « Zones » et « Faces totales ». Le composant PrinterOrderKpis reçoit le second sous le nom panelCount : lui repasser un nombre de lignes affiche un nombre de panneaux faux à celui qui fabrique. Sur la fiche d'un lot, la même carte est intitulée « Total panneaux du lot » et porte batch.totalPanels, car son périmètre est l'ensemble des magasins et non un seul.

Le nom de fichier dans le ZIP porte le suffixe _qte<N> construit sur quantity (resolveArchiveFilenames), et le bon de commande PDF affiche la quantité par ligne ainsi que le total de panneaux.

Dépôt des visuels — identité des emplacements

ReservationPrintPreflight.faceIndex identifie l'emplacement de dépôt :

  • un entier 0..quantity-1 pour un dépôt par face ;
  • null avec appliesToAllQualifiers: true pour un visuel unique appliqué à toutes les faces.

Le rattachement d'un fichier déjà téléversé se fait sur (zoneId, faceIndex), jamais sur le qualifier : renommer un quad côté admin ne doit pas détacher les fichiers déjà déposés par l'industriel. Les emplacements sont construits côté client par app/composables/reservation-upload-slots.ts (buildZoneGroups), à partir de la quantité renvoyée par GET /api/reservations/{id}/gabarits — jamais à partir du nombre de quads.

Noyau de dépôt partagé

Le dépôt unitaire (POST /api/reservations/{id}/upload/confirm) et le dépôt groupé (confirmBatch) partagent le même noyau de calcul, dans campaigns/application/print-deposit/ :

FichierRôle
zone-demands.tsgarde-fous de couverture et demande par zone
quantity-split.tsallocation des panneaux et vérification du découpage
order-lines.tsgabarits de fichier, lignes de commande, lignes de faces réutilisées
support-outcome.tsmode de support et statut d'impression dérivés de l'issue du dépôt
outcome-writer.tsécriture des faces réutilisées et du mode de support

Le dépôt unitaire est le cas dégénéré du dépôt groupé : reservationCount = 1, et aucune déclaration numérique de stock puisque le réemploi y est un interrupteur par emplacement — stockTotal y vaut donc toujours zéro. Les deux parcours passent par buildZoneDemands, qui refuse :

  • une zone qui n'appartient pas à la configuration de la réservation ;
  • plus de fichiers déposés que la zone n'a de faces ;
  • une déclaration de réemploi supérieure à la capacité de la zone ;
  • une face qui n'a ni fichier déposé ni déclaration de réemploi.
La quantity d'une ligne est dérivée de faceIndex et de la configuration (faceIndex === null ? zone.quantity : 1), jamais transmise par le client. Le payload de confirm ne porte donc pas de quantity sur confirmedFiles : la rétablir rouvrirait la porte à une commande dont le nombre de panneaux ne correspond pas au meuble.

Le mode de support (print / hybrid / reuse_all) n'est plus déduit du payload mais recalculé par resolveOutcomeSupportMode à partir de ce qui est réellement imprimé et réutilisé. Les faces réutilisées sont persistées dans reservation_print_reused_faces dans tous les modes, reuse_all compris, et relues par getImpressionDetailForIndustrial pour alimenter le récapitulatif de la fiche d'impression.

Déclarer « j'ai déjà mes supports en stock » depuis la fiche (PATCH /api/reservations/{id}/print-support) ne transporte aucun détail de face : l'ensemble complet est dérivé de la configuration du meuble, afin que les lignes persistées soient identiques quel que soit le parcours ayant mené à reuse_all. Une réservation sans configuration résolvable refuse la déclaration (PRINT_CONFIGURATION_UNAVAILABLE) plutôt que d'enregistrer un « tout en stock » vide.

Cycle de vie (PrintOrderStatus)

Défini dans campaigns/application/reservations/print-order-status.ts :

draft ──→ pending ──→ sent ──→ in_production ──→ produced ──→ delivered
                  └──→ cancelled (à tout moment)
StatutSignification
draftCréé, non encore transmis à l'imprimeur
pendingEn attente de prise en charge par l'imprimeur
sentTransmis à l'imprimeur
in_productionEn cours de fabrication
producedFabrication terminée
deliveredLivré au distributeur
cancelledAnnulé

La progression normale est séquentielle. Le statut peut être reculé d'un cran via le rollback (voir routes ci-dessous). draft et cancelled n'ont pas de transition forward/backward standard.

Lot de dépôt (PrintOrderBatch)

Un industriel ayant réservé de nombreux emplacements sur des meubles identiques (même campagne, même configuration PromoFurnitureType) peut déposer ses visuels une seule fois pour tout le lot plutôt qu'emplacement par emplacement. PrintOrderBatch est le conteneur de ce dépôt groupé ; il est additif — aucune sémantique du flux unitaire ci-dessus ne change.

model PrintOrderBatch {
  id                       String @id @default(cuid())
  code                     String @unique   // LOT-0001, alloué par séquence
  campaignId               String
  promoFurnitureTypeId     String           // clé de regroupement, avec campaignId
  status                   String @default("draft")   // draft | sent — voir note ci-dessous
  deliveryTarget           String @default("store")   // store | single_address
  orders     PrintOrder[]
  preflights PrintBatchPreflight[]
}
  • PrintOrder.batchId (nullable) est le seul lien entre un lot et ses commandes — pas de table de liaison. Confirmer un lot crée une PrintOrder par réservation retenue, chacune avec ses propres PrintOrderLine : le lot ne remplace pas l'invariant « une réservation = une commande », il en crée plusieurs d'un coup.
  • PrintBatchPreflight est le miroir de ReservationPrintPreflight sur le scope lot. Le cœur de vérification (upsertPreflightRow, campaigns/application/reservations/print-preflight-core.ts) est paramétré par un PreflightRepository<TScope> générique, implémenté une fois pour la réservation et une fois pour le lot (batchPreflightRepository, mass-impression/batch-preflight-service.ts) — la logique de fond n'existe qu'à un seul endroit.
  • PrintOrderBatch.status n'est toujours écrit qu'à deux valeurs — draft et sent — et le reste ainsi par construction. draft est écrit à l'ouverture (openBatchDraft, batch-draft-service.ts), sent à la confirmation (writeBatchOrders, batch-confirm-service.ts). Le champ n'a aucune contrainte d'énumération en base (String @default("draft")) et il n'existe aucune troisième valeur cancelled sur ce champ, ni aujourd'hui ni prévue : l'avancement affiché à l'imprimeur (voir « Vue imprimeur du lot » plus bas) est dérivé en lecture des PrintOrderStatus des commandes filles par deriveBatchStatus (mass-impression/batch-listing-service.ts), jamais persisté sur le lot. Trois règles, dans cet ordre : un lot sans aucune commande (jamais atteignable pour un lot sent, puisqu'un lot vide est supprimé — voir ci-dessous) donne draft ; un lot dont toutes les commandes sont cancelled donne cancelled ; sinon on retire les commandes cancelled et on compare le reste — statut commun si toutes s'accordent, 'mixed' sinon. La deuxième règle existe précisément parce que filtrer les cancelled puis tester la liste vide donnerait draft à un lot envoyé puis intégralement annulé, un lot qui n'a jamais été un brouillon.
  • Un lot dont la confirmation ne produit aucune commande est supprimé. Si toutes les zones sont entièrement réutilisées (isFullyReused), aucun PrintOrder n'est créé et le PrintOrderBatch est effacé en fin de transaction : un lot vide n'a rien à montrer. Ce lot ne reçoit alors aucun code — l'écran de rapport (BatchReportPanel.vue) le présente comme tel, « lot couvert par votre stock », et non comme un envoi réussi dont le code serait vide.
  • La déclaration de réutilisation n'est validée qu'une fois, contre la population réellement livrable. confirmBatch résout les destinataires (resolveDeliverableTargets) avant de construire le split (validateSplit(zones, input, targets.map(…))). La quantité réutilisée déclarée par le client est un total mis à l'échelle de la population que la prévisualisation a retenue ; valider d'abord contre candidates.length — la population avant résolution d'adresse — rejetait en 400 toute déclaration de réutilisation dès qu'un magasin de la sélection n'avait pas d'adresse livrable, alors que la prévisualisation venait de l'annoncer comme écarté. Une seule population, une seule validation.
  • Un lot dont rien n'est imprimé ne demande aucune adresse. La cible de livraison a une troisième valeur, { target: 'none' }, qui signifie « rien n'est expédié » : resolveBatchShipments retient alors tous les candidats sans interroger la moindre adresse, donc aucune exclusion missing_address n'est produite, ni à la prévisualisation ni à la confirmation. C'est ce qui permet de confirmer un lot intégralement couvert par les supports en stock même quand aucun magasin de la sélection n'a d'adresse renseignée. confirmBatch refuse en 400 un target: 'none' dont le split imprime encore au moins un panneau : la destination ne peut être omise que lorsqu'il n'y a réellement rien à livrer.
    • Côté écran, ce cas ne traverse pas le récapitulatif. L'étape Visuels porte un encadré « Supports visuels en stock » dont la case globale marque toutes les faces comme réutilisées ; dès que l'allocation n'imprime plus aucun panneau (resolveNothingToPrint, indifférent au fait que la déclaration vienne des cases par face ou des compteurs par zone), le contenu de dépôt est masqué et le bouton « Voir le récapitulatif » laisse place à « Finaliser sans impression », qui appelle directement la confirmation avec { target: 'none' }. La clause de réemploi vit dans ce même encadré — et non plus en bas du dépôt — précisément pour rester atteignable quand tout le reste est masqué ; elle conditionne aussi bien le passage au récapitulatif que la finalisation directe.
  • Le brouillon est idempotent par (organisation, campagne, configuration), tant qu'il est draft. L'unicité est portée par un index partiel WHERE status = 'draft' (voir docs/handover/modules/4.impression.md) : rouvrir l'étape de dépôt retrouve le même brouillon, mais un lot déjà sent n'empêche pas d'en ouvrir un second pour la même paire — une réservation approuvée après coup peut ainsi constituer un nouveau lot manuel.

Quantité réutilisée en lot — un total, pas une valeur par meuble

Sur un lot, la déclaration « j'en ai déjà N » (mode « même visuel ») porte sur le total du lot, pas sur chaque meuble : déclarer 1 sur 11 réservations portant une face à quantity: 2 (22 panneaux au total) déduit 1 panneau, pas 11, et fait imprimer 21 des 22. Comme le reste ne se répartit pas également, l'allocation par réservation est déterministe et démarre par les premières réservations de la sélection — la fonction pure allocateZonePanels (shared/utils/print-allocation.ts) est partagée telle quelle entre le serveur (mass-impression/batch-quantities.ts) et l'écran (app/composables/useMassImpression.ts), pour que le récapitulatif affiché ne puisse jamais annoncer autre chose que ce que le serveur va effectivement facturer à l'imprimeur.

Le récapitulatif nomme les magasins qui reçoivent moins de panneaux imprimés que leur pleine capacité — c'est ce qui indique à l'industriel où envoyer son propre stock physique. Cette allocation est calculée sur les candidats retenus par la prévisualisation (voir ci-dessous), pas sur la sélection brute : une réservation écartée ne doit pas fausser le magasin nommé.

Répartition par magasin — l'industriel choisit qui reçoit quoi, à total constant

L'allocation déterministe ci-dessus est le défaut, pas une fatalité : sous « Livrer à chaque magasin », le récapitulatif affiche une table magasin × format (BatchStoreQuantitiesTable.vue) où l'industriel fixe lui-même combien de panneaux de chaque format partent dans chaque magasin. La table n'existe que sous cette cible de livraison — sous « adresse unique », tout arrive au même endroit et il n'y a pas de répartition à décider ; l'écran garde alors l'allocation automatique.

Le total par format est fixé, la table ne fait que le répartir. Le nombre de panneaux à imprimer d'une zone reste printablePerMeuble × population − stock déclaré : éditer une cellule ne consomme pas plus de stock, cela déplace un panneau d'un magasin vers un autre. Un total réparti qui ne retombe pas sur le total attendu est un écart, signalé par format sous la table et bloquant à l'envoi (allocationMismatch, mass-impression-submit-gate.ts) — pour imprimer davantage, il faut revenir à l'étape Visuels et baisser le stock déclaré. Deux conséquences directes :

  • Seuls les formats portant du stock sont modifiables (isZoneAllocationEditable). Sans stock déclaré, le total vaut exactement facce × magasins et chaque magasin doit recevoir ses faces : il n'y a rien à choisir, la colonne est en lecture seule. Idem pour un lot d'un seul magasin.
  • Le plafond d'une cellule est printablePerMeuble, soit le nombre de faces réellement imprimables du meuble. Un magasin ne reçoit jamais plus de panneaux qu'il n'a de faces : la notion de panneau de réserve n'existe pas dans ce modèle, puisque buildReusedFaceRows déduit le réemploi d'une réservation de zone.quantity − imprimé.

Côté état, la matrice est dérivée, jamais synchronisée. useMassImpression ne stocke que les éditions de l'industriel, accompagnées de la signature (population, totaux par zone) sur laquelle elles ont été faites (storeAllocationSignature). La matrice effective vaut ces éditions tant que la signature est inchangée, et retombe sur l'allocation automatique sinon. C'est ce qui règle proprement le cas « un magasin sort de la population après coup » : la répartition est recalculée, un avertissement le dit (storeAllocationReset), et l'écran n'affiche jamais un total resté valable un tick de trop. Un watch qui aurait recopié l'allocation dans un ref aurait introduit exactement ce décalage d'un tick entre l'édition et la lecture.

Côté serveur, la répartition explicite est un mode du même chemin, pas un chemin parallèle.confirmBatch accepte un champ allocations: [{ zoneId, quantities: [{ reservationId, printed }] }] et, quand il est présent, construit le split avec buildSplitFromAllocations (mass-impression/batch-allocation-split.ts) au lieu de splitZoneQuantities. Les deux modes partagent buildZoneDemands puis passent par le même assertQuantitySplit : c'est lui, et non le client, qui garantit que Σ imprimé + réutilisé = quantité de faces du lot. Le payload est indexé par reservationId, pas positionnel, et projeté sur les destinataires que le serveur vient de résoudre (validateSplit(zones, input, targets.map(…))) — un ordre de sélection différent entre l'écran et le serveur ne peut donc pas décaler les quantités d'un magasin sur un autre. Une zone absente du payload garde l'allocation automatique ; une réservation retenue par le serveur mais absente du payload est un 400, jamais un magasin servi au hasard.

Prévisualisation — les mêmes exclusions qu'à la confirmation, avant l'envoi

POST /api/print-batches/{batchId}/preview (previewBatchConfirmation, mass-impression/batch-preview-service.ts) rejoue la classification que fera la confirmation, sans rien écrire : aucun verrou, aucune transaction, aucune commande créée. Elle réutilise exactementresolveOwnedBatch, resolveBatchCandidates et resolveBatchShipments — les mêmes fonctions que confirmBatch appelle — plutôt que d'en réimplémenter une copie ; si la confirmation gagne un jour une nouvelle raison d'exclusion, la prévisualisation la reçoit automatiquement puisqu'elle passe par les mêmes fonctions.

La prévisualisation prend en entrée la cible de livraison réelle (delivery: { target: 'store' }, { target: 'single_address', addressId?, snapshot } ou { target: 'none' }, validée par parseDeliverymass-impression/batch-delivery-parser.ts, partagée avec confirm.post.ts pour que les deux routes ne puissent jamais diverger sur cette règle) et la passe telle quelle à resolveBatchShipments, exactement comme le fait confirmBatch. Sous store, missing_address reste la seule raison que resolveBatchCandidates seul ne produit pas ; sous single_address, resolveBatchShipments ne produit jamais missing_address (le même destinataire est affecté à tous les candidats), donc la prévisualisation n'en montre aucune non plus dès que l'industriel a choisi cette destination — c'est ce couplage, et non un store supposé par défaut, qui garantit que l'aperçu ne ment jamais sur ce que la confirmation va effectivement livrer.

Côté écran, BatchDepositPanel.vue recharge la prévisualisation à chaque changement de cible ou de complétude d'adresse, pas seulement au montage : mass.loadPreview(delivery) reçoit la cible courante, et un état previewStatus: 'blocked' supplémentaire (distinct de pending/success/error) couvre le cas où single_address est choisi mais l'adresse n'est pas encore complète — aucun appel réseau n'est fait tant que ce n'est pas le cas. L'écran tire de la prévisualisation deux choses : les exclusions réelles affichées par BatchRecapPanel (au lieu du :skipped="[]" figé qui rendait ce panneau inatteignable), et la liste des candidats retenus, sur laquelle l'allocation par magasin et la capacité de réutilisation sont désormais calculées — plus sur selectedIds.length brut. Tant que la prévisualisation est en attente, bloquée ou en échec, l'écran l'affiche explicitement (previewStatus) et bloque l'envoi plutôt que de laisser croire qu'il n'y a aucune exclusion.

Les trois compteurs du récapitulatif comptent trois populations différentes, et le disent. « Emplacements sélectionnés » est la sélection brute (selectedIds.length), « Commandes à créer » est le nombre de réservations qui recevront au moins un panneau imprimé (countPrintingReservations, useMassImpression.ts, dérivé de l'allocation que l'écran affiche déjà), « Panneaux à imprimer » est le total imprimé. Le compteur de commandes n'est pas le nombre de candidats retenus : une réservation dont toutes les zones allouent 0 (le stock déclaré l'absorbe entièrement) ne produit aucune ligne, donc aucune commande — buildOrderLines (batch-order-lines.ts) renvoie [] et confirmBatch ne crée une PrintOrder que pour les plans qui ont des lignes.

Allocation des codes par séquence

PrintOrder.code et PrintOrderBatch.code sont uniques en base (contrainte ajoutée par 20260727022542_print_order_code_sequence / 20260727025840_add_print_order_batch) et alloués depuis des séquences PostgreSQL (print_order_code_seq, print_order_batch_code_seq), jamais depuis un count() :

allocatePrintOrderCodes(tx, count) // PM-0001, PM-0002, ... — count: 1 pour le flux unitaire
allocatePrintBatchCode(tx) // LOT-0001

Le flux unitaire appelle allocatePrintOrderCodes(tx, 1) : ce n'est pas un allocateur séparé pour le massif, c'est le remplacement du seul allocateur existant. Les séquences ne sont pas transactionnelles : une transaction annulée laisse des trous dans la série (acceptable), jamais de doublon (le risque qu'elles éliminent — un code dupliqué ferait servir la mauvaise commande par /api/printer/print-orders/{code}, en silence).

La migration 20260727022542_print_order_code_sequence amorce la séquence depuis le MAX existant avant de renuméroter les doublons, puis pose la contrainte d'unicité. L'ordre inverse — renuméroter depuis une séquence encore à 1 — réattribue PM-0001, PM-0002… aux doublons et fait échouer la contrainte sur les lignes conservées : la migration écrite pour réparer les bases contenant des doublons était exactement celle qui ne pouvait pas s'y appliquer.

Routes — dépôt et confirmation du lot (industriel)

GET  /api/campaigns/{campaignId}/print-batches/groups
GET  /api/campaigns/{campaignId}/print-batches/groups/{furnitureTypeId}/reservations
POST /api/print-batches                              { campaignId, promoFurnitureTypeId }
GET  /api/print-batches/{batchId}
POST /api/print-batches/{batchId}/upload/preflight
POST /api/print-batches/{batchId}/upload/preflight-from-asset
GET  /api/print-batches/{batchId}/upload/preflights
POST /api/print-batches/{batchId}/preview                { reservationIds, delivery }
POST /api/print-batches/{batchId}/confirm                { reservationIds, delivery, files, reuse?, allocations? }

Toutes requièrent le rôle industrial, isolées par organisation. La bibliothèque de visuels (/api/print-assets/*) est partagée entre le dépôt unitaire et le dépôt de lot — elle a été déplacée depuis /api/reservations/{id}/upload/assets/*, dont le segment reservationId était de toute façon ignoré par les handlers avant ce chantier.

Un imprimeur voit un lot comme une seule entrée dans son espace (/printer/commandes liste les lots, /printer/lots/{code} en montre le détail — voir « Vue imprimeur du lot » ci-dessous), et le ZIP + bon de commande agrégés existent depuis Task 18 (voir « Téléchargement agrégé du lot » plus bas).

Vue industrielle du lot — lire ce qui est parti, sans repasser par l'assistant

Un lot envoyé se lit côté industriel sur /industrial/lots/{code} : compteurs (commandes, panneaux, destination, statut dérivé), visuels agrégés et détail par magasin (magasin, emplacement, adresse de livraison, panneaux, statut de la commande, lien vers la fiche unitaire). La page est en lecture seule — aucune action d'imprimeur, aucun téléchargement agrégé : le lot est déjà parti.

GET /api/campaigns/{campaignId}/print-batches   → lots envoyés de la campagne
GET /api/industrial/print-batches/{code}        → détail d'un lot

Les deux routes exigent le rôle industrial et sont scopées sur industrialOrganizationId + status: 'sent' (mass-impression/batch-industrial-service.ts). La lecture imprimeur n'a pas été élargie : findSentBatchForPrinter reste scopé sur imprimerieOrganizationId, et l'industriel a son propre point d'entrée plutôt qu'un scope conditionnel dans le même service — deux lectures, deux périmètres, aucun paramètre « selon qui appelle » à ne pas oublier de passer.

Le manifeste industriel nomme le magasin (reservation → adSpace → store), là où le manifeste imprimeur se contente du destinataire de l'expédition : sous « adresse unique », toutes les commandes partagent le même destinataire, et l'industriel perdrait sinon l'information « quel magasin reçoit quoi ».

C'est cette page que visent tous les badges « Lot {code} » de l'espace industriel (/industrial/reservations, /industrial/impressions, fiche de commande unitaire). Ils pointaient auparavant vers /industrial/campaign/{campaignId}/impressions, c'est-à-dire vers l'assistant de dépôt — qui, une fois tout déposé, n'a plus rien à proposer et affiche « Aucun emplacement en attente de dépôt ». Un lot déjà envoyé n'est pas un dépôt à faire.

L'étape Configuration de l'assistant liste par ailleurs les lots déjà envoyés de la campagne (SentBatchesCard.vue), y compris lorsqu'il reste des emplacements à déposer : l'écran doit répondre à « qu'est-ce qui est déjà parti ? » avant de demander « que veux-tu déposer maintenant ? ».

Côté industriel et admin, /industrial/reservations, /industrial/impressions et /admin/print-orders affichent chacun un badge ou une colonne « Lot {code} » sur toute ligne dont la commande a été créée via un lot (ReservationItem.batchCode, ImpressionListItem.batchCode et AdminPrintOrderListItem.batchCode, tous string | null). Les deux listes n'ont pas la même granularité, donc pas le même mécanisme de résolution : /industrial/impressions liste des réservations (une réservation peut, en théorie, avoir plusieurs commandes dans le temps) et résout le code, ainsi que le statut de la commande active (ImpressionListItem.printOrderStatus, string | null), via loadPrintOrderInfoByReservationId (campaigns/application/reservations/print-order-batch-code.ts) — une requête unique par page pour toutes les réservations listées, qui exclut status: 'cancelled' et retient la commande la plus récente par createdAt en cas de doublon, jamais « la dernière rencontrée » de façon accidentelle. La colonne « Statut commande » de /industrial/impressions (après la colonne « Statut », qui reste le statut du dépôt — impressionStatus — et pas celui de la commande) rend printOrderStatus avec le même badge et la même table de couleurs que la fiche de commande unitaire (usePrintOrderStatus, voir ci-dessous) ; une réservation sans commande active affiche un texte neutre (« Aucune commande »), jamais un badge qui suggérerait un statut inexistant. /industrial/reservations liste elle aussi des réservations et réutilise donc le même loadPrintOrderInfoByReservationId, appelé en parallèle de la résolution des libellés de meuble : une requête par page, jamais une par ligne (vérifié par tests/integration/reservation-listing-batch-code.spec.ts, « resolves the whole page in a single print order query »). Le badge y est rendu par le composant partagé PrintOrderBatchBadge.vue, sous le badge de statut d'impression et quel que soit ce statut — un lot peut être visible aussi bien sur une réservation encore to_deposit que sur une réservation validated. La colonne ne propose de bouton d'action que lorsqu'une commande existe (« Voir la commande ») ; sur une réservation to_deposit elle n'en propose aucun, le dépôt se faisant depuis /industrial/impressions ou depuis l'écran d'impression de la campagne. /admin/print-orders liste des commandes : chaque ligne est déjà une PrintOrder précise, donc son batchCode est lu directement sur sa propre relation batch (doc.batch?.code, chargée dans le même include que le reste de la ligne, sans requête supplémentaire), forcé à null quand la commande elle-même est cancelled. Résoudre le code de l'admin par réservation plutôt que par commande a été tenté puis rejeté en revue : une réservation peut porter une commande cancelled et une commande active dans le même lot, et les deux apparaissent comme deux lignes distinctes dans /admin/print-orders (seul draft y est filtré) — la résolution par réservation faisait alors apparaître le code de la commande active sur la ligne de la commande annulée. Voir docs/handover/modules/4.impression.md pour le détail.

Deux lignes de statut coexistent sur ces écrans, et il ne faut pas les confondre.impressionStatus (to_deposit, in_review, validated, in_stock, locked) est le statut du dépôt — ce que montre la colonne « Statut ». PrintOrder.status (draft, pending, sent, in_production, produced, delivered, cancelled) est le statut de la commande d'impression qu'un imprimeur traite — ce que montre la colonne « Statut commande » (badge, couleur portée par usePrintOrderStatus, app/composables/usePrintOrderStatus.ts) et le bloc « Statut » de la fiche PrintOrderRecap/PrintOrderHeader.vue. Les deux composants partagent la même table de couleurs et la même clé i18n (printOrderRecap.status.*) — PrintOrderHeader.vue ne définit plus sa propre fonction statusColor, elle importe usePrintOrderStatus pour que les deux endroits ne puissent pas diverger.

Routage vers le dépôt groupé depuis les vues unitaires

Une réservation qui appartient à une campagne et attend encore son dépôt (impressionStatus === 'to_deposit') peut, selon sa configuration de meuble, faire partie d'un groupe de dépôt massif — même un groupe d'une seule réservation, le flux de lot fonctionnant aussi bien à 1 qu'à 1000 emplacements. Pour ce cas, la page unitaire /industrial/impressions/{reservationId} n'est pas le bon point d'entrée.

GET /api/reservations/{id}/gabarits résout le meuble en lisant AdSpace.promoFurnitureTypeId directement — le même champ que lit le flux de lot via resolveFurnitureConfigurationsByAdSpaceIds (catalog/application/furniture-faces-resolver.ts). La route résolvait auparavant le meuble par un détour, via prisma.promoFurniture.findFirst({ where: { distributorAdSpaceId } }) puis le promoFurnitureTypeId de cette ligne — un mécanisme distinct de celui du flux de lot, qui perdait silencieusement les zones imprimables d'un espace publicitaire quand sa ligne PromoFurniture compagnon était absente. Voir docs/handover/modules/1.magasins-et-espaces.md pour l'historique de ce correctif et la mesure qui l'a motivé.

resolveImpressionManageRoute (app/utils/impression-routing.ts, fonction pure testée) décide, à partir de campaignId, impressionStatus et « le meuble a-t-il une configuration résolvable » (déduit de printableZoneCount > 0), si le lecteur doit être envoyé vers /industrial/campaign/{campaignId}/impressions (le flux de lot, à l'étape « Configuration ») ou vers /industrial/impressions/{reservationId} (le flux unitaire). La règle : campagne renseignée, dépôt encore attendu et configuration de meuble résolvable → flux de lot ; sinon → flux unitaire. Une réservation dont le dépôt est déjà fait via un lot (impressionStatus a quitté to_deposit) n'est jamais renvoyée vers l'assistant de sélection — son impressionStatus ne vaut plus to_deposit dès que la confirmation du lot a écrit son statut, donc la règle la laisse sur la page unitaire, où PrintOrderRecap affiche déjà le résultat (campagne et lot, voir plus bas).

Cette fonction est appliquée aux trois points d'entrée qui menaient jusque-là systématiquement à la page unitaire : le bouton d'action de /industrial/impressions (liste), celui de /industrial/reservations/{reservationId}, et — en filet de sécurité pour un lien externe ou un favori — un watch au montage de la page unitaire elle-même qui redirige (navigateTo(..., { replace: true })) si la réservation s'avère éligible au flux de lot. ImpressionListItem.campaignId (résolu dans la même requête que le reste de la ligne, sans coût supplémentaire) et le champ printableZoneCount déjà présent alimentent la décision côté liste ; getIndustrialReservation (campaigns/application/reservations/listing-service.ts) porte désormais aussi printableZoneCount pour que la page de détail de réservation puisse calculer la même route.

Le libellé de ce bouton dépend du statut d'impression, et il est résolu au même endroit pour les trois écrans qui le portent (resolveImpressionActionI18nKey, app/composables/useImpressionStatus.ts) : « Voir la commande » quand impressionStatus vaut in_review ou validated — une commande d'impression existe alors et l'écran cible en montre le résultat — et « Gérer l'impression » dans les autres cas, où l'action reste à faire. in_stock conserve le second libellé : la réutilisation de supports ne crée aucune commande.

Campagne et lot sur la fiche de commande unitaire

/industrial/impressions/{reservationId} affiche systématiquement la campagne associée à la réservation quand il y en a une (lien vers /industrial/campaign/{campaignId}), et — quand la commande a été émise via un lot — le badge « Lot {code} » (même composant visuel que partout ailleurs dans l'application), lien vers /industrial/lots/{code} (voir « Vue industrielle du lot » ci-dessous). getImpressionDetailForIndustrial (impression-listing-service.ts) porte campaign: { id, name } | null et printOrderBatch: { code, campaignId } | null ; ce dernier est résolu par une requête ciblée sur le code unique de la commande active (prisma.printOrder.findUnique({ where: { code } })), sans toucher au type partagé PrintOrderSummary — les vues admin et imprimeur continuent de composer leurs propres extensions (AdminPrintOrderDetail, PrinterPrintOrderDetail) sur cette même base sans effet de bord.

Téléchargement agrégé du lot (imprimeur)

Un lot envoyé (status: 'sent') produit une PrintOrder par réservation retenue, chacune avec ses propres PrintOrderLine : deux commandes du même lot peuvent porter des quantités différentes sur la même zone, à cause de la réutilisation (voir « Quantité réutilisée en lot » ci-dessus). Un imprimeur ne doit pourtant télécharger chaque fichier visuel qu'une seule fois, pas une fois par magasin. aggregateBatchLines (mass-impression/batch-archive-service.ts) regroupe les lignes de toutes les commandes du lot sur la clé `${assetId}::${zoneId}` en additionnant les quantity, et ignore les lignes sans assetId. L'invariant qui doit toujours tenir : la somme des quantités imprimées sur toutes les commandes du lot, plus le total réutilisé, égale zone.quantity × nombre de réservations.

streamBatchArchive délègue au streamPrintOrderArchive existant (mode privileged) avec les lignes agrégées — le ZIP ne contient donc qu'un seul fichier par visuel distinct, nommé avec la quantité totale du lot (_qte<N>). generateBatchBonDeCommandePdf construit un bon de commande à deux niveaux : un récapitulatif agrégé (mêmes totaux que le ZIP) et un manifeste de livraison listant, pour chaque commande du lot, l'adresse de livraison et la quantité à prélever sur chaque fichier agrégé — c'est ce qui permet à l'imprimeur de répartir une pile de panneaux identiques entre plusieurs magasins.

Les deux routes résolvent le lot par code en filtrant sur imprimerieOrganizationId etstatus: 'sent' dans la même requête Prisma : un lot d'une autre organisation imprimeur, ou un lot encore en brouillon, retourne 404 — jamais une fuite de contenu inter-organisations.

resolveArchiveFilenames (technical-naming.ts) désambiguïse deux noms techniques identiques positionnellement (le premier garde base.pdf, le second devient base_2.pdf) — et une collision est atteignable : buildTechnicalAssetName ne porte que le slug de campagne, les dimensions et le qualifieur de face, sans identité de zone, donc deux zones symétriques de mêmes dimensions portant le même qualifieur (ex. un fronton gauche/droit tous deux « Recto ») produisent le même nom technique. Le ZIP et le bon de commande exécutant chacun leur propre requête, l'ordre des lignes doit donc être stable entre deux lectures indépendantes du même lot, sous peine que le _2 désigne un visuel différent d'un téléchargement à l'autre — et que le manifeste attribue alors la mauvaise quantité au mauvais magasin. flattenBatchLines trie explicitement les lignes de chaque commande par position avant l'agrégation, en plus du orderBy: { position: 'asc' } posé sur la requête elle-même : l'ordre ne dépend ni du plan d'exécution Postgres ni de l'ordre physique des lignes, uniquement de position.

Vue imprimeur du lot

mass-impression/batch-listing-service.ts porte les trois fonctions qui exposent un lot comme une seule entrée côté imprimeur, au lieu de N commandes séparées : listPrintBatchesForPrinter, getPrintBatchForPrinter et advancePrintBatchStatus. Le détail et l'avancement s'appuient sur findSentBatchForPrinter (batch-archive-service.ts, exporté pour l'occasion) — la même requête filtrée sur imprimerieOrganizationId et status: 'sent' que le téléchargement agrégé utilise déjà — plutôt que de réécrire une deuxième résolution de propriété ; le listing porte le même filtre sur sa propre requête.

  • listPrintBatchesForPrinter retourne, par lot : code, industriel, campagne, deliveryTarget, statut dérivé (deriveBatchStatus), nombre de commandes et total de panneaux sommé sur toutes les lignes du lot — jamais orders.length × quantité d'une commande, puisque deux commandes du même lot peuvent porter des quantités différentes sur la même zone (réutilisation, voir plus haut). Ces deux compteurs sont agrégés en base, pas en mémoire : un groupBy(batchId, status) sur print_orders (qui donne à la fois le nombre de commandes et les statuts distincts que deriveBatchStatus consomme) et un SUM(quantity) groupé par batchId sur print_order_lines. Hydrater les commandes et leurs lignes pour les compter chargeait, au volume visé par cette fonctionnalité (lots de 1000 magasins), des dizaines de milliers de lignes à chaque affichage de /printer/commandes. La liste des lots n'est pas paginée : le nombre de lots d'un imprimeur reste petit, c'est le nombre de commandes par lot qui explose.
  • getPrintBatchForPrinter ajoute le détail : les lignes agrégées (aggregateBatchLines, réutilisées telles quelles depuis le téléchargement ZIP) et un manifeste — une ligne par commande avec son statut, son adresse de livraison (formatShipmentAddress, également exportée de batch-archive-service.ts pour éviter une deuxième implémentation) et son propre total de panneaux.
  • advancePrintBatchStatus({ code, imprimerieOrganizationId, nextStatus }) fait avancer chaque commande du lot en appelant updatePrintOrderStatusByPrinter pour chacune — le même service que la route unitaire PATCH /api/printer/print-orders/{code}/status, sans second cycle de vie. Seule une erreur de transition invalide (AppError de code VALIDATION_ERROR — commande déjà à une autre étape, annulée) ne fait pas échouer l'appel individuel : advanceOneOrder la capture et retourne false, comptée dans skipped plutôt que advanced. Un NOT_FOUND, un INTERNAL_ERROR ou toute exception brute — la commande a disparu entre la lecture du lot et l'appel, ou la relecture après écriture échoue — n'est pas une transition refusée : advanceOneOrder la laisse remonter, et elle est comptée dans un troisième compteur, failed, distinct de skipped. Rapporter une anomalie réelle comme « déjà à une autre étape » serait un mensonge sur ce qui s'est passé — exactement la classe de défaut que la correction 2 de la Task 19 a fermée pour le succès/skip ; failed ferme la même classe pour l'échec. Chaque commande enchaîne quatre requêtes (findFirst + updateMany + getPrintOrderForPrinter, qui refait un findFirst avec les lignes, + une résolution de nom d'organisation), et le résultat détaillé de updatePrintOrderStatusByPrinter est jeté — seul le succès/échec de l'appel compte ici. Un lot de 1000 commandes (le cas d'usage nommé par le spec) représenterait donc environ 4000 aller-retours strictement séquentiels si on les attendait un par un : advancePrintBatchStatus les traite par groupes bornés de ADVANCE_STATUS_CONCURRENCY (10), traités séquentiellement les uns après les autres. Éviter la relecture jetée sans dupliquer la validation demanderait de paramétrer updatePrintOrderStatusByPrinter (ou d'en extraire un cœur commun) pour qu'un appelant puisse sauter la reconstruction du détail : personne ne l'a fait ici, précisément parce que le mandat de cette fonction est de rester l'unique chemin de transition — une deuxième signature, même dérivée de la même logique, est le genre de fourche qui a déjà coûté cher sur cette branche (voir docs/handover/modules/1.magasins-et-espaces.md).
    À l'intérieur d'un groupe, Promise.allSettled — jamais Promise.all. Une première version utilisait Promise.all : dès qu'une commande du groupe rejetait (un NOT_FOUND/INTERNAL_ERROR propagé, exactement ce que le point précédent vient de décrire), Promise.all rejetait tout le groupe avant que advancePrintBatchStatus n'ait pu comptabiliser quoi que ce soit — jetant à la fois les totaux des groupes précédents déjà validés et les commandes sœurs du même groupe qui avaient déjà réussi en parallèle. Leurs lignes en base étaient bel et bien mises à jour ; seul le compte n'atteignait jamais l'appelant, et la route renvoyait un 404/500 nu, sans aucun résumé. C'est un rayon d'impact strictement plus large que la boucle séquentielle remplacée par le chunking (qui ne perdait que la position de la commande en échec), et il devenait atteignable par des conditions par-commande ordinaires, pas seulement par un vrai crash serveur. Promise.allSettled ne rejette jamais : chaque résultat du groupe est inspecté (fulfilled avec true/falseadvanced/skipped, rejectedfailed), le groupe entier est comptabilisé avant de décider quoi que ce soit. Si le groupe contenait au moins un rejected, aucun groupe suivant n'est démarré (break après la boucle de comptage) — ne pas insister face à une panne systémique — mais rien de ce qui a déjà été tenté dans ce groupe ou les précédents n'est perdu : { advanced, skipped, failed } reflète toujours exactement ce qui a été écrit en base, jamais moins.

Côté écran, /printer/commandes liste les lots en tête (un par ligne, lien vers /printer/lots/{code}) puis les commandes du tableau ci-dessous. listPrintOrdersForPrinter (reservation-print-service.ts) ne filtre toujours pas sur batchId au niveau de la requête : elle reste la source commune du tableau de bord (/printer) et de la page /printer/commandes. Un filtrage batchId: null posé directement dans cette fonction casserait le tableau de bord — un imprimeur dont toute la charge de travail tient dans un seul lot y verrait un tableau à zéro — l'exclusion doit donc rester une décision d'affichage, propre à chaque page, jamais une décision de requête partagée.

Ce qui a changé : les compteurs d'onglets de /printer/commandes ne comptent plus la charge complète. La version d'origine calculait counts (badges d'onglets) et le texte « showing {shown}/{total} » sur orders (la réponse complète, batch compris), tandis que le tableau en dessous ne rendait que les commandes isolées (batchId === null) — un compromis assumé à l'époque : « les compteurs et l'export peuvent légitimement annoncer plus de commandes que de lignes affichées ». Un imprimeur dont la totalité des commandes produced appartenait à un lot cliquait sur l'onglet Produites (2) et tombait sur un tableau vide avec « Aucune commande à afficher » — le compromis promettait des lignes que la page ne pouvait pas montrer. Revu explicitement : sur cette page, les compteurs d'onglets et le texte « showing » sont désormais calculés sur les commandes isolées uniquement, à l'échelle du même onglet actif des deux côtés — un compteur d'onglet égale toujours exactement le nombre de lignes que ce même onglet affiche. Les commandes groupées en lot restent visibles sur la même page, dans le tableau des lots juste au-dessus, qui porte son propre statut et son propre décompte ; l'alerte sous les onglets (batchSplitNotice) le rappelle avec le nombre de commandes groupées.

Ce qui n'a pas changé : l'export CSV reste sur la charge complète, batch compris — par choix, pas par oubli. exportCsv() continue de lire filtered (calculé sur orders, la réponse complète), parce qu'un export est une extraction de données, pas un compteur qui promet des lignes visibles ; un imprimeur doit pouvoir extraire toute sa charge de travail en un fichier. Le bouton et le texte à côté (exportCsvHint) le disent explicitement, en fr et en, pour qu'un export plus large que le tableau ne soit jamais lu comme une incohérence.

Les fonctions de calcul (filtrage par onglet, isolement des commandes de lot, comptage par onglet, correspondance de recherche) vivent dans app/utils/printer-order-tabs.ts, testées directement (tests/unit/printer-order-tabs.spec.ts) — y compris par contre-preuve : un test reconstruit le calcul d'origine (compter sur orders complet) et vérifie qu'il diverge du nombre de lignes que le tableau isolé affiche, exactement le scénario rapporté. commandes/index.vue ne recalcule plus rien en ligne : isolatedOrders, counts, filtered (population complète, pour le CSV), isolatedFiltered (population isolée, pour le tableau) et isolatedTabTotal (counts[activeTab], pour le texte « showing ») sont tous dérivés de ce module.

Le tableau de bord (/printer/index.vue) n'a pas bougé, et ce n'est pas une incohérence à corriger. Ses tuiles KPI et ses quatre colonnes de prévisualisation continuent de compter et de lister chaque commande individuellement, batch compris — c'est le comportement que la revue finale du chantier (voir docs/handover/modules/4.impression.md, correctif I5) a délibérément préservé après avoir trouvé un Critical symétrique : un imprimeur dont toute la charge tenait dans un seul lot voyait « 0 nouvelles commandes » sur sa page d'accueil quand cette page filtrait déjà les commandes de lot. Rendre les deux pages uniformes dans un sens ou dans l'autre réintroduirait l'un des deux défauts déjà payés sur cette branche : filtrer le tableau de bord reproduirait le Critical I5, ne pas filtrer les compteurs de /printer/commandes reproduirait le défaut documenté ci-dessus. Les deux pages comptent délibérément des populations différentes, chacune cohérente avec ce qu'elle affiche.

Pour que l'imprimeur retrouve malgré tout le lot depuis une commande individuelle du tableau de bord (et depuis la fiche détail d'une commande), PrinterPrintOrderListItem et PrinterPrintOrderDetail (reservation-print-service.ts) portent désormais batchCode: string | null, résolu par batch: { select: { code: true } } ajouté à l'include/select existant de listPrintOrdersForPrinter et getPrintOrderForPrinter — même pattern que listPrintOrdersForAdmin, zéro requête supplémentaire. Les deux résolveurs portent aussi la même garde qu'admin :batchCode: doc.status === 'cancelled' ? null : (doc.batch?.code ?? null). Cette garde n'est pas liée à la mésattribution par réservation qui a fait abandonner loadBatchCodesByReservationId côté admin (voir plus haut) — c'est une règle indépendante, déjà vraie pour la lecture directe sur la ligne : une commande cancelled ignore sa propre relation batch, même quand batchId n'est jamais effacé à l'annulation. Le chemin est atteignable côté imprimeur : listPrintOrdersForPrinter n'exclut que draft, le manifeste d'un lot (batch-listing-service.ts) liste ses commandes cancelled, et /printer/lots/{code} relie chaque ligne du manifeste — annulée comprise — vers /printer/commandes/{code}. Sans la garde, un imprimeur atterrissant sur une commande annulée depuis ce chemin verrait un badge de lot actif pour un lot dont elle ne fait plus partie. Pinné par test (reports null for a cancelled order, even when it still carries a batch relation in the database, list-print-orders-for-printer.spec.ts et get-print-order-for-printer.spec.ts), confirmé par mutation : retirer la garde fait échouer les deux tests.

Le code de commande, sur les quatre listes de /printer/index.vue et sur l'en-tête de /printer/commandes/{code}, porte alors un badge « Lot {code} » qui ouvre /printer/lots/{code} quand batchCode n'est pas nul. Sur /printer/index.vue, trois des quatre listes couvrent toute la ligne d'un <NuxtLink> vers la commande ; imbriquer un second <a> (le badge) y casserait la navigation (fermeture implicite de l'ancre par le parseur HTML dès qu'il rencontre une ancre imbriquée). La solution retenue n'est pas un badge cliquable non-ancre (@click/role="button") — inatteignable et invisible au clavier — mais le patron lien étiré (« stretched link ») : la ligne n'est plus elle-même un <NuxtLink> mais un conteneur position: relative contenant deux ancres réelles, sœurs l'une de l'autre plutôt qu'imbriquées — un <NuxtLink class="absolute inset-0"> sans texte visible (accessible via aria-label, clé workspace.printer.atelier.openOrder) qui couvre toute la ligne, et le badge de lot, lui-même un <NuxtLink class="relative z-10">, qui gagne le test de survol/clic grâce à son z-index positif alors que le lien étiré reste à z-index: auto. Les deux sont de vraies ancres, atteignables au clavier et annoncées par un lecteur d'écran, sans imbrication invalide. Le reste du contenu de la ligne (code, badge de statut, nom industriel, date) reste volontairement non positionné : c'est précisément ce qui le laisse « sous » le lien étiré pour le clic, sans qu'aucun texte n'ait besoin d'être lui-même un lien. Sur l'en-tête de la fiche détail, qui n'est pas dans un lien ambiant, le badge reste un NuxtLink classique, sans restructuration nécessaire.


API — routes imprimeur

Toutes les routes requièrent le rôle printer et un contexte d'organisation printer:* valide.

Authentification — la plateforme utilise des sessions par cookie (Better Auth). La session est établie à la connexion et transmise automatiquement via le cookie better-auth.session_token. Dans les exemples curl, utilisez -b 'better-auth.session_token=<jeton-de-session>'.

Lister les ordres d'impression

GET /api/printer/print-orders
Cookie: better-auth.session_token=<jeton-de-session>

Retourne la liste des ordres d'impression associés à l'organisation imprimeur connectée.

{
  "orders": [
    {
      "code": "PO-2026-001",
      "status": "pending",
      "lines": [...]
    }
  ]
}

Consulter un ordre d'impression

GET /api/printer/print-orders/{code}
Cookie: better-auth.session_token=<jeton-de-session>

Retourne le détail de l'ordre : métadonnées, statut courant, lignes de fabrication et informations de livraison.

Avancer le statut d'un ordre

PATCH /api/printer/print-orders/{code}/status
Content-Type: application/json
Cookie: better-auth.session_token=<jeton-de-session>

{
  "nextStatus": "in_production"
}

nextStatus doit être une valeur valide de PrintOrderStatus. Le service vérifie la légitimité de la transition.

Reculer le statut d'un ordre (rollback)

PATCH /api/printer/print-orders/{code}/status-rollback
Cookie: better-auth.session_token=<jeton-de-session>

Retourne le statut au précédent selon la chaîne delivered → produced → in_production → sent → pending.

Télécharger les fichiers à imprimer (ZIP)

GET /api/printer/print-orders/{code}/download
Cookie: better-auth.session_token=<jeton-de-session>

Retourne une archive ZIP contenant les fichiers visuels (assets) de toutes les lignes de l'ordre. La réponse est un flux binaire (Content-Type: application/zip). Si aucun fichier n'est disponible sur les lignes, retourne 404.

Télécharger le bon de commande (PDF)

GET /api/printer/print-orders/{code}/bon-de-commande
Cookie: better-auth.session_token=<jeton-de-session>

Génère et retourne le bon de commande PDF associé à la réservation source (Content-Type: application/pdf).

Enregistrer les informations de livraison

PATCH /api/printer/print-orders/{code}/delivery
Content-Type: application/json
Cookie: better-auth.session_token=<jeton-de-session>

{
  "carrier": "Chronopost",
  "trackingNumber": "CP123456789FR",
  "trackingUrl": "https://www.chronopost.fr/tracking-no-cms/suivi-page?listeNumerosLT=CP123456789FR",
  "notifyIndustrial": true
}

Tous les champs sont optionnels. Si notifyIndustrial est true, une notification est envoyée à l'industriel concerné.

Télécharger le ZIP agrégé d'un lot

GET /api/printer/print-batches/{code}/download
Cookie: better-auth.session_token=<jeton-de-session>

Retourne une archive ZIP contenant un fichier par visuel distinct du lot (agrégé par aggregateBatchLines, voir « Téléchargement agrégé du lot » ci-dessus), avec la quantité totale du lot dans le nom de fichier. {code} est le code du lot (LOT-0001), pas celui d'une commande individuelle. Retourne 404 si le lot n'existe pas, n'appartient pas à l'organisation imprimeur connectée, n'est pas sent, ou n'a aucun fichier téléchargeable.

Télécharger le bon de commande agrégé d'un lot (PDF)

GET /api/printer/print-batches/{code}/bon-de-commande
Cookie: better-auth.session_token=<jeton-de-session>

Génère et retourne un bon de commande PDF (Content-Type: application/pdf) avec les totaux agrégés du lot et un manifeste de livraison par commande (adresse + quantité à prélever sur chaque fichier agrégé). Mêmes règles de résolution/404 que la route de téléchargement ci-dessus.

Lister les lots

GET /api/printer/print-batches
Cookie: better-auth.session_token=<jeton-de-session>

Retourne les lots sent de l'organisation imprimeur connectée, triés par updatedAt décroissant.

{
  "batches": [
    {
      "code": "LOT-0001",
      "industrialOrganizationId": "industrial:org-1",
      "industrialName": "Marque X",
      "campaignId": "campaign-1",
      "campaignName": "Campagne Noël",
      "deliveryTarget": "store",
      "status": "pending",
      "ordersCount": 11,
      "totalPanels": 21,
      "createdAt": "2026-07-27T10:00:00.000Z",
      "updatedAt": "2026-07-27T10:05:00.000Z"
    }
  ]
}

Consulter un lot

GET /api/printer/print-batches/{code}
Cookie: better-auth.session_token=<jeton-de-session>

Retourne le détail du lot : les champs de la liste, plus distinctVisualsCount, aggregatedLines (mêmes lignes que le ZIP agrégé) et manifest (une entrée par commande — code, statut, destinataire, adresse formatée, panneaux). Retourne 404 si le lot n'existe pas, n'appartient pas à l'organisation imprimeur connectée ou n'est pas sent.

Avancer le statut de toutes les commandes d'un lot

PATCH /api/printer/print-batches/{code}/status
Content-Type: application/json
Cookie: better-auth.session_token=<jeton-de-session>

{
  "nextStatus": "in_production"
}

Applique la transition à chaque commande du lot via updatePrintOrderStatusByPrinter (même règle de transition que la route unitaire). Répond { advanced, skipped, failed } — trois compteurs disjoints : advanced est le nombre de commandes qui ont effectivement changé de statut, skipped celles qui ne le pouvaient pas (déjà à une autre étape, annulées), failed les anomalies réelles (NOT_FOUND, INTERNAL_ERROR, exception brute) qui ne sont pas des transitions refusées — jamais une erreur globale pour un lot dont les commandes ne sont pas toutes au même point.


Sécurité et isolation

  • Le guard organizationId.startsWith('printer:') est appliqué à chaque handler — un token valide mais sans organisation printer:* retourne 403.
  • Un utilisateur printer ne peut accéder qu'aux ordres et lots de sa propre organisation imprimeur ; aucune fuite inter-organisations n'est possible. Sur les routes de lot — téléchargement, listing, détail et avancement de statut — ce guard est porté par le filtre imprimerieOrganizationId de la requête Prisma qui résout le lot (findSentBatchForPrinter, mass-impression/batch-archive-service.ts, réutilisée par batch-listing-service.ts), pas par une vérification a posteriori.
  • L'accès aux assets pour le téléchargement utilise le mode privileged de streamPrintOrderArchive, réservé aux utilisateurs avec le rôle printer — y compris pour le ZIP agrégé d'un lot, qui délègue à la même fonction.

Variables d'environnement pertinentes

Les routes imprimeur n'introduisent pas de variables d'environnement spécifiques au-delà de la configuration S3 utilisée pour le stockage des assets (S3_*). Voir la page Installation pour la liste complète.