@flowselections/layout-maker 1.36.7 → 1.45.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/dist-lib/components/AfleverbriefBuilderPage.d.ts +10 -0
  2. package/dist-lib/components/AfleverbriefBuilderPage.d.ts.map +1 -0
  3. package/dist-lib/components/AfleverbriefBuilderPage.js +101 -0
  4. package/dist-lib/components/BlokOntwerperPage.d.ts.map +1 -1
  5. package/dist-lib/components/BlokOntwerperPage.js +8 -3
  6. package/dist-lib/components/ComputersLijst.d.ts.map +1 -1
  7. package/dist-lib/components/ComputersLijst.js +1 -1
  8. package/dist-lib/components/FustKolomVolgorde.d.ts +11 -0
  9. package/dist-lib/components/FustKolomVolgorde.d.ts.map +1 -0
  10. package/dist-lib/components/FustKolomVolgorde.js +52 -0
  11. package/dist-lib/components/LayoutsPage.d.ts.map +1 -1
  12. package/dist-lib/components/LayoutsPage.js +10 -9
  13. package/dist-lib/components/PaginaDocument.d.ts +4 -1
  14. package/dist-lib/components/PaginaDocument.d.ts.map +1 -1
  15. package/dist-lib/components/PaginaDocument.js +10 -5
  16. package/dist-lib/components/PakbonBuilderPage.d.ts.map +1 -1
  17. package/dist-lib/components/PakbonBuilderPage.js +34 -14
  18. package/dist-lib/components/PakbonPreview.d.ts +4 -2
  19. package/dist-lib/components/PakbonPreview.d.ts.map +1 -1
  20. package/dist-lib/components/PakbonPreview.js +74 -24
  21. package/dist-lib/components/PakbonSectiesPanel.d.ts +5 -3
  22. package/dist-lib/components/PakbonSectiesPanel.d.ts.map +1 -1
  23. package/dist-lib/components/PakbonSectiesPanel.js +8 -5
  24. package/dist-lib/components/PdfMapToestemmingBanner.d.ts +2 -1
  25. package/dist-lib/components/PdfMapToestemmingBanner.d.ts.map +1 -1
  26. package/dist-lib/components/PdfMapToestemmingBanner.js +73 -14
  27. package/dist-lib/components/PdfVoorbeeld.d.ts +20 -0
  28. package/dist-lib/components/PdfVoorbeeld.d.ts.map +1 -0
  29. package/dist-lib/components/PdfVoorbeeld.js +95 -0
  30. package/dist-lib/components/PrintProgrammaUitleg.d.ts.map +1 -1
  31. package/dist-lib/components/PrintProgrammaUitleg.js +1 -1
  32. package/dist-lib/components/PrintQueuePage.d.ts.map +1 -1
  33. package/dist-lib/components/PrintQueuePage.js +81 -43
  34. package/dist-lib/components/PrintplekDetailPage.d.ts.map +1 -1
  35. package/dist-lib/components/PrintplekDetailPage.js +2 -2
  36. package/dist-lib/components/PrintplekWizard.d.ts.map +1 -1
  37. package/dist-lib/components/PrintplekWizard.js +6 -2
  38. package/dist-lib/components/ProductiebonBuilderPage.d.ts.map +1 -1
  39. package/dist-lib/components/ProductiebonBuilderPage.js +13 -7
  40. package/dist-lib/index.d.ts +16 -6
  41. package/dist-lib/index.d.ts.map +1 -1
  42. package/dist-lib/index.js +11 -5
  43. package/dist-lib/integrations/supabase/auth-middleware.d.ts +3585 -1267
  44. package/dist-lib/integrations/supabase/auth-middleware.d.ts.map +1 -1
  45. package/dist-lib/integrations/supabase/client.server.d.ts +3585 -1267
  46. package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -1
  47. package/dist-lib/integrations/supabase/moduleClient.d.ts +25898 -0
  48. package/dist-lib/integrations/supabase/moduleClient.d.ts.map +1 -0
  49. package/dist-lib/integrations/supabase/moduleClient.js +29 -0
  50. package/dist-lib/integrations/supabase/types.d.ts +3607 -1265
  51. package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
  52. package/dist-lib/integrations/supabase/types.js +1 -0
  53. package/dist-lib/lib/aiLayout.functions.d.ts +3585 -1267
  54. package/dist-lib/lib/aiLayout.functions.d.ts.map +1 -1
  55. package/dist-lib/lib/code128.d.ts +5 -0
  56. package/dist-lib/lib/code128.d.ts.map +1 -0
  57. package/dist-lib/lib/code128.js +51 -0
  58. package/dist-lib/lib/documentVoorbeeld.functions.d.ts +23 -0
  59. package/dist-lib/lib/documentVoorbeeld.functions.d.ts.map +1 -0
  60. package/dist-lib/lib/documentVoorbeeld.functions.js +37 -0
  61. package/dist-lib/lib/fontMetrics.d.ts +15 -0
  62. package/dist-lib/lib/fontMetrics.d.ts.map +1 -0
  63. package/dist-lib/lib/fontMetrics.js +118 -0
  64. package/dist-lib/lib/htmlNaarPdf.server.d.ts +2 -2
  65. package/dist-lib/lib/htmlNaarPdf.server.d.ts.map +1 -1
  66. package/dist-lib/lib/htmlNaarPdf.server.js +2 -1
  67. package/dist-lib/lib/htmlPdf.server.d.ts +11 -0
  68. package/dist-lib/lib/htmlPdf.server.d.ts.map +1 -1
  69. package/dist-lib/lib/htmlPdf.server.js +170 -9
  70. package/dist-lib/lib/layoutPreview.d.ts.map +1 -1
  71. package/dist-lib/lib/layoutPreview.js +6 -3
  72. package/dist-lib/lib/orderData.server.d.ts.map +1 -1
  73. package/dist-lib/lib/orderData.server.js +49 -16
  74. package/dist-lib/lib/pakbonBarcodeVerzoek.d.ts +39 -0
  75. package/dist-lib/lib/pakbonBarcodeVerzoek.d.ts.map +1 -0
  76. package/dist-lib/lib/pakbonBarcodeVerzoek.js +80 -0
  77. package/dist-lib/lib/pakbonBuilder.d.ts +90 -5
  78. package/dist-lib/lib/pakbonBuilder.d.ts.map +1 -1
  79. package/dist-lib/lib/pakbonBuilder.js +455 -58
  80. package/dist-lib/lib/pakbonContent.d.ts +41 -0
  81. package/dist-lib/lib/pakbonContent.d.ts.map +1 -0
  82. package/dist-lib/lib/pakbonContent.js +668 -0
  83. package/dist-lib/lib/pakbonDocument.d.ts +199 -0
  84. package/dist-lib/lib/pakbonDocument.d.ts.map +1 -0
  85. package/dist-lib/lib/pakbonDocument.js +735 -0
  86. package/dist-lib/lib/pakbonHtml.d.ts +21 -2
  87. package/dist-lib/lib/pakbonHtml.d.ts.map +1 -1
  88. package/dist-lib/lib/pakbonHtml.js +144 -36
  89. package/dist-lib/lib/pakbonPresentation.d.ts +8 -0
  90. package/dist-lib/lib/pakbonPresentation.d.ts.map +1 -0
  91. package/dist-lib/lib/pakbonPresentation.js +31 -0
  92. package/dist-lib/lib/pakbonScan.d.ts +44 -0
  93. package/dist-lib/lib/pakbonScan.d.ts.map +1 -0
  94. package/dist-lib/lib/pakbonScan.js +87 -0
  95. package/dist-lib/lib/pdfJobSaver.d.ts +10 -6
  96. package/dist-lib/lib/pdfJobSaver.d.ts.map +1 -1
  97. package/dist-lib/lib/pdfJobSaver.js +302 -99
  98. package/dist-lib/lib/pdfMap.d.ts +37 -2
  99. package/dist-lib/lib/pdfMap.d.ts.map +1 -1
  100. package/dist-lib/lib/pdfMap.js +120 -7
  101. package/dist-lib/lib/pdfOpslagStatus.d.ts +23 -0
  102. package/dist-lib/lib/pdfOpslagStatus.d.ts.map +1 -0
  103. package/dist-lib/lib/pdfOpslagStatus.js +74 -0
  104. package/dist-lib/lib/printApi.d.ts +31 -0
  105. package/dist-lib/lib/printApi.d.ts.map +1 -1
  106. package/dist-lib/lib/printApi.js +62 -1
  107. package/dist-lib/lib/printQueue.server.d.ts +13 -0
  108. package/dist-lib/lib/printQueue.server.d.ts.map +1 -1
  109. package/dist-lib/lib/printQueue.server.js +673 -96
  110. package/dist-lib/lib/printService.functions.d.ts +11840 -2568
  111. package/dist-lib/lib/printService.functions.d.ts.map +1 -1
  112. package/dist-lib/lib/profielHtml.d.ts.map +1 -1
  113. package/dist-lib/lib/profielHtml.js +8 -1
  114. package/dist-lib/lib/supabaseClient.d.ts +1 -3
  115. package/dist-lib/lib/supabaseClient.d.ts.map +1 -1
  116. package/dist-lib/lib/supabaseClient.js +4 -8
  117. package/dist-lib/lib/useDocumentData.d.ts +8 -0
  118. package/dist-lib/lib/useDocumentData.d.ts.map +1 -1
  119. package/dist-lib/lib/useDocumentData.js +1 -1
  120. package/dist-lib/lib/voorbeeldPrint.d.ts +3 -0
  121. package/dist-lib/lib/voorbeeldPrint.d.ts.map +1 -1
  122. package/dist-lib/lib/voorbeeldPrint.js +65 -17
  123. package/package.json +9 -2
@@ -4,8 +4,11 @@
4
4
  // de juiste printer. Draait alleen op de server (cron / interne aanroep).
5
5
  // ============================================================================
6
6
  import { maakPakbonOrder, renderPakbonDocument, voorbeeldPakbonOrder, } from "./pakbonHtml.js";
7
+ import { bouwPakbonScanInhoud, bewaarPakbonScanInhoud } from "./pakbonScan.js";
8
+ import { bouwPakbonBarcodeVerzoek, geleverdeBarcodeVoorJob, vraagPakbonBarcodeAan, } from "./pakbonBarcodeVerzoek.js";
9
+ import { deelkarRegels, leesAfleverbriefConfig, pakbonLayoutVariant } from "./pakbonBuilder.js";
7
10
  import { renderProductiebonDocument } from "./productiebonHtml.js";
8
- import { heeftOrderInhoud, laadOrderVanuitDatabase } from "./orderData.server.js";
11
+ import { heeftOrderInhoud, laadOrderVanuitDatabase, laadPakbonData } from "./orderData.server.js";
9
12
  import { profielHeeftInhoud, renderProfielDocument } from "./profielHtml.js";
10
13
  import { bouwDocumentHtml, voorbeeldContext } from "./layoutPreview.js";
11
14
  import { htmlNaarPdfServer, PdfRenderFout } from "./htmlNaarPdf.server.js";
@@ -47,6 +50,20 @@ export class GeenDocumentFout extends Error {
47
50
  this.name = "GeenDocumentFout";
48
51
  }
49
52
  }
53
+ /** De Engine heeft de taak gepland, maar de layout is nog niet aangeleverd. */
54
+ export class WachtOpLayoutFout extends Error {
55
+ constructor() {
56
+ super(...arguments);
57
+ this.name = "WachtOpLayoutFout";
58
+ }
59
+ }
60
+ /** Blijvende contractfout: opnieuw proberen met dezelfde invoer kan niet slagen. */
61
+ export class EngineContractFout extends GeenDocumentFout {
62
+ constructor() {
63
+ super(...arguments);
64
+ this.name = "EngineContractFout";
65
+ }
66
+ }
50
67
  /**
51
68
  * Oudere/andere benamingen voor hetzelfde document. De Logistiek Engine noemt
52
69
  * de afleverbrief bijvoorbeeld nog "verzendbrief"; hier vertalen we dat naar
@@ -65,6 +82,149 @@ export function normaliseerDocumenttype(type) {
65
82
  return "document";
66
83
  return DOCUMENTTYPE_ALIASSEN[t] ?? t;
67
84
  }
85
+ // ---------------------------------------------------------------------------
86
+ // Contract met de Logistics Engine.
87
+ // De Engine bepaalt documentsoort, variant, layout en printplek; deze module
88
+ // rendert en print alleen. Alles hieronder leest die keuzes uit de opdracht en
89
+ // overruled ze nooit.
90
+ // ---------------------------------------------------------------------------
91
+ /** Het layout-id zoals de Engine het aanlevert (kolom of payload). */
92
+ export function leesEngineLayoutId(job) {
93
+ const p = (job.payload ?? {});
94
+ const d = (p.data ?? {});
95
+ for (const k of [job.layout_id, p.layout_id, p.layoutId, d.layout_id, d.layoutId]) {
96
+ const v = String(k ?? "").trim();
97
+ if (v)
98
+ return v;
99
+ }
100
+ return "";
101
+ }
102
+ /** Documentsoort zoals de Engine hem noemt (`document_code`). */
103
+ function leesDocumentCode(job) {
104
+ const p = (job.payload ?? {});
105
+ const d = (p.data ?? {});
106
+ for (const k of [
107
+ job.document_code,
108
+ job.document_type_code,
109
+ p.document_code,
110
+ p.documentCode,
111
+ p.document_type_code,
112
+ p.documentTypeCode,
113
+ d.document_code,
114
+ d.document_type_code,
115
+ d.documentTypeCode,
116
+ ]) {
117
+ const v = String(k ?? "").trim();
118
+ if (v)
119
+ return v;
120
+ }
121
+ return "";
122
+ }
123
+ /** Is dit een opdracht die door de Logistics Engine is gepland? */
124
+ function isEngineOpdracht(job) {
125
+ const p = (job.payload ?? {});
126
+ if (leesDocumentCode(job))
127
+ return true;
128
+ const bron = String(p.bron_module ?? p.owner_module ?? p.source_module ?? "").toLowerCase();
129
+ return bron.includes("logistic") || bron.includes("engine");
130
+ }
131
+ /** Handmatig opnieuw printen? */
132
+ function isHerprint(job) {
133
+ const p = (job.payload ?? {});
134
+ const d = (p.data ?? {});
135
+ return job.is_herprint === true || p.is_herprint === true || d.is_herprint === true;
136
+ }
137
+ /**
138
+ * Een herprint behoudt de eerder door de Engine aangeleverde barcode en kar.
139
+ * Er wordt nooit lokaal een barcode bedacht. We zoeken uitsluitend binnen
140
+ * dezelfde queue-/orderreferentie en hetzelfde documenttype.
141
+ */
142
+ async function zoekHerprintBarcodeBron(db, job) {
143
+ if (!isHerprint(job))
144
+ return null;
145
+ const p = (job.payload ?? {});
146
+ const d = (p.data ?? {});
147
+ const bronJobId = String(job.bron_job_id ?? p.bron_job_id ?? "").trim();
148
+ if (bronJobId) {
149
+ const { data: bron } = await db
150
+ .from("print_jobs")
151
+ .select("payload")
152
+ .eq("id", bronJobId)
153
+ .maybeSingle();
154
+ const bp = (bron?.payload ?? {});
155
+ const bd = (bp.data ?? {});
156
+ const barcode = String(bp.barcode ?? bd.cart?.barcode ?? bp.cart?.barcode ?? "").trim();
157
+ if (barcode) {
158
+ const cart = (bd.cart ?? bp.cart ?? null);
159
+ return { barcode, cart: cart && typeof cart === "object" ? cart : null };
160
+ }
161
+ }
162
+ const queueItem = String(p.queue_item_id ?? d.queue_item_id ?? "").trim();
163
+ const orderNummer = String(p.order_number ?? p.ordernummer ?? d.order_number ?? d.ordernummer ?? "").trim();
164
+ if (!queueItem && !orderNummer)
165
+ return null;
166
+ const { data } = await db
167
+ .from("print_jobs")
168
+ .select("id, documenttype, payload, created_at")
169
+ .lt("created_at", String(job.created_at ?? new Date().toISOString()))
170
+ .order("created_at", { ascending: false })
171
+ .limit(30);
172
+ const type = normaliseerDocumenttype(leesDocumentCode(job) || job.documenttype);
173
+ for (const kandidaat of arr(data)) {
174
+ if (normaliseerDocumenttype(leesDocumentCode(kandidaat) || kandidaat.documenttype) !== type)
175
+ continue;
176
+ const kp = (kandidaat.payload ?? {});
177
+ const kd = (kp.data ?? {});
178
+ const zelfdeQueue = queueItem && String(kp.queue_item_id ?? kd.queue_item_id ?? "").trim() === queueItem;
179
+ const zelfdeOrder = orderNummer && String(kp.order_number ?? kp.ordernummer ?? kd.order_number ?? kd.ordernummer ?? "").trim() === orderNummer;
180
+ if (!zelfdeQueue && !zelfdeOrder)
181
+ continue;
182
+ const barcode = String(kp.barcode ?? kd.cart?.barcode ?? kp.cart?.barcode ?? "").trim();
183
+ if (!barcode)
184
+ continue;
185
+ const cart = (kd.cart ?? kp.cart ?? null);
186
+ return { barcode, cart: cart && typeof cart === "object" ? cart : null };
187
+ }
188
+ return null;
189
+ }
190
+ /** De ordersleutel waarop een herprint bij de oorspronkelijke print hoort. */
191
+ function leesOrderRef(job) {
192
+ const p = (job.payload ?? {});
193
+ const d = (p.data ?? {});
194
+ for (const k of [job.order_ref, p.order_ref, p.orderRef, d.order_ref, d.orderRef, job.order_id]) {
195
+ const v = String(k ?? "").trim();
196
+ if (v)
197
+ return v;
198
+ }
199
+ return "";
200
+ }
201
+ /**
202
+ * Een herprint gebruikt dezelfde layout als de oorspronkelijke print. Stuurt de
203
+ * Engine geen layout mee, dan zoeken we die op bij de laatste geslaagde print
204
+ * van hetzelfde document van dezelfde order.
205
+ */
206
+ async function zoekHerprintLayoutId(db, job) {
207
+ const orderRef = leesOrderRef(job);
208
+ if (!orderRef)
209
+ return "";
210
+ const type = normaliseerDocumenttype(leesDocumentCode(job) || job.documenttype);
211
+ const { data } = await db
212
+ .from("print_jobs")
213
+ .select("gebruikte_layout_id, documenttype, payload, created_at")
214
+ .not("gebruikte_layout_id", "is", null)
215
+ .order("created_at", { ascending: false })
216
+ .limit(50);
217
+ for (const rij of arr(data)) {
218
+ if (normaliseerDocumenttype(rij.documenttype) !== type)
219
+ continue;
220
+ if (leesOrderRef(rij) !== orderRef)
221
+ continue;
222
+ const id = String(rij.gebruikte_layout_id ?? "").trim();
223
+ if (id)
224
+ return id;
225
+ }
226
+ return "";
227
+ }
68
228
  /**
69
229
  * Zoekt de layout die bij de opdracht hoort.
70
230
  * Staat er een layout_id op de opdracht (bijv. gezet door de Logistiek
@@ -94,15 +254,19 @@ function magOntwerpWinnen(layout) {
94
254
  return String(layout.bron ?? "") !== "floriday";
95
255
  }
96
256
  async function zoekLayout(db, job) {
257
+ const engineJob = isEngineOpdracht(job);
97
258
  const heeftEigenOpmaak = typeof job.render_html === "string" && job.render_html.trim().length > 40;
98
- // Een layout-id in het kenmerkenveld is alleen een verwijzing, geen
99
- // koppeling: is er opmaak meegestuurd (bijv. een voorbeeldafdruk uit de
100
- // ontwerper), dan is die opmaak leidend.
101
- const uitPayload = heeftEigenOpmaak
259
+ const uitPayload = engineJob
102
260
  ? null
103
261
  : (job.payload?.layoutId ?? job.payload?.data?.layoutId ?? job.payload?.layout_id ?? null);
104
- const id = job.layout_id ?? uitPayload;
105
- const voorbeeld = heeftEigenOpmaak && isVoorbeeldAfdruk(job);
262
+ let id = leesEngineLayoutId(job) || job.layout_id || uitPayload;
263
+ if (engineJob && !id && isHerprint(job)) {
264
+ id = await zoekHerprintLayoutId(db, job);
265
+ }
266
+ if (engineJob && !id) {
267
+ throw new WachtOpLayoutFout("Wacht op layout_id van de Logistics Engine. Deze taak wordt niet met een vervangende layout uitgevoerd.");
268
+ }
269
+ const voorbeeld = !engineJob && heeftEigenOpmaak && isVoorbeeldAfdruk(job);
106
270
  if (id) {
107
271
  const { data } = await db
108
272
  .from("document_layouts")
@@ -113,19 +277,24 @@ async function zoekLayout(db, job) {
113
277
  throw new GeenDocumentFout("De layout die aan deze opdracht hangt bestaat niet meer. Koppel een geldige layout.");
114
278
  }
115
279
  const layout = data;
116
- return { layout, ontwerpWint: !voorbeeld && magOntwerpWinnen(layout) };
280
+ return {
281
+ layout,
282
+ // Een Engine-layout is altijd leidend, ook als er render_html is meegestuurd.
283
+ ontwerpWint: engineJob || (!voorbeeld && magOntwerpWinnen(layout)),
284
+ };
117
285
  }
286
+ const documenttype = normaliseerDocumenttype(leesDocumentCode(job) || job.documenttype);
118
287
  const { data } = await db
119
288
  .from("document_layouts")
120
289
  .select("*")
121
- .eq("documenttype", normaliseerDocumenttype(job.documenttype))
290
+ .eq("documenttype", documenttype)
122
291
  .eq("actief", true)
123
292
  .order("is_standaard", { ascending: false })
124
293
  .order("updated_at", { ascending: false })
125
294
  .limit(1);
126
295
  const gevonden = (arr(data)[0] ?? null);
127
296
  if (!gevonden && !job.render_html) {
128
- throw new GeenDocumentFout(`Geen layout gevonden voor documenttype '${normaliseerDocumenttype(job.documenttype)}'. ` +
297
+ throw new GeenDocumentFout(`Geen layout gevonden voor documenttype '${documenttype}'. ` +
129
298
  "Maak een layout aan en zet die op actief.");
130
299
  }
131
300
  return { layout: gevonden, ontwerpWint: !voorbeeld && magOntwerpWinnen(gevonden) };
@@ -148,7 +317,7 @@ async function zoekBronregel(db, job) {
148
317
  const regel = data;
149
318
  if (!regel || regel.bron === "auto")
150
319
  return null;
151
- if (regel.bron === "eigen_layout" && regel.layout_id && !job.layout_id) {
320
+ if (regel.bron === "eigen_layout" && regel.layout_id && !job.layout_id && !isEngineOpdracht(job)) {
152
321
  job.layout_id = regel.layout_id;
153
322
  }
154
323
  return regel.bron;
@@ -159,6 +328,8 @@ async function zoekBronregel(db, job) {
159
328
  */
160
329
  async function isFloridayLayoutKeuze(db, job) {
161
330
  const payload = (job.payload ?? {});
331
+ if (isEngineOpdracht(job))
332
+ return false;
162
333
  const id = job.layout_id ?? payload?.["layoutId"] ?? payload?.["layout_id"] ?? null;
163
334
  if (!id)
164
335
  return false;
@@ -269,8 +440,37 @@ async function zoekPrintPlekId(db, job) {
269
440
  .select("print_plek_id")
270
441
  .eq("computer_naam", werkplek)
271
442
  .maybeSingle();
272
- return String(data?.print_plek_id ?? "").trim();
443
+ const id = String(data?.print_plek_id ?? "").trim();
444
+ if (id)
445
+ return id;
273
446
  }
447
+ // Terugval: de opdracht kwam zonder plek binnen (bijvoorbeeld van de scanner
448
+ // bij "kar compleet"). Gebruik dan de plek die in de documentroutering voor
449
+ // deze documentsoort staat. Zo blijft een PDF-opdracht niet eindeloos op een
450
+ // werkplek wachten die niemand heeft toegewezen.
451
+ const documenttype = normaliseerDocumenttype(job.documenttype);
452
+ if (documenttype) {
453
+ const { data } = await db
454
+ .from("document_printplek_printers")
455
+ .select("print_plek_id")
456
+ .eq("documenttype", documenttype)
457
+ .not("print_plek_id", "is", null);
458
+ const plekken = Array.isArray(data) ? data : [];
459
+ const uniek = Array.from(new Set(plekken.map((r) => String(r?.print_plek_id ?? "").trim()).filter(Boolean)));
460
+ if (uniek.length === 1)
461
+ return uniek[0];
462
+ }
463
+ // Laatste terugval: is er in de hele routering maar één plek bekend, dan kan
464
+ // er niets misgaan door die te kiezen. Bij meerdere plekken raden we nooit.
465
+ const { data: alle } = await db
466
+ .from("document_printplek_printers")
467
+ .select("print_plek_id")
468
+ .not("print_plek_id", "is", null);
469
+ const alleUniek = Array.from(new Set((Array.isArray(alle) ? alle : [])
470
+ .map((r) => String(r?.print_plek_id ?? "").trim())
471
+ .filter(Boolean)));
472
+ if (alleUniek.length === 1)
473
+ return alleUniek[0];
274
474
  return "";
275
475
  }
276
476
  /** Naam van de printplek waar deze opdracht bij hoort, voor nette meldingen. */
@@ -329,13 +529,36 @@ async function zoekPrinter(db, job, layout) {
329
529
  .maybeSingle();
330
530
  return data && data.actief !== false ? data : null;
331
531
  };
332
- const type = normaliseerDocumenttype(job.documenttype);
333
- // 1. Printer die expliciet op de opdracht staat (bijv. handmatig herprinten).
334
- let printer = await pak(job.printer_id);
532
+ const type = normaliseerDocumenttype(leesDocumentCode(job) || job.documenttype);
533
+ // Bij Engine-opdrachten is de centrale routering altijd leidend. De Engine
534
+ // kan een printer_id als momentopname meesturen, maar die kan inmiddels zijn
535
+ // gewijzigd (bijvoorbeeld van PDF naar een fysieke printer). Laat zo'n oud
536
+ // id daarom nooit de actuele printplek-, werkplek- of standaardregel
537
+ // overschrijven.
538
+ let printer = isEngineOpdracht(job) ? null : await pak(job.printer_id);
335
539
  if (printer)
336
540
  return { printer, reden: "" };
337
541
  const werkplek = String(job.payload?.werkplek ?? "").trim();
338
542
  const pickstation = leesPickstation(job);
543
+ const explicietePlek = leesPrintPlekId(job);
544
+ // Een Engine-printplek is een harde keuze. Nooit terugvallen op computer,
545
+ // layout of bedrijfsregels wanneer de Engine een print_plek_id meegaf.
546
+ if (explicietePlek) {
547
+ const { data: pp } = await db
548
+ .from("document_printplek_printers")
549
+ .select("printer_id")
550
+ .eq("print_plek_id", explicietePlek)
551
+ .eq("documenttype", type)
552
+ .maybeSingle();
553
+ printer = await pak(pp?.printer_id);
554
+ if (printer)
555
+ return { printer, reden: "" };
556
+ const { data: plek } = await db.from("print_plekken").select("naam").eq("id", explicietePlek).maybeSingle();
557
+ return {
558
+ printer: null,
559
+ reden: `Printplek '${String(plek?.naam ?? explicietePlek)}' heeft geen printer voor documenttype '${type}'.`,
560
+ };
561
+ }
339
562
  // 2. Regel van de printplek waar de opdracht vandaan komt.
340
563
  const plekId = await zoekPrintPlekId(db, job);
341
564
  if (plekId) {
@@ -508,6 +731,40 @@ export async function bouwHtml(db, job, keuze, uitkomst) {
508
731
  throw new GeenDocumentFout("Deze layout gebruikt altijd het ontwerp van de klant, maar er is geen klantontwerp meegestuurd.");
509
732
  }
510
733
  }
734
+ // 0) Afleverbrief-/veilingbrieflayouts. Deze hebben hun eigen sectie-opbouw
735
+ // onder `afleverbrief`. Een afleverbrief hoort altijd bij precies één kar:
736
+ // hij vertelt wat er op díé kar staat.
737
+ const afleverType = String(layout.documenttype) === "afleverbrief" ||
738
+ String(layout.documenttype) === "veilingbrief";
739
+ if (afleverType || Array.isArray(cfg.afleverbrief?.secties)) {
740
+ const orderBron = p.order ?? p.data?.order ?? p.data ?? p;
741
+ const cartId = String(p.data?.cart?.cart_id ?? p.cart?.cart_id ?? p.data?.cart?.cartId ?? p.cart?.cartId ?? "").trim();
742
+ const uitDb = heeftOrderInhoud(orderBron)
743
+ ? null
744
+ : cartId
745
+ ? ((await laadPakbonData(db, { scope: "cart", id: cartId })) ??
746
+ (await laadOrderVanuitDatabase(db, p)))
747
+ : await laadOrderVanuitDatabase(db, p);
748
+ const bron = uitDb ??
749
+ (p.voorbeeld === true && !heeftOrderInhoud(orderBron)
750
+ ? voorbeeldPakbonOrder()
751
+ : orderBron);
752
+ const volledig = maakPakbonOrder((bron && typeof bron === "object" ? bron : {}));
753
+ // Beperk tot de aangewezen kar; zonder aanwijzing tot de eerste kar, zodat
754
+ // een afleverbrief nooit de inhoud van meerdere karren door elkaar toont.
755
+ const karren = arr(volledig.karren);
756
+ const gekozen = (cartId ? karren.find((k) => String(k?.cart_id ?? "").trim() === cartId) : null) ??
757
+ karren[0] ??
758
+ null;
759
+ const order = { ...volledig, karren: gekozen ? [gekozen] : [] };
760
+ return renderPakbonDocument(leesAfleverbriefConfig(layout.config), order, {
761
+ breedteMm,
762
+ hoogteMm,
763
+ margeMm: Number(layout.marge_boven ?? 10),
764
+ titel: job.documentnaam ?? String(layout.naam ?? "Afleverbrief"),
765
+ logos: await logosVoor(db, layout),
766
+ });
767
+ }
511
768
  // 1) Pakbon-layouts. De secties staan onder `pakbon.secties`; oudere
512
769
  // layouts hebben ze op het hoogste niveau. Beide vormen tellen mee.
513
770
  const pakbonSecties = Array.isArray(cfg.secties)
@@ -517,21 +774,154 @@ export async function bouwHtml(db, job, keuze, uitkomst) {
517
774
  : null;
518
775
  if ((pakbonSecties && pakbonSecties.length) || String(layout.documenttype) === "pakbon") {
519
776
  const orderBron = p.order ?? p.data?.order ?? p.data ?? p;
520
- const uitDb = heeftOrderInhoud(orderBron) ? null : await laadOrderVanuitDatabase(db, p);
777
+ const pakbonMethode = Array.isArray(pakbonSecties)
778
+ ? String(pakbonSecties.find((s) => s?.type === "pickmethode")?.opties?.methode ?? "kar")
779
+ : "kar";
780
+ const engineVariant = job.layout_variant ?? p.layout_variant ?? p.layoutVariant ?? p.data?.layout_variant ?? null;
781
+ if (isEngineOpdracht(job) && pakbonMethode === "beide" && !["full_cart", "partial_order"].includes(String(engineVariant ?? ""))) {
782
+ throw new WachtOpLayoutFout("Wacht op layout_variant van de Logistics Engine: kies full_cart of partial_order.");
783
+ }
784
+ // Barcode en kargegevens van de Engine mogen niet verloren gaan wanneer
785
+ // de ordergegevens uit een subobject komen.
786
+ const herprintBron = await zoekHerprintBarcodeBron(db, job);
787
+ const engineBarcode = p.barcode ?? p.data?.cart?.barcode ?? p.cart?.barcode ?? herprintBron?.barcode ?? null;
788
+ const ruweEngineCart = p.data?.cart ?? p.cart ?? herprintBron?.cart ?? null;
789
+ const engineCart = ruweEngineCart && typeof ruweEngineCart === "object"
790
+ ? {
791
+ ...ruweEngineCart,
792
+ cart_id: ruweEngineCart.cart_id ?? ruweEngineCart.cartId,
793
+ cart_label: ruweEngineCart.cart_label ?? ruweEngineCart.cartLabel,
794
+ cart_index: ruweEngineCart.cart_index ?? ruweEngineCart.cartIndex,
795
+ cart_total: ruweEngineCart.cart_total ?? ruweEngineCart.cartTotal,
796
+ cart_items: ruweEngineCart.cart_items ?? ruweEngineCart.items,
797
+ barcode: ruweEngineCart.barcode ?? engineBarcode,
798
+ }
799
+ : null;
800
+ const engineCartId = String(engineCart?.cart_id ?? "").trim();
801
+ // Wijst de Engine één kar aan, dan halen we de producten rechtstreeks bij
802
+ // díé kar op. Zo staan altijd exact de artikelen op de pakbon die fysiek op
803
+ // de kar liggen — ook bij gedeelde karren met regels van meerdere orders.
804
+ const uitDb = heeftOrderInhoud(orderBron)
805
+ ? null
806
+ : engineCartId
807
+ ? ((await laadPakbonData(db, { scope: "cart", id: engineCartId })) ??
808
+ (await laadOrderVanuitDatabase(db, p)))
809
+ : await laadOrderVanuitDatabase(db, p);
521
810
  // Een voorbeeldafdruk zonder ordergegevens krijgt een voorbeeldorder, zodat
522
811
  // je ziet hoe het document er in de praktijk uitziet.
523
812
  const bron = uitDb ??
524
813
  (p.voorbeeld === true && !heeftOrderInhoud(orderBron)
525
814
  ? voorbeeldPakbonOrder()
526
815
  : orderBron);
527
- const order = maakPakbonOrder(bron);
528
- return renderPakbonDocument(layout.config, order, {
816
+ const bronRecord = (bron && typeof bron === "object" ? bron : {});
817
+ // Verwijder order-/payloadbarcodes: bij Engine-opdrachten zijn alleen de
818
+ // twee contractvelden hierboven toegestaan als bron voor de afleverbriefscan.
819
+ const { barcode: _bronBarcode, cart: _bronCart, ...bronZonderEngineBarcode } = bronRecord;
820
+ let order = maakPakbonOrder({
821
+ ...bronZonderEngineBarcode,
822
+ ...(engineBarcode ? { barcode: engineBarcode } : {}),
823
+ ...(engineCart ? { cart: engineCart } : {}),
824
+ });
825
+ if (isEngineOpdracht(job)) {
826
+ let barcode = String(engineBarcode ?? "").trim();
827
+ if (!barcode) {
828
+ // De Engine heeft (nog) geen barcode meegestuurd. Heeft zij er
829
+ // inmiddels één geleverd op ons eerdere verzoek, dan gebruiken we die.
830
+ barcode = (await geleverdeBarcodeVoorJob(db, String(job.id ?? ""))) ?? "";
831
+ }
832
+ if (!barcode) {
833
+ // Zelf een barcode bedenken mag nooit. Wél laten we de Engine expliciet
834
+ // weten dat er voor deze pakbon een barcode ontbreekt, zodat zij er één
835
+ // kan aanleveren in plaats van dat de opdracht stil blijft hangen.
836
+ try {
837
+ await vraagPakbonBarcodeAan(db, bouwPakbonBarcodeVerzoek(order, {
838
+ printJobId: String(job.id ?? ""),
839
+ documentnaam: job.documentnaam,
840
+ cart: engineCart,
841
+ layoutVariant: pakbonLayoutVariant(engineVariant),
842
+ layoutId: String(layout.id ?? "") || null,
843
+ isHerprint: isHerprint(job),
844
+ }));
845
+ }
846
+ catch (e) {
847
+ await log(db, job.id, "barcode_verzoek_niet_opgeslagen", e instanceof Error ? e.message : "Barcodeverzoek kon niet worden vastgelegd");
848
+ }
849
+ throw new WachtOpLayoutFout("Barcode opgevraagd bij de Logistics Engine: deze kar heeft nog geen pakbonbarcode.");
850
+ }
851
+ // Restpakbon: zonder meegestuurde restregels weten we niet wát er nog
852
+ // verzameld moet worden. Dan liever geen pakbon dan een pakbon met de
853
+ // volledige karinhoud erop.
854
+ if (pakbonLayoutVariant(engineVariant) === "partial_order" && !deelkarRegels(order).length) {
855
+ throw new EngineContractFout("Restpakbon zonder restregels: stuur de resterende regels mee als cart_items.");
856
+ }
857
+ const cartId = engineCartId;
858
+ const karren = Array.isArray(order.karren) ? order.karren : [];
859
+ const passendeKarren = cartId
860
+ ? karren.filter((kar) => String(kar.cart_id ?? "").trim() === cartId)
861
+ : karren;
862
+ // Eén Engine-opdracht staat voor precies één kar en dus één pakbon. Een
863
+ // opdracht met meerdere karren mag niet één barcode aan meerdere karren
864
+ // koppelen; de Engine moet daarvoor afzonderlijke jobs aanmaken.
865
+ if (passendeKarren.length > 1 || (!cartId && karren.length > 1)) {
866
+ throw new WachtOpLayoutFout("Wacht op afzonderlijke kar-opdrachten van de Logistics Engine: maak per kar één print_job met een eigen barcode.");
867
+ }
868
+ // Wijst de Engine een kar aan die niet in de gegevens voorkomt, dan mag
869
+ // er nóóit een andere kar worden afgedrukt: dan staan er producten op de
870
+ // pakbon die er niet op horen. De opdracht mislukt dan bewust.
871
+ if (cartId && passendeKarren.length === 0) {
872
+ throw new EngineContractFout("De door de Logistics Engine gekozen kar is niet gevonden in de pakbongegevens.");
873
+ }
874
+ const gekozenKar = passendeKarren[0] ?? (karren.length === 1 ? karren[0] : null);
875
+ // Een kar zonder productregels levert een lege pakbon op. Liever een
876
+ // duidelijke fout dan een pakbon zonder de producten die erop horen.
877
+ const heeftProducten = !!gekozenKar &&
878
+ arr(gekozenKar.lagen).some((laag) => arr(laag?.producten).length > 0);
879
+ if (gekozenKar && !heeftProducten) {
880
+ throw new EngineContractFout("De gekozen kar bevat geen productregels; de pakbon zou zonder producten worden afgedrukt.");
881
+ }
882
+ if (gekozenKar) {
883
+ order = {
884
+ ...order,
885
+ cart: {
886
+ ...order.cart,
887
+ ...engineCart,
888
+ barcode,
889
+ cart_id: cartId || gekozenKar.cart_id,
890
+ cart_label: String(engineCart?.cart_label ?? gekozenKar.cart_label ?? "").trim() || undefined,
891
+ cart_index: Number(engineCart?.cart_index ?? gekozenKar.cart_index ?? 0) || undefined,
892
+ cart_total: Number(engineCart?.cart_total ?? gekozenKar.cart_total ?? 0) || undefined,
893
+ },
894
+ karren: [{ ...gekozenKar, barcode }],
895
+ };
896
+ }
897
+ }
898
+ const html = renderPakbonDocument(layout.config, order, {
529
899
  breedteMm,
530
900
  hoogteMm,
531
901
  margeMm: Number(layout.marge_boven ?? 12),
532
902
  titel: job.documentnaam ?? "Document",
903
+ layoutVariant: engineVariant,
533
904
  logos: await logosVoor(db, layout),
534
905
  });
906
+ // De Engine bepaalt de barcode. De module registreert uitsluitend de
907
+ // gerenderde inhoud onder die bestaande waarde voor de scanner. Als de
908
+ // registratie tijdelijk niet kan, mag dat een afdruk nooit blokkeren.
909
+ const scanInhoud = bouwPakbonScanInhoud(order, {
910
+ barcode: order?.cart?.barcode ?? engineBarcode,
911
+ layoutVariant: pakbonLayoutVariant(engineVariant),
912
+ layoutId: String(layout.id ?? "") || null,
913
+ printJobId: String(job.id ?? "") || null,
914
+ isHerprint: isHerprint(job),
915
+ });
916
+ if (scanInhoud) {
917
+ try {
918
+ await bewaarPakbonScanInhoud(db, scanInhoud);
919
+ }
920
+ catch (e) {
921
+ await log(db, job.id, "barcode_inhoud_niet_opgeslagen", e instanceof Error ? e.message : "Registratie van barcode-inhoud mislukt");
922
+ }
923
+ }
924
+ return html;
535
925
  }
536
926
  // 2) Productiebon-layouts (blokken onder `productiebon.blokken`).
537
927
  if (Array.isArray(cfg.productiebon?.blokken) ||
@@ -575,15 +965,30 @@ export async function bouwHtml(db, job, keuze, uitkomst) {
575
965
  "Zet in de layoutinstellingen minstens één onderdeel aan.");
576
966
  }
577
967
  const logos = await logosVoor(db, layout);
578
- const gegevens = (p.data ?? p);
968
+ const meegegeven = (p.data ?? p);
969
+ // De opdracht bevat vaak alleen verwijzingen (order-id, kar-id). Zonder de
970
+ // echte ordergegevens zou het document als een lijst lege veldnamen worden
971
+ // afgedrukt, terwijl het ontwerp op het scherm wél gevuld is. Daarom halen
972
+ // we dezelfde ordergegevens op als de pakbon gebruikt.
973
+ const uitDatabase = heeftOrderInhoud(meegegeven)
974
+ ? null
975
+ : await laadOrderVanuitDatabase(db, p).catch(() => null);
976
+ const gegevens = { ...(uitDatabase ?? {}), ...(meegegeven ?? {}) };
579
977
  const profielData = p.voorbeeld === true ? { ...voorbeeldContext(), ...(gegevens ?? {}) } : gegevens;
580
- return renderProfielDocument(String(layout.documenttype), cfg, profielData, {
978
+ const profielHtml = renderProfielDocument(String(layout.documenttype), cfg, profielData, {
581
979
  breedteMm,
582
980
  hoogteMm,
583
981
  margeMm: Number(layout.marge_boven ?? 8),
584
982
  titel: job.documentnaam ?? String(layout.naam ?? "Document"),
585
983
  logoUrl: logos[0]?.viewUrl ?? null,
586
984
  });
985
+ // Fail closed: liever een duidelijke fout dan een vel papier met alleen
986
+ // veldnamen en streepjes erop.
987
+ if (!/<div class="blok"/.test(profielHtml)) {
988
+ throw new GeenDocumentFout(`Voor "${String(layout.naam ?? "")}" zijn geen gegevens gevonden om af te drukken. ` +
989
+ "Controleer of de opdracht een order of kar meestuurt.");
990
+ }
991
+ return profielHtml;
587
992
  }
588
993
  /**
589
994
  * Controleert of het document echt iets afdrukt. Zonder deze controle kan een
@@ -623,6 +1028,22 @@ function plattePayload(payload) {
623
1028
  loop(payload, "", 0);
624
1029
  return uit;
625
1030
  }
1031
+ async function wachtOpLayout(db, job, reden) {
1032
+ await db
1033
+ .from("print_jobs")
1034
+ .update({
1035
+ status: "wachtend",
1036
+ lokale_pdf_status: "wachtend",
1037
+ lokale_pdf_fout: reden.slice(0, 500),
1038
+ foutmelding: reden.slice(0, 500),
1039
+ volgende_poging: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
1040
+ opgepakt_op: null,
1041
+ voltooid_op: null,
1042
+ updated_at: new Date().toISOString(),
1043
+ })
1044
+ .eq("id", job.id);
1045
+ await log(db, job.id, "wacht_op_engine_layout", reden);
1046
+ }
626
1047
  async function mislukt(db, job, reden, terugNaarWachtend) {
627
1048
  const pogingen = Number(job.pogingen ?? 0);
628
1049
  const opnieuw = terugNaarWachtend && pogingen < MAX_POGINGEN;
@@ -632,6 +1053,9 @@ async function mislukt(db, job, reden, terugNaarWachtend) {
632
1053
  .from("print_jobs")
633
1054
  .update({
634
1055
  status: opnieuw ? "wachtend" : "mislukt",
1056
+ verwerkingsfase: opnieuw ? "retry_wacht" : "mislukt",
1057
+ fase_voltooid_op: opnieuw ? null : new Date().toISOString(),
1058
+ foutcode: opnieuw ? "tijdelijke_fout" : "blijvende_fout",
635
1059
  foutmelding: reden.slice(0, 500),
636
1060
  volgende_poging: opnieuw ? new Date(Date.now() + wachtMin * 60000).toISOString() : null,
637
1061
  voltooid_op: opnieuw ? null : new Date().toISOString(),
@@ -640,7 +1064,67 @@ async function mislukt(db, job, reden, terugNaarWachtend) {
640
1064
  .eq("id", job.id);
641
1065
  await log(db, job.id, opnieuw ? "opnieuw_proberen" : "mislukt", reden);
642
1066
  }
1067
+ function pdfBytes(base64) {
1068
+ const bron = Buffer.from(base64, "base64");
1069
+ const inhoud = new Uint8Array(new ArrayBuffer(bron.byteLength));
1070
+ inhoud.set(bron);
1071
+ return inhoud;
1072
+ }
1073
+ async function bewaarPdfArtefact(db, jobId, base64) {
1074
+ const inhoud = pdfBytes(base64);
1075
+ const pad = `${jobId}.pdf`;
1076
+ const digest = await crypto.subtle.digest("SHA-256", inhoud);
1077
+ const sha256 = Array.from(new Uint8Array(digest), (waarde) => waarde.toString(16).padStart(2, "0")).join("");
1078
+ const { error } = await db.storage
1079
+ .from("print-artifacts")
1080
+ .upload(pad, inhoud, { contentType: "application/pdf", upsert: true });
1081
+ if (error)
1082
+ throw new PdfRenderFout("mislukt", `Het PDF-bestand kon niet veilig worden opgeslagen: ${error.message}`);
1083
+ return { pad, sha256, bytes: inhoud.byteLength };
1084
+ }
1085
+ /**
1086
+ * Kleine faseklok: meet per opdracht hoe lang claimen, layout kiezen, printer
1087
+ * kiezen, renderen en versturen duren. Eén logregel per opdracht, zodat de
1088
+ * meting zelf het printpad niet vertraagt.
1089
+ */
1090
+ class Faseklok {
1091
+ constructor(job) {
1092
+ this.start = Date.now();
1093
+ this.vorige = Date.now();
1094
+ this.fasen = [];
1095
+ const aangemaakt = job.created_at ? Date.parse(String(job.created_at)) : NaN;
1096
+ if (Number.isFinite(aangemaakt))
1097
+ this.fasen.push(`claimed=${this.start - aangemaakt}ms`);
1098
+ }
1099
+ markeer(fase) {
1100
+ const nu = Date.now();
1101
+ this.fasen.push(`${fase}=${nu - this.vorige}ms`);
1102
+ this.vorige = nu;
1103
+ }
1104
+ samenvatting() {
1105
+ return [...this.fasen, `totaal=${Date.now() - this.start}ms`].join(" ");
1106
+ }
1107
+ }
1108
+ /**
1109
+ * Verwerkt items met een vaste maximale gelijktijdigheid. Geen onbeperkte
1110
+ * Promise.all: één trage of mislukte opdracht mag de rest niet meeslepen.
1111
+ */
1112
+ async function metPool(items, max, fn) {
1113
+ const uit = new Array(items.length);
1114
+ let volgende = 0;
1115
+ const werkers = Array.from({ length: Math.max(1, Math.min(max, items.length)) }, async () => {
1116
+ for (;;) {
1117
+ const i = volgende++;
1118
+ if (i >= items.length)
1119
+ return;
1120
+ uit[i] = await fn(items[i]);
1121
+ }
1122
+ });
1123
+ await Promise.all(werkers);
1124
+ return uit;
1125
+ }
643
1126
  async function verwerkJob(db, job) {
1127
+ const klok = new Faseklok(job);
644
1128
  try {
645
1129
  // Dubbel printen voorkomen: deze opdracht staat al bij de printer.
646
1130
  if (job.extern_job_id) {
@@ -650,26 +1134,29 @@ async function verwerkJob(db, job) {
650
1134
  // 1) Bronregel: bepaalt of het meegestuurde document wordt gebruikt of dat
651
1135
  // er altijd een eigen ontwerp wordt opgemaakt.
652
1136
  let bron = await zoekBronregel(db, job);
653
- // 1b) Is er een vaste Floriday-layout gekozen? Die staat alleen voor het
654
- // kant-en-klare document uit Floriday: dat gaat 1-op-1 naar de printer.
655
- if (await isFloridayLayoutKeuze(db, job)) {
1137
+ // 1b) Legacy Floriday-keuze; Engine-opdrachten volgen uitsluitend hun
1138
+ // eigen layout_id en worden nooit teruggezet naar een externe PDF.
1139
+ if (!isEngineOpdracht(job) && await isFloridayLayoutKeuze(db, job)) {
656
1140
  bron = "extern_pdf";
657
1141
  job.layout_id = null;
658
1142
  }
659
- if (bron === "eigen_layout") {
1143
+ if (bron === "eigen_layout" || isEngineOpdracht(job)) {
660
1144
  job.pdf_base64 = null;
661
1145
  job.pdf_url = null;
662
1146
  }
663
- // 2) Kant-en-klare PDF (klantsticker, Floriday-document) gaat ongewijzigd door.
664
- const klaar = await kantEnKlarePdf(db, job);
665
- if (!klaar && bron === "extern_pdf") {
1147
+ // 2) Kant-en-klare PDF gaat alleen buiten het Engine-contract ongewijzigd
1148
+ // door. De Engine bepaalt altijd de layout, ook als er een PDF aanwezig is.
1149
+ const klaar = isEngineOpdracht(job) ? null : await kantEnKlarePdf(db, job);
1150
+ if (!klaar && bron === "extern_pdf" && !isEngineOpdracht(job)) {
666
1151
  await mislukt(db, job, "Floriday-document ontbreekt: voor dit document is ingesteld dat het " +
667
1152
  "aangeleverde document wordt gebruikt, maar er is geen PDF meegestuurd.", true);
668
1153
  return "mislukt";
669
1154
  }
670
1155
  const keuze = klaar ? { layout: null, ontwerpWint: false } : await zoekLayout(db, job);
1156
+ klok.markeer("layout_resolved");
671
1157
  const layout = keuze.layout;
672
1158
  const printerKeuze = await zoekPrinter(db, job, layout);
1159
+ klok.markeer("printer_resolved");
673
1160
  const printer = printerKeuze.printer;
674
1161
  // Virtuele PDF-printer: geen PrintNode nodig; de PDF wordt bewaard.
675
1162
  const isPdfPrinter = String(printer?.afdrukmethode ?? "") === "pdf";
@@ -701,26 +1188,38 @@ async function verwerkJob(db, job) {
701
1188
  landschap: liggend,
702
1189
  });
703
1190
  }
1191
+ klok.markeer("rendered");
704
1192
  // Virtuele PDF-printer: niets gaat naar een fysieke printer; de opgemaakte
705
1193
  // PDF wordt op de opdracht bewaard en is daarna te downloaden in de
706
1194
  // wachtrij. De opdracht is meteen voltooid.
707
1195
  if (isPdfPrinter) {
708
- // In twee stappen: eerst de status (klein en altijd betrouwbaar), daarna
709
- // pas de PDF zelf. Een grote PDF kan door de databaselimiet geweigerd
710
- // worden; de opdracht mag daar nooit door blijven hangen op 'bezig'.
1196
+ // Bewaar de PDF één keer als binair artefact. De wachtrij en Realtime
1197
+ // vervoeren daarna alleen metadata; de werkplek downloadt precies dit bestand.
1198
+ const artefact = await bewaarPdfArtefact(db, String(job.id), pdfBase64);
1199
+ // De opdracht blijft bewust op 'bezig' staan: hij is pas voltooid als de
1200
+ // werkplek het bestand echt in de PDF-map heeft gezet. De lokale service
1201
+ // zet daarna 'opgeslagen' + 'voltooid', of 'mislukt' met een leesbare zin.
711
1202
  const { error: statusFout } = await db
712
1203
  .from("print_jobs")
713
1204
  .update({
714
1205
  printer_id: printer.id,
715
1206
  gebruikte_layout_id: uitkomst.bron === "extern_pdf" ? null : (layout?.id ?? null),
716
1207
  gebruikte_bron: uitkomst.bron,
717
- status: "voltooid",
1208
+ status: "bezig",
1209
+ verwerkingsfase: "pdf_gereed",
1210
+ fase_voltooid_op: new Date().toISOString(),
1211
+ foutcode: null,
718
1212
  lokale_pdf_status: "gereed",
1213
+ lokale_pdf_print_plek_id: (await zoekPrintPlekId(db, job)) || null,
719
1214
  lokale_pdf_fout: null,
720
1215
  lokaal_opgeslagen_op: null,
721
1216
  foutmelding: null,
1217
+ artefact_pad: artefact.pad,
1218
+ artefact_sha256: artefact.sha256,
1219
+ artefact_bytes: artefact.bytes,
1220
+ artefact_gereed_op: new Date().toISOString(),
1221
+ resultaat_pdf_base64: null,
722
1222
  volgende_poging: null,
723
- voltooid_op: new Date().toISOString(),
724
1223
  updated_at: new Date().toISOString(),
725
1224
  })
726
1225
  .eq("id", job.id);
@@ -728,23 +1227,8 @@ async function verwerkJob(db, job) {
728
1227
  await mislukt(db, job, `Opdracht afronden mislukt: ${statusFout.message}`, true);
729
1228
  return "mislukt";
730
1229
  }
731
- const { error: pdfFout } = await db
732
- .from("print_jobs")
733
- .update({ resultaat_pdf_base64: pdfBase64 })
734
- .eq("id", job.id);
735
- if (pdfFout) {
736
- await db
737
- .from("print_jobs")
738
- .update({
739
- lokale_pdf_status: "fout",
740
- lokale_pdf_fout: `PDF kon niet bewaard worden: ${pdfFout.message}`.slice(0, 500),
741
- updated_at: new Date().toISOString(),
742
- })
743
- .eq("id", job.id);
744
- await log(db, job.id, "mislukt", `PDF bewaren mislukt: ${pdfFout.message}`);
745
- return "verstuurd";
746
- }
747
- await log(db, job.id, "pdf_gereed", `PDF gereed voor lokale opslag via ${printer.naam ?? "PDF-printer"}.`);
1230
+ klok.markeer("pdf_ready");
1231
+ await log(db, job.id, "pdf_gereed", `PDF gereed voor lokale opslag via ${printer.naam ?? "PDF-printer"}. ${klok.samenvatting()}`);
748
1232
  return "verstuurd";
749
1233
  }
750
1234
  const formaten = await zorgVoorFormaten(db, printer);
@@ -768,15 +1252,23 @@ async function verwerkJob(db, job) {
768
1252
  gebruikte_layout_id: uitkomst.bron === "extern_pdf" ? null : (layout?.id ?? null),
769
1253
  gebruikte_bron: uitkomst.bron,
770
1254
  extern_job_id: externJobId,
1255
+ verwerkingsfase: "afleveren",
1256
+ fase_voltooid_op: new Date().toISOString(),
1257
+ foutcode: null,
771
1258
  foutmelding: null,
772
1259
  volgende_poging: null,
773
1260
  updated_at: new Date().toISOString(),
774
1261
  })
775
1262
  .eq("id", job.id);
776
- await log(db, job.id, "verstuurd", `Naar printer ${printer.naam ?? ""}. ${papier.uitleg}`);
1263
+ klok.markeer("submitted");
1264
+ await log(db, job.id, "verstuurd", `Naar printer ${printer.naam ?? ""}. ${papier.uitleg} ${klok.samenvatting()}`);
777
1265
  return "verstuurd";
778
1266
  }
779
1267
  catch (e) {
1268
+ if (e instanceof WachtOpLayoutFout) {
1269
+ await wachtOpLayout(db, job, e.message);
1270
+ return "wachtend";
1271
+ }
780
1272
  // Ontbreekt er een document of layout? Dan heeft opnieuw proberen geen zin.
781
1273
  const blijvend = e instanceof GeenDocumentFout;
782
1274
  const tijdelijk = !blijvend && (e instanceof PdfRenderFout || e?.name === "PrintToegangFout");
@@ -788,16 +1280,26 @@ async function verwerkJob(db, job) {
788
1280
  const VERVAL_BEZIG_MIN = 30;
789
1281
  /** Een claim zonder verzending mag hooguit zo lang blijven hangen. */
790
1282
  const VERVAL_CLAIM_MIN = 10;
791
- /** Werkt de status bij van opdrachten die al naar een printer zijn gestuurd. */
1283
+ /** Hoeveel statuscontroles hooguit per ronde, en met welke gelijktijdigheid. */
1284
+ const STATUS_MAX_PER_RONDE = 12;
1285
+ const STATUS_PARALLEL = 3;
1286
+ /**
1287
+ * Werkt de status bij van opdrachten die al naar een printer zijn gestuurd.
1288
+ * Dit is historisch werk: het draait pas nadat nieuwe opdrachten zijn
1289
+ * afgehandeld, met een begrensde pool en een vaste limiet per ronde.
1290
+ */
792
1291
  async function synchroniseerStatussen(db) {
793
1292
  const { data } = await db
794
1293
  .from("print_jobs")
795
1294
  .select("id, extern_job_id, pogingen, opgepakt_op")
796
1295
  .eq("status", "bezig")
797
1296
  .not("extern_job_id", "is", null)
798
- .limit(25);
799
- let bijgewerkt = 0;
800
- for (const job of arr(data)) {
1297
+ .order("created_at", { ascending: true })
1298
+ .limit(STATUS_MAX_PER_RONDE);
1299
+ const jobs = arr(data);
1300
+ if (!jobs.length)
1301
+ return 0;
1302
+ const uitkomsten = await metPool(jobs, STATUS_PARALLEL, async (job) => {
801
1303
  try {
802
1304
  const status = await opdrachtStatus(String(job.extern_job_id));
803
1305
  if (status === "voltooid" || status === "mislukt") {
@@ -811,8 +1313,9 @@ async function synchroniseerStatussen(db) {
811
1313
  })
812
1314
  .eq("id", job.id);
813
1315
  await log(db, job.id, status);
814
- bijgewerkt++;
1316
+ return 1;
815
1317
  }
1318
+ return 0;
816
1319
  }
817
1320
  catch (e) {
818
1321
  // Een onbereikbare printdienst mag niet stil blijven: tel hem als poging
@@ -828,9 +1331,10 @@ async function synchroniseerStatussen(db) {
828
1331
  .eq("id", job.id);
829
1332
  await log(db, job.id, "status_onbekend", reden);
830
1333
  console.warn(`[printwachtrij] status ophalen mislukt voor job ${job.id}: ${reden}`);
1334
+ return 0;
831
1335
  }
832
- }
833
- return bijgewerkt;
1336
+ });
1337
+ return uitkomsten.reduce((n, v) => n + v, 0);
834
1338
  }
835
1339
  /**
836
1340
  * Vangnet voor opdrachten die blijven hangen op 'bezig'. Twee gevallen:
@@ -872,7 +1376,8 @@ async function vervalControle(db) {
872
1376
  .select("id, pogingen")
873
1377
  .eq("status", "bezig")
874
1378
  .is("extern_job_id", null)
875
- .neq("lokale_pdf_status", "gereed")
1379
+ // PDF-opdrachten die op de werkplek wachten zijn niet vastgelopen.
1380
+ .or("lokale_pdf_status.is.null,lokale_pdf_status.not.in.(wachtend,bezig,gereed)")
876
1381
  .or(`opgepakt_op.lt.${grensClaim},and(opgepakt_op.is.null,updated_at.lt.${grensClaim})`)
877
1382
  .limit(50);
878
1383
  for (const job of arr(vastgelopen)) {
@@ -896,46 +1401,116 @@ async function vervalControle(db) {
896
1401
  }
897
1402
  return aantal;
898
1403
  }
1404
+ /** Hoeveel opdrachten tegelijk worden gerenderd binnen één ronde. */
1405
+ const JOB_PARALLEL = 3;
1406
+ /** Zo lang mag één ronde het slot houden; daarna is hij vanzelf herstelbaar. */
1407
+ const LEASE_SECONDEN = 120;
1408
+ const LEASE_NAAM = "print-queue-worker";
1409
+ /** Rustpauze voordat een volgende ronde wordt gewekt. */
1410
+ const VERVOLG_COOLDOWN_MS = 1500;
1411
+ /**
1412
+ * Wekt de wachtrij opnieuw, maar uitsluitend wanneer er aantoonbaar wachtend
1413
+ * werk overblijft. Het adres van de runner staat in de database, nooit in code.
1414
+ */
1415
+ async function wekVervolgRonde(db) {
1416
+ const { count } = await db
1417
+ .from("print_jobs")
1418
+ .select("id", { count: "exact", head: true })
1419
+ .eq("status", "wachtend")
1420
+ .or(`volgende_poging.is.null,volgende_poging.lte.${new Date().toISOString()}`);
1421
+ if (!count)
1422
+ return;
1423
+ await new Promise((r) => setTimeout(r, VERVOLG_COOLDOWN_MS));
1424
+ const { error } = await db.rpc("wake_print_queue", { _min_interval_ms: 1000 });
1425
+ if (error)
1426
+ console.warn(`[printwachtrij] vervolgronde wekken mislukt: ${error.message}`);
1427
+ }
899
1428
  /** Eén ronde: vastgelopen opdrachten opruimen, afdrukken en statussen bijwerken. */
900
1429
  export async function verwerkWachtrij(db, max = 5) {
901
- // Eerst het vangnet: dit draait ook als er niets te claimen valt.
1430
+ const leeg = {
1431
+ opgepakt: 0,
1432
+ verstuurd: 0,
1433
+ mislukt: 0,
1434
+ wachtend: 0,
1435
+ bijgewerkt: 0,
1436
+ };
1437
+ // Single-flight: hooguit één ronde tegelijk. Het slot verloopt vanzelf, dus
1438
+ // een gecrashte ronde blokkeert de wachtrij nooit langer dan de leasetijd.
1439
+ const houder = crypto.randomUUID();
1440
+ const { data: slot, error: slotFout } = await db.rpc("claim_print_queue_lease", {
1441
+ _naam: LEASE_NAAM,
1442
+ _houder: houder,
1443
+ _seconden: LEASE_SECONDEN,
1444
+ });
1445
+ if (slotFout)
1446
+ throw new Error(`Printronde kon niet starten: ${slotFout.message}`);
1447
+ if (slot !== true)
1448
+ return { ...leeg, overgeslagen: "Er draait al een printronde." };
1449
+ let vervolgNodig = false;
902
1450
  try {
903
- await vervalControle(db);
1451
+ // Eerst het vangnet: dit draait ook als er niets te claimen valt.
1452
+ try {
1453
+ await vervalControle(db);
1454
+ }
1455
+ catch (e) {
1456
+ console.warn(`[printwachtrij] vervalcontrole mislukt: ${e instanceof Error ? e.message : "onbekend"}`);
1457
+ }
1458
+ // PDF-opdrachten hebben een eigen claimroute. Daardoor kunnen oudere
1459
+ // gepubliceerde moduleversies, die een PDF-printer nog als PrintNode-printer
1460
+ // behandelen, deze opdrachten niet meer vóór de actuele runner oppakken.
1461
+ const { data: pdfData, error: pdfError } = await db.rpc("claim_pdf_print_jobs", { _max: max });
1462
+ if (pdfError)
1463
+ throw new Error(pdfError.message);
1464
+ const pdfJobs = arr(pdfData);
1465
+ const resterend = Math.max(0, max - pdfJobs.length);
1466
+ let gewoneJobs = [];
1467
+ if (resterend > 0) {
1468
+ const { data, error } = await db.rpc("claim_cloud_print_jobs", { _max: resterend });
1469
+ if (error)
1470
+ throw new Error(error.message);
1471
+ gewoneJobs = arr(data);
1472
+ }
1473
+ // Begrensd parallel: meerdere pakbonnen van één order lopen gelijktijdig,
1474
+ // maar één mislukte opdracht annuleert de andere nooit.
1475
+ const jobs = [...pdfJobs, ...gewoneJobs];
1476
+ const uitkomsten = await metPool(jobs, JOB_PARALLEL, async (job) => {
1477
+ try {
1478
+ return await verwerkJob(db, job);
1479
+ }
1480
+ catch (e) {
1481
+ console.error(`[printwachtrij] opdracht ${job.id} viel onverwacht uit: ${e instanceof Error ? e.message : "onbekend"}`);
1482
+ return "mislukt";
1483
+ }
1484
+ });
1485
+ const resultaat = {
1486
+ opgepakt: jobs.length,
1487
+ verstuurd: uitkomsten.filter((u) => u === "verstuurd").length,
1488
+ wachtend: uitkomsten.filter((u) => u === "wachtend").length,
1489
+ mislukt: uitkomsten.filter((u) => u === "mislukt").length,
1490
+ // Historische statuscontrole komt ná het nieuwe werk.
1491
+ bijgewerkt: await synchroniseerStatussen(db),
1492
+ };
1493
+ // Alleen wanneer de batchlimiet vol zat kan er nog werk klaarstaan.
1494
+ vervolgNodig = jobs.length >= max;
1495
+ return resultaat;
904
1496
  }
905
- catch (e) {
906
- console.warn(`[printwachtrij] vervalcontrole mislukt: ${e instanceof Error ? e.message : "onbekend"}`);
907
- }
908
- // PDF-opdrachten hebben een eigen claimroute. Daardoor kunnen oudere
909
- // gepubliceerde moduleversies, die een PDF-printer nog als PrintNode-printer
910
- // behandelen, deze opdrachten niet meer vóór de actuele runner oppakken.
911
- const { data: pdfData, error: pdfError } = await db.rpc("claim_pdf_print_jobs", { _max: max });
912
- if (pdfError)
913
- throw new Error(pdfError.message);
914
- const pdfJobs = arr(pdfData);
915
- const resterend = Math.max(0, max - pdfJobs.length);
916
- let gewoneJobs = [];
917
- if (resterend > 0) {
918
- const { data, error } = await db.rpc("claim_cloud_print_jobs", { _max: resterend });
1497
+ finally {
1498
+ const { error } = await db.rpc("release_print_queue_lease", {
1499
+ _naam: LEASE_NAAM,
1500
+ _houder: houder,
1501
+ });
919
1502
  if (error)
920
- throw new Error(error.message);
921
- gewoneJobs = arr(data);
922
- }
923
- const jobs = [...pdfJobs, ...gewoneJobs];
924
- let verstuurd = 0;
925
- let mislukteJobs = 0;
926
- for (const job of jobs) {
927
- const uitkomst = await verwerkJob(db, job);
928
- if (uitkomst === "verstuurd")
929
- verstuurd++;
930
- else
931
- mislukteJobs++;
1503
+ console.warn(`[printwachtrij] slot vrijgeven mislukt: ${error.message}`);
1504
+ if (vervolgNodig) {
1505
+ try {
1506
+ await wekVervolgRonde(db);
1507
+ }
1508
+ catch (fout) {
1509
+ const melding = fout instanceof Error ? fout.message : "onbekend";
1510
+ console.warn(`[printwachtrij] vervolgronde mislukt: ${melding}`);
1511
+ }
1512
+ }
932
1513
  }
933
- return {
934
- opgepakt: jobs.length,
935
- verstuurd,
936
- mislukt: mislukteJobs,
937
- bijgewerkt: await synchroniseerStatussen(db),
938
- };
939
1514
  }
940
1515
  /**
941
1516
  * Dezelfde documentkeuze als de printwachtrij maakt, maar zonder printer en
@@ -944,16 +1519,18 @@ export async function verwerkWachtrij(db, max = 5) {
944
1519
  */
945
1520
  export async function bepaalDocumentKeuze(db, job) {
946
1521
  let bron = await zoekBronregel(db, job);
947
- if (await isFloridayLayoutKeuze(db, job)) {
1522
+ // Engine-opdrachten houden hun eigen layout_id; nooit vervangen door een
1523
+ // Floriday-PDF-keuze.
1524
+ if (!isEngineOpdracht(job) && (await isFloridayLayoutKeuze(db, job))) {
948
1525
  bron = "extern_pdf";
949
1526
  job.layout_id = null;
950
1527
  }
951
- if (bron === "eigen_layout") {
1528
+ if (bron === "eigen_layout" || isEngineOpdracht(job)) {
952
1529
  job.pdf_base64 = null;
953
1530
  job.pdf_url = null;
954
1531
  }
955
- const klaar = await kantEnKlarePdf(db, job);
956
- if (!klaar && bron === "extern_pdf") {
1532
+ const klaar = isEngineOpdracht(job) ? null : await kantEnKlarePdf(db, job);
1533
+ if (!klaar && bron === "extern_pdf" && !isEngineOpdracht(job)) {
957
1534
  throw new GeenDocumentFout("Voor dit document is ingesteld dat het aangeleverde document wordt " +
958
1535
  "gebruikt, maar er is geen PDF meegestuurd.");
959
1536
  }