@flowselections/logistiek-engine 1.0.27 → 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 (95) 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 +11 -1
  54. package/dist-lib/lib/dimensionData.server.d.ts.map +1 -1
  55. package/dist-lib/lib/dimensionData.server.js +58 -48
  56. package/dist-lib/lib/dimensionPlanner.d.ts +26 -1
  57. package/dist-lib/lib/dimensionPlanner.d.ts.map +1 -1
  58. package/dist-lib/lib/dimensionPlanner.js +37 -6
  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/loadCarrierPlanning.functions.d.ts +10302 -377
  68. package/dist-lib/lib/loadCarrierPlanning.functions.d.ts.map +1 -1
  69. package/dist-lib/lib/loadCarrierPlanning.functions.js +6 -5
  70. package/dist-lib/lib/loadCarrierPlanning.server.d.ts +42 -0
  71. package/dist-lib/lib/loadCarrierPlanning.server.d.ts.map +1 -1
  72. package/dist-lib/lib/loadCarrierPlanning.server.js +563 -31
  73. package/dist-lib/lib/logisticsPlanner.d.ts.map +1 -1
  74. package/dist-lib/lib/logisticsPlanner.js +24 -9
  75. package/dist-lib/lib/logisticsRecalcJob.functions.d.ts +4163 -193
  76. package/dist-lib/lib/logisticsRecalcJob.functions.d.ts.map +1 -1
  77. package/dist-lib/lib/logisticsRecalcJob.functions.js +3 -2
  78. package/dist-lib/lib/logisticsRecalcJob.server.d.ts.map +1 -1
  79. package/dist-lib/lib/logisticsRecalcJob.server.js +1 -0
  80. package/dist-lib/lib/logisticsRecalculate.functions.d.ts +2064 -79
  81. package/dist-lib/lib/logisticsRecalculate.functions.d.ts.map +1 -1
  82. package/dist-lib/lib/logisticsRecalculate.functions.js +3 -1
  83. package/dist-lib/lib/modulePrompts.d.ts.map +1 -1
  84. package/dist-lib/lib/modulePrompts.js +2560 -1586
  85. package/dist-lib/lib/planQueue.server.d.ts.map +1 -1
  86. package/dist-lib/lib/planQueue.server.js +6 -0
  87. package/dist-lib/lib/readableServerErrors.d.ts +2 -0
  88. package/dist-lib/lib/readableServerErrors.d.ts.map +1 -0
  89. package/dist-lib/lib/readableServerErrors.js +36 -0
  90. package/dist-lib/lib/ruleEngineSchema.d.ts +1 -1
  91. package/dist-lib/lib/ruleEngineSchema.d.ts.map +1 -1
  92. package/dist-lib/lib/ruleEngineSchema.js +72 -51
  93. package/dist-lib/lib/ruleEngineWizard.d.ts.map +1 -1
  94. package/dist-lib/lib/ruleEngineWizard.js +5 -6
  95. package/package.json +1 -1
@@ -182,6 +182,16 @@ export async function expandOrderItemsForPlanner(sb, rows, options = {}) {
182
182
  .map((r) => r.product_id)
183
183
  .filter((id) => typeof id === "string" && UUID_RE.test(id));
184
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
+ }
185
195
  if (groupIds.length > 0) {
186
196
  const { data: groups } = await sb.from("product_groups").select("id").in("id", groupIds);
187
197
  groupSet = new Set((groups ?? []).map((g) => g.id));
@@ -190,8 +200,14 @@ export async function expandOrderItemsForPlanner(sb, rows, options = {}) {
190
200
  const packing = row.order_id ? options.packingByOrder?.get(String(row.order_id)) ?? null : null;
191
201
  const { belading: base, source } = resolveBelading(row, packing);
192
202
  const plants = Math.max(0, Number(row.quantity ?? 0));
193
- const isGroup = (row.product_id && groupSet.has(row.product_id)) ||
194
- 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]");
195
211
  if (!isGroup) {
196
212
  options.sources?.set(String(row.id), source);
197
213
  out.push({
@@ -207,11 +223,13 @@ export async function expandOrderItemsForPlanner(sb, rows, options = {}) {
207
223
  });
208
224
  continue;
209
225
  }
210
- const { data: members } = await sb
211
- .from("product_group_items")
212
- .select("trade_item_id, quantity, sort_order")
213
- .eq("product_group_id", row.product_id)
214
- .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: [] };
215
233
  const memberList = Array.isArray(members) ? members : [];
216
234
  if (memberList.length === 0) {
217
235
  options.sources?.set(String(row.id), source);
@@ -419,6 +437,73 @@ export function planByFloridayPacking(inputs, profile, packing) {
419
437
  // ---------------------------------------------------------------------------
420
438
  // Indeling op afmetingen omzetten naar het bestaande opslagmodel
421
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
+ }
422
507
  export function dimensionCartsToPlannedUnits(carts) {
423
508
  return carts.map((cart) => {
424
509
  const layerCount = cart.layers.length || 1;
@@ -465,15 +550,131 @@ export function dimensionCartsToPlannedUnits(carts) {
465
550
  // ---------------------------------------------------------------------------
466
551
  /** Maximaal aantal ladingdragers dat één orderbuffer mag opleveren. */
467
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
+ }
468
646
  export async function replanBufferInternal(sb, bufferId, options = {}) {
469
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;
470
652
  const { data: orders } = await sb
471
653
  .from("orders")
472
- .select("id, customer_id, custom_fields, plan_locked_at")
654
+ .select("id, order_number, customer_id, custom_fields, plan_locked_at")
473
655
  .eq("buffer_id", bufferId)
474
656
  .is("deleted_at", null);
475
- const orderRows = Array.isArray(orders) ? orders : [];
657
+ const allOrderRows = Array.isArray(orders) ? orders : [];
658
+ const orderRows = allOrderRows;
476
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;
477
678
  if (orderRows.some((o) => o.plan_locked_at)) {
478
679
  return {
479
680
  ok: true,
@@ -486,6 +687,66 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
486
687
  skipped: "locked",
487
688
  };
488
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
+ }
489
750
  const idList = orderIds.length > 0 ? orderIds : ["00000000-0000-0000-0000-000000000000"];
490
751
  const { data: items } = await sb
491
752
  .from("order_items")
@@ -505,7 +766,63 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
505
766
  packingByOrder.set(String(o.id), belading);
506
767
  }
507
768
  const sources = new Map();
508
- 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
+ }
509
826
  const itemToOrder = new Map();
510
827
  for (const i of inputs)
511
828
  if (i.order_id)
@@ -566,6 +883,9 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
566
883
  };
567
884
  }
568
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;
569
889
  let usedFloridayPacking = false;
570
890
  // Overname-pad: één order in de buffer én een volledige Floriday-verpakking.
571
891
  // Dan neemt de engine de verhouding van Floriday over en optimaliseert niet.
@@ -580,7 +900,8 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
580
900
  // Globale methode: indelen op basis van fysieke afmetingen.
581
901
  const carrierRow = profiles.find((p) => p.code === (options.carrierType ?? defaultProfile.code)) ?? null;
582
902
  const { carrier, missing: carrierMissing } = carrierDimensions(carrierRow);
583
- const { items: dimItems, missing: itemMissing } = await buildDimensionItems(sb, inputs);
903
+ const { items: dimItems, missing: itemMissing, derived: derivedHeights, } = await buildDimensionItems(sb, inputs, carrier, carrierRow?.layers_per_carrier ?? null);
904
+ warnings.push(...derivedHeights);
584
905
  const missing = [...carrierMissing, ...itemMissing];
585
906
  let planError = null;
586
907
  if (missing.length === 0 && carrier) {
@@ -588,8 +909,14 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
588
909
  if (result.error)
589
910
  planError = result.error;
590
911
  else {
591
- plannedCarts = dimensionCartsToPlannedUnits(result.carts);
592
- warnings.push(...result.warnings);
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
+ }
593
920
  }
594
921
  }
595
922
  if (missing.length > 0 || planError) {
@@ -621,6 +948,19 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
621
948
  });
622
949
  if (blockError)
623
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
+ }
624
964
  return {
625
965
  ok: false,
626
966
  bufferId,
@@ -660,8 +1000,10 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
660
1000
  // Zonder optimale vulling wordt er niet bijgemengd in een laag.
661
1001
  maxBeladingenPerLayer: rules && !rules.optimalFill ? 1 : (settings?.max_beladingen_per_layer ?? 2),
662
1002
  neverMixProductIds: neverMix,
663
- maxOrders: rules?.maxOrders ?? null,
664
- 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,
665
1007
  oneCustomerPerLayer: Boolean(settings?.max_one_customer_per_layer),
666
1008
  ownCarrierFromLayers: settings?.own_cart_from_layers ?? null,
667
1009
  sortOrder: "none",
@@ -719,24 +1061,93 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
719
1061
  carts: plannedCarts,
720
1062
  };
721
1063
  }
722
- 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();
723
1134
  // In blokken wegschrijven: per ladingdrager een aparte insert is bij grote
724
1135
  // orders veel te traag.
725
1136
  let created = 0;
726
1137
  const CHUNK = 100;
727
1138
  for (let start = 0; start < plannedCarts.length; start += CHUNK) {
728
1139
  const chunk = plannedCarts.slice(start, start + CHUNK);
729
- const cartRows = chunk.map((cart) => {
730
- const orderIdsOnCart = new Set();
731
- for (const i of cart.items) {
732
- const oid = i.order_id ?? itemToOrder.get(i.order_item_id);
733
- if (oid)
734
- 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}.`);
735
1145
  }
736
1146
  return {
737
- order_id: orderIdsOnCart.size === 1 ? [...orderIdsOnCart][0] : null,
1147
+ order_id: identity.ownerOrderId,
738
1148
  buffer_id: bufferId,
739
- cart_number: cart.cart_number,
1149
+ cart_number: identity.cartNumber,
1150
+ total_carts: identity.totalCarts,
740
1151
  is_auto: true,
741
1152
  is_complete: cart.is_complete,
742
1153
  source: usedFloridayPacking ? "floriday" : "auto",
@@ -753,18 +1164,23 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
753
1164
  const { data: insertedCarts, error } = await sb
754
1165
  .from("order_carts")
755
1166
  .insert(cartRows)
756
- .select("id, cart_number");
1167
+ .select("id, order_id, cart_number");
757
1168
  if (error || !Array.isArray(insertedCarts)) {
758
1169
  const message = `Ladingdragers konden niet worden opgeslagen: ${error?.message ?? "onbekende fout"}`;
759
1170
  console.error(`[logistiek-engine] buffer ${bufferId}: ${message}`);
760
- await sb.from("order_carts").delete().eq("buffer_id", bufferId);
1171
+ await clearExistingCarts();
761
1172
  throw new Error(message);
762
1173
  }
763
1174
  created += insertedCarts.length;
764
- 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)]));
765
1177
  const itemRows = [];
766
- for (const cart of chunk) {
767
- 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;
768
1184
  if (!cartId)
769
1185
  continue;
770
1186
  for (const i of cart.items) {
@@ -774,7 +1190,7 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
774
1190
  order_item_id: i.order_item_id.includes("::") ? i.order_item_id.split("::")[0] : i.order_item_id,
775
1191
  product_id: i.product_id,
776
1192
  product_name: i.product_name,
777
- crates: Math.max(1, Math.round(i.crates)),
1193
+ crates: Math.round(i.crates),
778
1194
  boxes: i.boxes ?? null,
779
1195
  weight_kg: i.weight_kg ?? null,
780
1196
  plants_per_crate: i.plants_per_crate,
@@ -799,10 +1215,126 @@ export async function replanBufferInternal(sb, bufferId, options = {}) {
799
1215
  (itemError.details ? ` — ${itemError.details}` : "") +
800
1216
  (itemError.hint ? ` (${itemError.hint})` : "");
801
1217
  console.error(`[logistiek-engine] buffer ${bufferId}: ${message}`);
802
- await sb.from("order_carts").delete().eq("buffer_id", bufferId);
1218
+ await clearExistingCarts();
803
1219
  throw new Error(message);
804
1220
  }
805
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
+ }
806
1338
  }
807
1339
  return {
808
1340
  ok: true,