@flowselections/floriday-verkoop-module 1.0.17 → 1.0.19

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 (57) hide show
  1. package/dist-lib/components/verkoop/CreateOrderPage.d.ts.map +1 -1
  2. package/dist-lib/components/verkoop/CreateOrderPage.js +6 -4
  3. package/dist-lib/components/verkoop/EditLocalOrderDialog.d.ts +6 -0
  4. package/dist-lib/components/verkoop/EditLocalOrderDialog.d.ts.map +1 -0
  5. package/dist-lib/components/verkoop/EditLocalOrderDialog.js +102 -0
  6. package/dist-lib/components/verkoop/OrderKarindelingCard.d.ts.map +1 -1
  7. package/dist-lib/components/verkoop/OrderKarindelingCard.js +26 -28
  8. package/dist-lib/components/verkoop/OrderShipmentsCard.d.ts +5 -0
  9. package/dist-lib/components/verkoop/OrderShipmentsCard.d.ts.map +1 -0
  10. package/dist-lib/components/verkoop/OrderShipmentsCard.js +137 -0
  11. package/dist-lib/components/verkoop/SalesOrderDetailPage.d.ts.map +1 -1
  12. package/dist-lib/components/verkoop/SalesOrderDetailPage.js +8 -2
  13. package/dist-lib/components/verkoop/SalesOrderDetailView.d.ts.map +1 -1
  14. package/dist-lib/components/verkoop/SalesOrderDetailView.js +2 -1
  15. package/dist-lib/components/verkoop/SalesOrdersTable.d.ts +4 -1
  16. package/dist-lib/components/verkoop/SalesOrdersTable.d.ts.map +1 -1
  17. package/dist-lib/components/verkoop/SalesOrdersTable.js +2 -2
  18. package/dist-lib/components/verkoop/VerkoopOrdersPage.d.ts.map +1 -1
  19. package/dist-lib/components/verkoop/VerkoopOrdersPage.js +43 -1
  20. package/dist-lib/integrations/supabase/auth-middleware.d.ts +436 -149
  21. package/dist-lib/integrations/supabase/auth-middleware.d.ts.map +1 -1
  22. package/dist-lib/integrations/supabase/client.d.ts +436 -149
  23. package/dist-lib/integrations/supabase/client.d.ts.map +1 -1
  24. package/dist-lib/integrations/supabase/client.server.d.ts +436 -149
  25. package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -1
  26. package/dist-lib/integrations/supabase/types.d.ts +452 -153
  27. package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
  28. package/dist-lib/lib/cart-lock.functions.d.ts +436 -149
  29. package/dist-lib/lib/cart-lock.functions.d.ts.map +1 -1
  30. package/dist-lib/lib/cart-replan.functions.d.ts +884 -310
  31. package/dist-lib/lib/cart-replan.functions.d.ts.map +1 -1
  32. package/dist-lib/lib/create-order.functions.d.ts +39491 -1379
  33. package/dist-lib/lib/create-order.functions.d.ts.map +1 -1
  34. package/dist-lib/lib/create-order.functions.js +814 -64
  35. package/dist-lib/lib/expand-group-order-items.d.ts +13 -0
  36. package/dist-lib/lib/expand-group-order-items.d.ts.map +1 -0
  37. package/dist-lib/lib/expand-group-order-items.js +216 -0
  38. package/dist-lib/lib/floriday-auto-fulfillment.functions.d.ts +1321 -460
  39. package/dist-lib/lib/floriday-auto-fulfillment.functions.d.ts.map +1 -1
  40. package/dist-lib/lib/floriday-logistic-labels.functions.d.ts +436 -149
  41. package/dist-lib/lib/floriday-logistic-labels.functions.d.ts.map +1 -1
  42. package/dist-lib/lib/floriday-public.functions.d.ts +3052 -1043
  43. package/dist-lib/lib/floriday-public.functions.d.ts.map +1 -1
  44. package/dist-lib/lib/floriday-writes.functions.d.ts +2180 -745
  45. package/dist-lib/lib/floriday-writes.functions.d.ts.map +1 -1
  46. package/dist-lib/lib/order-shipments.functions.d.ts +47382 -0
  47. package/dist-lib/lib/order-shipments.functions.d.ts.map +1 -0
  48. package/dist-lib/lib/order-shipments.functions.js +178 -0
  49. package/dist-lib/lib/salesorders-mapping.d.ts +1 -0
  50. package/dist-lib/lib/salesorders-mapping.d.ts.map +1 -1
  51. package/dist-lib/lib/salesorders.functions.d.ts +1321 -460
  52. package/dist-lib/lib/salesorders.functions.d.ts.map +1 -1
  53. package/dist-lib/lib/tradeitems.functions.d.ts +436 -149
  54. package/dist-lib/lib/tradeitems.functions.d.ts.map +1 -1
  55. package/dist-lib/lib/useSalesOrdersQuery.d.ts.map +1 -1
  56. package/dist-lib/lib/useSalesOrdersQuery.js +39 -2
  57. package/package.json +2 -2
@@ -16,28 +16,61 @@ export const listCustomersForCreateOrder = createServerFn({ method: "GET" })
16
16
  .range(0, 4999);
17
17
  return { items: data ?? [] };
18
18
  });
19
- // ─── Producten (uit voorraadmodule: alle actieve Floriday trade items) ─────
20
- // Bron = zelfde data als de Voorraad-pagina: floriday_trade_items_cache
21
- // gejoined met trade_item_stock. Items zonder voorraad worden ook getoond
22
- // (grijs in de UI, met melding "niet op voorraad"), zodat gebruikers ook
23
- // vooruit kunnen bestellen op verwachte voorraad.
19
+ // ─── Producten (uit voorraadmodule) ────────────────────────────────────────
20
+ // Bron = de volledige voorraad, ongeacht Floriday-publicatie:
21
+ // 1) floriday_trade_items_cache — gepubliceerde Floriday trade items
22
+ // 2) trade_item_drafts — interne artikelen (o.a. BV-artikelcodes)
23
+ // 3) product_groups — productgroepen / samenstellingen
24
+ // 4) products — losse voorraadartikelen (fallback)
25
+ // Stock komt uit trade_item_stock (key-formaten: `<floriday_id>`,
26
+ // `draft:<uuid>`, `product:<uuid>`, `group:<uuid>`).
24
27
  export const listProductsForCreateOrder = createServerFn({ method: "GET" })
25
28
  .middleware([requireSupabaseAuth])
26
29
  .handler(async ({ context }) => {
27
- const [tradeItemsRes, stockRes] = await Promise.all([
30
+ const [tradeItemsRes, draftsRes, groupsRes, groupItemsRes, productsRes] = await Promise.all([
28
31
  context.supabase
29
32
  .from("floriday_trade_items_cache")
30
33
  .select("floriday_id, data")
31
34
  .eq("is_deleted", false)
32
35
  .range(0, 4999),
33
36
  context.supabase
34
- .from("trade_item_stock")
35
- .select("trade_item_key, physical_stock, reserved_stock, incoming_stock"),
37
+ .from("trade_item_drafts")
38
+ .select("id, data, floriday_id")
39
+ .range(0, 4999),
40
+ context.supabase
41
+ .from("product_groups")
42
+ .select("id, name, description, is_active, head_trade_item_floriday_id, head_product_id")
43
+ .eq("is_active", true)
44
+ .range(0, 4999),
45
+ context.supabase
46
+ .from("product_group_items")
47
+ .select("product_group_id, trade_item_id, sort_order")
48
+ .order("sort_order", { ascending: true })
49
+ .range(0, 9999),
50
+ context.supabase
51
+ .from("products")
52
+ .select("id, product, unit, sale_price, plants_per_crate, crates_per_layer, layers_per_cart, quantity, custom_fields")
53
+ .range(0, 4999),
36
54
  ]);
37
- const tradeItems = tradeItemsRes.data;
38
- const stockRows = stockRes.data;
55
+ // Supabase geeft standaard max. 1000 rijen terug. Voor productgroepen kan de
56
+ // relevante voorraad op een member-key verderop in trade_item_stock staan,
57
+ // dus voorraad pagineren we expliciet volledig uit.
58
+ const stockRows = [];
59
+ const stockPageSize = 1000;
60
+ for (let from = 0; from < 50000; from += stockPageSize) {
61
+ const { data: batch, error } = await context.supabase
62
+ .from("trade_item_stock")
63
+ .select("trade_item_key, physical_stock, reserved_stock, incoming_stock")
64
+ .range(from, from + stockPageSize - 1);
65
+ if (error)
66
+ throw error;
67
+ const rows = Array.isArray(batch) ? batch : [];
68
+ stockRows.push(...rows);
69
+ if (rows.length < stockPageSize)
70
+ break;
71
+ }
39
72
  const stockByKey = new Map();
40
- for (const s of Array.isArray(stockRows) ? stockRows : []) {
73
+ for (const s of stockRows) {
41
74
  const k = s?.trade_item_key;
42
75
  if (typeof k !== "string")
43
76
  continue;
@@ -54,35 +87,278 @@ export const listProductsForCreateOrder = createServerFn({ method: "GET" })
54
87
  return n;
55
88
  return String(n.nl ?? n.en ?? n.de ?? Object.values(n)[0] ?? "");
56
89
  };
57
- const items = (Array.isArray(tradeItems) ? tradeItems : [])
58
- .map((r) => {
90
+ const byId = new Map();
91
+ // Dedup-index op logische identiteit (article_code of genormaliseerde naam),
92
+ // zodat hetzelfde product niet twee keer verschijnt als het zowel als
93
+ // Floriday-item, draft én products-rij bestaat.
94
+ const identityIndex = new Map(); // identityKey -> byId key
95
+ const norm = (s) => s.trim().toLowerCase().replace(/\s+/g, " ");
96
+ const identityKeysFor = (name, articleCode) => {
97
+ const keys = [];
98
+ if (articleCode && articleCode.trim())
99
+ keys.push(`ac:${norm(articleCode)}`);
100
+ if (name && name.trim())
101
+ keys.push(`nm:${norm(name)}`);
102
+ return keys;
103
+ };
104
+ const registerIdentity = (id, name, articleCode) => {
105
+ for (const k of identityKeysFor(name, articleCode)) {
106
+ if (!identityIndex.has(k))
107
+ identityIndex.set(k, id);
108
+ }
109
+ };
110
+ const findExistingId = (name, articleCode) => {
111
+ for (const k of identityKeysFor(name, articleCode)) {
112
+ const hit = identityIndex.get(k);
113
+ if (hit)
114
+ return hit;
115
+ }
116
+ return null;
117
+ };
118
+ const stockOf = (key) => {
119
+ const s = stockByKey.get(key);
120
+ const physical = s?.physical ?? 0;
121
+ const reserved = s?.reserved ?? 0;
122
+ return { physical, reserved, available: Math.max(0, physical - reserved) };
123
+ };
124
+ const stockOfAliases = (keys) => {
125
+ let fallback = null;
126
+ for (const k of keys) {
127
+ const s = stockByKey.get(k);
128
+ if (!s)
129
+ continue;
130
+ fallback ?? (fallback = s);
131
+ if (s.physical !== 0 || s.reserved !== 0 || s.incoming !== 0)
132
+ return s;
133
+ }
134
+ return fallback ?? { physical: 0, reserved: 0, incoming: 0 };
135
+ };
136
+ // Bouw index: draft_id/floriday_id → mirror products.id. De voorraad kan
137
+ // op de mirror-key (`product:<mirrorId>`) staan i.p.v. op `draft:<id>`
138
+ // of het bare Floriday-id.
139
+ const mirrorProductIdsByDraft = new Map();
140
+ const mirrorProductIdsByFloriday = new Map();
141
+ for (const p of Array.isArray(productsRes.data) ? productsRes.data : []) {
142
+ const cf = (p.custom_fields ?? {});
143
+ const pid = p.id;
144
+ const draftId = typeof cf.draft_id === "string" ? cf.draft_id : null;
145
+ const floridayId = typeof cf.floriday_id === "string" ? cf.floriday_id : null;
146
+ if (draftId) {
147
+ const arr = mirrorProductIdsByDraft.get(draftId) ?? [];
148
+ arr.push(pid);
149
+ mirrorProductIdsByDraft.set(draftId, arr);
150
+ }
151
+ if (floridayId) {
152
+ const arr = mirrorProductIdsByFloriday.get(floridayId) ?? [];
153
+ arr.push(pid);
154
+ mirrorProductIdsByFloriday.set(floridayId, arr);
155
+ }
156
+ }
157
+ // 1) Floriday trade items
158
+ for (const r of Array.isArray(tradeItemsRes.data) ? tradeItemsRes.data : []) {
59
159
  const d = r?.data ?? {};
60
160
  const name = pickName(d.tradeItemName) || d.tradeItemReference || "(naamloos)";
61
161
  const packs = Array.isArray(d.packingConfigurations) ? d.packingConfigurations : [];
62
- const primary = packs.find((p) => p?.primary) ?? packs[0] ?? {};
63
- const ppc = Math.max(1, Number(primary?.piecesPerPackage ?? 1));
64
- const cpl = Math.max(1, Number(primary?.packagesPerLayer ?? 1));
65
- const lpc = Math.max(1, Number(primary?.layersPerLoadCarrier ?? 1));
66
- const stock = stockByKey.get(r.floriday_id);
67
- const physical = stock?.physical ?? 0;
68
- const reserved = stock?.reserved ?? 0;
162
+ const primary = packs.find((p) => p?.primary || p?.isPrimary) ?? packs[0] ?? {};
163
+ const id = r.floriday_id;
164
+ const articleCode = d.supplierArticleCode ?? null;
165
+ const mirrorKeys = (mirrorProductIdsByFloriday.get(id) ?? []).map((pid) => `product:${pid}`);
166
+ const sAgg = stockOfAliases([id, ...mirrorKeys]);
167
+ const physical = sAgg.physical;
168
+ const reserved = sAgg.reserved;
69
169
  const available = Math.max(0, physical - reserved);
70
- return {
71
- id: r.floriday_id,
170
+ byId.set(id, {
171
+ id,
72
172
  product: name,
73
- article_code: d.supplierArticleCode ?? null,
173
+ article_code: articleCode,
74
174
  unit: "stuks",
75
175
  sale_price: null,
76
- plants_per_crate: ppc,
77
- crates_per_layer: cpl,
78
- layers_per_cart: lpc,
176
+ plants_per_crate: Math.max(1, Number(primary?.piecesPerPackage ?? primary?.unitsPerPackage ?? 1)),
177
+ crates_per_layer: Math.max(1, Number(primary?.packagesPerLayer ?? 1)),
178
+ layers_per_cart: Math.max(1, Number(primary?.layersPerLoadCarrier ?? 1)),
79
179
  physical_stock: physical,
80
180
  reserved_stock: reserved,
81
181
  available_stock: available,
82
182
  in_stock: available > 0,
83
- };
84
- })
85
- .sort((a, b) => {
183
+ source: "floriday",
184
+ });
185
+ registerIdentity(id, name, articleCode);
186
+ }
187
+ // 2) Interne concept-artikelen (BV-codes, Excel-import, etc.)
188
+ for (const r of Array.isArray(draftsRes.data) ? draftsRes.data : []) {
189
+ const d = r?.data ?? {};
190
+ // Sla drafts over die al als Floriday-item verschijnen
191
+ const linkedFloridayId = r.floriday_id;
192
+ if (linkedFloridayId && byId.has(linkedFloridayId))
193
+ continue;
194
+ const id = r.id;
195
+ const name = pickName(d.name) || pickName(d.tradeItemName) || d.articleCode || "(naamloos concept)";
196
+ const articleCode = d.articleCode ?? d.supplierArticleCode ?? null;
197
+ // Skip als er al een logisch identiek item bestaat (zelfde artikelcode of naam)
198
+ if (findExistingId(name, articleCode))
199
+ continue;
200
+ const packs = Array.isArray(d.packingConfigurations) ? d.packingConfigurations : [];
201
+ const primary = packs.find((p) => p?.primary || p?.isPrimary) ?? packs[0] ?? {};
202
+ const draftMirrorKeys = (mirrorProductIdsByDraft.get(id) ?? []).map((pid) => `product:${pid}`);
203
+ const sAggD = stockOfAliases([`draft:${id}`, ...draftMirrorKeys]);
204
+ const physical = sAggD.physical;
205
+ const reserved = sAggD.reserved;
206
+ const available = Math.max(0, physical - reserved);
207
+ byId.set(id, {
208
+ id,
209
+ product: name,
210
+ article_code: articleCode,
211
+ unit: "stuks",
212
+ sale_price: typeof d.salePricePerPiece === "number" ? d.salePricePerPiece : null,
213
+ // Drafts gebruiken Floriday-veldnamen: unitsPerPackage / packagesPerLayer / layersPerLoadCarrier
214
+ plants_per_crate: Math.max(1, Number(primary?.unitsPerPackage ?? primary?.piecesPerPackage ?? 1)),
215
+ crates_per_layer: Math.max(1, Number(primary?.packagesPerLayer ?? 1)),
216
+ layers_per_cart: Math.max(1, Number(primary?.layersPerLoadCarrier ?? 1)),
217
+ physical_stock: physical,
218
+ reserved_stock: reserved,
219
+ available_stock: available,
220
+ in_stock: available > 0,
221
+ source: "draft",
222
+ });
223
+ registerIdentity(id, name, articleCode);
224
+ }
225
+ // 3) Productgroepen (samenstellingen) — belading komt van het eerste
226
+ // (hoogst gerangschikte) product uit de verkoopranglijst binnen de groep.
227
+ // Bij een wijziging in sort_order past de belading zich automatisch aan
228
+ // omdat we hier live opnieuw kijken naar de bovenste rij.
229
+ const groupItemsByGroup = new Map();
230
+ for (const gi of Array.isArray(groupItemsRes.data) ? groupItemsRes.data : []) {
231
+ const gid = gi.product_group_id;
232
+ const arr = groupItemsByGroup.get(gid) ?? [];
233
+ arr.push({
234
+ trade_item_id: gi.trade_item_id,
235
+ sort_order: Number(gi.sort_order ?? 0),
236
+ });
237
+ groupItemsByGroup.set(gid, arr);
238
+ }
239
+ // Products-rijen ook indexeren, zodat groep-members die naar een products.id
240
+ // wijzen (i.p.v. floriday_id of draft-id) alsnog gevonden worden.
241
+ const productsById = new Map();
242
+ for (const p of Array.isArray(productsRes.data) ? productsRes.data : []) {
243
+ productsById.set(p.id, p);
244
+ }
245
+ const packingFromProductRow = (p) => ({
246
+ plants_per_crate: Math.max(1, Number(p?.plants_per_crate ?? 1)),
247
+ crates_per_layer: Math.max(1, Number(p?.crates_per_layer ?? 1)),
248
+ layers_per_cart: Math.max(1, Number(p?.layers_per_cart ?? 1)),
249
+ });
250
+ for (const r of Array.isArray(groupsRes.data) ? groupsRes.data : []) {
251
+ const id = r.id;
252
+ if (byId.has(id))
253
+ continue;
254
+ const groupName = r.name ?? "(naamloos)";
255
+ // Groepen laten we niet dupliceren met een gelijknamig product/draft:
256
+ // de groep wint (want geeft samenstelling). Verwijder een eerder toegevoegd
257
+ // los product met dezelfde identiteit.
258
+ const displayName = `[Groep] ${groupName}`;
259
+ const dup = findExistingId(groupName, null);
260
+ if (dup && byId.has(dup))
261
+ byId.delete(dup);
262
+ // Voorraad: eigen key `group:<id>` óf gesommeerd over de members
263
+ // (voorraad kan op de member-id, `draft:<id>` of `product:<id>` staan).
264
+ let { physical, reserved, available } = stockOf(`group:${id}`);
265
+ const members = (groupItemsByGroup.get(id) ?? [])
266
+ .slice()
267
+ .sort((a, b) => a.sort_order - b.sort_order);
268
+ if (physical === 0 && reserved === 0) {
269
+ let phySum = 0;
270
+ let resSum = 0;
271
+ for (const m of members) {
272
+ const s = stockOfAliases([`draft:${m.trade_item_id}`, `product:${m.trade_item_id}`, m.trade_item_id]);
273
+ phySum += s.physical;
274
+ resSum += s.reserved;
275
+ }
276
+ physical = phySum;
277
+ reserved = resSum;
278
+ available = Math.max(0, physical - reserved);
279
+ }
280
+ // Probeer belading van de bovenste member; val terug op head_trade_item /
281
+ // head_product_id als de member niet in byId of products staat.
282
+ let packing = { plants_per_crate: 1, crates_per_layer: 1, layers_per_cart: 1 };
283
+ const candidates = [
284
+ members[0]?.trade_item_id ?? null,
285
+ r.head_trade_item_floriday_id ?? null,
286
+ r.head_product_id ?? null,
287
+ ];
288
+ for (const cand of candidates) {
289
+ if (!cand)
290
+ continue;
291
+ const fromBy = byId.get(cand);
292
+ if (fromBy) {
293
+ packing = {
294
+ plants_per_crate: fromBy.plants_per_crate,
295
+ crates_per_layer: fromBy.crates_per_layer,
296
+ layers_per_cart: fromBy.layers_per_cart,
297
+ };
298
+ break;
299
+ }
300
+ const fromProd = productsById.get(cand);
301
+ if (fromProd) {
302
+ packing = packingFromProductRow(fromProd);
303
+ break;
304
+ }
305
+ }
306
+ byId.set(id, {
307
+ id,
308
+ product: displayName,
309
+ article_code: null,
310
+ unit: "stuks",
311
+ sale_price: null,
312
+ plants_per_crate: packing.plants_per_crate,
313
+ crates_per_layer: packing.crates_per_layer,
314
+ layers_per_cart: packing.layers_per_cart,
315
+ physical_stock: physical,
316
+ reserved_stock: reserved,
317
+ available_stock: available,
318
+ in_stock: available > 0,
319
+ source: "group",
320
+ });
321
+ registerIdentity(id, groupName, null);
322
+ }
323
+ // 4) Losse voorraadartikelen (products-tabel) — fallback
324
+ for (const r of Array.isArray(productsRes.data) ? productsRes.data : []) {
325
+ const id = r.id;
326
+ if (byId.has(id))
327
+ continue;
328
+ const cf = (r.custom_fields ?? {});
329
+ // Sla mirrors van Floriday/draft items over
330
+ if (typeof cf.floriday_id === "string" && byId.has(cf.floriday_id))
331
+ continue;
332
+ if (typeof cf.draft_id === "string" && byId.has(cf.draft_id))
333
+ continue;
334
+ const name = r.product ?? "(naamloos)";
335
+ const articleCode = cf.article_code ?? null;
336
+ // Skip als er al een logisch identiek item (draft/floriday/group) bestaat
337
+ if (findExistingId(name, articleCode))
338
+ continue;
339
+ const stockA = stockOf(`product:${id}`);
340
+ const stockB = stockOf(id);
341
+ const physical = stockA.physical || stockB.physical || Number(r.quantity ?? 0);
342
+ const reserved = stockA.reserved || stockB.reserved || 0;
343
+ const available = Math.max(0, physical - reserved);
344
+ byId.set(id, {
345
+ id,
346
+ product: name,
347
+ article_code: articleCode,
348
+ unit: r.unit ?? "stuks",
349
+ sale_price: r.sale_price ?? null,
350
+ plants_per_crate: Math.max(1, Number(r.plants_per_crate ?? 1)),
351
+ crates_per_layer: Math.max(1, Number(r.crates_per_layer ?? 1)),
352
+ layers_per_cart: Math.max(1, Number(r.layers_per_cart ?? 1)),
353
+ physical_stock: physical,
354
+ reserved_stock: reserved,
355
+ available_stock: available,
356
+ in_stock: available > 0,
357
+ source: "product",
358
+ });
359
+ registerIdentity(id, name, articleCode);
360
+ }
361
+ const items = Array.from(byId.values()).sort((a, b) => {
86
362
  if (a.in_stock !== b.in_stock)
87
363
  return a.in_stock ? -1 : 1;
88
364
  return a.product.localeCompare(b.product, "nl");
@@ -172,7 +448,8 @@ const CreateManualOrderInput = z.object({
172
448
  deliveryLocationId: z.string().uuid(),
173
449
  pickupLocationId: z.string().min(1),
174
450
  carrierId: z.string().uuid(),
175
- deliveryDate: z.string().min(1).max(50),
451
+ deliveryDate: z.string().min(1).max(50).nullable().optional(),
452
+ isReservation: z.boolean().optional(),
176
453
  notes: z.string().max(2000).nullable().optional(),
177
454
  lines: z.array(LineInput).min(1),
178
455
  });
@@ -199,49 +476,118 @@ export const createManualOrder = createServerFn({ method: "POST" })
199
476
  .limit(1).maybeSingle();
200
477
  const pickDays = Math.max(0, Number(settings?.pick_days_before_transport ?? 0));
201
478
  const earliestISO = toISODate(addCalendarDays(new Date(), pickDays + carrierDeliveryDays));
202
- if (data.deliveryDate < earliestISO) {
203
- throw new Error(`Leverdatum (${data.deliveryDate}) ligt voor de eerst mogelijke datum (${earliestISO}) o.b.v. orderpicktijd (${pickDays}d) + levertijd vervoerder (${carrierDeliveryDays}d).`);
479
+ if (!data.isReservation) {
480
+ if (!data.deliveryDate)
481
+ throw new Error("Leverdatum ontbreekt.");
482
+ if (data.deliveryDate < earliestISO) {
483
+ throw new Error(`Leverdatum (${data.deliveryDate}) ligt voor de eerst mogelijke datum (${earliestISO}) o.b.v. orderpicktijd (${pickDays}d) + levertijd vervoerder (${carrierDeliveryDays}d).`);
484
+ }
204
485
  }
205
486
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
206
- // Zorg dat elk product_id (= trade_item_key uit voorraad) een rij in
207
- // `products` heeft — de order_items FK vereist dit én de karindeling
487
+ // Zorg dat elk product_id (= identiteit uit de voorraadmodule) een rij
488
+ // in `products` heeft — de order_items FK vereist dit én de karindeling
208
489
  // (planAndSaveAutoCarts) joined op products voor de packing-waarden.
490
+ //
491
+ // BELANGRIJK: de wizard geeft als product_id een identiteit terug die uit
492
+ // 1 van 4 bronnen komt: floriday_trade_items_cache.floriday_id,
493
+ // trade_item_drafts.id, product_groups.id, of products.id. Om te
494
+ // voorkomen dat de voorraadmodule onze "spiegelrijen" verwart met
495
+ // eigen data, zetten we per bron het juiste `trade_item_key`-formaat,
496
+ // een niet-misleidend `article_number` en een duidelijke `source` in
497
+ // `custom_fields` (zodat de voorraadmodule ze veilig kan negeren of
498
+ // opruimen).
209
499
  const productIds = Array.from(new Set(data.lines.map((l) => l.product_id)));
210
- const { data: existingProducts } = await supabaseAdmin
211
- .from("products").select("id").in("id", productIds);
212
- const existingIds = new Set((existingProducts ?? []).map((p) => p.id));
213
- const missingLines = data.lines.filter((l) => !existingIds.has(l.product_id));
500
+ const [existingProductsRes, draftsRes, floridayRes, groupsRes] = await Promise.all([
501
+ supabaseAdmin.from("products").select("id").in("id", productIds),
502
+ supabaseAdmin.from("trade_item_drafts").select("id, data").in("id", productIds),
503
+ supabaseAdmin.from("floriday_trade_items_cache").select("floriday_id, data").in("floriday_id", productIds),
504
+ supabaseAdmin.from("product_groups").select("id, name").in("id", productIds),
505
+ ]);
506
+ const existingIds = new Set((existingProductsRes.data ?? []).map((p) => p.id));
507
+ const draftById = new Map();
508
+ for (const r of draftsRes.data ?? [])
509
+ draftById.set(r.id, r.data ?? {});
510
+ const floridayById = new Map();
511
+ for (const r of floridayRes.data ?? [])
512
+ floridayById.set(r.floriday_id, r.data ?? {});
513
+ const groupById = new Map();
514
+ for (const r of groupsRes.data ?? [])
515
+ groupById.set(r.id, r);
516
+ // Groepen krijgen GEEN spiegelrij in `products`: de database heeft een
517
+ // trigger die id-botsingen tussen products en product_groups tegenhoudt.
518
+ // De order-regel wordt daarom met product_id = null én naamprefix
519
+ // "[Groep] <naam>" opgeslagen; expand-group-order-items resolvt de groep
520
+ // dan op naam en splitst hem in members.
521
+ const missingLines = data.lines.filter((l) => !existingIds.has(l.product_id) && !groupById.has(l.product_id));
214
522
  if (missingLines.length > 0) {
523
+ const shortRand = () => Array.from({ length: 6 }, () => Math.floor(Math.random() * 16).toString(16).toUpperCase()).join("");
215
524
  const seen = new Set();
216
525
  const toInsert = missingLines.filter((l) => {
217
526
  if (seen.has(l.product_id))
218
527
  return false;
219
528
  seen.add(l.product_id);
220
529
  return true;
221
- }).map((l) => ({
222
- id: l.product_id,
223
- product: l.product_name,
224
- batch: "",
225
- location: "",
226
- unit: l.unit ?? "stuks",
227
- plants_per_crate: Math.max(1, Number(l.plants_per_crate ?? 1)),
228
- crates_per_layer: Math.max(1, Number(l.crates_per_layer ?? 1)),
229
- layers_per_cart: Math.max(1, Number(l.layers_per_cart ?? 1)),
230
- sale_price: l.unit_price ?? null,
231
- trade_item_key: l.product_id,
232
- custom_fields: { floriday_id: l.product_id, source: "auto-from-voorraad" },
233
- }));
234
- const { error: prodErr } = await supabaseAdmin.from("products").insert(toInsert);
235
- if (prodErr)
236
- throw new Error(`Product aanmaken faalde: ${prodErr.message}`);
530
+ }).map((l) => {
531
+ const id = l.product_id;
532
+ let source = "unknown";
533
+ let tradeItemKey = id;
534
+ let articleNumber = null;
535
+ const customFields = {};
536
+ if (draftById.has(id)) {
537
+ source = "draft";
538
+ const draft = draftById.get(id) ?? {};
539
+ tradeItemKey = `draft:${id}`;
540
+ articleNumber = (typeof draft.articleCode === "string" && draft.articleCode.trim())
541
+ ? String(draft.articleCode).trim()
542
+ : `DRAFT-${shortRand()}`;
543
+ customFields.draft_id = id;
544
+ customFields.source = "sales-module-draft-mirror";
545
+ }
546
+ else if (floridayById.has(id)) {
547
+ source = "floriday";
548
+ const fd = floridayById.get(id) ?? {};
549
+ tradeItemKey = id; // Floriday-format is de kale floriday_id
550
+ articleNumber = (typeof fd.supplierArticleCode === "string" && fd.supplierArticleCode.trim())
551
+ ? String(fd.supplierArticleCode).trim()
552
+ : `FLR-${shortRand()}`;
553
+ customFields.floriday_id = id;
554
+ customFields.source = "sales-module-floriday-mirror";
555
+ }
556
+ else {
557
+ source = "unknown";
558
+ tradeItemKey = `product:${id}`;
559
+ articleNumber = l.article_code?.trim() || `ART-${shortRand()}`;
560
+ customFields.source = "sales-module-unknown-mirror";
561
+ }
562
+ return {
563
+ id,
564
+ product: l.product_name,
565
+ batch: "",
566
+ location: "",
567
+ unit: l.unit ?? "stuks",
568
+ plants_per_crate: Math.max(1, Number(l.plants_per_crate ?? 1)),
569
+ crates_per_layer: Math.max(1, Number(l.crates_per_layer ?? 1)),
570
+ layers_per_cart: Math.max(1, Number(l.layers_per_cart ?? 1)),
571
+ sale_price: l.unit_price ?? null,
572
+ trade_item_key: tradeItemKey,
573
+ article_number: articleNumber,
574
+ custom_fields: { ...customFields, mirror_kind: source },
575
+ };
576
+ });
577
+ if (toInsert.length > 0) {
578
+ const { error: prodErr } = await supabaseAdmin.from("products").insert(toInsert);
579
+ if (prodErr)
580
+ throw new Error(`Product aanmaken faalde: ${prodErr.message}`);
581
+ }
237
582
  }
238
583
  const total = data.lines.reduce((s, l) => s + (Number(l.unit_price ?? 0) * Number(l.quantity)), 0);
239
584
  const { data: order, error: orderErr } = await supabaseAdmin.from("orders").insert({
240
585
  customer_name: data.customerName,
241
586
  customer_id: data.customerId,
242
587
  order_date: new Date().toISOString().slice(0, 10),
243
- delivery_date: data.deliveryDate,
244
- status: "nieuw",
588
+ delivery_date: data.isReservation ? null : data.deliveryDate,
589
+ is_reservation: !!data.isReservation,
590
+ status: data.isReservation ? "gereserveerd" : "nieuw",
245
591
  total: String(total.toFixed(2)),
246
592
  notes: data.notes ?? null,
247
593
  custom_fields: {
@@ -249,18 +595,29 @@ export const createManualOrder = createServerFn({ method: "POST" })
249
595
  delivery_location: data.deliveryLocationId,
250
596
  pickup_location: data.pickupLocationId,
251
597
  carrier: data.carrierId,
598
+ is_reservation: !!data.isReservation,
252
599
  },
253
600
  }).select("id, order_number").single();
254
601
  if (orderErr)
255
602
  throw new Error(orderErr.message);
256
- const items = data.lines.map((l) => ({
257
- order_id: order.id,
258
- product_id: l.product_id,
259
- product_name: l.product_name,
260
- quantity: Math.max(1, Math.round(l.quantity)),
261
- unit: l.unit ?? "stuks",
262
- unit_price: l.unit_price ?? undefined,
263
- }));
603
+ const items = data.lines.map((l) => {
604
+ const isGroup = groupById.has(l.product_id);
605
+ const groupRow = isGroup ? groupById.get(l.product_id) : null;
606
+ const groupName = groupRow?.name ?? l.product_name;
607
+ const displayName = isGroup
608
+ ? (/^\s*\[Groep\]/i.test(l.product_name) ? l.product_name : `[Groep] ${groupName}`)
609
+ : l.product_name;
610
+ return {
611
+ order_id: order.id,
612
+ // Groepen krijgen product_id = null; expand-group-order-items
613
+ // resolvt hen op naam ("[Groep] <naam>").
614
+ product_id: isGroup ? null : l.product_id,
615
+ product_name: displayName,
616
+ quantity: Math.max(1, Math.round(l.quantity)),
617
+ unit: l.unit ?? "stuks",
618
+ unit_price: l.unit_price ?? undefined,
619
+ };
620
+ });
264
621
  const { error: itemsErr } = await supabaseAdmin.from("order_items").insert(items);
265
622
  if (itemsErr)
266
623
  throw new Error(itemsErr.message);
@@ -275,6 +632,112 @@ export const createManualOrder = createServerFn({ method: "POST" })
275
632
  void context; // reserveren indien later audit-log nodig is
276
633
  return { ok: true, orderId: order.id };
277
634
  });
635
+ // ─── Verwijderen van een lokale bestelling ────────────────────────────────
636
+ // Draait alle voorraadmutaties van deze order terug (fysiek + gereserveerd)
637
+ // en verwijdert de order (order_items, order_carts en cart_replan_queue via
638
+ // cascade). FO-orders (Floriday) worden geweigerd — die kunnen alleen via
639
+ // Floriday zelf worden geannuleerd.
640
+ export const deleteLocalOrder = createServerFn({ method: "POST" })
641
+ .middleware([requireSupabaseAuth])
642
+ .inputValidator((input) => z.object({ orderId: z.string().uuid() }).parse(input))
643
+ .handler(async ({ data, context }) => {
644
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
645
+ const { data: order, error: oErr } = await supabaseAdmin
646
+ .from("orders")
647
+ .select("id, custom_fields, order_source")
648
+ .eq("id", data.orderId)
649
+ .maybeSingle();
650
+ if (oErr)
651
+ throw new Error(oErr.message);
652
+ if (!order)
653
+ throw new Error("Bestelling niet gevonden");
654
+ const cf = order.custom_fields ?? {};
655
+ const src = String(cf?.source ?? order.order_source ?? "").toLowerCase();
656
+ if (src === "floriday" || src === "fo") {
657
+ throw new Error("FO-orders kunnen niet verwijderd worden. Annuleer deze in Floriday.");
658
+ }
659
+ // Voorraadmutaties terugdraaien.
660
+ const { data: mutations, error: mErr } = await supabaseAdmin
661
+ .from("stock_mutation_log")
662
+ .select("trade_item_key, qty_delta, physical_before, physical_after, reserved_before, reserved_after")
663
+ .eq("order_id", data.orderId);
664
+ if (mErr)
665
+ throw new Error(`Voorraadmutaties ophalen faalde: ${mErr.message}`);
666
+ const perKey = new Map();
667
+ for (const m of mutations ?? []) {
668
+ const key = m.trade_item_key;
669
+ if (!key)
670
+ continue;
671
+ const pd = Number(m.physical_after ?? 0) - Number(m.physical_before ?? 0);
672
+ const rd = Number(m.reserved_after ?? 0) - Number(m.reserved_before ?? 0);
673
+ const qd = Number(m.qty_delta ?? 0);
674
+ const cur = perKey.get(key) ?? { physicalDelta: 0, reservedDelta: 0, qtyDelta: 0 };
675
+ cur.physicalDelta += pd;
676
+ cur.reservedDelta += rd;
677
+ cur.qtyDelta += qd;
678
+ perKey.set(key, cur);
679
+ }
680
+ const reversalRows = [];
681
+ for (const [key, agg] of perKey.entries()) {
682
+ if (agg.physicalDelta === 0 && agg.reservedDelta === 0)
683
+ continue;
684
+ const { data: stockRows } = await supabaseAdmin
685
+ .from("trade_item_stock")
686
+ .select("id, connection_id, physical_stock, reserved_stock")
687
+ .eq("trade_item_key", key);
688
+ for (const s of stockRows ?? []) {
689
+ const physBefore = Number(s.physical_stock ?? 0);
690
+ const resvBefore = Number(s.reserved_stock ?? 0);
691
+ const physAfter = physBefore - agg.physicalDelta;
692
+ const resvAfter = Math.max(0, resvBefore - agg.reservedDelta);
693
+ const { error: uErr } = await supabaseAdmin.from("trade_item_stock")
694
+ .update({
695
+ physical_stock: physAfter,
696
+ reserved_stock: resvAfter,
697
+ updated_at: new Date().toISOString(),
698
+ })
699
+ .eq("id", s.id);
700
+ if (uErr)
701
+ throw new Error(`Voorraad terugdraaien faalde: ${uErr.message}`);
702
+ reversalRows.push({
703
+ trade_item_key: key,
704
+ connection_id: s.connection_id,
705
+ order_id: data.orderId,
706
+ order_source: src || "manual",
707
+ mutation_type: "reversal",
708
+ qty_delta: -agg.qtyDelta,
709
+ physical_before: physBefore,
710
+ physical_after: physAfter,
711
+ reserved_before: resvBefore,
712
+ reserved_after: resvAfter,
713
+ reason: "Bestelling verwijderd — mutaties teruggedraaid",
714
+ created_by: context.userId,
715
+ });
716
+ }
717
+ }
718
+ if (reversalRows.length > 0) {
719
+ const { error: lErr } = await supabaseAdmin.from("stock_mutation_log").insert(reversalRows);
720
+ if (lErr)
721
+ throw new Error(`Reversal-log schrijven faalde: ${lErr.message}`);
722
+ }
723
+ // Eerst order_items verwijderen — de AFTER DELETE trigger op order_items
724
+ // enqueue't naar cart_replan_queue met order_id, dus de orders-rij moet
725
+ // op dat moment nog bestaan (anders FK-violation).
726
+ await supabaseAdmin.from("order_items").delete().eq("order_id", data.orderId);
727
+ // Queue-entries opruimen die de trigger zojuist heeft aangemaakt.
728
+ await supabaseAdmin.from("cart_replan_queue").delete().eq("order_id", data.orderId);
729
+ const { error: dErr } = await supabaseAdmin
730
+ .from("orders")
731
+ .delete()
732
+ .eq("id", data.orderId);
733
+ if (dErr)
734
+ throw new Error(`Bestelling verwijderen faalde: ${dErr.message}`);
735
+ return {
736
+ ok: true,
737
+ reversedKeys: perKey.size,
738
+ reversalEntries: reversalRows.length,
739
+ };
740
+ });
278
741
  export const planAndSaveAutoCarts = createServerFn({ method: "POST" })
279
742
  .middleware([requireSupabaseAuth])
280
743
  .inputValidator((input) => z.object({ orderId: z.string().uuid() }).parse(input))
@@ -424,3 +887,290 @@ export const saveManualCarts = createServerFn({ method: "POST" })
424
887
  }
425
888
  return { ok: true };
426
889
  });
890
+ // ─── Lokale bestelling bewerken (aantal + leverdatum) ─────────────────────
891
+ // Alleen voor lokale/handmatige bestellingen. FO-orders worden geweigerd.
892
+ // Stock (reserved) en karindeling worden automatisch bijgewerkt door
893
+ // DB-triggers op order_items / orders (trg_order_items_reserve,
894
+ // trg_orders_recompute_reserved, orders_replan_trg, order_items_replan_trg).
895
+ const UpdateLocalOrderInput = z.object({
896
+ orderId: z.string().uuid(),
897
+ deliveryDate: z.string().min(1).max(50).optional(),
898
+ items: z
899
+ .array(z.object({
900
+ itemId: z.string().uuid(),
901
+ quantity: z.number().int().positive(),
902
+ }))
903
+ .optional(),
904
+ });
905
+ export const getLocalOrderForEdit = createServerFn({ method: "POST" })
906
+ .middleware([requireSupabaseAuth])
907
+ .inputValidator((input) => z.object({ orderId: z.string().uuid() }).parse(input))
908
+ .handler(async ({ data, context }) => {
909
+ const { data: order, error: oErr } = await context.supabase
910
+ .from("orders")
911
+ .select("id, delivery_date, custom_fields, order_source, order_number, is_reservation, status")
912
+ .eq("id", data.orderId)
913
+ .maybeSingle();
914
+ if (oErr)
915
+ throw new Error(oErr.message);
916
+ if (!order)
917
+ throw new Error("Bestelling niet gevonden");
918
+ const cf = order.custom_fields ?? {};
919
+ const src = String(cf?.source ?? order.order_source ?? "").toLowerCase();
920
+ if (src === "floriday" || src === "fo") {
921
+ throw new Error("FO-orders kunnen niet bewerkt worden.");
922
+ }
923
+ const { data: items, error: iErr } = await context.supabase
924
+ .from("order_items")
925
+ .select("id, product_id, product_name, quantity, unit, products(plants_per_crate)")
926
+ .eq("order_id", data.orderId)
927
+ .order("id");
928
+ if (iErr)
929
+ throw new Error(iErr.message);
930
+ // Voor group order-items (en items die naar drafts/trade_items verwijzen)
931
+ // levert de products-join niets op. Resolve belading via:
932
+ // 1) product_groups → product_group_items → belading van eerste member
933
+ // (via products / trade_item_drafts / floriday_trade_items_cache)
934
+ // 2) direct via drafts/cache voor niet-group items
935
+ const rows = (items ?? []);
936
+ const GROUP_PREFIX = /^\s*\[Groep\]\s*/i;
937
+ // Verzamel group-ids (per id of per naam)
938
+ const groupCandidateIds = rows
939
+ .filter((it) => it.product_id && !it.products)
940
+ .map((it) => it.product_id);
941
+ const groupNameCandidates = Array.from(new Set(rows
942
+ .filter((it) => !it.product_id && GROUP_PREFIX.test(it.product_name ?? ""))
943
+ .map((it) => it.product_name.replace(GROUP_PREFIX, "").trim())
944
+ .filter(Boolean)));
945
+ const groupIdSet = new Set();
946
+ const groupIdByName = new Map();
947
+ if (groupCandidateIds.length || groupNameCandidates.length) {
948
+ const [byId, byName] = await Promise.all([
949
+ groupCandidateIds.length
950
+ ? context.supabase.from("product_groups").select("id, name").in("id", groupCandidateIds)
951
+ : Promise.resolve({ data: [] }),
952
+ groupNameCandidates.length
953
+ ? context.supabase.from("product_groups").select("id, name").in("name", groupNameCandidates)
954
+ : Promise.resolve({ data: [] }),
955
+ ]);
956
+ for (const g of byId.data ?? [])
957
+ groupIdSet.add(g.id);
958
+ for (const g of byName.data ?? []) {
959
+ groupIdSet.add(g.id);
960
+ groupIdByName.set(g.name.trim().toLowerCase(), g.id);
961
+ }
962
+ }
963
+ // Members per group
964
+ const memberMap = new Map();
965
+ const memberIds = new Set();
966
+ if (groupIdSet.size) {
967
+ const { data: gis } = await context.supabase
968
+ .from("product_group_items")
969
+ .select("product_group_id, trade_item_id, sort_order")
970
+ .in("product_group_id", Array.from(groupIdSet));
971
+ for (const gi of gis ?? []) {
972
+ const gid = gi.product_group_id;
973
+ const arr = memberMap.get(gid) ?? [];
974
+ arr.push({
975
+ trade_item_id: gi.trade_item_id,
976
+ sort_order: Number(gi.sort_order ?? 0),
977
+ });
978
+ memberMap.set(gid, arr);
979
+ memberIds.add(gi.trade_item_id);
980
+ }
981
+ }
982
+ // Ook non-group items zonder products-join proberen we via drafts/cache.
983
+ const directIds = rows
984
+ .filter((it) => it.product_id && !it.products && !groupIdSet.has(it.product_id))
985
+ .map((it) => it.product_id);
986
+ for (const id of directIds)
987
+ memberIds.add(id);
988
+ const ppcById = new Map();
989
+ if (memberIds.size) {
990
+ const ids = Array.from(memberIds);
991
+ const [prodRes, draftRes, tradeRes] = await Promise.all([
992
+ context.supabase
993
+ .from("products")
994
+ .select("id, plants_per_crate, custom_fields")
995
+ .or(`id.in.(${ids.join(",")}),custom_fields->>floriday_id.in.(${ids.join(",")})`),
996
+ context.supabase.from("trade_item_drafts").select("id, data").in("id", ids),
997
+ context.supabase.from("floriday_trade_items_cache").select("floriday_id, data").in("floriday_id", ids),
998
+ ]);
999
+ for (const p of prodRes.data ?? []) {
1000
+ const ppc = Math.max(1, Number(p.plants_per_crate ?? 1));
1001
+ ppcById.set(p.id, ppc);
1002
+ const fid = p.custom_fields?.floriday_id;
1003
+ if (fid && !ppcById.has(fid))
1004
+ ppcById.set(fid, ppc);
1005
+ }
1006
+ for (const d of draftRes.data ?? []) {
1007
+ if (ppcById.has(d.id))
1008
+ continue;
1009
+ const cfg = Array.isArray(d.data?.packingConfigurations)
1010
+ ? (d.data.packingConfigurations.find((c) => c?.isPrimary) ??
1011
+ d.data.packingConfigurations[0])
1012
+ : null;
1013
+ const ppc = Math.max(1, Number(cfg?.unitsPerPackage ?? cfg?.piecesPerPackage ?? 1));
1014
+ if (ppc > 1)
1015
+ ppcById.set(d.id, ppc);
1016
+ }
1017
+ for (const t of tradeRes.data ?? []) {
1018
+ if (ppcById.has(t.floriday_id))
1019
+ continue;
1020
+ const cfg = t.data?.packingConfiguration;
1021
+ const ppc = Math.max(1, Number(cfg?.piecesPerPackage ?? cfg?.unitsPerPackage ?? 1));
1022
+ if (ppc > 1)
1023
+ ppcById.set(t.floriday_id, ppc);
1024
+ }
1025
+ }
1026
+ const groupPpc = (groupId) => {
1027
+ const members = (memberMap.get(groupId) ?? []).slice().sort((a, b) => a.sort_order - b.sort_order);
1028
+ for (const m of members) {
1029
+ const p = ppcById.get(m.trade_item_id);
1030
+ if (p && p > 1)
1031
+ return p;
1032
+ }
1033
+ return 1;
1034
+ };
1035
+ // Reeds geleverde/geplande aantallen per order-item — nodig zodat het
1036
+ // bewerkscherm niet onder deze ondergrens kan gaan.
1037
+ const itemIds = rows.map((r) => r.id);
1038
+ const shippedByItem = new Map();
1039
+ const plannedByItem = new Map();
1040
+ if (itemIds.length > 0) {
1041
+ const { data: sRows } = await context.supabase
1042
+ .from("order_shipment_items")
1043
+ .select("order_item_id, quantity, order_shipments!inner(status)")
1044
+ .in("order_item_id", itemIds);
1045
+ for (const r of sRows ?? []) {
1046
+ const st = r.order_shipments?.status;
1047
+ const oi = r.order_item_id;
1048
+ const q = Number(r.quantity ?? 0);
1049
+ if (st === "shipped")
1050
+ shippedByItem.set(oi, (shippedByItem.get(oi) ?? 0) + q);
1051
+ else if (st === "planned")
1052
+ plannedByItem.set(oi, (plannedByItem.get(oi) ?? 0) + q);
1053
+ }
1054
+ }
1055
+ return {
1056
+ orderId: order.id,
1057
+ orderNumber: order.order_number,
1058
+ deliveryDate: order.delivery_date,
1059
+ isReservation: !!order.is_reservation,
1060
+ status: order.status,
1061
+ items: rows.map((it) => {
1062
+ let ppc = Math.max(1, Number(it.products?.plants_per_crate ?? 1));
1063
+ if (ppc === 1 && it.product_id && groupIdSet.has(it.product_id)) {
1064
+ ppc = groupPpc(it.product_id);
1065
+ }
1066
+ else if (ppc === 1 && !it.product_id && GROUP_PREFIX.test(it.product_name ?? "")) {
1067
+ const key = it.product_name.replace(GROUP_PREFIX, "").trim().toLowerCase();
1068
+ const gid = groupIdByName.get(key);
1069
+ if (gid)
1070
+ ppc = groupPpc(gid);
1071
+ }
1072
+ else if (ppc === 1 && it.product_id && ppcById.has(it.product_id)) {
1073
+ ppc = ppcById.get(it.product_id);
1074
+ }
1075
+ const shipped = shippedByItem.get(it.id) ?? 0;
1076
+ const planned = plannedByItem.get(it.id) ?? 0;
1077
+ return {
1078
+ id: it.id,
1079
+ productId: it.product_id,
1080
+ productName: it.product_name,
1081
+ quantity: Number(it.quantity ?? 0),
1082
+ unit: it.unit,
1083
+ plantsPerCrate: ppc,
1084
+ shippedQuantity: shipped,
1085
+ plannedQuantity: planned,
1086
+ minQuantity: shipped + planned,
1087
+ };
1088
+ }),
1089
+ };
1090
+ });
1091
+ export const updateLocalOrder = createServerFn({ method: "POST" })
1092
+ .middleware([requireSupabaseAuth])
1093
+ .inputValidator((input) => UpdateLocalOrderInput.parse(input))
1094
+ .handler(async ({ data }) => {
1095
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
1096
+ const { data: order, error: oErr } = await supabaseAdmin
1097
+ .from("orders")
1098
+ .select("id, custom_fields, order_source, is_reservation")
1099
+ .eq("id", data.orderId)
1100
+ .maybeSingle();
1101
+ if (oErr)
1102
+ throw new Error(oErr.message);
1103
+ if (!order)
1104
+ throw new Error("Bestelling niet gevonden");
1105
+ const cf = order.custom_fields ?? {};
1106
+ const src = String(cf?.source ?? order.order_source ?? "").toLowerCase();
1107
+ if (src === "floriday" || src === "fo") {
1108
+ throw new Error("FO-orders kunnen niet bewerkt worden.");
1109
+ }
1110
+ // Valideer per regel dat het nieuwe aantal niet onder de reeds geleverde
1111
+ // of geplande hoeveelheid zakt — anders zouden bezorgmomenten negatief
1112
+ // openstaand krijgen.
1113
+ if (data.items && data.items.length > 0) {
1114
+ const itemIds = data.items.map((i) => i.itemId);
1115
+ const { data: existing } = await supabaseAdmin
1116
+ .from("order_items")
1117
+ .select("id, product_name")
1118
+ .in("id", itemIds);
1119
+ const nameById = new Map((existing ?? []).map((r) => [r.id, r.product_name]));
1120
+ const { data: sRows } = await supabaseAdmin
1121
+ .from("order_shipment_items")
1122
+ .select("order_item_id, quantity, order_shipments!inner(status)")
1123
+ .in("order_item_id", itemIds);
1124
+ const minByItem = new Map();
1125
+ for (const r of sRows ?? []) {
1126
+ const st = r.order_shipments?.status;
1127
+ if (st !== "shipped" && st !== "planned")
1128
+ continue;
1129
+ const oi = r.order_item_id;
1130
+ minByItem.set(oi, (minByItem.get(oi) ?? 0) + Number(r.quantity ?? 0));
1131
+ }
1132
+ for (const it of data.items) {
1133
+ const min = minByItem.get(it.itemId) ?? 0;
1134
+ if (it.quantity < min) {
1135
+ const name = nameById.get(it.itemId) ?? "Regel";
1136
+ throw new Error(`"${name}" kan niet naar ${it.quantity} — er is al ${min} gepland/geleverd op bezorgmomenten. Verwijder of verlaag eerst een bezorgmoment.`);
1137
+ }
1138
+ }
1139
+ }
1140
+ // Leverdatum bijwerken (alleen als niet-reservering).
1141
+ if (data.deliveryDate && !order.is_reservation) {
1142
+ const { error: uErr } = await supabaseAdmin.from("orders")
1143
+ .update({ delivery_date: data.deliveryDate, updated_at: new Date().toISOString() })
1144
+ .eq("id", data.orderId);
1145
+ if (uErr)
1146
+ throw new Error(`Leverdatum bijwerken faalde: ${uErr.message}`);
1147
+ }
1148
+ if (data.items && data.items.length > 0) {
1149
+ for (const it of data.items) {
1150
+ const { error: iErr } = await supabaseAdmin.from("order_items")
1151
+ .update({ quantity: it.quantity })
1152
+ .eq("id", it.itemId)
1153
+ .eq("order_id", data.orderId);
1154
+ if (iErr)
1155
+ throw new Error(`Aantal bijwerken faalde: ${iErr.message}`);
1156
+ }
1157
+ // Hertellen ordertotaal op basis van items × unit_price.
1158
+ const { data: itemsAll } = await supabaseAdmin
1159
+ .from("order_items")
1160
+ .select("quantity, unit_price")
1161
+ .eq("order_id", data.orderId);
1162
+ const total = (itemsAll ?? []).reduce((s, r) => s + Number(r.unit_price ?? 0) * Number(r.quantity ?? 0), 0);
1163
+ await supabaseAdmin.from("orders")
1164
+ .update({ total: String(total.toFixed(2)), updated_at: new Date().toISOString() })
1165
+ .eq("id", data.orderId);
1166
+ }
1167
+ // Karindeling opnieuw draaien zodat het scherm de nieuwe belading toont.
1168
+ try {
1169
+ const { replanOrderCartsFromCron } = await import("./cart-replan.functions");
1170
+ await replanOrderCartsFromCron(data.orderId);
1171
+ }
1172
+ catch (err) {
1173
+ console.error("[updateLocalOrder] karindeling faalde:", err);
1174
+ }
1175
+ return { ok: true };
1176
+ });