Impression
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,distributoretadmin. - 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
printerest redirigé vers/printer. - Le middleware de routage résout
actor_workspace = printeret 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ôt | Lignes créées | quantity par ligne |
|---|---|---|
| « même visuel » | 1 ligne pour toute la zone | quantité de la face |
| « un visuel par face » | 1 ligne par face déposée | 1 |
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 desquantity) ;totalSurfaceM2— surface cumulée, chaque ligne comptéequantityfois.
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-1pour un dépôt par face ; nullavecappliesToAllQualifiers: truepour 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/ :
| Fichier | Rôle |
|---|---|
zone-demands.ts | garde-fous de couverture et demande par zone |
quantity-split.ts | allocation des panneaux et vérification du découpage |
order-lines.ts | gabarits de fichier, lignes de commande, lignes de faces réutilisées |
support-outcome.ts | mode 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.
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)
| Statut | Signification |
|---|---|
draft | Créé, non encore transmis à l'imprimeur |
pending | En attente de prise en charge par l'imprimeur |
sent | Transmis à l'imprimeur |
in_production | En cours de fabrication |
produced | Fabrication terminée |
delivered | Livré au distributeur |
cancelled | Annulé |
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 unePrintOrderpar réservation retenue, chacune avec ses propresPrintOrderLine: le lot ne remplace pas l'invariant « une réservation = une commande », il en crée plusieurs d'un coup.PrintBatchPreflightest le miroir deReservationPrintPreflightsur le scope lot. Le cœur de vérification (upsertPreflightRow,campaigns/application/reservations/print-preflight-core.ts) est paramétré par unPreflightRepository<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.statusn'est toujours écrit qu'à deux valeurs —draftetsent— et le reste ainsi par construction.draftest é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 valeurcancelledsur 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 desPrintOrderStatusdes commandes filles parderiveBatchStatus(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 lotsent, puisqu'un lot vide est supprimé — voir ci-dessous) donnedraft; un lot dont toutes les commandes sontcancelleddonnecancelled; sinon on retire les commandescancelledet on compare le reste — statut commun si toutes s'accordent,'mixed'sinon. La deuxième règle existe précisément parce que filtrer lescancelledpuis tester la liste vide donneraitdraftà 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), aucunPrintOrdern'est créé et lePrintOrderBatchest 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.
confirmBatchré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 contrecandidates.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é » :resolveBatchShipmentsretient alors tous les candidats sans interroger la moindre adresse, donc aucune exclusionmissing_addressn'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.confirmBatchrefuse en400untarget: '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.
- 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 (
- Le brouillon est idempotent par (organisation, campagne, configuration), tant qu'il est
draft. L'unicité est portée par un index partielWHERE status = 'draft'(voirdocs/handover/modules/4.impression.md) : rouvrir l'étape de dépôt retrouve le même brouillon, mais un lot déjàsentn'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 exactementfacce × magasinset 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, puisquebuildReusedFaceRowsdéduit le réemploi d'une réservation dezone.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 parseDelivery —
mass-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.
listPrintBatchesForPrinterretourne, par lot : code, industriel, campagne,deliveryTarget, statut dérivé (deriveBatchStatus), nombre de commandes et total de panneaux sommé sur toutes les lignes du lot — jamaisorders.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 : ungroupBy(batchId, status)surprint_orders(qui donne à la fois le nombre de commandes et les statuts distincts quederiveBatchStatusconsomme) et unSUM(quantity)groupé parbatchIdsurprint_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.getPrintBatchForPrinterajoute 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 debatch-archive-service.tspour éviter une deuxième implémentation) et son propre total de panneaux.advancePrintBatchStatus({ code, imprimerieOrganizationId, nextStatus })fait avancer chaque commande du lot en appelantupdatePrintOrderStatusByPrinterpour chacune — le même service que la route unitairePATCH /api/printer/print-orders/{code}/status, sans second cycle de vie. Seule une erreur de transition invalide (AppErrorde codeVALIDATION_ERROR— commande déjà à une autre étape, annulée) ne fait pas échouer l'appel individuel :advanceOneOrderla capture et retournefalse, comptée dansskippedplutôt queadvanced. UnNOT_FOUND, unINTERNAL_ERRORou 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 :advanceOneOrderla laisse remonter, et elle est comptée dans un troisième compteur,failed, distinct deskipped. 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 ;failedferme la même classe pour l'échec. Chaque commande enchaîne quatre requêtes (findFirst+updateMany+getPrintOrderForPrinter, qui refait unfindFirstavec les lignes, + une résolution de nom d'organisation), et le résultat détaillé deupdatePrintOrderStatusByPrinterest 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 :advancePrintBatchStatusles traite par groupes bornés deADVANCE_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étrerupdatePrintOrderStatusByPrinter(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 (voirdocs/handover/modules/1.magasins-et-espaces.md).
À l'intérieur d'un groupe,Promise.allSettled— jamaisPromise.all. Une première version utilisaitPromise.all: dès qu'une commande du groupe rejetait (unNOT_FOUND/INTERNAL_ERRORpropagé, exactement ce que le point précédent vient de décrire),Promise.allrejetait tout le groupe avant queadvancePrintBatchStatusn'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.allSettledne rejette jamais : chaque résultat du groupe est inspecté (fulfilledavectrue/false→advanced/skipped,rejected→failed), le groupe entier est comptabilisé avant de décider quoi que ce soit. Si le groupe contenait au moins unrejected, aucun groupe suivant n'est démarré (breakaprè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 organisationprinter:*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
imprimerieOrganizationIdde la requête Prisma qui résout le lot (findSentBatchForPrinter,mass-impression/batch-archive-service.ts, réutilisée parbatch-listing-service.ts), pas par une vérification a posteriori. - L'accès aux assets pour le téléchargement utilise le mode
privilegeddestreamPrintOrderArchive, 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.