@flowselections/floriday-verkoop-module 1.0.52 → 1.0.54

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.
@@ -76,6 +76,7 @@ export async function replanScopeInternal(scope) {
76
76
  // (pieces_per_package / packages_per_layer / layers_per_load_carrier) —
77
77
  // géén voorgekauwde karrenlijst; de karindeling rekenen we zelf.
78
78
  const floridayByOrder = new Map();
79
+ const loadCarrierByOrder = new Map();
79
80
  for (const o of orderRows) {
80
81
  const fp = (o.custom_fields ?? {})?.floriday_packing ?? null;
81
82
  if (!fp)
@@ -83,7 +84,48 @@ export async function replanScopeInternal(scope) {
83
84
  const bel = completeSource(fp.pieces_per_package, fp.packages_per_layer, fp.layers_per_load_carrier);
84
85
  if (bel)
85
86
  floridayByOrder.set(o.id, bel);
87
+ const lc = typeof fp.load_carrier === "string" ? fp.load_carrier.trim() : "";
88
+ if (lc)
89
+ loadCarrierByOrder.set(o.id, lc.toUpperCase());
86
90
  }
91
+ // ── Ladingdragerprofiel ────────────────────────────────────────────────
92
+ // Vertrouw NIET op de kolomdefault 'trolley' van order_carts: die verbergt
93
+ // dat er geen profielkeuze is gemaakt. Zonder profiel plannen we niet.
94
+ const { data: carrierProfiles } = await supabaseAdmin
95
+ .from("load_carrier_types")
96
+ .select("id, code, name, layers_per_carrier, is_active, is_default");
97
+ const activeProfiles = (carrierProfiles ?? []).filter((p) => p.is_active);
98
+ const defaultProfile = activeProfiles.find((p) => p.is_default) ??
99
+ activeProfiles.find((p) => String(p.code).toLowerCase() === "trolley") ??
100
+ null;
101
+ /** Floriday-ladingdragercode → code in load_carrier_types. */
102
+ const CARRIER_CODE_MAP = {
103
+ DANISH_TROLLEY: ["danish_container", "danish_trolley"],
104
+ DANISH_TROLLEY_NON_CC_RFID: ["danish_container"],
105
+ DANISH_TROLLEY_WATER_TRAY: ["danish_container"],
106
+ ONE_THIRD_DANISH_TROLLEY: ["danish_container"],
107
+ AUCTION_TROLLEY: ["auction_trolley", "trolley"],
108
+ EURO_TROLLEY: ["euro_rolcontainer", "trolley"],
109
+ ROLL_CONTAINER: ["euro_rolcontainer"],
110
+ EURO_PALLET: ["euro_pallet", "pallet"],
111
+ HT_EURO_PALLET: ["euro_pallet", "pallet"],
112
+ PALLET: ["pallet"],
113
+ BLOCK_PALLET: ["pallet"],
114
+ NONE: ["loose"],
115
+ };
116
+ const profileFor = (loadCarrier) => {
117
+ if (!loadCarrier)
118
+ return defaultProfile;
119
+ const direct = activeProfiles.find((p) => String(p.code).toUpperCase() === loadCarrier.toUpperCase());
120
+ if (direct)
121
+ return direct;
122
+ for (const code of CARRIER_CODE_MAP[loadCarrier.toUpperCase()] ?? []) {
123
+ const hit = activeProfiles.find((p) => String(p.code).toLowerCase() === code);
124
+ if (hit)
125
+ return hit;
126
+ }
127
+ return defaultProfile;
128
+ };
87
129
  let usedFloridayPacking = false;
88
130
  const inputs = (items ?? []).map((it) => {
89
131
  const prod = it.products ?? {};
@@ -95,6 +137,12 @@ export async function replanScopeInternal(scope) {
95
137
  completeSource(prod.plants_per_crate, prod.crates_per_layer, prod.layers_per_cart);
96
138
  if (bel && fromFloriday)
97
139
  usedFloridayPacking = true;
140
+ const loadCarrier = loadCarrierByOrder.get(it.order_id) ?? null;
141
+ // layers_per_cart = LEAST(profiel, bron) — identiek aan logistics_plan_buffer_sql.
142
+ const profileLayers = pos(profileFor(loadCarrier)?.layers_per_carrier);
143
+ const layers = bel?.layers_per_cart != null && profileLayers != null
144
+ ? Math.min(bel.layers_per_cart, profileLayers)
145
+ : (bel?.layers_per_cart ?? null);
98
146
  return {
99
147
  order_item_id: it.id,
100
148
  product_id: it.product_id,
@@ -102,7 +150,8 @@ export async function replanScopeInternal(scope) {
102
150
  plants: Math.max(0, Number(it.quantity ?? 0)),
103
151
  plants_per_crate: bel?.plants_per_crate ?? null,
104
152
  crates_per_layer: bel?.crates_per_layer ?? null,
105
- layers_per_cart: bel?.layers_per_cart ?? null,
153
+ layers_per_cart: layers,
154
+ load_carrier: loadCarrier,
106
155
  };
107
156
  });
108
157
  // Item → order-map voor het toewijzen van cart.order_id bij homogene karren.
@@ -111,6 +160,11 @@ export async function replanScopeInternal(scope) {
111
160
  itemToOrder.set(it.id, it.order_id);
112
161
  }
113
162
  // Bestaande karren in de scope tellen (voor waarschuwing) en wissen.
163
+ // Nummering én delete-scope liggen altijd op bufferniveau als de order een
164
+ // buffer heeft; order-scope is uitsluitend terugval.
165
+ if (!bufferId) {
166
+ console.warn("[cart-replan] geen buffer voor order %s — nummering en delete vallen terug op order-scope", soloOrderId);
167
+ }
114
168
  const cartScope = (q) => bufferId ? q.eq("buffer_id", bufferId) : q.eq("order_id", soloOrderId);
115
169
  const { data: existingCarts } = await cartScope(supabaseAdmin.from("order_carts").select("id, source"));
116
170
  const replacedManualCount = (existingCarts ?? []).filter((c) => c.source === "manual").length;
@@ -121,7 +175,7 @@ export async function replanScopeInternal(scope) {
121
175
  // levert alleen de belading-VELDEN (pieces_per_package / packages_per_layer /
122
176
  // layers_per_load_carrier) — de mirror schrijft nooit een voorgekauwde
123
177
  // carts-array, dus die tak bestaat bewust niet meer.
124
- let plannedCarts = [];
178
+ const plannedCarts = [];
125
179
  const warnings = [];
126
180
  let blockedItems = [];
127
181
  {
@@ -132,13 +186,50 @@ export async function replanScopeInternal(scope) {
132
186
  const neverMix = new Set((overrides ?? [])
133
187
  .filter((o) => o.never_mix && o.product_id)
134
188
  .map((o) => o.product_id));
135
- const result = planCarts(inputs, {
136
- maxPartySharePerLayerPct: settings?.max_party_share_per_layer_pct ?? 50,
137
- maxBeladingenPerLayer: settings?.max_beladingen_per_layer ?? 2,
138
- neverMixProductIds: neverMix,
139
- });
140
- plannedCarts = result.carts;
141
- warnings.push(...result.warnings);
189
+ // Ladingdrager is een HARDE partitie: één planner-run per load_carrier.
190
+ // Items zonder load_carrier (lokale orders) vormen hun eigen partitie op
191
+ // het defaultprofiel.
192
+ const partitions = new Map();
193
+ for (const input of inputs) {
194
+ const key = input.load_carrier ?? "";
195
+ const list = partitions.get(key);
196
+ if (list)
197
+ list.push(input);
198
+ else
199
+ partitions.set(key, [input]);
200
+ }
201
+ const allBlocked = [];
202
+ let nextCartNumber = 1;
203
+ for (const [key, partitionInputs] of partitions) {
204
+ const profile = profileFor(key || null);
205
+ if (!profile) {
206
+ // Geen actief profiel én geen 'trolley': niet plannen, wel blokkeren.
207
+ for (const i of partitionInputs) {
208
+ allBlocked.push({
209
+ order_item_id: i.order_item_id,
210
+ product_id: i.product_id,
211
+ product_name: i.product_name,
212
+ reason: "geen_belading",
213
+ missing: ["load_carrier_profile"],
214
+ });
215
+ }
216
+ warnings.push(`Geen actief ladingdragerprofiel gevonden${key ? ` voor ${key}` : ""} — karindeling overgeslagen.`);
217
+ continue;
218
+ }
219
+ const result = planCarts(partitionInputs, {
220
+ maxPartySharePerLayerPct: settings?.max_party_share_per_layer_pct ?? 50,
221
+ maxBeladingenPerLayer: settings?.max_beladingen_per_layer ?? 2,
222
+ neverMixProductIds: neverMix,
223
+ startCartNumber: nextCartNumber,
224
+ });
225
+ for (const cart of result.carts) {
226
+ nextCartNumber = Math.max(nextCartNumber, cart.cart_number + 1);
227
+ plannedCarts.push({ cart, profile });
228
+ }
229
+ warnings.push(...result.warnings);
230
+ allBlocked.push(...result.blocked);
231
+ }
232
+ const result = { blocked: allBlocked };
142
233
  // Regels zonder (volledige) belading: NOOIT karren maken. Meld de
143
234
  // blokkade aan de logistiek-engine zodat die zichtbaar wordt in
144
235
  // logistics_plan_queue / logistics_events / logistics_decisions.
@@ -187,7 +278,7 @@ export async function replanScopeInternal(scope) {
187
278
  }
188
279
  }
189
280
  let cartsCreated = 0;
190
- for (const cart of plannedCarts) {
281
+ for (const { cart, profile } of plannedCarts) {
191
282
  // Bepaal cart.order_id: null bij mix van orders, anders die ene order.
192
283
  const orderIdsOnCart = new Set();
193
284
  for (const i of cart.items) {
@@ -207,6 +298,10 @@ export async function replanScopeInternal(scope) {
207
298
  source: usedFloridayPacking ? "floriday" : "auto",
208
299
  occupancy_pct: cart.occupancy_pct,
209
300
  total_layers_used: cart.total_layers_used,
301
+ // Profiel expliciet meegeven; nooit leunen op de kolomdefault.
302
+ carrier_type: profile.code,
303
+ carrier_type_id: profile.id,
304
+ max_layers: profile.layers_per_carrier ?? null,
210
305
  algorithm_version: CART_PLANNER_VERSION,
211
306
  })
212
307
  .select("id")
@@ -306,22 +401,95 @@ export async function drainCartReplanQueue(limit = 50) {
306
401
  // ─── Lezen — voor de UI ─────────────────────────────────────────────────────
307
402
  // Retourneert alle karren van de buffer waarin de order valt, zodat karren
308
403
  // die items van meerdere orders bevatten volledig zichtbaar zijn.
404
+ // Levert ook de order-context (nummers per orderregel) en, als er geen karren
405
+ // zijn, de laatste blokkadereden uit de engine (logistics_decisions /
406
+ // logistics_plan_queue). De UI berekent NIETS zelf: hij toont wat er staat.
309
407
  export const listOrderCarts = createServerFn({ method: "GET" })
310
408
  .middleware([requireSupabaseAuth])
311
409
  .inputValidator((input) => z.object({ orderId: z.string().uuid() }).parse(input))
312
410
  .handler(async ({ data, context }) => {
313
411
  const { data: order } = await context.supabase
314
412
  .from("orders")
315
- .select("buffer_id")
413
+ .select("buffer_id, plan_locked_at")
316
414
  .eq("id", data.orderId)
317
415
  .maybeSingle();
318
416
  const bufferId = order?.buffer_id;
319
- const select = "id, order_id, buffer_id, cart_number, source, is_complete, occupancy_pct, total_layers_used, algorithm_version, notes, order_cart_items(id, order_item_id, product_id, product_name, crates, plants_per_crate, crates_per_layer, layers_per_cart, layer_fraction, layer_share_pct)";
417
+ const planLockedAt = order?.plan_locked_at ?? null;
418
+ const select = "id, order_id, buffer_id, cart_number, source, is_complete, occupancy_pct, total_layers_used, algorithm_version, carrier_type, max_layers, notes, order_cart_items(id, order_item_id, product_id, product_name, crates, plants_per_crate, crates_per_layer, layers_per_cart, layer_fraction, layer_share_pct)";
320
419
  const base = context.supabase.from("order_carts").select(select);
321
420
  const { data: carts } = await (bufferId
322
421
  ? base.eq("buffer_id", bufferId)
323
422
  : base.eq("order_id", data.orderId)).order("cart_number");
324
- return { carts: carts ?? [], bufferId };
423
+ // Orders in de scope + ordernummer per orderregel (voor de badges).
424
+ let orders = [];
425
+ if (bufferId) {
426
+ const { data: sibs } = await context.supabase
427
+ .from("orders")
428
+ .select("id, order_number")
429
+ .eq("buffer_id", bufferId)
430
+ .is("deleted_at", null);
431
+ orders = (sibs ?? []).map((o) => ({
432
+ id: o.id,
433
+ order_number: o.order_number ?? null,
434
+ }));
435
+ }
436
+ if (orders.length === 0) {
437
+ const { data: own } = await context.supabase
438
+ .from("orders")
439
+ .select("id, order_number")
440
+ .eq("id", data.orderId)
441
+ .maybeSingle();
442
+ if (own) {
443
+ orders = [{ id: own.id, order_number: own.order_number ?? null }];
444
+ }
445
+ }
446
+ const orderIds = orders.map((o) => o.id);
447
+ let itemOrders = [];
448
+ if (orderIds.length > 0) {
449
+ const { data: its } = await context.supabase
450
+ .from("order_items")
451
+ .select("id, order_id")
452
+ .in("order_id", orderIds);
453
+ itemOrders = (its ?? []).map((i) => ({
454
+ id: i.id,
455
+ order_id: i.order_id,
456
+ }));
457
+ }
458
+ // Blokkadereden alleen ophalen als er niets gepland staat.
459
+ let blocked = null;
460
+ if ((carts ?? []).length === 0 && orderIds.length > 0) {
461
+ const { data: dec } = await context.supabase
462
+ .from("logistics_decisions")
463
+ .select("subject_id, slot, explanation, result, created_at")
464
+ .in("subject_id", orderIds)
465
+ .order("created_at", { ascending: false })
466
+ .limit(1);
467
+ const d = (dec ?? [])[0];
468
+ if (d) {
469
+ blocked = {
470
+ reason: d.result?.reason ?? d.slot ?? null,
471
+ explanation: d.explanation ?? null,
472
+ at: d.created_at ?? null,
473
+ };
474
+ }
475
+ if (!blocked) {
476
+ const { data: q } = await context.supabase
477
+ .from("logistics_plan_queue")
478
+ .select("order_id, status, reason, error, updated_at")
479
+ .in("order_id", orderIds)
480
+ .order("updated_at", { ascending: false })
481
+ .limit(1);
482
+ const r = (q ?? [])[0];
483
+ if (r && (r.error || r.reason)) {
484
+ blocked = {
485
+ reason: r.reason ?? null,
486
+ explanation: r.error ?? null,
487
+ at: r.updated_at ?? null,
488
+ };
489
+ }
490
+ }
491
+ }
492
+ return { carts: carts ?? [], bufferId, planLockedAt, orders, itemOrders, blocked };
325
493
  });
326
494
  export const replanBufferForOrder = createServerFn({ method: "POST" })
327
495
  .middleware([requireSupabaseAuth])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowselections/floriday-verkoop-module",
3
- "version": "1.0.52",
3
+ "version": "1.0.54",
4
4
  "private": false,
5
5
  "sideEffects": false,
6
6
  "type": "module",