@flowselections/logistiek-engine 1.0.26 → 1.0.28

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 (97) hide show
  1. package/README.md +24 -24
  2. package/dist-lib/components/AfmetingenBronnenCard.d.ts +2 -0
  3. package/dist-lib/components/AfmetingenBronnenCard.d.ts.map +1 -0
  4. package/dist-lib/components/AfmetingenBronnenCard.js +46 -0
  5. package/dist-lib/components/BufferDebugCard.d.ts.map +1 -1
  6. package/dist-lib/components/BufferDebugCard.js +6 -1
  7. package/dist-lib/components/ConsistentieCard.d.ts.map +1 -1
  8. package/dist-lib/components/ConsistentieCard.js +1 -1
  9. package/dist-lib/components/KarplanStatusCard.d.ts +2 -0
  10. package/dist-lib/components/KarplanStatusCard.d.ts.map +1 -0
  11. package/dist-lib/components/KarplanStatusCard.js +31 -0
  12. package/dist-lib/components/RegiePage.d.ts.map +1 -1
  13. package/dist-lib/components/RegiePage.js +3 -1
  14. package/dist-lib/components/RuleEnginePage.d.ts.map +1 -1
  15. package/dist-lib/components/RuleEnginePage.js +7 -4
  16. package/dist-lib/components/rule-engine/FloridayConnectionBanner.d.ts +2 -0
  17. package/dist-lib/components/rule-engine/FloridayConnectionBanner.d.ts.map +1 -0
  18. package/dist-lib/components/rule-engine/FloridayConnectionBanner.js +41 -0
  19. package/dist-lib/components/rule-engine/RuleFieldInput.d.ts.map +1 -1
  20. package/dist-lib/components/rule-engine/RuleFieldInput.js +9 -0
  21. package/dist-lib/hooks/useRecalcJob.d.ts.map +1 -1
  22. package/dist-lib/hooks/useRecalcJob.js +26 -1
  23. package/dist-lib/hooks/useScannersPrinters.d.ts +11 -0
  24. package/dist-lib/hooks/useScannersPrinters.d.ts.map +1 -0
  25. package/dist-lib/hooks/useScannersPrinters.js +65 -0
  26. package/dist-lib/index.d.ts +2 -0
  27. package/dist-lib/index.d.ts.map +1 -1
  28. package/dist-lib/index.js +2 -0
  29. package/dist-lib/integrations/supabase/auth-middleware.d.ts +2063 -78
  30. package/dist-lib/integrations/supabase/auth-middleware.d.ts.map +1 -1
  31. package/dist-lib/integrations/supabase/client.d.ts +20293 -0
  32. package/dist-lib/integrations/supabase/client.d.ts.map +1 -0
  33. package/dist-lib/integrations/supabase/client.js +13 -0
  34. package/dist-lib/integrations/supabase/client.server.d.ts +2063 -78
  35. package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -1
  36. package/dist-lib/integrations/supabase/client.server.js +1 -1
  37. package/dist-lib/integrations/supabase/moduleClient.d.ts +2063 -78
  38. package/dist-lib/integrations/supabase/moduleClient.d.ts.map +1 -1
  39. package/dist-lib/integrations/supabase/types.d.ts +2097 -68
  40. package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
  41. package/dist-lib/integrations/supabase/types.js +1 -0
  42. package/dist-lib/lib/attachAuthToken.d.ts +2 -0
  43. package/dist-lib/lib/attachAuthToken.d.ts.map +1 -0
  44. package/dist-lib/lib/attachAuthToken.js +50 -0
  45. package/dist-lib/lib/bufferMatching.d.ts.map +1 -1
  46. package/dist-lib/lib/bufferMatching.js +1 -0
  47. package/dist-lib/lib/cartPlanRequests.server.d.ts +13 -0
  48. package/dist-lib/lib/cartPlanRequests.server.d.ts.map +1 -0
  49. package/dist-lib/lib/cartPlanRequests.server.js +168 -0
  50. package/dist-lib/lib/controlScan.server.d.ts +12 -0
  51. package/dist-lib/lib/controlScan.server.d.ts.map +1 -0
  52. package/dist-lib/lib/controlScan.server.js +197 -0
  53. package/dist-lib/lib/dimensionData.server.d.ts +37 -0
  54. package/dist-lib/lib/dimensionData.server.d.ts.map +1 -0
  55. package/dist-lib/lib/dimensionData.server.js +167 -0
  56. package/dist-lib/lib/dimensionPlanner.d.ts +95 -0
  57. package/dist-lib/lib/dimensionPlanner.d.ts.map +1 -0
  58. package/dist-lib/lib/dimensionPlanner.js +248 -0
  59. package/dist-lib/lib/floridayDocuments.functions.d.ts +6240 -285
  60. package/dist-lib/lib/floridayDocuments.functions.d.ts.map +1 -1
  61. package/dist-lib/lib/floridayDocuments.functions.js +4 -3
  62. package/dist-lib/lib/floridayPush.functions.d.ts +2064 -79
  63. package/dist-lib/lib/floridayPush.functions.d.ts.map +1 -1
  64. package/dist-lib/lib/floridayPush.functions.js +2 -1
  65. package/dist-lib/lib/handlers/logistics-tick.server.d.ts.map +1 -1
  66. package/dist-lib/lib/handlers/logistics-tick.server.js +57 -0
  67. package/dist-lib/lib/loadCarrierPlanner.d.ts +3 -0
  68. package/dist-lib/lib/loadCarrierPlanner.d.ts.map +1 -1
  69. package/dist-lib/lib/loadCarrierPlanning.functions.d.ts +10302 -377
  70. package/dist-lib/lib/loadCarrierPlanning.functions.d.ts.map +1 -1
  71. package/dist-lib/lib/loadCarrierPlanning.functions.js +6 -5
  72. package/dist-lib/lib/loadCarrierPlanning.server.d.ts +46 -0
  73. package/dist-lib/lib/loadCarrierPlanning.server.d.ts.map +1 -1
  74. package/dist-lib/lib/loadCarrierPlanning.server.js +673 -31
  75. package/dist-lib/lib/logisticsPlanner.d.ts.map +1 -1
  76. package/dist-lib/lib/logisticsPlanner.js +24 -9
  77. package/dist-lib/lib/logisticsRecalcJob.functions.d.ts +4163 -193
  78. package/dist-lib/lib/logisticsRecalcJob.functions.d.ts.map +1 -1
  79. package/dist-lib/lib/logisticsRecalcJob.functions.js +3 -2
  80. package/dist-lib/lib/logisticsRecalcJob.server.d.ts.map +1 -1
  81. package/dist-lib/lib/logisticsRecalcJob.server.js +1 -0
  82. package/dist-lib/lib/logisticsRecalculate.functions.d.ts +2064 -79
  83. package/dist-lib/lib/logisticsRecalculate.functions.d.ts.map +1 -1
  84. package/dist-lib/lib/logisticsRecalculate.functions.js +3 -1
  85. package/dist-lib/lib/modulePrompts.d.ts.map +1 -1
  86. package/dist-lib/lib/modulePrompts.js +2567 -1563
  87. package/dist-lib/lib/planQueue.server.d.ts.map +1 -1
  88. package/dist-lib/lib/planQueue.server.js +6 -0
  89. package/dist-lib/lib/readableServerErrors.d.ts +2 -0
  90. package/dist-lib/lib/readableServerErrors.d.ts.map +1 -0
  91. package/dist-lib/lib/readableServerErrors.js +36 -0
  92. package/dist-lib/lib/ruleEngineSchema.d.ts +1 -1
  93. package/dist-lib/lib/ruleEngineSchema.d.ts.map +1 -1
  94. package/dist-lib/lib/ruleEngineSchema.js +83 -51
  95. package/dist-lib/lib/ruleEngineWizard.d.ts.map +1 -1
  96. package/dist-lib/lib/ruleEngineWizard.js +6 -6
  97. package/package.json +1 -1
@@ -7,6 +7,8 @@
7
7
  // ============================================================================
8
8
  import { planLoadUnits, LOAD_CARRIER_PLANNER_VERSION, } from "./loadCarrierPlanner";
9
9
  import { fallbackCarrierProfile } from "./loadCarrierTypes";
10
+ import { planByDimensions, DIMENSION_PLANNER_VERSION } from "./dimensionPlanner";
11
+ import { buildDimensionItems, carrierDimensions } from "./dimensionData.server";
10
12
  // ---------------------------------------------------------------------------
11
13
  // Buffer
12
14
  // ---------------------------------------------------------------------------
@@ -180,6 +182,16 @@ export async function expandOrderItemsForPlanner(sb, rows, options = {}) {
180
182
  .map((r) => r.product_id)
181
183
  .filter((id) => typeof id === "string" && UUID_RE.test(id));
182
184
  let groupSet = new Set();
185
+ // Groepsregels dragen niet altijd een product_id. Zonder koppeling wordt de
186
+ // groep op naam opgezocht, zodat de leden en hun afmetingen alsnog meetellen.
187
+ const groupByName = new Map();
188
+ {
189
+ const { data: allGroups } = await sb.from("product_groups").select("id, name");
190
+ for (const g of Array.isArray(allGroups) ? allGroups : []) {
191
+ if (g?.name)
192
+ groupByName.set(String(g.name).trim().toLowerCase(), String(g.id));
193
+ }
194
+ }
183
195
  if (groupIds.length > 0) {
184
196
  const { data: groups } = await sb.from("product_groups").select("id").in("id", groupIds);
185
197
  groupSet = new Set((groups ?? []).map((g) => g.id));
@@ -188,8 +200,14 @@ export async function expandOrderItemsForPlanner(sb, rows, options = {}) {
188
200
  const packing = row.order_id ? options.packingByOrder?.get(String(row.order_id)) ?? null : null;
189
201
  const { belading: base, source } = resolveBelading(row, packing);
190
202
  const plants = Math.max(0, Number(row.quantity ?? 0));
191
- const isGroup = (row.product_id && groupSet.has(row.product_id)) ||
192
- String(row.product_name ?? "").startsWith("[Groep]");
203
+ const groupNameKey = String(row.product_name ?? "")
204
+ .replace(/^\[Groep\]\s*/i, "")
205
+ .trim()
206
+ .toLowerCase();
207
+ const groupId = row.product_id && groupSet.has(row.product_id)
208
+ ? String(row.product_id)
209
+ : groupByName.get(groupNameKey) ?? null;
210
+ const isGroup = groupId != null || String(row.product_name ?? "").startsWith("[Groep]");
193
211
  if (!isGroup) {
194
212
  options.sources?.set(String(row.id), source);
195
213
  out.push({
@@ -205,11 +223,13 @@ export async function expandOrderItemsForPlanner(sb, rows, options = {}) {
205
223
  });
206
224
  continue;
207
225
  }
208
- const { data: members } = await sb
209
- .from("product_group_items")
210
- .select("trade_item_id, quantity, sort_order")
211
- .eq("product_group_id", row.product_id)
212
- .order("sort_order", { ascending: true });
226
+ const { data: members } = groupId
227
+ ? await sb
228
+ .from("product_group_items")
229
+ .select("trade_item_id, quantity, sort_order")
230
+ .eq("product_group_id", groupId)
231
+ .order("sort_order", { ascending: true })
232
+ : { data: [] };
213
233
  const memberList = Array.isArray(members) ? members : [];
214
234
  if (memberList.length === 0) {
215
235
  options.sources?.set(String(row.id), source);
@@ -328,6 +348,7 @@ export async function loadBeladingRules(sb) {
328
348
  maxLayers: k.fill_layers_as_much_as_fits ? null : numOrNull(k.max_layers),
329
349
  maxBoxes: k.fill_boxes_as_much_as_fits ? null : numOrNull(k.max_boxes_per_pallet),
330
350
  optimalFill: k.optimal_fill !== false,
351
+ layoutMethod: k.cart_layout_method === "afmetingen" ? "afmetingen" : "belading",
331
352
  };
332
353
  }
333
354
  /** Past de grenzen uit de werkwijze toe op het capaciteitsprofiel van een ladingdrager. */
@@ -414,19 +435,246 @@ export function planByFloridayPacking(inputs, profile, packing) {
414
435
  return carts;
415
436
  }
416
437
  // ---------------------------------------------------------------------------
438
+ // Indeling op afmetingen omzetten naar het bestaande opslagmodel
439
+ // ---------------------------------------------------------------------------
440
+ /** Herkomst van de laagindeling, zoals vastgelegd in order_cart_layer_positions. */
441
+ export const LAYER_POSITION_SOURCE = "afmetingen-planken";
442
+ /** cm -> mm; de weergavemodules rekenen in millimeters. */
443
+ const mm = (cm) => Number((cm * 10).toFixed(1));
444
+ /**
445
+ * Zet de laagindeling van één kar om naar rijen voor
446
+ * public.order_cart_layer_positions. (0,0) is linksboven van de laag,
447
+ * x loopt naar rechts, y naar beneden, alles in millimeters.
448
+ */
449
+ export function layerPositionRows(cartId, cart) {
450
+ const rows = [];
451
+ for (const layer of cart.layers) {
452
+ for (const pos of layer.positions) {
453
+ const rawItemId = pos.order_item_id;
454
+ // Samengestelde sleutels van groepsleden bestaan niet als orderregel.
455
+ const orderItemId = rawItemId.includes("::") ? rawItemId.split("::")[0] : rawItemId;
456
+ const isUuid = /^[0-9a-f-]{36}$/i.test(orderItemId);
457
+ rows.push({
458
+ cart_id: cartId,
459
+ layer: layer.layer_index,
460
+ keg_id: pos.keg_id,
461
+ x: mm(pos.x),
462
+ y: mm(pos.y),
463
+ width: mm(pos.width),
464
+ height: mm(pos.height),
465
+ rotation: pos.rotation,
466
+ layer_width: mm(layer.layer_length_cm),
467
+ layer_height: mm(layer.layer_width_cm),
468
+ product_id: pos.product_id,
469
+ order_item_id: isUuid ? orderItemId : null,
470
+ label: pos.label,
471
+ source: LAYER_POSITION_SOURCE,
472
+ algorithm_version: DIMENSION_PLANNER_VERSION,
473
+ });
474
+ }
475
+ }
476
+ return rows;
477
+ }
478
+ function validateDimensionCart(cart) {
479
+ const itemCrates = cart.layers.reduce((sum, layer) => sum + layer.placements.reduce((layerSum, placement) => layerSum + placement.crates, 0), 0);
480
+ const positions = cart.layers.reduce((sum, layer) => sum + layer.positions.length, 0);
481
+ if (itemCrates !== positions) {
482
+ return `Kar ${cart.cart_number} bevat ${itemCrates} fusten, maar heeft ${positions} fysieke posities.`;
483
+ }
484
+ for (const layer of cart.layers) {
485
+ for (let index = 0; index < layer.positions.length; index++) {
486
+ const position = layer.positions[index];
487
+ if (position.x < 0 ||
488
+ position.y < 0 ||
489
+ position.x + position.width > layer.layer_length_cm + 1e-9 ||
490
+ position.y + position.height > layer.layer_width_cm + 1e-9) {
491
+ return `Fust ${position.keg_id} van kar ${cart.cart_number} valt buiten de bruikbare afmetingen.`;
492
+ }
493
+ for (let otherIndex = index + 1; otherIndex < layer.positions.length; otherIndex++) {
494
+ const other = layer.positions[otherIndex];
495
+ const overlaps = position.x < other.x + other.width - 1e-9 &&
496
+ position.x + position.width > other.x + 1e-9 &&
497
+ position.y < other.y + other.height - 1e-9 &&
498
+ position.y + position.height > other.y + 1e-9;
499
+ if (overlaps) {
500
+ return `Fusten ${position.keg_id} en ${other.keg_id} overlappen op kar ${cart.cart_number}, laag ${layer.layer_index}.`;
501
+ }
502
+ }
503
+ }
504
+ }
505
+ return null;
506
+ }
507
+ export function dimensionCartsToPlannedUnits(carts) {
508
+ return carts.map((cart) => {
509
+ const layerCount = cart.layers.length || 1;
510
+ const items = [];
511
+ for (const layer of cart.layers) {
512
+ const cratesInLayer = layer.placements.reduce((s, p) => s + p.crates, 0) || 1;
513
+ for (const p of layer.placements) {
514
+ const fraction = p.crates / cratesInLayer;
515
+ items.push({
516
+ order_item_id: p.order_item_id,
517
+ order_id: p.order_id,
518
+ product_id: p.product_id,
519
+ product_name: p.product_name,
520
+ crates: p.crates,
521
+ plants: p.plants,
522
+ boxes: p.crates,
523
+ weight_kg: p.weight_kg,
524
+ plants_per_crate: p.plants_per_crate,
525
+ crates_per_layer: cratesInLayer,
526
+ layers_per_cart: layerCount,
527
+ layer_fraction: Number(fraction.toFixed(3)),
528
+ layer_share_pct: Number(((100 * fraction) / layerCount).toFixed(2)),
529
+ layer_index: layer.layer_index,
530
+ crates_in_layer: p.crates,
531
+ });
532
+ }
533
+ }
534
+ return {
535
+ cart_number: cart.cart_number,
536
+ carrier_type: cart.carrier_code,
537
+ carrier_type_id: cart.carrier_id,
538
+ source: "auto",
539
+ is_complete: cart.is_complete,
540
+ occupancy_pct: cart.occupancy_pct,
541
+ total_layers_used: layerCount,
542
+ max_layers: layerCount,
543
+ weight_kg: cart.weight_kg,
544
+ items,
545
+ };
546
+ });
547
+ }
548
+ // ---------------------------------------------------------------------------
417
549
  // Herplannen van een buffer
418
550
  // ---------------------------------------------------------------------------
419
551
  /** Maximaal aantal ladingdragers dat één orderbuffer mag opleveren. */
420
552
  const MAX_CARTS_PER_BUFFER = 500;
553
+ /**
554
+ * Bepaalt de definitieve, persistente identiteit van iedere fysieke kar.
555
+ * Bij een bufferorder met meerdere orders zijn alle karren gedeelde
556
+ * bufferkarren: geen eigen order, doorlopend genummerd binnen de buffer.
557
+ * Bij één order per buffer krijgt die order exact 1..N.
558
+ */
559
+ export function assignPersistentCartIdentities(carts, itemToOrder, identityOptions = {}) {
560
+ if (identityOptions.shared) {
561
+ const start = Math.max(1, identityOptions.startNumber ?? 1);
562
+ const total = (identityOptions.existingCount ?? 0) + carts.length;
563
+ return carts.map((_, index) => ({
564
+ ownerOrderId: null,
565
+ cartNumber: start + index,
566
+ totalCarts: total,
567
+ }));
568
+ }
569
+ const owners = carts.map((cart) => {
570
+ const orderIds = new Set();
571
+ for (const item of cart.items) {
572
+ const orderId = item.order_id ?? itemToOrder.get(item.order_item_id);
573
+ if (orderId)
574
+ orderIds.add(orderId);
575
+ }
576
+ return orderIds.size === 1 ? [...orderIds][0] : null;
577
+ });
578
+ const totals = new Map();
579
+ for (const owner of owners) {
580
+ if (owner)
581
+ totals.set(owner, (totals.get(owner) ?? 0) + 1);
582
+ }
583
+ const sequences = new Map();
584
+ let sharedSequence = 0;
585
+ return owners.map((ownerOrderId) => {
586
+ if (!ownerOrderId) {
587
+ sharedSequence += 1;
588
+ return { ownerOrderId: null, cartNumber: -sharedSequence, totalCarts: null };
589
+ }
590
+ const cartNumber = (sequences.get(ownerOrderId) ?? 0) + 1;
591
+ sequences.set(ownerOrderId, cartNumber);
592
+ return { ownerOrderId, cartNumber, totalCarts: totals.get(ownerOrderId) ?? null };
593
+ });
594
+ }
595
+ function assertPersistentCartIdentities(identities, orderIds) {
596
+ for (const orderId of orderIds) {
597
+ const own = identities.filter((identity) => identity.ownerOrderId === orderId);
598
+ if (own.length === 0)
599
+ continue;
600
+ const numbers = own.map((identity) => identity.cartNumber).sort((a, b) => a - b);
601
+ const valid = numbers.every((number, index) => number === index + 1) &&
602
+ own.every((identity) => identity.totalCarts === own.length);
603
+ if (!valid) {
604
+ throw new Error(`Karrenindeling ongeldig voor order ${orderId}: verwacht vaste karnummers 1 t/m ${own.length}.`);
605
+ }
606
+ }
607
+ }
608
+ /**
609
+ * Leden van een buffer die nog helemaal geen fysieke kar hebben: geen eigen kar
610
+ * (order_carts.order_id) én geen gedeelde bufferkar waar een van hun orderregels
611
+ * op ligt. Zonder kar krijgt zo'n order ook geen pickdocument, dus dit mag nooit
612
+ * blijven staan.
613
+ */
614
+ export async function bufferOrdersWithoutCarts(sb, bufferId, orderIds) {
615
+ const ids = orderIds.map(String).filter(Boolean);
616
+ if (ids.length === 0)
617
+ return [];
618
+ const { data: carts, error: cartsError } = await sb
619
+ .from("order_carts")
620
+ .select("id, order_id")
621
+ .or(`buffer_id.eq.${bufferId},order_id.in.(${ids.join(",")})`);
622
+ if (cartsError) {
623
+ throw new Error(`Karren van de buffer konden niet worden gelezen: ${cartsError.message}`);
624
+ }
625
+ const cartRows = Array.isArray(carts) ? carts : [];
626
+ const withCarts = new Set(cartRows.map((c) => (c.order_id ? String(c.order_id) : "")).filter(Boolean));
627
+ // Gedeelde karren (zonder eigen order) tellen mee voor elk lid waarvan er een
628
+ // orderregel op ligt.
629
+ const sharedIds = cartRows.filter((c) => !c.order_id).map((c) => String(c.id));
630
+ if (sharedIds.length > 0) {
631
+ const { data: items, error: itemsError } = await sb
632
+ .from("order_cart_items")
633
+ .select("order_item_id, order_items(order_id)")
634
+ .in("cart_id", sharedIds);
635
+ if (itemsError) {
636
+ throw new Error(`Inhoud van gedeelde karren kon niet worden gelezen: ${itemsError.message}`);
637
+ }
638
+ for (const row of Array.isArray(items) ? items : []) {
639
+ const orderId = row?.order_items?.order_id;
640
+ if (orderId)
641
+ withCarts.add(String(orderId));
642
+ }
643
+ }
644
+ return ids.filter((id) => !withCarts.has(id));
645
+ }
421
646
  export async function replanBufferInternal(sb, bufferId, options = {}) {
422
647
  const warnings = [];
648
+ // Bevroren karren blijven staan; al het overige van de héle buffer wordt
649
+ // samen ingedeeld, zodat orders van de buffer op dezelfde karren komen.
650
+ const keepCartIds = (options.keepCartIds ?? []).map(String).filter(Boolean);
651
+ const isSupplement = keepCartIds.length > 0;
423
652
  const { data: orders } = await sb
424
653
  .from("orders")
425
- .select("id, customer_id, custom_fields, plan_locked_at")
654
+ .select("id, order_number, customer_id, custom_fields, plan_locked_at")
426
655
  .eq("buffer_id", bufferId)
427
656
  .is("deleted_at", null);
428
- const orderRows = Array.isArray(orders) ? orders : [];
657
+ const allOrderRows = Array.isArray(orders) ? orders : [];
658
+ const orderRows = allOrderRows;
429
659
  const orderIds = orderRows.map((o) => o.id);
660
+ // Binnen een bufferorder mogen orders en klanten altijd samen op een kar.
661
+ const isBufferOrder = orderRows.length > 1;
662
+ const orderRefs = orderRows.map((o) => String(o.order_number ?? "")).filter(Boolean);
663
+ const { data: releasedTasks } = orderRefs.length > 0
664
+ ? await sb
665
+ .from("logistics_tasks")
666
+ .select("order_ref")
667
+ .eq("task_type", "pick.execute")
668
+ .eq("shadow", false)
669
+ .not("status", "in", "(done,cancelled,superseded,skipped)")
670
+ .lte("planned_start", new Date().toISOString())
671
+ .in("order_ref", orderRefs)
672
+ : { data: [] };
673
+ // Alleen een expliciet slot op de order blokkeert een herplanning hard.
674
+ // Vrijgave alleen is niet genoeg: zolang er nog niets is geprint, gescand of
675
+ // gecontroleerd mag een gewijzigde belading nog doorwerken. De bevriezing van
676
+ // karidentiteiten hieronder bewaakt de fysieke werkelijkheid.
677
+ void releasedTasks;
430
678
  if (orderRows.some((o) => o.plan_locked_at)) {
431
679
  return {
432
680
  ok: true,
@@ -439,6 +687,66 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
439
687
  skipped: "locked",
440
688
  };
441
689
  }
690
+ // Een eenmaal geprinte, gescande of gecontroleerde fysieke kar heeft een
691
+ // blijvende identiteit. Ook vóór de formele vrijgavetijd mag een replan die
692
+ // id, het nummer of total_carts daarom niet meer vervangen.
693
+ if (!isSupplement) {
694
+ const { data: existingCartRows, error: existingCartError } = await sb
695
+ .from("order_carts")
696
+ .select("id, order_id, cart_number, total_carts")
697
+ .or(`buffer_id.eq.${bufferId},order_id.in.(${orderIds.join(",")})`);
698
+ if (existingCartError) {
699
+ throw new Error(`Bestaande karidentiteiten konden niet worden gecontroleerd: ${existingCartError.message}`);
700
+ }
701
+ const existingCartIds = (Array.isArray(existingCartRows) ? existingCartRows : [])
702
+ .map((cart) => String(cart.id ?? ""))
703
+ .filter(Boolean);
704
+ if (existingCartIds.length > 0) {
705
+ const [controlResult, barcodeResult, printResult] = await Promise.all([
706
+ sb.from("cart_control_items").select("cart_number_id").in("cart_number_id", existingCartIds),
707
+ sb.from("pakbon_barcode_inhoud").select("cart_id").in("cart_id", existingCartIds),
708
+ sb.from("print_jobs").select("id, payload").in("payload->>cart_id", existingCartIds),
709
+ ]);
710
+ const lockCheckError = controlResult.error ?? barcodeResult.error ?? printResult.error;
711
+ if (lockCheckError) {
712
+ throw new Error(`Karvergrendeling kon niet veilig worden gecontroleerd: ${lockCheckError.message}`);
713
+ }
714
+ const frozen = new Set();
715
+ for (const r of controlResult.data ?? []) {
716
+ const id = r?.cart_number_id;
717
+ if (id)
718
+ frozen.add(String(id));
719
+ }
720
+ for (const r of barcodeResult.data ?? []) {
721
+ const id = r?.cart_id;
722
+ if (id)
723
+ frozen.add(String(id));
724
+ }
725
+ for (const r of printResult.data ?? []) {
726
+ const id = r?.payload?.cart_id;
727
+ if (id)
728
+ frozen.add(String(id));
729
+ }
730
+ const frozenIds = existingCartIds.filter((id) => frozen.has(id));
731
+ if (frozenIds.length > 0) {
732
+ // Geprinte, gescande of gecontroleerde karren blijven exact zoals ze zijn.
733
+ // Al het overige van de buffer wordt in één ronde samen ingedeeld, zodat
734
+ // de resterende fusten van alle orders op dezelfde karren komen.
735
+ const supplement = await replanBufferInternal(sb, bufferId, {
736
+ ...options,
737
+ keepCartIds: frozenIds,
738
+ });
739
+ return {
740
+ ...supplement,
741
+ warnings: [
742
+ ...(supplement.warnings ?? []),
743
+ `${frozenIds.length} reeds geprinte of gecontroleerde kar(ren) zijn ongewijzigd gebleven; ` +
744
+ "de overige fusten van de buffer zijn samen opnieuw ingedeeld.",
745
+ ],
746
+ };
747
+ }
748
+ }
749
+ }
442
750
  const idList = orderIds.length > 0 ? orderIds : ["00000000-0000-0000-0000-000000000000"];
443
751
  const { data: items } = await sb
444
752
  .from("order_items")
@@ -458,7 +766,63 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
458
766
  packingByOrder.set(String(o.id), belading);
459
767
  }
460
768
  const sources = new Map();
461
- const inputs = await expandOrderItemsForPlanner(sb, rawRows, { packingByOrder, sources });
769
+ let inputs = await expandOrderItemsForPlanner(sb, rawRows, { packingByOrder, sources });
770
+ // Wat al op een bevroren kar ligt, wordt niet opnieuw ingedeeld.
771
+ let frozenCartCount = 0;
772
+ let cartNumberStart = 1;
773
+ if (isSupplement) {
774
+ const { data: keptCarts, error: keptError } = await sb
775
+ .from("order_carts")
776
+ .select("id, cart_number")
777
+ .in("id", keepCartIds);
778
+ if (keptError) {
779
+ throw new Error(`Bevroren karren konden niet worden gelezen: ${keptError.message}`);
780
+ }
781
+ const keptRows = Array.isArray(keptCarts) ? keptCarts : [];
782
+ frozenCartCount = keptRows.length;
783
+ cartNumberStart =
784
+ keptRows.reduce((max, c) => Math.max(max, Number(c.cart_number) || 0), 0) + 1;
785
+ const { data: frozenItems, error: frozenItemsError } = await sb
786
+ .from("order_cart_items")
787
+ .select("order_item_id, product_id, crates, plants_per_crate")
788
+ .in("cart_id", keepCartIds);
789
+ if (frozenItemsError) {
790
+ throw new Error(`Inhoud van bevroren karren kon niet worden gelezen: ${frozenItemsError.message}`);
791
+ }
792
+ const doneKey = (itemId, productId) => `${String(itemId ?? "")}|${String(productId ?? "")}`;
793
+ const done = new Map();
794
+ for (const r of Array.isArray(frozenItems) ? frozenItems : []) {
795
+ const plants = Math.max(0, Math.round(Number(r.crates ?? 0))) *
796
+ Math.max(1, Math.round(Number(r.plants_per_crate ?? 1)));
797
+ const key = doneKey(r.order_item_id, r.product_id);
798
+ done.set(key, (done.get(key) ?? 0) + plants);
799
+ }
800
+ for (const i of inputs) {
801
+ const baseId = i.order_item_id.includes("::") ? i.order_item_id.split("::")[0] : i.order_item_id;
802
+ const key = doneKey(baseId, i.product_id);
803
+ const already = done.get(key) ?? 0;
804
+ if (already <= 0)
805
+ continue;
806
+ const used = Math.min(already, i.plants);
807
+ i.plants -= used;
808
+ done.set(key, already - used);
809
+ }
810
+ inputs = inputs.filter((i) => i.plants > 0);
811
+ if (inputs.length === 0) {
812
+ return {
813
+ ok: true,
814
+ bufferId,
815
+ orderIds,
816
+ cartsCreated: 0,
817
+ replacedManualCount: 0,
818
+ usedFloridayPacking: false,
819
+ warnings: [
820
+ "Alle fusten van deze buffer liggen al op geprinte of gecontroleerde karren; niets opnieuw ingedeeld.",
821
+ ],
822
+ skipped: "locked",
823
+ };
824
+ }
825
+ }
462
826
  const itemToOrder = new Map();
463
827
  for (const i of inputs)
464
828
  if (i.order_id)
@@ -487,7 +851,8 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
487
851
  const guardProfile = profileFor(profiles, options.carrierType ?? defaultProfile.code);
488
852
  const needsBelading = guardProfile.capacity_model !== "single" && guardProfile.capacity_model !== "none";
489
853
  const unknownItems = inputs.filter((i) => sources.get(i.order_item_id) === "geen" && i.plants > 0);
490
- if (needsBelading && unknownItems.length > 0 && !options.dryRun) {
854
+ const useDimensions = rules?.layoutMethod === "afmetingen";
855
+ if (!useDimensions && needsBelading && unknownItems.length > 0 && !options.dryRun) {
491
856
  const reason = `Belading onbekend voor ${unknownItems.length} orderregel(s): geen Floriday-verpakking, ` +
492
857
  `geen waarden op de orderregel en geen productinstelling. Er is niets ingedeeld.`;
493
858
  console.error(`[logistiek-engine] buffer ${bufferId} geblokkeerd: ${reason}`);
@@ -518,6 +883,9 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
518
883
  };
519
884
  }
520
885
  let plannedCarts = [];
886
+ // Laagindeling van de afmetingenmethode, per cart_number. Alleen gevuld als
887
+ // er daadwerkelijk op afmetingen is ingedeeld.
888
+ let dimensionPlan = null;
521
889
  let usedFloridayPacking = false;
522
890
  // Overname-pad: één order in de buffer én een volledige Floriday-verpakking.
523
891
  // Dan neemt de engine de verhouding van Floriday over en optimaliseert niet.
@@ -528,7 +896,86 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
528
896
  const soloPacking = solo ? beladingFromFloridayPacking(solo.custom_fields?.floriday_packing) : null;
529
897
  const takeoverProfile = profileFor(profiles, options.carrierType ?? defaultProfile.code);
530
898
  const takeoverAllowed = takeoverProfile.capacity_model === "layered" || takeoverProfile.capacity_model === "stacked";
531
- if (solo && soloPacking && takeoverAllowed) {
899
+ if (useDimensions) {
900
+ // Globale methode: indelen op basis van fysieke afmetingen.
901
+ const carrierRow = profiles.find((p) => p.code === (options.carrierType ?? defaultProfile.code)) ?? null;
902
+ const { carrier, missing: carrierMissing } = carrierDimensions(carrierRow);
903
+ const { items: dimItems, missing: itemMissing, derived: derivedHeights, } = await buildDimensionItems(sb, inputs, carrier, carrierRow?.layers_per_carrier ?? null);
904
+ warnings.push(...derivedHeights);
905
+ const missing = [...carrierMissing, ...itemMissing];
906
+ let planError = null;
907
+ if (missing.length === 0 && carrier) {
908
+ const result = planByDimensions(dimItems, carrier);
909
+ if (result.error)
910
+ planError = result.error;
911
+ else {
912
+ const invalidCart = result.carts.map(validateDimensionCart).find((message) => message != null) ?? null;
913
+ if (invalidCart)
914
+ planError = invalidCart;
915
+ else {
916
+ plannedCarts = dimensionCartsToPlannedUnits(result.carts);
917
+ dimensionPlan = new Map(result.carts.map((c) => [c.cart_number, c]));
918
+ warnings.push(...result.warnings);
919
+ }
920
+ }
921
+ }
922
+ if (missing.length > 0 || planError) {
923
+ const reason = planError ?? `Afmetingen onbekend: ${missing.map((m) => m.message).join(" ")}`;
924
+ if (options.dryRun) {
925
+ return {
926
+ ok: false,
927
+ bufferId,
928
+ orderIds,
929
+ cartsCreated: 0,
930
+ replacedManualCount: 0,
931
+ usedFloridayPacking: false,
932
+ warnings: [...warnings, reason],
933
+ skipped: "blocked",
934
+ blockedReason: reason,
935
+ beladingSources,
936
+ };
937
+ }
938
+ console.error(`[logistiek-engine] buffer ${bufferId} geblokkeerd (afmetingen): ${reason}`);
939
+ const { error: blockError } = await sb.rpc("logistics_plan_block", {
940
+ p_order_ids: orderIds,
941
+ p_reason: reason,
942
+ p_details: {
943
+ bufferId,
944
+ path: "afmetingen",
945
+ carrier: carrierRow?.code ?? null,
946
+ missing: missing.slice(0, 20),
947
+ },
948
+ });
949
+ if (blockError)
950
+ warnings.push(`Blokkade kon niet worden vastgelegd: ${blockError.message}`);
951
+ // Karren die met een andere (oudere) werkwijze zijn gemaakt mogen niet
952
+ // blijven staan: dan lijkt er een geldige indeling te zijn terwijl de
953
+ // afmetingen ontbreken. Karren van de afmetingenmethode blijven staan.
954
+ // Bij aanvullen blijven bestaande (bevroren) karren altijd staan.
955
+ if (!isSupplement) {
956
+ const { error: staleError } = await sb
957
+ .from("order_carts")
958
+ .delete()
959
+ .eq("buffer_id", bufferId)
960
+ .or("layout_method.is.null,layout_method.neq.afmetingen");
961
+ if (staleError)
962
+ warnings.push(`Oude karrenindeling kon niet worden opgeruimd: ${staleError.message}`);
963
+ }
964
+ return {
965
+ ok: false,
966
+ bufferId,
967
+ orderIds,
968
+ cartsCreated: 0,
969
+ replacedManualCount: 0,
970
+ usedFloridayPacking: false,
971
+ warnings: [...warnings, reason],
972
+ skipped: "blocked",
973
+ blockedReason: reason,
974
+ beladingSources,
975
+ };
976
+ }
977
+ }
978
+ else if (solo && soloPacking && takeoverAllowed) {
532
979
  usedFloridayPacking = true;
533
980
  plannedCarts = planByFloridayPacking(inputs, takeoverProfile, soloPacking);
534
981
  console.log(`[logistiek-engine] buffer ${bufferId}: overname Floriday-verpakking ` +
@@ -553,8 +1000,10 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
553
1000
  // Zonder optimale vulling wordt er niet bijgemengd in een laag.
554
1001
  maxBeladingenPerLayer: rules && !rules.optimalFill ? 1 : (settings?.max_beladingen_per_layer ?? 2),
555
1002
  neverMixProductIds: neverMix,
556
- maxOrders: rules?.maxOrders ?? null,
557
- maxCustomers: rules?.maxCustomers ?? settings?.max_customers_per_cart ?? null,
1003
+ // Binnen een bufferorder mag alles samen op een kar: de grenzen voor
1004
+ // aantal orders en klanten gelden dan niet.
1005
+ maxOrders: isBufferOrder ? null : rules?.maxOrders ?? null,
1006
+ maxCustomers: isBufferOrder ? null : rules?.maxCustomers ?? settings?.max_customers_per_cart ?? null,
558
1007
  oneCustomerPerLayer: Boolean(settings?.max_one_customer_per_layer),
559
1008
  ownCarrierFromLayers: settings?.own_cart_from_layers ?? null,
560
1009
  sortOrder: "none",
@@ -612,24 +1061,93 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
612
1061
  carts: plannedCarts,
613
1062
  };
614
1063
  }
615
- await sb.from("order_carts").delete().eq("buffer_id", bufferId);
1064
+ const persistentIdentities = assignPersistentCartIdentities(plannedCarts, itemToOrder, {
1065
+ shared: isBufferOrder || isSupplement,
1066
+ startNumber: cartNumberStart,
1067
+ existingCount: frozenCartCount,
1068
+ });
1069
+ assertPersistentCartIdentities(persistentIdentities, orderIds);
1070
+ // Houd de fysieke afmetingenindeling aan dezelfde definitieve karidentiteit.
1071
+ const dimensionCartsByPlanIndex = dimensionPlan
1072
+ ? plannedCarts.map((cart) => dimensionPlan?.get(cart.cart_number) ?? null)
1073
+ : null;
1074
+ if (dimensionPlan) {
1075
+ const normalizedDimensionPlan = new Map();
1076
+ plannedCarts.forEach((cart, index) => {
1077
+ const dimensionCart = dimensionCartsByPlanIndex?.[index] ?? null;
1078
+ const identity = persistentIdentities[index];
1079
+ if (!dimensionCart || !identity)
1080
+ return;
1081
+ cart.cart_number = identity.cartNumber;
1082
+ dimensionCart.cart_number = identity.cartNumber;
1083
+ normalizedDimensionPlan.set(index, dimensionCart);
1084
+ });
1085
+ dimensionPlan = normalizedDimensionPlan;
1086
+ }
1087
+ else {
1088
+ plannedCarts.forEach((cart, index) => {
1089
+ const identity = persistentIdentities[index];
1090
+ if (identity)
1091
+ cart.cart_number = identity.cartNumber;
1092
+ });
1093
+ }
1094
+ if (plannedCarts.length === 0 && orderIds.length > 0) {
1095
+ const reason = `Karrenindeling ongeldig voor order(s) ${orderRefs.join(", ")}: ` +
1096
+ "een vrij te geven order moet minimaal één fysieke kar hebben.";
1097
+ const { error: blockError } = await sb.rpc("logistics_plan_block", {
1098
+ p_order_ids: orderIds,
1099
+ p_reason: reason,
1100
+ p_details: { bufferId, path: useDimensions ? "afmetingen" : "belading", carts: 0 },
1101
+ });
1102
+ if (blockError)
1103
+ warnings.push(`Blokkade kon niet worden vastgelegd: ${blockError.message}`);
1104
+ return {
1105
+ ok: false,
1106
+ bufferId,
1107
+ orderIds,
1108
+ cartsCreated: 0,
1109
+ replacedManualCount,
1110
+ usedFloridayPacking,
1111
+ warnings: [...warnings, reason],
1112
+ skipped: "blocked",
1113
+ blockedReason: reason,
1114
+ beladingSources,
1115
+ };
1116
+ }
1117
+ // Opruimen op buffer EN op order. Verhuist een order naar een andere buffer,
1118
+ // dan blijven de karren van de oude buffer anders als weeskar staan en telt
1119
+ // Controle karren ze dubbel. Bevroren karren (geprint, gescand, gecontroleerd)
1120
+ // blijven altijd staan.
1121
+ const keepFilter = keepCartIds.length > 0 ? `(${keepCartIds.join(",")})` : null;
1122
+ const clearExistingCarts = async () => {
1123
+ const ids = orderIds.filter(Boolean);
1124
+ const del = () => {
1125
+ const q = sb.from("order_carts").delete();
1126
+ return keepFilter ? q.not("id", "in", keepFilter) : q;
1127
+ };
1128
+ await del().eq("buffer_id", bufferId);
1129
+ if (ids.length > 0) {
1130
+ await del().in("order_id", ids);
1131
+ }
1132
+ };
1133
+ await clearExistingCarts();
616
1134
  // In blokken wegschrijven: per ladingdrager een aparte insert is bij grote
617
1135
  // orders veel te traag.
618
1136
  let created = 0;
619
1137
  const CHUNK = 100;
620
1138
  for (let start = 0; start < plannedCarts.length; start += CHUNK) {
621
1139
  const chunk = plannedCarts.slice(start, start + CHUNK);
622
- const cartRows = chunk.map((cart) => {
623
- const orderIdsOnCart = new Set();
624
- for (const i of cart.items) {
625
- const oid = i.order_id ?? itemToOrder.get(i.order_item_id);
626
- if (oid)
627
- orderIdsOnCart.add(oid);
1140
+ const cartRows = chunk.map((cart, chunkIndex) => {
1141
+ const cartIndex = start + chunkIndex;
1142
+ const identity = persistentIdentities[cartIndex];
1143
+ if (!identity) {
1144
+ throw new Error(`Karrenindeling ongeldig: identiteit ontbreekt voor kar ${cartIndex + 1}.`);
628
1145
  }
629
1146
  return {
630
- order_id: orderIdsOnCart.size === 1 ? [...orderIdsOnCart][0] : null,
1147
+ order_id: identity.ownerOrderId,
631
1148
  buffer_id: bufferId,
632
- cart_number: cart.cart_number,
1149
+ cart_number: identity.cartNumber,
1150
+ total_carts: identity.totalCarts,
633
1151
  is_auto: true,
634
1152
  is_complete: cart.is_complete,
635
1153
  source: usedFloridayPacking ? "floriday" : "auto",
@@ -639,24 +1157,30 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
639
1157
  carrier_type_id: cart.carrier_type_id,
640
1158
  max_layers: cart.max_layers,
641
1159
  weight_kg: cart.weight_kg,
642
- algorithm_version: LOAD_CARRIER_PLANNER_VERSION,
1160
+ algorithm_version: useDimensions ? DIMENSION_PLANNER_VERSION : LOAD_CARRIER_PLANNER_VERSION,
1161
+ layout_method: useDimensions ? "afmetingen" : "belading",
643
1162
  };
644
1163
  });
645
1164
  const { data: insertedCarts, error } = await sb
646
1165
  .from("order_carts")
647
1166
  .insert(cartRows)
648
- .select("id, cart_number");
1167
+ .select("id, order_id, cart_number");
649
1168
  if (error || !Array.isArray(insertedCarts)) {
650
1169
  const message = `Ladingdragers konden niet worden opgeslagen: ${error?.message ?? "onbekende fout"}`;
651
1170
  console.error(`[logistiek-engine] buffer ${bufferId}: ${message}`);
652
- await sb.from("order_carts").delete().eq("buffer_id", bufferId);
1171
+ await clearExistingCarts();
653
1172
  throw new Error(message);
654
1173
  }
655
1174
  created += insertedCarts.length;
656
- const idByNumber = new Map(insertedCarts.map((c) => [Number(c.cart_number), String(c.id)]));
1175
+ const identityKey = (orderId, cartNumber) => `${orderId == null ? "shared" : String(orderId)}:${Number(cartNumber)}`;
1176
+ const idByIdentity = new Map(insertedCarts.map((c) => [identityKey(c.order_id, c.cart_number), String(c.id)]));
657
1177
  const itemRows = [];
658
- for (const cart of chunk) {
659
- const cartId = idByNumber.get(cart.cart_number);
1178
+ for (let chunkIndex = 0; chunkIndex < chunk.length; chunkIndex++) {
1179
+ const cart = chunk[chunkIndex];
1180
+ const identity = persistentIdentities[start + chunkIndex];
1181
+ const cartId = identity
1182
+ ? idByIdentity.get(identityKey(identity.ownerOrderId, identity.cartNumber))
1183
+ : undefined;
660
1184
  if (!cartId)
661
1185
  continue;
662
1186
  for (const i of cart.items) {
@@ -666,7 +1190,7 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
666
1190
  order_item_id: i.order_item_id.includes("::") ? i.order_item_id.split("::")[0] : i.order_item_id,
667
1191
  product_id: i.product_id,
668
1192
  product_name: i.product_name,
669
- crates: Math.max(1, Math.round(i.crates)),
1193
+ crates: Math.round(i.crates),
670
1194
  boxes: i.boxes ?? null,
671
1195
  weight_kg: i.weight_kg ?? null,
672
1196
  plants_per_crate: i.plants_per_crate,
@@ -674,6 +1198,8 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
674
1198
  layers_per_cart: i.layers_per_cart,
675
1199
  layer_fraction: i.layer_fraction,
676
1200
  layer_share_pct: i.layer_share_pct,
1201
+ layer_index: i.layer_index ?? null,
1202
+ crates_in_layer: i.crates_in_layer ?? null,
677
1203
  });
678
1204
  }
679
1205
  }
@@ -689,10 +1215,126 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
689
1215
  (itemError.details ? ` — ${itemError.details}` : "") +
690
1216
  (itemError.hint ? ` (${itemError.hint})` : "");
691
1217
  console.error(`[logistiek-engine] buffer ${bufferId}: ${message}`);
692
- await sb.from("order_carts").delete().eq("buffer_id", bufferId);
1218
+ await clearExistingCarts();
693
1219
  throw new Error(message);
694
1220
  }
695
1221
  }
1222
+ // Fysieke laagindeling per kar. Alleen de afmetingenmethode levert posities;
1223
+ // bij belading blijft de indeling leeg ("nog geen laagindeling aangeleverd").
1224
+ if (dimensionPlan) {
1225
+ for (let chunkIndex = 0; chunkIndex < chunk.length; chunkIndex++) {
1226
+ const cart = chunk[chunkIndex];
1227
+ const identity = persistentIdentities[start + chunkIndex];
1228
+ const cartId = identity
1229
+ ? idByIdentity.get(identityKey(identity.ownerOrderId, identity.cartNumber))
1230
+ : undefined;
1231
+ const dimCart = dimensionPlan.get(start + chunkIndex);
1232
+ if (!cartId || !dimCart)
1233
+ continue;
1234
+ const rows = layerPositionRows(cartId, dimCart);
1235
+ // Atomair vervangen: eerst de vorige indeling van deze kar weg.
1236
+ await sb.from("order_cart_layer_positions").delete().eq("cart_id", cartId);
1237
+ for (let s = 0; s < rows.length; s += 500) {
1238
+ const slice = rows.slice(s, s + 500);
1239
+ if (slice.length === 0)
1240
+ continue;
1241
+ const { error: posError } = await sb
1242
+ .from("order_cart_layer_positions")
1243
+ .upsert(slice, { onConflict: "cart_id,layer,keg_id" });
1244
+ if (posError) {
1245
+ // Een halve indeling is misleidend: de volledige buffer verwijderen
1246
+ // en de wachtrij laten falen, zodat niets als geldig wordt getoond.
1247
+ await clearExistingCarts();
1248
+ throw new Error(`De fysieke laagindeling van kar ${cart.cart_number} kon niet worden opgeslagen: ${posError.message}`);
1249
+ }
1250
+ }
1251
+ const expectedPositions = rows.length;
1252
+ const { count: storedPositions, error: countError } = await sb
1253
+ .from("order_cart_layer_positions")
1254
+ .select("id", { count: "exact", head: true })
1255
+ .eq("cart_id", cartId);
1256
+ if (countError || storedPositions !== expectedPositions) {
1257
+ await clearExistingCarts();
1258
+ throw new Error(`De fysieke indeling van kar ${cart.cart_number} is niet volledig opgeslagen: ` +
1259
+ `${expectedPositions} fusten verwacht, ${storedPositions ?? 0} opgeslagen.`);
1260
+ }
1261
+ }
1262
+ }
1263
+ }
1264
+ // Sluitcontrole: elke geschreven kar moet de actieve methode én het bijbehorende
1265
+ // versienummer dragen. Zo kan een oudere rekenwijze (bijvoorbeeld een nog niet
1266
+ // bijgewerkte publicatie) nooit stilzwijgend een indeling achterlaten.
1267
+ {
1268
+ const expectedMethod = useDimensions ? "afmetingen" : "belading";
1269
+ const expectedVersion = useDimensions ? DIMENSION_PLANNER_VERSION : LOAD_CARRIER_PLANNER_VERSION;
1270
+ const verifyQuery = sb
1271
+ .from("order_carts")
1272
+ .select("cart_number, layout_method, algorithm_version")
1273
+ .eq("buffer_id", bufferId);
1274
+ // Bevroren karren zijn met een eerdere ronde geschreven en blijven buiten
1275
+ // deze controle: hun identiteit mag nooit meer wijzigen.
1276
+ const { data: writtenCarts, error: verifyError } = keepFilter
1277
+ ? await verifyQuery.not("id", "in", keepFilter)
1278
+ : await verifyQuery;
1279
+ const rows = Array.isArray(writtenCarts) ? writtenCarts : [];
1280
+ const wrong = rows.filter((c) => String(c.layout_method ?? "") !== expectedMethod ||
1281
+ Number(c.algorithm_version) !== expectedVersion);
1282
+ if (verifyError || wrong.length > 0) {
1283
+ const reason = verifyError
1284
+ ? `Karrenindeling kon niet worden gecontroleerd: ${verifyError.message}`
1285
+ : `Karrenindeling is niet volgens de ingestelde werkwijze "${expectedMethod}" vastgelegd ` +
1286
+ `(${wrong.length} van ${rows.length} karren). Er is niets ingedeeld.`;
1287
+ console.error(`[logistiek-engine] buffer ${bufferId}: ${reason}`);
1288
+ await clearExistingCarts();
1289
+ await sb.rpc("logistics_plan_block", {
1290
+ p_order_ids: orderIds,
1291
+ p_reason: reason,
1292
+ p_details: { bufferId, path: "verificatie", expectedMethod, expectedVersion },
1293
+ });
1294
+ return {
1295
+ ok: false,
1296
+ bufferId,
1297
+ orderIds,
1298
+ cartsCreated: 0,
1299
+ replacedManualCount,
1300
+ usedFloridayPacking,
1301
+ warnings: [...warnings, reason],
1302
+ skipped: "blocked",
1303
+ blockedReason: reason,
1304
+ beladingSources,
1305
+ };
1306
+ }
1307
+ }
1308
+ // Sluitcontrole op de buffer: elk lid moet na afloop een fysieke kar hebben,
1309
+ // eigen kar of een gedeelde bufferkar met een van zijn orderregels. Zonder kar
1310
+ // krijgt dat lid geen pickdocument, en dat is nooit een geldige uitkomst.
1311
+ {
1312
+ const membersToCheck = allOrderRows.map((o) => String(o.id));
1313
+ const stillMissing = await bufferOrdersWithoutCarts(sb, bufferId, membersToCheck);
1314
+ if (stillMissing.length > 0) {
1315
+ const numberById = new Map(allOrderRows.map((o) => [String(o.id), String(o.order_number ?? o.id)]));
1316
+ const refs = stillMissing.map((id) => numberById.get(id) ?? id);
1317
+ const reason = `Bufferindeling onvolledig: order(s) ${refs.join(", ")} hebben geen enkele kar ` +
1318
+ `en zouden dus geen pickdocument krijgen.`;
1319
+ console.error(`[logistiek-engine] buffer ${bufferId}: ${reason}`);
1320
+ await sb.rpc("logistics_plan_block", {
1321
+ p_order_ids: stillMissing,
1322
+ p_reason: reason,
1323
+ p_details: { bufferId, path: "bufferdekking", missing: refs },
1324
+ });
1325
+ return {
1326
+ ok: false,
1327
+ bufferId,
1328
+ orderIds,
1329
+ cartsCreated: created,
1330
+ replacedManualCount,
1331
+ usedFloridayPacking,
1332
+ warnings: [...warnings, reason],
1333
+ skipped: "blocked",
1334
+ blockedReason: reason,
1335
+ beladingSources,
1336
+ };
1337
+ }
696
1338
  }
697
1339
  return {
698
1340
  ok: true,