@flowselections/logistiek-engine 1.0.203 → 1.0.209

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.
@@ -9,22 +9,126 @@
9
9
  // - verticaal : totalHeightPct <= 100
10
10
  // - horizontaal: crateShare per laag <= 1
11
11
  // ============================================================================
12
- export const LOAD_CARRIER_PLANNER_VERSION = 3;
12
+ export const LOAD_CARRIER_PLANNER_VERSION = 5;
13
+ /** Grens vanaf waar "beladingen per laag" als onbeperkt geldt (instelling 999 = onbeperkt). */
14
+ export const BELADINGEN_ONBEPERKT = 999;
15
+ /**
16
+ * Leidt de menging per laag af uit de werkwijze.
17
+ * Niet vrij als: geen optimale vulling, maximaal N beladingen per laag (N < 999),
18
+ * maximaal N klanten per laag, of één klant per laag.
19
+ */
20
+ export function bepaalLaagMenging(w) {
21
+ if (w.optimalFill === false)
22
+ return { menging: "per_regel", groepen: { klantenPerLaag: null, beladingenPerLaag: null }, reden: "optimale vulling staat uit" };
23
+ const b = Number(w.maxBeladingenPerLayer);
24
+ const bel = w.maxBeladingenPerLayer != null && Number.isFinite(b) && b > 0 && b < BELADINGEN_ONBEPERKT ? Math.round(b) : null;
25
+ const k = Number(w.maxCustomersPerLayer);
26
+ const kl = w.oneCustomerPerLayer ? 1 : w.maxCustomersPerLayer != null && Number.isFinite(k) && k > 0 ? Math.round(k) : null;
27
+ const delen = [];
28
+ if (kl != null)
29
+ delen.push(`maximaal ${kl} klant(en) per laag`);
30
+ if (bel != null)
31
+ delen.push(`maximaal ${bel} beladingen per laag binnen een klant`);
32
+ return {
33
+ menging: "vrij",
34
+ groepen: { klantenPerLaag: kl, beladingenPerLaag: bel },
35
+ reden: delen.length ? delen.join("; ") : "mengen per laag toegestaan",
36
+ };
37
+ }
38
+ /**
39
+ * 1.0.205: lagen op een kar volgens de mengregels.
40
+ * - "per_regel" (optimale vulling uit): Σ ceil(fust ÷ fusten per laag) per regel.
41
+ * - anders: regels vormen groepen die een laag mogen delen (zelfde klant; klanten in
42
+ * groepen van N; binnen een klant hoogstens M regels). Eén groep op de kar → fractie.
43
+ * Meer groepen → elke groep telt in hele lagen: Σ ceil(groep).
44
+ */
45
+ export function karLagen(regels, menging = "vrij", groepen) {
46
+ const f = (r) => r.crates / Math.max(1, r.cpl);
47
+ if (menging === "per_regel")
48
+ return regels.reduce((s, r) => s + Math.ceil(f(r) - 1e-9), 0);
49
+ const N = groepen?.klantenPerLaag ?? null;
50
+ const M = groepen?.beladingenPerLaag ?? null;
51
+ if (N == null && M == null)
52
+ return regels.reduce((s, r) => s + f(r), 0);
53
+ // Per klant, vaste volgorde.
54
+ const perKlant = new Map();
55
+ for (const r of regels) {
56
+ const k = r.klantId ?? `regel:${r.regelId}`;
57
+ const l = perKlant.get(k) ?? [];
58
+ l.push(r);
59
+ perKlant.set(k, l);
60
+ }
61
+ const klanten = [...perKlant.keys()].sort();
62
+ const units = [];
63
+ if (M != null) {
64
+ // Binnen een klant groepen van M regels; klanten delen niet als N gezet is.
65
+ for (const k of klanten) {
66
+ const l = perKlant.get(k).sort((a, b) => a.regelId.localeCompare(b.regelId));
67
+ for (let i = 0; i < l.length; i += M)
68
+ units.push(l.slice(i, i + M).reduce((s, r) => s + f(r), 0));
69
+ }
70
+ }
71
+ else {
72
+ for (let i = 0; i < klanten.length; i += N) {
73
+ units.push(klanten.slice(i, i + N).reduce((s, k) => s + perKlant.get(k).reduce((t, r) => t + f(r), 0), 0));
74
+ }
75
+ }
76
+ if (units.length <= 1)
77
+ return units.reduce((s, u) => s + u, 0);
78
+ return units.reduce((s, u) => s + Math.ceil(u - 1e-9), 0);
79
+ }
80
+ /**
81
+ * 1.0.208: vulgraad van een kar zoals Floriday (fractie, 1 = vol):
82
+ * Σ per regel (lagen van de regel ÷ lagen per kar van die regel).
83
+ * Mengregels blijven: een afgeronde laag (tussen groepen die geen laag mogen delen)
84
+ * telt met de kleinste lagen-per-kar binnen die groep.
85
+ */
86
+ export function karVulgraadFractie(regels, menging = "vrij", groepen) {
87
+ const f = (r) => r.crates / Math.max(1, r.cpl);
88
+ const l = (r) => Math.max(1, Number(r.lpc) || 1);
89
+ if (menging === "per_regel")
90
+ return regels.reduce((s, r) => s + Math.ceil(f(r) - 1e-9) / l(r), 0);
91
+ const N = groepen?.klantenPerLaag ?? null;
92
+ const M = groepen?.beladingenPerLaag ?? null;
93
+ if (N == null && M == null)
94
+ return regels.reduce((s, r) => s + f(r) / l(r), 0);
95
+ const perKlant = new Map();
96
+ for (const r of regels) {
97
+ const k = r.klantId ?? `regel:${r.regelId}`;
98
+ const lijst = perKlant.get(k) ?? [];
99
+ lijst.push(r);
100
+ perKlant.set(k, lijst);
101
+ }
102
+ const klanten = [...perKlant.keys()].sort();
103
+ const units = [];
104
+ if (M != null) {
105
+ for (const k of klanten) {
106
+ const lijst = perKlant.get(k).sort((a, b) => a.regelId.localeCompare(b.regelId));
107
+ for (let i = 0; i < lijst.length; i += M)
108
+ units.push(lijst.slice(i, i + M));
109
+ }
110
+ }
111
+ else {
112
+ for (let i = 0; i < klanten.length; i += N)
113
+ units.push(klanten.slice(i, i + N).flatMap((k) => perKlant.get(k)));
114
+ }
115
+ if (units.length <= 1)
116
+ return regels.reduce((s, r) => s + f(r) / l(r), 0);
117
+ return units.reduce((s, u) => {
118
+ const lagen = u.reduce((t, r) => t + f(r), 0);
119
+ return s + Math.ceil(lagen - 1e-9) / Math.min(...u.map(l));
120
+ }, 0);
121
+ }
122
+ /** Lagen van één regel op een kar volgens de menging per laag. */
123
+ export function regelLagen(crates, cpl, menging = "vrij") {
124
+ const f = crates / Math.max(1, cpl);
125
+ return menging === "per_regel" ? Math.ceil(f - 1e-9) : f;
126
+ }
13
127
  const EPS = 1e-9;
14
128
  function clampInt(value, min) {
15
129
  const n = typeof value === "string" ? Number(value) : value;
16
130
  return Math.max(min, Math.round(Number.isFinite(n) ? n : min));
17
131
  }
18
- /** Effectieve lagen: het minimum van de productbelading en de ladingdrager. */
19
- function effectiveLayers(item, carrier) {
20
- const carrierLayers = Math.max(1, Math.round(carrier.layers_per_carrier || 1));
21
- if (carrier.capacity_model === "single" || carrier.capacity_model === "none")
22
- return 1;
23
- // Floriday-verpakking is leidend en wordt niet afgeknepen op het profiel.
24
- if (item.packing_authoritative)
25
- return Math.max(1, Math.round(item.layers_per_cart));
26
- return Math.max(1, Math.min(item.layers_per_cart, carrierLayers));
27
- }
28
132
  /** Effectieve fusten per laag: begrensd door de ladingdrager. */
29
133
  function effectiveCratesPerLayer(item, carrier) {
30
134
  const cap = carrier.max_crates_per_layer;
@@ -33,197 +137,6 @@ function effectiveCratesPerLayer(item, carrier) {
33
137
  return Math.max(1, Math.min(base, Math.round(cap)));
34
138
  return base;
35
139
  }
36
- // ---------------------------------------------------------------------------
37
- // Plaatsingsregels
38
- // ---------------------------------------------------------------------------
39
- function placeAsFreshLayer(cart, batch, batchIdx, crates) {
40
- if (cart.totalHeightPct + batch.layerHeightPct > 100 + EPS)
41
- return false;
42
- cart.layers.push({
43
- heightPct: batch.layerHeightPct,
44
- crateShare: crates / batch.cratesPerLayer,
45
- entries: [{ batchIdx, crates }],
46
- customerIds: new Set(batch.input.customer_id ? [batch.input.customer_id] : []),
47
- });
48
- cart.totalHeightPct += batch.layerHeightPct;
49
- return true;
50
- }
51
- function placeInLayer(cart, batch, batchIdx, crates, r) {
52
- const partyShare = crates / batch.cratesPerLayer;
53
- const maxShare = r.opts.maxPartySharePerLayerPct / 100;
54
- // REGEL 1 — één partij mag nooit meer dan X% van een laag beslaan
55
- if (partyShare > maxShare + EPS)
56
- return false;
57
- // REGEL 2 — never-mix producten en niet-mengbare ladingdragers
58
- const neverMix = r.opts.carrier.allow_mixing === false ||
59
- (batch.input.product_id ? r.opts.neverMixProductIds.has(batch.input.product_id) : false);
60
- if (neverMix)
61
- return false;
62
- for (const layer of cart.layers) {
63
- // REGEL 3 — max aantal verschillende beladingen per laag
64
- if (layer.entries.length >= r.opts.maxBeladingenPerLayer)
65
- continue;
66
- // REGEL 4 — dezelfde partij niet twee keer in dezelfde laag
67
- if (layer.entries.some((e) => e.batchIdx === batchIdx))
68
- continue;
69
- // REGEL 4b — maximaal aantal verschillende klanten op één laag
70
- const layerCustomerLimit = r.opts.oneCustomerPerLayer
71
- ? 1
72
- : r.opts.maxCustomersPerLayer && r.opts.maxCustomersPerLayer > 0
73
- ? r.opts.maxCustomersPerLayer
74
- : null;
75
- const customerId = batch.input.customer_id ?? null;
76
- if (layerCustomerLimit != null &&
77
- customerId &&
78
- !layer.customerIds.has(customerId) &&
79
- layer.customerIds.size >= layerCustomerLimit) {
80
- continue;
81
- }
82
- // REGEL 5 — vloeroppervlak van de laag mag niet over 100%
83
- if (layer.crateShare + partyShare > 1 + EPS)
84
- continue;
85
- // REGEL 6 — hoogte: bij mengen telt de HOOGSTE partij
86
- const newHeight = Math.max(layer.heightPct, batch.layerHeightPct);
87
- const heightDelta = newHeight - layer.heightPct;
88
- if (cart.totalHeightPct + heightDelta > 100 + EPS)
89
- continue;
90
- layer.entries.push({ batchIdx, crates });
91
- if (customerId)
92
- layer.customerIds.add(customerId);
93
- layer.crateShare += partyShare;
94
- layer.heightPct = newHeight;
95
- cart.totalHeightPct += heightDelta;
96
- return true;
97
- }
98
- return false;
99
- }
100
- /** Extra grenzen die niet over hoogte of oppervlak gaan. */
101
- function acceptsBatch(cart, batch, crates, r) {
102
- const carrier = r.opts.carrier;
103
- const orderId = batch.input.order_id ?? null;
104
- if (orderId && !cart.orderIds.has(orderId) && r.opts.maxOrders && cart.orderIds.size >= r.opts.maxOrders) {
105
- return false;
106
- }
107
- const customerId = batch.input.customer_id ?? null;
108
- if (customerId &&
109
- !cart.customerIds.has(customerId) &&
110
- r.opts.maxCustomers &&
111
- cart.customerIds.size >= r.opts.maxCustomers) {
112
- return false;
113
- }
114
- if (typeof carrier.max_boxes === "number" && carrier.max_boxes > 0) {
115
- if (cart.boxes + crates > carrier.max_boxes)
116
- return false;
117
- }
118
- if (typeof carrier.max_weight_kg === "number" && carrier.max_weight_kg > 0) {
119
- const perCrate = Number(batch.input.weight_per_crate ?? 0) || 0;
120
- if (perCrate > 0 && cart.weightKg + perCrate * crates > carrier.max_weight_kg + EPS)
121
- return false;
122
- }
123
- return true;
124
- }
125
- function registerPlacement(cart, batch, crates) {
126
- if (batch.input.order_id)
127
- cart.orderIds.add(batch.input.order_id);
128
- if (batch.input.customer_id)
129
- cart.customerIds.add(batch.input.customer_id);
130
- cart.boxes += crates;
131
- cart.weightKg += (Number(batch.input.weight_per_crate ?? 0) || 0) * crates;
132
- }
133
- function tryPlaceBatch(cart, batch, batchIdx, r) {
134
- const cpl = batch.cratesPerLayer;
135
- let placed = 0;
136
- let remaining = batch.cratesRemaining;
137
- // 1) zo veel mogelijk VOLLE eigen lagen
138
- while (remaining >= cpl && cart.totalHeightPct + batch.layerHeightPct <= 100 + EPS) {
139
- if (!acceptsBatch(cart, batch, cpl, r))
140
- break;
141
- if (!placeAsFreshLayer(cart, batch, batchIdx, cpl))
142
- break;
143
- registerPlacement(cart, batch, cpl);
144
- remaining -= cpl;
145
- placed += cpl;
146
- }
147
- // 2) het restant: eerst bijmengen, anders eigen (deels lege) laag
148
- if (remaining > 0) {
149
- const partial = Math.min(remaining, Math.ceil(cpl * 0.5));
150
- if (acceptsBatch(cart, batch, partial, r) && placeInLayer(cart, batch, batchIdx, partial, r)) {
151
- registerPlacement(cart, batch, partial);
152
- remaining -= partial;
153
- placed += partial;
154
- }
155
- else if (acceptsBatch(cart, batch, remaining, r) && placeAsFreshLayer(cart, batch, batchIdx, remaining)) {
156
- registerPlacement(cart, batch, remaining);
157
- placed += remaining;
158
- remaining = 0;
159
- }
160
- }
161
- return placed;
162
- }
163
- // ---------------------------------------------------------------------------
164
- // Samenvatten
165
- // ---------------------------------------------------------------------------
166
- function summarizeCart(cart, rests, cartNumber, r) {
167
- const byBatch = new Map();
168
- for (const layer of cart.layers) {
169
- const shareSum = layer.entries.reduce((s, e) => s + e.crates, 0);
170
- for (const e of layer.entries) {
171
- const cur = byBatch.get(e.batchIdx) ?? { crates: 0, layerFraction: 0, heightPct: 0 };
172
- const ratio = shareSum > 0 ? e.crates / shareSum : 0;
173
- cur.crates += e.crates;
174
- cur.layerFraction += ratio;
175
- cur.heightPct += layer.heightPct * ratio;
176
- byBatch.set(e.batchIdx, cur);
177
- }
178
- }
179
- const totalHeight = cart.totalHeightPct;
180
- const items = [];
181
- let weight = 0;
182
- for (const [batchIdx, agg] of byBatch) {
183
- const input = rests[batchIdx].input;
184
- const perCrate = Number(input.weight_per_crate ?? 0) || 0;
185
- weight += perCrate * agg.crates;
186
- items.push({
187
- order_item_id: input.order_item_id,
188
- order_id: input.order_id ?? null,
189
- product_id: input.product_id,
190
- product_name: input.product_name,
191
- crates: agg.crates,
192
- plants: agg.crates * input.plants_per_crate,
193
- boxes: agg.crates,
194
- weight_kg: perCrate > 0 ? Math.round(perCrate * agg.crates * 100) / 100 : null,
195
- plants_per_crate: input.plants_per_crate,
196
- crates_per_layer: rests[batchIdx].cratesPerLayer,
197
- layers_per_cart: Math.max(1, Math.round(100 / rests[batchIdx].layerHeightPct)),
198
- // 1.0.190: layer_fraction = vulling per laag (0–1); lagen = fusten ÷ fusten per laag.
199
- layer_fraction: Math.min(1, Math.round((agg.crates / Math.max(1, rests[batchIdx].cratesPerLayer)) * 1000) / 1000),
200
- lagen: Math.round((agg.crates / Math.max(1, rests[batchIdx].cratesPerLayer)) * 1000) / 1000,
201
- layer_share_pct: agg.heightPct,
202
- });
203
- }
204
- return {
205
- cart_number: cartNumber,
206
- carrier_type: r.opts.carrier.code,
207
- carrier_type_id: r.opts.carrier.id ?? null,
208
- source: "auto",
209
- is_complete: Math.abs(cart.totalHeightPct - 100) < 1e-6,
210
- occupancy_pct: Math.round(cart.totalHeightPct * 100) / 100,
211
- total_layers_used: cart.layers.length,
212
- max_layers: Math.max(1, Math.round(r.opts.carrier.layers_per_carrier || 1)),
213
- weight_kg: weight > 0 ? Math.round(weight * 100) / 100 : null,
214
- items,
215
- };
216
- }
217
- function newCart() {
218
- return {
219
- layers: [],
220
- totalHeightPct: 0,
221
- boxes: 0,
222
- weightKg: 0,
223
- orderIds: new Set(),
224
- customerIds: new Set(),
225
- };
226
- }
227
140
  function fullCart(input, crates, cratesPerLayer, layers, cartNumber, r) {
228
141
  const perCrate = Number(input.weight_per_crate ?? 0) || 0;
229
142
  return {
@@ -232,9 +145,9 @@ function fullCart(input, crates, cratesPerLayer, layers, cartNumber, r) {
232
145
  carrier_type_id: r.opts.carrier.id ?? null,
233
146
  source: "auto",
234
147
  is_complete: true,
235
- occupancy_pct: 100,
236
- total_layers_used: layers,
237
- max_layers: Math.max(1, Math.round(r.opts.carrier.layers_per_carrier || 1)),
148
+ occupancy_pct: Math.round((10000 * crates) / Math.max(1, cratesPerLayer * layers)) / 100,
149
+ total_layers_used: Math.round((crates / Math.max(1, cratesPerLayer)) * 100) / 100,
150
+ max_layers: layers,
238
151
  weight_kg: perCrate > 0 ? Math.round(perCrate * crates * 100) / 100 : null,
239
152
  items: [
240
153
  {
@@ -272,8 +185,188 @@ function sortInputs(items, sortOrder) {
272
185
  }
273
186
  }
274
187
  // ---------------------------------------------------------------------------
275
- // Hoofdfunctie
188
+ // 1.0.204: capaciteit per kar (zelfde regel als Floriday)
276
189
  // ---------------------------------------------------------------------------
190
+ // lagen op de kar = Σ (fust ÷ fusten per laag) per regel
191
+ // capaciteit = hoogste "lagen per kar" van de producten op de kar
192
+ // vol = lagen ≥ capaciteit (− 0,001)
193
+ // verdelen = vul een kar tot de capaciteit, rest naar de volgende kar
194
+ // Voegt een product met meer lagen per kar toe, dan stijgt de capaciteit.
195
+ // ---------------------------------------------------------------------------
196
+ export const LAGEN_MARGE = 0.001;
197
+ /** Capaciteit van een kar: hoogste lagen-per-kar van de producten; anders de ladingdrager. */
198
+ export function karCapaciteit(productLagen, ladingdragerLagen) {
199
+ const geldig = productLagen.map(Number).filter((n) => Number.isFinite(n) && n > 0);
200
+ if (geldig.length > 0)
201
+ return { capaciteit: Math.max(...geldig), bron: "product" };
202
+ const l = Number(ladingdragerLagen);
203
+ if (ladingdragerLagen != null && Number.isFinite(l) && l > 0)
204
+ return { capaciteit: l, bron: "ladingdrager" };
205
+ return { capaciteit: null, bron: null };
206
+ }
207
+ /** Vulgraad in procenten (heel getal), of null als de capaciteit onbekend is. */
208
+ export function karVulgraad(lagen, capaciteit) {
209
+ if (capaciteit == null || !(capaciteit > 0) || !Number.isFinite(lagen))
210
+ return null;
211
+ return Math.round((100 * lagen) / capaciteit);
212
+ }
213
+ function productLagen(item, carrier) {
214
+ if (carrier.capacity_model === "single")
215
+ return 1;
216
+ let l = Math.max(1, Math.round(item.layers_per_cart));
217
+ const grens = carrier.werkwijze_max_lagen;
218
+ if (typeof grens === "number" && grens > 0 && !item.packing_authoritative)
219
+ l = Math.min(l, Math.round(grens));
220
+ return l;
221
+ }
222
+ function accepteert(kar, rest, crates, r) {
223
+ const c = r.opts.carrier;
224
+ const orderId = rest.input.order_id ?? null;
225
+ if (orderId && !kar.orderIds.has(orderId) && r.opts.maxOrders && kar.orderIds.size >= r.opts.maxOrders)
226
+ return false;
227
+ const klant = rest.input.customer_id ?? null;
228
+ if (klant && !kar.customerIds.has(klant) && r.opts.maxCustomers && kar.customerIds.size >= r.opts.maxCustomers)
229
+ return false;
230
+ if (typeof c.max_boxes === "number" && c.max_boxes > 0 && kar.boxes + crates > c.max_boxes)
231
+ return false;
232
+ if (typeof c.max_weight_kg === "number" && c.max_weight_kg > 0) {
233
+ const pc = Number(rest.input.weight_per_crate ?? 0) || 0;
234
+ if (pc > 0 && kar.weightKg + pc * crates > c.max_weight_kg + EPS)
235
+ return false;
236
+ }
237
+ return true;
238
+ }
239
+ /** Hoeveel fusten van deze rest passen nog op de kar (0 = niets). */
240
+ /** Lagen van een kar volgens de mengregels, optioneel met extra fusten van een rest. */
241
+ function regelsVan(kar, rests, extra) {
242
+ const per = new Map();
243
+ for (const e of kar.entries)
244
+ per.set(e.restIdx, (per.get(e.restIdx) ?? 0) + e.crates);
245
+ if (extra)
246
+ per.set(extra.restIdx, (per.get(extra.restIdx) ?? 0) + extra.crates);
247
+ return [...per.entries()].map(([i, crates]) => ({
248
+ regelId: rests[i].input.order_item_id,
249
+ klantId: rests[i].input.customer_id ?? rests[i].input.order_id ?? null,
250
+ crates,
251
+ cpl: rests[i].cpl,
252
+ lpc: rests[i].lpc,
253
+ }));
254
+ }
255
+ function vulVan(kar, rests, r, extra) {
256
+ return karVulgraadFractie(regelsVan(kar, rests, extra), r.opts.laagMenging, r.opts.laagGroepen);
257
+ }
258
+ function lagenVan(kar, rests, r, extra) {
259
+ const per = new Map();
260
+ for (const e of kar.entries)
261
+ per.set(e.restIdx, (per.get(e.restIdx) ?? 0) + e.crates);
262
+ if (extra)
263
+ per.set(extra.restIdx, (per.get(extra.restIdx) ?? 0) + extra.crates);
264
+ const regels = [...per.entries()].map(([i, crates]) => ({
265
+ regelId: rests[i].input.order_item_id,
266
+ klantId: rests[i].input.customer_id ?? rests[i].input.order_id ?? null,
267
+ crates,
268
+ cpl: rests[i].cpl,
269
+ }));
270
+ return karLagen(regels, r.opts.laagMenging, r.opts.laagGroepen);
271
+ }
272
+ function pastErop(kar, rest, r, rests, restIdx) {
273
+ const neverMix = r.opts.carrier.allow_mixing === false ||
274
+ (rest.input.product_id ? r.opts.neverMixProductIds.has(rest.input.product_id) : false);
275
+ if (kar.entries.length > 0 && (neverMix || kar.neverMix))
276
+ return 0;
277
+ // 1.0.208: vol = vulgraad 100 % (Σ lagen van regel ÷ lagen per kar van die regel).
278
+ const vrij = 1 - kar.vul;
279
+ if (vrij <= LAGEN_MARGE)
280
+ return 0;
281
+ let n = Math.min(rest.crates, Math.floor((vrij + LAGEN_MARGE) * rest.lpc * rest.cpl + 1e-9));
282
+ const c = r.opts.carrier;
283
+ if (typeof c.max_boxes === "number" && c.max_boxes > 0)
284
+ n = Math.min(n, c.max_boxes - kar.boxes);
285
+ if (typeof c.max_weight_kg === "number" && c.max_weight_kg > 0) {
286
+ const pc = Number(rest.input.weight_per_crate ?? 0) || 0;
287
+ if (pc > 0)
288
+ n = Math.min(n, Math.floor((c.max_weight_kg - kar.weightKg + EPS) / pc));
289
+ }
290
+ // Mengregels: grootste n waarbij de kar binnen de capaciteit blijft (monotoon → binair zoeken).
291
+ const past = (m) => vulVan(kar, rests, r, { restIdx, crates: m }) <= 1 + LAGEN_MARGE;
292
+ if (n > 0 && !past(n)) {
293
+ let lo = 0, hi = n;
294
+ while (lo < hi) {
295
+ const mid = Math.ceil((lo + hi) / 2);
296
+ if (past(mid))
297
+ lo = mid;
298
+ else
299
+ hi = mid - 1;
300
+ }
301
+ n = lo;
302
+ }
303
+ if (n <= 0 || !accepteert(kar, rest, n, r))
304
+ return 0;
305
+ return n;
306
+ }
307
+ function zetErop(kar, rest, restIdx, crates, r, rests) {
308
+ kar.entries.push({ restIdx, crates });
309
+ kar.capaciteit = Math.max(kar.capaciteit, rest.lpc);
310
+ kar.boxes += crates;
311
+ kar.weightKg += (Number(rest.input.weight_per_crate ?? 0) || 0) * crates;
312
+ if (rest.input.order_id)
313
+ kar.orderIds.add(rest.input.order_id);
314
+ if (rest.input.customer_id)
315
+ kar.customerIds.add(rest.input.customer_id);
316
+ if (r.opts.carrier.allow_mixing === false ||
317
+ (rest.input.product_id ? r.opts.neverMixProductIds.has(rest.input.product_id) : false))
318
+ kar.neverMix = true;
319
+ rest.crates -= crates;
320
+ kar.lagen = lagenVan(kar, rests, r);
321
+ kar.vul = vulVan(kar, rests, r);
322
+ }
323
+ function nieuweKar() {
324
+ return { entries: [], lagen: 0, vul: 0, capaciteit: 0, boxes: 0, weightKg: 0, orderIds: new Set(), customerIds: new Set(), neverMix: false, eigen: false };
325
+ }
326
+ function samenvatten(kar, rests, nr, r) {
327
+ const per = new Map();
328
+ for (const e of kar.entries)
329
+ per.set(e.restIdx, (per.get(e.restIdx) ?? 0) + e.crates);
330
+ const items = [];
331
+ let weight = 0;
332
+ const sleutel = (i) => `${rests[i].input.product_name}\u0000${rests[i].input.order_item_id}`;
333
+ for (const [idx, crates] of [...per.entries()].sort((a, b) => sleutel(a[0]).localeCompare(sleutel(b[0])))) {
334
+ const rest = rests[idx];
335
+ const input = rest.input;
336
+ const pc = Number(input.weight_per_crate ?? 0) || 0;
337
+ weight += pc * crates;
338
+ const lagen = regelLagen(crates, rest.cpl, r.opts.laagMenging);
339
+ items.push({
340
+ order_item_id: input.order_item_id,
341
+ order_id: input.order_id ?? null,
342
+ product_id: input.product_id,
343
+ product_name: input.product_name,
344
+ crates,
345
+ plants: crates * input.plants_per_crate,
346
+ boxes: crates,
347
+ weight_kg: pc > 0 ? Math.round(pc * crates * 100) / 100 : null,
348
+ plants_per_crate: input.plants_per_crate,
349
+ crates_per_layer: rest.cpl,
350
+ layers_per_cart: rest.lpc,
351
+ layer_fraction: Math.min(1, Math.round(lagen * 1000) / 1000),
352
+ lagen: Math.round(lagen * 1000) / 1000,
353
+ layer_share_pct: Math.round((10000 * lagen) / kar.capaciteit) / 100,
354
+ });
355
+ }
356
+ const lagen = Math.round(kar.lagen * 100) / 100;
357
+ return {
358
+ cart_number: nr,
359
+ carrier_type: r.opts.carrier.code,
360
+ carrier_type_id: r.opts.carrier.id ?? null,
361
+ source: "auto",
362
+ is_complete: kar.vul >= 1 - LAGEN_MARGE,
363
+ occupancy_pct: Math.round(10000 * kar.vul) / 100,
364
+ total_layers_used: lagen,
365
+ max_layers: kar.capaciteit,
366
+ weight_kg: weight > 0 ? Math.round(weight * 100) / 100 : null,
367
+ items,
368
+ };
369
+ }
277
370
  export function planLoadUnits(inputs, options) {
278
371
  const carrier = options.carrier;
279
372
  const r = {
@@ -288,6 +381,8 @@ export function planLoadUnits(inputs, options) {
288
381
  oneCustomerPerLayer: options.oneCustomerPerLayer ?? false,
289
382
  ownCarrierFromLayers: options.ownCarrierFromLayers ?? null,
290
383
  sortOrder: options.sortOrder ?? "none",
384
+ laagMenging: options.laagMenging ?? "vrij",
385
+ laagGroepen: options.laagGroepen ?? null,
291
386
  },
292
387
  };
293
388
  const warnings = [];
@@ -301,7 +396,6 @@ export function planLoadUnits(inputs, options) {
301
396
  layers_per_cart: clampInt(i.layers_per_cart, 1),
302
397
  }))
303
398
  .filter((i) => i.plants > 0), r.opts.sortOrder);
304
- // --- Model "none": elke partij is één losse eenheid, geen menging ---------
305
399
  if (carrier.capacity_model === "none") {
306
400
  let n = 0;
307
401
  for (const it of items) {
@@ -310,91 +404,70 @@ export function planLoadUnits(inputs, options) {
310
404
  }
311
405
  return { carts, warnings };
312
406
  }
407
+ // STAP A: volle karren per product (capaciteit = lagen per kar van dat product).
313
408
  const rests = [];
314
- let cartNumber = 0;
315
- // --- STAP A: volledige ladingdragers per artikel --------------------------
316
409
  for (const it of items) {
317
- const layers = effectiveLayers(it, carrier);
410
+ const lpc = productLagen(it, carrier);
318
411
  const cpl = effectiveCratesPerLayer(it, carrier);
319
- let cratesPerCart = cpl * layers;
320
- if (typeof carrier.max_boxes === "number" && carrier.max_boxes > 0) {
321
- cratesPerCart = Math.min(cratesPerCart, carrier.max_boxes);
322
- }
323
- const totalCrates = Math.ceil(it.plants / it.plants_per_crate);
324
- const fullCarts = Math.floor(totalCrates / cratesPerCart);
325
- let remainderCrates = totalCrates - fullCarts * cratesPerCart;
326
- for (let c = 0; c < fullCarts; c++) {
327
- carts.push(fullCart(it, cratesPerCart, cpl, layers, ++cartNumber, r));
328
- }
329
- // Partij die zelfstandig genoeg volle lagen vult krijgt een eigen drager.
330
- // 1.0.190: die drager blijft open voor restfusten (hele fusten, eigen laag
331
- // of bijmengen volgens de laagregels), zodat er geen extra kar ontstaat.
332
- const own = r.opts.ownCarrierFromLayers;
333
- const ownCarrier = Boolean(own && own > 0 && remainderCrates >= cpl * (own ?? 0));
334
- if (remainderCrates > 0) {
335
- rests.push({
336
- input: it,
337
- cratesRemaining: remainderCrates,
338
- layerHeightPct: 100 / layers,
339
- cratesPerLayer: cpl,
340
- beladingKey: `${it.plants_per_crate}x${cpl}x${layers}`,
341
- ownCarrier,
342
- });
412
+ let perKar = cpl * lpc;
413
+ if (typeof carrier.max_boxes === "number" && carrier.max_boxes > 0)
414
+ perKar = Math.min(perKar, carrier.max_boxes);
415
+ const totaal = Math.ceil(it.plants / it.plants_per_crate);
416
+ const vol = Math.floor(totaal / perKar);
417
+ for (let c = 0; c < vol; c++)
418
+ carts.push(fullCart(it, perKar, cpl, lpc, carts.length + 1, r));
419
+ const rest = totaal - vol * perKar;
420
+ if (rest > 0) {
421
+ const own = r.opts.ownCarrierFromLayers;
422
+ rests.push({ input: it, crates: rest, cpl, lpc, ownCarrier: Boolean(own && own > 0 && rest >= cpl * own) });
343
423
  }
344
424
  }
345
- // --- Sorteren van de restpartijen: eigen dragers eerst, dan first-fit decreasing
346
- rests.sort((a, b) => {
347
- if (Boolean(a.ownCarrier) !== Boolean(b.ownCarrier))
348
- return a.ownCarrier ? -1 : 1;
349
- if (a.beladingKey !== b.beladingKey)
350
- return a.beladingKey.localeCompare(b.beladingKey);
351
- return b.cratesRemaining - a.cratesRemaining;
425
+ // STAP B: restpartijen. Vaste volgorde (deterministisch): eigen drager eerst,
426
+ // dan hoogste capaciteit, dan meeste lagen, dan productnaam en regel-id.
427
+ const volgorde = rests
428
+ .map((rest, idx) => ({ rest, idx }))
429
+ .sort((a, b) => {
430
+ if (a.rest.ownCarrier !== b.rest.ownCarrier)
431
+ return a.rest.ownCarrier ? -1 : 1;
432
+ if (a.rest.lpc !== b.rest.lpc)
433
+ return b.rest.lpc - a.rest.lpc;
434
+ const la = a.rest.crates / a.rest.cpl, lb = b.rest.crates / b.rest.cpl;
435
+ if (Math.abs(la - lb) > 1e-9)
436
+ return lb - la;
437
+ return (a.rest.input.product_name.localeCompare(b.rest.input.product_name) ||
438
+ a.rest.input.order_item_id.localeCompare(b.rest.input.order_item_id));
352
439
  });
353
- // --- STAP B: de hoofdlus --------------------------------------------------
354
- const openCarts = [];
355
- for (let bi = 0; bi < rests.length; bi++) {
356
- const batch = rests[bi];
440
+ const open = [];
441
+ for (const { rest, idx } of volgorde) {
357
442
  let guard = 0;
358
- let eigenDrager = null;
359
- while (batch.cratesRemaining > 0 && guard++ < 1000) {
360
- let placed = 0;
361
- // Eigen drager: op een nieuwe drager beginnen en daar blijven (niet bij een ander inschuiven).
362
- const kandidaten = batch.ownCarrier ? (eigenDrager ? [eigenDrager] : []) : openCarts;
363
- for (const cart of kandidaten) {
364
- placed = tryPlaceBatch(cart, batch, bi, r);
365
- if (placed > 0) {
366
- batch.cratesRemaining -= placed;
443
+ while (rest.crates > 0 && guard++ < 10000) {
444
+ let geplaatst = false;
445
+ const kandidaten = rest.ownCarrier ? [] : open;
446
+ for (const kar of kandidaten) {
447
+ const n = pastErop(kar, rest, r, rests, idx);
448
+ if (n > 0) {
449
+ zetErop(kar, rest, idx, n, r, rests);
450
+ geplaatst = true;
367
451
  break;
368
452
  }
369
453
  }
370
- if (placed === 0) {
371
- const cart = newCart();
372
- placed = tryPlaceBatch(cart, batch, bi, r);
373
- if (placed === 0) {
374
- warnings.push(`Partij "${batch.input.product_name}" past niet volgens de veiligheidsregels van ${carrier.name} — geplaatst als losse ladingdrager.`);
375
- cart.layers.push({
376
- heightPct: 100,
377
- crateShare: 1,
378
- entries: [{ batchIdx: bi, crates: batch.cratesRemaining }],
379
- customerIds: new Set(batch.input.customer_id ? [batch.input.customer_id] : []),
380
- });
381
- cart.totalHeightPct = 100;
382
- registerPlacement(cart, batch, batch.cratesRemaining);
383
- placed = batch.cratesRemaining;
384
- }
385
- openCarts.push(cart);
386
- if (batch.ownCarrier && !eigenDrager)
387
- eigenDrager = cart;
388
- batch.cratesRemaining -= placed;
454
+ if (geplaatst)
455
+ continue;
456
+ const kar = nieuweKar();
457
+ kar.eigen = rest.ownCarrier;
458
+ const n = pastErop(kar, rest, r, rests, idx);
459
+ if (n > 0) {
460
+ zetErop(kar, rest, idx, n, r, rests);
389
461
  }
462
+ else {
463
+ warnings.push(`Partij "${rest.input.product_name}" past niet volgens de grenzen van ${carrier.name} — geplaatst als losse ladingdrager.`);
464
+ zetErop(kar, rest, idx, rest.crates, r, rests);
465
+ }
466
+ open.push(kar);
390
467
  }
391
468
  }
392
- for (const cart of openCarts) {
393
- carts.push(summarizeCart(cart, rests, ++cartNumber, r));
394
- }
395
- // Doornummeren zodat de volgorde altijd 1..n is.
396
- carts.forEach((c, idx) => {
397
- c.cart_number = idx + 1;
398
- });
469
+ for (const kar of open)
470
+ carts.push(samenvatten(kar, rests, carts.length + 1, r));
471
+ carts.forEach((c, i) => { c.cart_number = i + 1; });
399
472
  return { carts, warnings };
400
473
  }