@flowselections/mailbox-orders 1.0.9 → 1.0.10

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/MailboxAuthBanner.d.ts +16 -0
  2. package/dist-lib/components/MailboxAuthBanner.d.ts.map +1 -0
  3. package/dist-lib/components/MailboxAuthBanner.js +14 -0
  4. package/dist-lib/components/MailboxProcessedPage.d.ts.map +1 -1
  5. package/dist-lib/components/MailboxProcessedPage.js +10 -2
  6. package/dist-lib/components/MailboxProposalsPage.d.ts.map +1 -1
  7. package/dist-lib/components/MailboxProposalsPage.js +425 -104
  8. package/dist-lib/components/SearchableSelect.d.ts +1 -0
  9. package/dist-lib/components/SearchableSelect.d.ts.map +1 -1
  10. package/dist-lib/components/SearchableSelect.js +2 -1
  11. package/dist-lib/components/settings/MailboxAiPromptCard.d.ts +2 -0
  12. package/dist-lib/components/settings/MailboxAiPromptCard.d.ts.map +1 -0
  13. package/dist-lib/components/settings/MailboxAiPromptCard.js +39 -0
  14. package/dist-lib/index.d.ts.map +1 -1
  15. package/dist-lib/index.js +2 -0
  16. package/dist-lib/integrations/supabase/auth-attacher.d.ts.map +1 -1
  17. package/dist-lib/integrations/supabase/auth-attacher.js +5 -1
  18. package/dist-lib/integrations/supabase/auth-middleware.d.ts +2 -11202
  19. package/dist-lib/integrations/supabase/auth-middleware.d.ts.map +1 -1
  20. package/dist-lib/integrations/supabase/auth-middleware.js +8 -7
  21. package/dist-lib/integrations/supabase/client.d.ts +16514 -7163
  22. package/dist-lib/integrations/supabase/client.d.ts.map +1 -1
  23. package/dist-lib/integrations/supabase/client.server.d.ts +16516 -7164
  24. package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -1
  25. package/dist-lib/integrations/supabase/types.d.ts +16768 -7202
  26. package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
  27. package/dist-lib/integrations/supabase/types.js +59 -8
  28. package/dist-lib/lib/ai-config.server.d.ts +46 -0
  29. package/dist-lib/lib/ai-config.server.d.ts.map +1 -0
  30. package/dist-lib/lib/ai-config.server.js +99 -0
  31. package/dist-lib/lib/delivery-date.d.ts +37 -0
  32. package/dist-lib/lib/delivery-date.d.ts.map +1 -0
  33. package/dist-lib/lib/delivery-date.js +96 -0
  34. package/dist-lib/lib/load-carriers.d.ts +26 -0
  35. package/dist-lib/lib/load-carriers.d.ts.map +1 -0
  36. package/dist-lib/lib/load-carriers.js +73 -0
  37. package/dist-lib/lib/mailbox-ai-prompt.d.ts +9 -0
  38. package/dist-lib/lib/mailbox-ai-prompt.d.ts.map +1 -0
  39. package/dist-lib/lib/mailbox-ai-prompt.js +30 -0
  40. package/dist-lib/lib/mailbox-ai-settings.functions.d.ts +23 -0
  41. package/dist-lib/lib/mailbox-ai-settings.functions.d.ts.map +1 -0
  42. package/dist-lib/lib/mailbox-ai-settings.functions.js +58 -0
  43. package/dist-lib/lib/mailbox-ai.server.d.ts +49 -4
  44. package/dist-lib/lib/mailbox-ai.server.d.ts.map +1 -1
  45. package/dist-lib/lib/mailbox-ai.server.js +284 -6
  46. package/dist-lib/lib/mailbox-body.d.ts +7 -0
  47. package/dist-lib/lib/mailbox-body.d.ts.map +1 -0
  48. package/dist-lib/lib/mailbox-body.js +38 -0
  49. package/dist-lib/lib/mailbox-ingest.server.d.ts +76 -0
  50. package/dist-lib/lib/mailbox-ingest.server.d.ts.map +1 -0
  51. package/dist-lib/lib/mailbox-ingest.server.js +863 -0
  52. package/dist-lib/lib/mailbox-template.functions.d.ts +7 -67213
  53. package/dist-lib/lib/mailbox-template.functions.d.ts.map +1 -1
  54. package/dist-lib/lib/mailbox.functions.d.ts +107 -190506
  55. package/dist-lib/lib/mailbox.functions.d.ts.map +1 -1
  56. package/dist-lib/lib/mailbox.functions.js +544 -270
  57. package/package.json +4 -3
@@ -3,6 +3,9 @@ import { createServerFn } from "@tanstack/react-start";
3
3
  import { requireSupabaseAuth } from "../integrations/supabase/auth-middleware";
4
4
  import { createClient } from "@supabase/supabase-js";
5
5
  import { z } from "zod";
6
+ import { computeEarliestDelivery } from "../lib/delivery-date";
7
+ import { emailBodyText } from "./mailbox-body";
8
+ import { EMPTY_LOAD_CARRIERS, isLoadCarrierAllowed, loadCarrierLabel, normalizeAllowedLoadCarriers, } from "./load-carriers";
6
9
  // ─── Actieve mailbox (uit gedeelde e-mail-auth module) ──────────────────────
7
10
  // Geen auth-middleware: de RPC is SECURITY DEFINER en EXECUTE staat ook aan
8
11
  // `anon`. Zo blijft de status-banner werken als de bearer-token tijdelijk
@@ -37,242 +40,80 @@ export const getActiveOrdersMailbox = createServerFn({ method: "GET" })
37
40
  needs_reauth: !!row.needs_reauth,
38
41
  };
39
42
  });
40
- // ─── Event ingest helpers ────────────────────────────────────────────────────
41
- function pickStr(obj, ...keys) {
42
- for (const k of keys) {
43
- const v = k.split(".").reduce((a, p) => (a == null ? a : a[p]), obj);
44
- if (typeof v === "string" && v.length > 0)
45
- return v;
46
- }
47
- return null;
48
- }
49
- async function ingestSingleEvent(supabaseAdmin, connectionId, ev) {
50
- const p = ev.payload ?? {};
51
- const externalId = ev.external_id ?? pickStr(p, "message_id", "messageId", "id") ?? ev.id;
52
- // Skip als deze external_id al bestaat
53
- if (externalId) {
54
- const { data: existing } = await supabaseAdmin
55
- .from("mailbox_messages")
56
- .select("id")
57
- .eq("external_id", externalId)
58
- .maybeSingle();
59
- if (existing?.id) {
60
- return { inserted: false, messageId: existing.id };
43
+ // ─── Ingest-triggers ────────────────────────────────────────────────────────
44
+ // De volledige ingest-pipeline (events → messages → stub-proposals) leeft in
45
+ // `src/lib/mailbox-ingest.server.ts`. Onderstaande server-fns zijn dunne
46
+ // wrappers zodat de client dezelfde pipeline kan afvuren als pg_cron.
47
+ // Bewust ZONDER `requireSupabaseAuth`-middleware: die gooit een raw `Response`
48
+ // (401/500) die in de browser als onleesbare "[object Response]"-runtimefout met
49
+ // blanco scherm eindigt. We verifiëren de bearer-token hier zelf en geven altijd
50
+ // nette JSON terug, zodat de UI de fout gewoon kan tonen.
51
+ export const syncMailboxNow = createServerFn({ method: "POST" })
52
+ .inputValidator((input) => z.object({ trigger: z.enum(["manual", "realtime"]).default("manual") }).parse(input ?? {}))
53
+ .handler(async ({ data }) => {
54
+ try {
55
+ const { getRequest } = await import("@tanstack/react-start/server");
56
+ const token = getRequest()?.headers?.get("authorization")?.replace(/^Bearer\s+/i, "") ?? "";
57
+ const url = process.env.SUPABASE_URL;
58
+ const key = process.env.SUPABASE_PUBLISHABLE_KEY;
59
+ if (!token || !url || !key) {
60
+ return { ok: false, error: "unauthorized", processed: 0, inserted: 0, errors: 1 };
61
61
  }
62
- }
63
- const fromEmail = pickStr(p, "from_email", "fromEmail", "from.address", "from.email", "sender.address") ?? "";
64
- const fromName = pickStr(p, "from_name", "fromName", "from.name", "sender.name");
65
- const subject = pickStr(p, "subject", "title") ?? "";
66
- const bodyText = pickStr(p, "body_text", "bodyText", "text", "body.text", "snippet");
67
- const bodyHtml = pickStr(p, "body_html", "bodyHtml", "html", "body.html");
68
- const receivedAt = pickStr(p, "received_at", "receivedAt", "date", "internalDate") ?? ev.received_at ?? new Date().toISOString();
69
- const { data: inserted, error: insErr } = await supabaseAdmin
70
- .from("mailbox_messages")
71
- .insert({
72
- external_id: externalId,
73
- message_id: pickStr(p, "message_id", "messageId") ?? externalId,
74
- connection_id: connectionId,
75
- from_email: fromEmail,
76
- from_name: fromName,
77
- subject,
78
- body_text: bodyText,
79
- body_html: bodyHtml,
80
- received_at: receivedAt,
81
- status: "new",
82
- })
83
- .select("id")
84
- .single();
85
- if (insErr) {
86
- // Duplicate race → markeer alsnog als consumed
87
- if (insErr.code === "23505") {
88
- return { inserted: false, messageId: null };
62
+ const sb = createClient(url, key, {
63
+ auth: { storage: undefined, persistSession: false, autoRefreshToken: false },
64
+ });
65
+ const { data: userData, error: userErr } = await sb.auth.getUser(token);
66
+ if (userErr || !userData?.user) {
67
+ return { ok: false, error: "unauthorized", processed: 0, inserted: 0, errors: 1 };
89
68
  }
90
- throw new Error(insErr.message);
91
- }
92
- // Maak direct een leeg voorstel aan (AI staat uit) — match klant op afzender
93
- await createStubProposal(supabaseAdmin, inserted.id, fromEmail);
94
- return { inserted: true, messageId: inserted.id };
95
- }
96
- async function createStubProposal(supabaseAdmin, messageId, fromEmail) {
97
- const { data: existing } = await supabaseAdmin
98
- .from("mailbox_order_proposals").select("id").eq("message_id", messageId).maybeSingle();
99
- if (existing?.id)
100
- return;
101
- const matchResult = await matchCustomerByEmail(supabaseAdmin, fromEmail);
102
- await supabaseAdmin.from("mailbox_order_proposals").insert({
103
- message_id: messageId,
104
- matched_customer_id: matchResult.customerId,
105
- confidence: matchResult.customerId ? 1 : 0,
106
- status: "pending",
107
- parsed_payload: {
108
- stub: true,
109
- reason: "ai_disabled",
110
- auto_matched_customer: matchResult.customerId !== null,
111
- auto_match_source: matchResult.source,
112
- auto_match_email: fromEmail,
113
- auto_match_warning: matchResult.warning,
114
- auto_match_candidates: matchResult.candidates,
115
- },
116
- });
117
- }
118
- // Exact (case-insensitive) match op afzender-e-mailadres.
119
- // Eerst customers.email, dan customer_contacts.email als fallback.
120
- // Bij meerdere treffers: geen automatische koppeling, wel waarschuwing.
121
- async function matchCustomerByEmail(supabaseAdmin, fromEmail) {
122
- if (!fromEmail) {
123
- return { customerId: null, source: null, warning: null, candidates: [] };
124
- }
125
- const email = fromEmail.trim();
126
- if (!email) {
127
- return { customerId: null, source: null, warning: null, candidates: [] };
128
- }
129
- // 1) customers.email (case-insensitive exact)
130
- const { data: directHits } = await supabaseAdmin
131
- .from("customers")
132
- .select("id, company_name")
133
- .ilike("email", email)
134
- .limit(5);
135
- const direct = Array.isArray(directHits) ? directHits : [];
136
- if (direct.length === 1) {
137
- return {
138
- customerId: direct[0].id,
139
- source: "customers",
140
- warning: null,
141
- candidates: direct.map((c) => ({ id: c.id, name: c.company_name })),
142
- };
143
- }
144
- if (direct.length > 1) {
145
- return {
146
- customerId: null,
147
- source: "customers",
148
- warning: `Meerdere klanten met e-mailadres ${email} — kies handmatig.`,
149
- candidates: direct.map((c) => ({ id: c.id, name: c.company_name })),
150
- };
151
- }
152
- // 2) customer_contacts.email (case-insensitive exact)
153
- const { data: contactHits } = await supabaseAdmin
154
- .from("customer_contacts")
155
- .select("customer_id")
156
- .ilike("email", email)
157
- .limit(5);
158
- const contacts = Array.isArray(contactHits) ? contactHits : [];
159
- const uniqueCustomerIds = Array.from(new Set(contacts.map((c) => c.customer_id).filter(Boolean)));
160
- if (uniqueCustomerIds.length === 0) {
161
- return { customerId: null, source: null, warning: null, candidates: [] };
69
+ const { runIngest } = await import("../lib/mailbox-ingest.server");
70
+ return await runIngest(data.trigger);
162
71
  }
163
- const { data: custRows } = await supabaseAdmin
164
- .from("customers")
165
- .select("id, company_name")
166
- .in("id", uniqueCustomerIds);
167
- const cands = (Array.isArray(custRows) ? custRows : []).map((c) => ({
168
- id: c.id,
169
- name: c.company_name,
170
- }));
171
- if (uniqueCustomerIds.length === 1) {
72
+ catch (e) {
172
73
  return {
173
- customerId: uniqueCustomerIds[0],
174
- source: "customer_contacts",
175
- warning: null,
176
- candidates: cands,
74
+ ok: false,
75
+ error: String(e?.message ?? e),
76
+ processed: 0,
77
+ inserted: 0,
78
+ errors: 1,
177
79
  };
178
80
  }
179
- return {
180
- customerId: null,
181
- source: "customer_contacts",
182
- warning: `Meerdere klanten gevonden via contactpersoon met e-mailadres ${email} — kies handmatig.`,
183
- candidates: cands,
184
- };
185
- }
186
- // ─── Ingest één specifiek event (vanuit realtime subscription) ───────────────
187
- export const ingestEmailEvent = createServerFn({ method: "POST" })
81
+ });
82
+ export const getLastIngestRun = createServerFn({ method: "GET" })
188
83
  .middleware([requireSupabaseAuth])
189
- .inputValidator((input) => z.object({ eventId: z.string().uuid() }).parse(input))
190
- .handler(async ({ data }) => {
84
+ .handler(async () => {
191
85
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
192
- const { data: ev, error: evErr } = await supabaseAdmin
193
- .from("email_auth_events")
194
- .select("id, connection_id, external_id, payload, received_at, consumed_at")
195
- .eq("id", data.eventId)
86
+ const { data } = await supabaseAdmin
87
+ .from("mailbox_ingest_runs")
88
+ .select("id, trigger, started_at, finished_at, processed, inserted, errors, error_sample")
89
+ .order("started_at", { ascending: false })
90
+ .limit(1)
196
91
  .maybeSingle();
197
- if (evErr)
198
- throw new Error(evErr.message);
199
- if (!ev)
200
- return { ok: false, error: "Event niet gevonden" };
201
- if (ev.consumed_at)
202
- return { ok: true, inserted: 0, autoProcessed: 0, autoApproved: 0 };
203
- const res = await ingestSingleEvent(supabaseAdmin, ev.connection_id, ev);
204
- await supabaseAdmin
205
- .from("email_auth_events")
206
- .update({ consumed_at: new Date().toISOString() })
207
- .eq("id", ev.id);
208
- // AI-verwerking uitgeschakeld — wacht op externe model-koppeling
209
- const autoProcessed = 0;
210
- const autoApproved = 0;
211
- return { ok: true, inserted: res.inserted ? 1 : 0, autoProcessed, autoApproved };
92
+ return { run: data ?? null };
212
93
  });
213
- // ─── Backfill: haal alle nog niet-consumed events op voor 'orders/receive' ───
214
- export const backfillRecentEvents = createServerFn({ method: "POST" })
94
+ /** Handmatig één voorstel opnieuw laten analyseren met de huidige AI-agent. */
95
+ export const reparseProposalNow = createServerFn({ method: "POST" })
215
96
  .middleware([requireSupabaseAuth])
216
- .handler(async ({ context }) => {
97
+ .inputValidator((input) => z.object({ proposalId: z.string().uuid() }).parse(input))
98
+ .handler(async ({ data }) => {
217
99
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
218
- // Welke connection is gekoppeld aan orders/receive?
219
- const { data: active, error: actErr } = await context.supabase.rpc("email_auth_for_purpose", {
220
- _purpose: "orders",
221
- _direction: "receive",
222
- });
223
- if (actErr)
224
- return { ok: false, error: actErr.message };
225
- const row = Array.isArray(active) ? active[0] : null;
226
- if (!row?.id)
227
- return { ok: false, error: "Geen actieve koppeling voor orders/receive" };
228
- const { data: events, error: evErr } = await supabaseAdmin
229
- .from("email_auth_events")
230
- .select("id, connection_id, external_id, payload, received_at")
231
- .eq("connection_id", row.id)
232
- .is("consumed_at", null)
233
- .order("received_at", { ascending: true })
234
- .limit(200);
235
- if (evErr)
236
- return { ok: false, error: evErr.message };
237
- let inserted = 0;
238
- const consumedIds = [];
239
- for (const ev of events ?? []) {
240
- try {
241
- const res = await ingestSingleEvent(supabaseAdmin, ev.connection_id, ev);
242
- if (res.inserted)
243
- inserted++;
244
- consumedIds.push(ev.id);
245
- }
246
- catch {
247
- // Fout → niet markeren als consumed, volgende ronde opnieuw proberen
248
- }
249
- }
250
- if (consumedIds.length > 0) {
251
- await supabaseAdmin
252
- .from("email_auth_events")
253
- .update({ consumed_at: new Date().toISOString() })
254
- .in("id", consumedIds);
255
- }
256
- // Maak ook stubs voor eerder ontvangen berichten zonder voorstel
257
- const { data: orphanMsgs } = await supabaseAdmin
258
- .from("mailbox_messages")
259
- .select("id, from_email, mailbox_order_proposals(id)")
260
- .order("received_at", { ascending: false })
261
- .limit(200);
262
- let stubbed = 0;
263
- for (const m of (orphanMsgs ?? [])) {
264
- if (Array.isArray(m.mailbox_order_proposals) && m.mailbox_order_proposals.length > 0)
265
- continue;
266
- await createStubProposal(supabaseAdmin, m.id, m.from_email ?? null);
267
- stubbed++;
268
- }
269
- return { ok: true, inserted: inserted + stubbed, autoProcessed: 0, autoApproved: 0 };
100
+ const { reparseProposal } = await import("../lib/mailbox-ingest.server");
101
+ return reparseProposal(supabaseAdmin, data.proposalId);
102
+ });
103
+ /** Detecteert een gewijzigde AI-agent en herverwerkt openstaande voorstellen. */
104
+ export const refreshProposalsForAgentChange = createServerFn({ method: "POST" })
105
+ .middleware([requireSupabaseAuth])
106
+ .handler(async () => {
107
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
108
+ const { reparseChangedAgentProposals } = await import("../lib/mailbox-ingest.server");
109
+ return reparseChangedAgentProposals(supabaseAdmin);
270
110
  });
271
111
  // ─── Messages & Proposals ────────────────────────────────────────────────────
272
112
  export const listMessages = createServerFn({ method: "GET" })
273
113
  .middleware([requireSupabaseAuth])
274
- .handler(async ({ context }) => {
275
- const { data, error } = await context.supabase
114
+ .handler(async () => {
115
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
116
+ const { data, error } = await supabaseAdmin
276
117
  .from("mailbox_messages")
277
118
  .select("id, uid, from_email, from_name, subject, received_at, status, error_message, created_at")
278
119
  .order("received_at", { ascending: false })
@@ -286,6 +127,7 @@ export const parseMessage = createServerFn({ method: "POST" })
286
127
  .inputValidator((input) => z.object({ messageId: z.string().uuid() }).parse(input))
287
128
  .handler(async ({ data }) => {
288
129
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
130
+ const { createStubProposal } = await import("../lib/mailbox-ingest.server");
289
131
  const { data: msg, error: msgErr } = await supabaseAdmin
290
132
  .from("mailbox_messages").select("*").eq("id", data.messageId).single();
291
133
  if (msgErr || !msg)
@@ -299,41 +141,41 @@ export const parseMessage = createServerFn({ method: "POST" })
299
141
  export const listProposals = createServerFn({ method: "GET" })
300
142
  .middleware([requireSupabaseAuth])
301
143
  .inputValidator((input) => z.object({ status: z.enum(["pending", "approved", "rejected"]).optional() }).parse(input ?? {}))
302
- .handler(async ({ data, context }) => {
303
- // Beperk tot berichten die binnenkwamen via de actieve orders/receive mailbox
304
- const { data: active } = await context.supabase.rpc("email_auth_for_purpose", {
305
- _purpose: "orders",
306
- _direction: "receive",
307
- });
308
- const activeRow = Array.isArray(active) ? active[0] : null;
309
- const activeConnectionId = activeRow?.id ?? null;
310
- if (!activeConnectionId)
311
- return { items: [] };
312
- // Stap 1: message-ids van de actieve mailbox
313
- const { data: msgs, error: msgErr } = await context.supabase
314
- .from("mailbox_messages")
315
- .select("id")
316
- .eq("connection_id", activeConnectionId)
317
- .order("received_at", { ascending: false })
318
- .limit(500);
319
- if (msgErr)
320
- throw new Error(msgErr.message);
321
- const messageIds = (msgs ?? []).map((m) => m.id);
322
- if (messageIds.length === 0)
323
- return { items: [] };
324
- // Stap 2: voorstellen op die berichten
325
- let q = context.supabase
144
+ .handler(async ({ data }) => {
145
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
146
+ // Alle voorstellen uit de database, ongeacht via welke mailbox ze binnenkwamen.
147
+ // Bewust server-side: mailbox-voorstellen mogen niet rechtstreeks door andere
148
+ // browsermodules als verkooporders worden gelezen.
149
+ let q = supabaseAdmin
326
150
  .from("mailbox_order_proposals")
327
- .select("*, mailbox_messages(subject, from_email, from_name, body_text, received_at, connection_id), mailbox_proposal_lines(*), mailbox_proposal_field_values(*)")
328
- .in("message_id", messageIds)
151
+ .select("*, mailbox_messages(subject, from_email, from_name, body_text, body_html, received_at, connection_id), mailbox_proposal_lines(*), mailbox_proposal_field_values(*)")
329
152
  .order("created_at", { ascending: false })
330
- .limit(100);
153
+ .limit(500);
331
154
  if (data.status)
332
155
  q = q.eq("status", data.status);
333
156
  const { data: rows, error } = await q;
334
157
  if (error)
335
158
  throw new Error(error.message);
336
- return { items: rows ?? [] };
159
+ const items = (Array.isArray(rows) ? rows : []).map((r) => {
160
+ const m = r?.mailbox_messages;
161
+ if (!m)
162
+ return r;
163
+ const { body_html, ...rest } = m;
164
+ return { ...r, mailbox_messages: { ...rest, body_text: emailBodyText(m.body_text, body_html) } };
165
+ });
166
+ return { items };
167
+ });
168
+ // ─── Ladingdragers uit de Logistics Engine ─────────────────────────────────
169
+ // Nooit cachen: de instelling kan op elk moment wijzigen.
170
+ export const listAllowedLoadCarriers = createServerFn({ method: "GET" })
171
+ .middleware([requireSupabaseAuth])
172
+ .handler(async ({ context }) => {
173
+ const { data, error } = await context.supabase.rpc("logistics_allowed_load_carriers", {
174
+ p_strategy_id: undefined,
175
+ });
176
+ if (error)
177
+ return { ...EMPTY_LOAD_CARRIERS, error: error.message };
178
+ return { ...normalizeAllowedLoadCarriers(data), error: null };
337
179
  });
338
180
  const LineEdit = z.object({
339
181
  id: z.string().uuid().optional(),
@@ -363,12 +205,29 @@ export const approveProposal = createServerFn({ method: "POST" })
363
205
  (data.fieldValues ?? []).forEach((fv) => {
364
206
  customFields[fv.field_key] = fv.value;
365
207
  });
208
+ // ─── Ladingdrager: uitsluitend wat de Engine toestaat ───────────────────
209
+ const { data: lcRaw, error: lcErr } = await context.supabase.rpc("logistics_allowed_load_carriers", { p_strategy_id: undefined });
210
+ if (lcErr)
211
+ throw new Error(`Ladingdragers ophalen mislukt: ${lcErr.message}`);
212
+ const lcList = normalizeAllowedLoadCarriers(lcRaw);
213
+ const requestedCarrier = typeof customFields["load_carrier"] === "string" && customFields["load_carrier"]
214
+ ? customFields["load_carrier"]
215
+ : null;
216
+ const loadCarrierType = requestedCarrier ?? lcList.default;
217
+ if (!isLoadCarrierAllowed(lcList, loadCarrierType)) {
218
+ throw new Error(`Ladingdrager "${loadCarrierLabel(lcList, loadCarrierType) ?? "onbekend"}" is uitgeschakeld in de Rule Engine — bestelling niet aangemaakt.`);
219
+ }
220
+ customFields["load_carrier"] = loadCarrierType;
366
221
  // ─── Validatie eerst mogelijke leverdatum ────────────────────────────────
367
222
  const carrierId = typeof customFields["carrier"] === "string" ? customFields["carrier"] : null;
368
223
  let carrierDeliveryDays = 0;
224
+ let carrierSchedule = null;
369
225
  if (carrierId) {
370
226
  const { data: c } = await context.supabase
371
- .from("carriers").select("delivery_days").eq("id", carrierId).maybeSingle();
227
+ .from("carriers")
228
+ .select("delivery_days, pickup_weekdays, delivery_weekdays, pickup_moments")
229
+ .eq("id", carrierId).maybeSingle();
230
+ carrierSchedule = c ?? null;
372
231
  carrierDeliveryDays = Math.max(0, Number(c?.delivery_days ?? 0));
373
232
  }
374
233
  const { data: settings } = await context.supabase
@@ -377,10 +236,45 @@ export const approveProposal = createServerFn({ method: "POST" })
377
236
  .order("updated_at", { ascending: false })
378
237
  .limit(1).maybeSingle();
379
238
  const pickDays = Math.max(0, Number(settings?.pick_days_before_transport ?? 0));
380
- const earliest = addCalendarDays(new Date(), pickDays + carrierDeliveryDays);
381
- const earliestISO = toISODate(earliest);
239
+ const earliestISO = computeEarliestDelivery(carrierSchedule, pickDays).iso;
382
240
  if (data.deliveryDate < earliestISO) {
383
- throw new Error(`Leverdatum (${data.deliveryDate}) ligt voor de eerst mogelijke datum (${earliestISO}) o.b.v. orderpicktijd (${pickDays}d) + levertijd vervoerder (${carrierDeliveryDays}d).`);
241
+ throw new Error(`Leverdatum (${data.deliveryDate}) ligt voor het eerstvolgende aanmeldmoment van de vervoerder (${earliestISO}).`);
242
+ }
243
+ // ─── Voorraadcontrole door de Logistics Engine (nooit zelf beslissen) ────
244
+ const checkLines = data.lines
245
+ .filter((l) => l.product_id)
246
+ .map((l) => ({
247
+ productId: l.product_id,
248
+ quantity: Math.max(1, Math.round(l.quantity ?? 1)),
249
+ loadCarrierType,
250
+ }));
251
+ let shortages = [];
252
+ if (checkLines.length > 0) {
253
+ const { data: check, error: checkErr } = await context.supabase.rpc("logistics_check_order_allowed", { p_lines: checkLines });
254
+ if (checkErr)
255
+ throw new Error(`Voorraadcontrole mislukt: ${checkErr.message}`);
256
+ const result = (check ?? {});
257
+ shortages = Array.isArray(result.shortages) ? result.shortages : [];
258
+ if (result.allowed === false) {
259
+ const detail = shortages
260
+ .map((s) => `${s.product ?? s.productId}: besteld ${s.wanted}, beschikbaar ${s.available}, tekort ${s.missing}`)
261
+ .join("\n");
262
+ const blockText = `Geblokkeerd - onvoldoende voorraad\n${detail}`;
263
+ await supabaseAdmin
264
+ .from("mailbox_order_proposals")
265
+ .update({ notes: blockText })
266
+ .eq("id", data.proposalId);
267
+ const { data: blockedProp } = await supabaseAdmin
268
+ .from("mailbox_order_proposals")
269
+ .select("message_id").eq("id", data.proposalId).maybeSingle();
270
+ if (blockedProp?.message_id) {
271
+ await supabaseAdmin
272
+ .from("mailbox_messages")
273
+ .update({ status: "blocked_insufficient_stock" })
274
+ .eq("id", blockedProp.message_id);
275
+ }
276
+ throw new Error(blockText);
277
+ }
384
278
  }
385
279
  const orderNumber = `MB-${Date.now()}`;
386
280
  const { data: order, error: orderErr } = await supabaseAdmin
@@ -429,7 +323,26 @@ export const approveProposal = createServerFn({ method: "POST" })
429
323
  if (prop?.message_id) {
430
324
  await supabaseAdmin.from("mailbox_messages").update({ status: "converted" }).eq("id", prop.message_id);
431
325
  }
432
- return { ok: true, orderId: order.id };
326
+ // ─── Gebeurtenis melden aan de Logistics Engine ─────────────────────────
327
+ const deliveryLocationRef = typeof customFields["delivery_location"] === "string" ? customFields["delivery_location"] : null;
328
+ await context.supabase.rpc("logistics_emit_event", {
329
+ p_source_module: "mailbox",
330
+ p_event_type: "order.received",
331
+ p_order_ref: orderNumber,
332
+ p_payload: {
333
+ checkinAt: data.deliveryDate,
334
+ pickupAt: data.deliveryDate,
335
+ customerRef: data.customerId,
336
+ deliveryLocationRef,
337
+ bufferRef: null,
338
+ loadCarrierType,
339
+ loadCarriers: [loadCarrierType],
340
+ ruleVersion: null,
341
+ },
342
+ p_shadow: true,
343
+ p_idempotency_key: `mailbox:${orderNumber}:order.received:v1`,
344
+ });
345
+ return { ok: true, orderId: order.id, orderNumber, shortages };
433
346
  });
434
347
  export const rejectProposal = createServerFn({ method: "POST" })
435
348
  .middleware([requireSupabaseAuth])
@@ -464,13 +377,107 @@ export const listCustomersForPicker = createServerFn({ method: "GET" })
464
377
  .from("customers").select("id, company_name, email").eq("is_active", true).order("company_name").range(0, 4999);
465
378
  return { items: data ?? [] };
466
379
  });
467
- // Producten voor de picker — alleen items met beschikbare voorraad volgens de
468
- // Floriday Voorraad Module (`trade_item_stock`). Beschikbaar = physical - reserved.
469
- // De koppeling loopt via `products.custom_fields.floriday_id` = `trade_item_stock.trade_item_key`.
380
+ // Producten voor de picker — ALLE artikelen uit de voorraad:
381
+ // 1. alles in `products`
382
+ // 2. elk handelsitem uit `floriday_trade_items_cache` (Voorraad Module) dat nog
383
+ // geen products-rij heeft, wordt hier automatisch aangemaakt (id = floriday_id)
384
+ // zodat bestelregels, belading en orders er gewoon naar kunnen verwijzen
385
+ // Voorraad komt uit `trade_item_stock` en `trade_item_stock_view`.
470
386
  export const listProductsForPicker = createServerFn({ method: "GET" })
471
387
  .middleware([requireSupabaseAuth])
472
388
  .handler(async ({ context }) => {
473
- const { data: stockRows } = await context.supabase
389
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
390
+ // ── 1. Handelsitems uit de Voorraad Module materialiseren ───────────────
391
+ const { data: cacheRows } = await supabaseAdmin
392
+ .from("floriday_trade_items_cache")
393
+ .select("floriday_id, is_deleted, data")
394
+ .range(0, 4999);
395
+ const tradeItems = (Array.isArray(cacheRows) ? cacheRows : []).filter((r) => !r?.is_deleted && typeof r?.floriday_id === "string");
396
+ const { data: existingRows } = await supabaseAdmin
397
+ .from("products")
398
+ .select("id, custom_fields, floriday_trade_item_id")
399
+ .range(0, 9999);
400
+ const existing = Array.isArray(existingRows) ? existingRows : [];
401
+ const known = new Set();
402
+ for (const p of existing) {
403
+ known.add(String(p.id));
404
+ const fid = p?.custom_fields?.floriday_id ?? p?.floriday_trade_item_id;
405
+ if (typeof fid === "string" && fid)
406
+ known.add(fid);
407
+ }
408
+ const norm = (v) => Math.max(1, Math.round(Number(v ?? 1)) || 1);
409
+ const nameOf = (d) => {
410
+ const n = d?.tradeItemName;
411
+ if (typeof n === "string" && n.trim())
412
+ return n.trim();
413
+ if (n && typeof n === "object") {
414
+ const v = n.nl ?? n.en ?? Object.values(n).find((x) => typeof x === "string");
415
+ if (typeof v === "string" && v.trim())
416
+ return v.trim();
417
+ }
418
+ return typeof d?.tradeItemReference === "string" && d.tradeItemReference
419
+ ? d.tradeItemReference
420
+ : "Onbekend artikel";
421
+ };
422
+ const missing = tradeItems.filter((r) => !known.has(r.floriday_id));
423
+ const buildRow = (r) => {
424
+ const d = r.data ?? {};
425
+ const pk = Array.isArray(d.packingConfigurations) ? d.packingConfigurations[0] ?? {} : {};
426
+ return {
427
+ id: r.floriday_id,
428
+ product: nameOf(d),
429
+ // batch + location zijn NOT NULL zonder default in de Voorraad Module
430
+ batch: "",
431
+ location: "",
432
+ unit: "stuks",
433
+ quantity: 0,
434
+ plants_per_crate: norm(pk?.piecesPerPackage ?? pk?.unitsPerPackage),
435
+ crates_per_layer: norm(pk?.packagesPerLayer),
436
+ layers_per_cart: norm(pk?.layersPerLoadCarrier),
437
+ floriday_trade_item_id: r.floriday_id,
438
+ article_number: typeof d?.supplierArticleCode === "string" ? d.supplierArticleCode : null,
439
+ custom_fields: { floriday_id: r.floriday_id, source: "floriday_trade_items_cache" },
440
+ };
441
+ };
442
+ // Concept-artikelen uit de Voorraad Module (nog niet in Floriday) ook tonen
443
+ const { data: draftRowsAll } = await supabaseAdmin
444
+ .from("trade_item_drafts")
445
+ .select("id, data, floriday_id")
446
+ .range(0, 4999);
447
+ const draftMissing = (Array.isArray(draftRowsAll) ? draftRowsAll : []).filter((d) => typeof d?.id === "string" && !known.has(d.id) &&
448
+ !(typeof d?.floriday_id === "string" && known.has(d.floriday_id)));
449
+ const buildDraftRow = (r) => {
450
+ const d = r.data ?? {};
451
+ const pk = Array.isArray(d.packingConfigurations) ? d.packingConfigurations[0] ?? {} : {};
452
+ return {
453
+ id: r.id,
454
+ product: nameOf(d),
455
+ batch: "",
456
+ location: "",
457
+ unit: "stuks",
458
+ quantity: 0,
459
+ plants_per_crate: norm(pk?.unitsPerPackage ?? pk?.piecesPerPackage),
460
+ crates_per_layer: norm(pk?.packagesPerLayer),
461
+ layers_per_cart: norm(pk?.layersPerLoadCarrier),
462
+ article_number: typeof d?.supplierArticleCode === "string" ? d.supplierArticleCode : null,
463
+ custom_fields: { draft_id: r.id, source: "trade_item_drafts" },
464
+ };
465
+ };
466
+ const toInsert = [...missing.map(buildRow), ...draftMissing.map(buildDraftRow)];
467
+ // Artikelen die (nog) niet in `products` staan, tonen we hoe dan ook —
468
+ // ook als materialiseren faalt.
469
+ let virtualRows = [];
470
+ if (toInsert.length > 0) {
471
+ const { error: upsertError } = await supabaseAdmin
472
+ .from("products")
473
+ .upsert(toInsert, { onConflict: "id" });
474
+ if (upsertError) {
475
+ console.error("[listProductsForPicker] materialiseren mislukt:", upsertError.message);
476
+ virtualRows = toInsert;
477
+ }
478
+ }
479
+ // ── 2. Voorraad verzamelen ──────────────────────────────────────────────
480
+ const { data: stockRows } = await supabaseAdmin
474
481
  .from("trade_item_stock")
475
482
  .select("trade_item_key, physical_stock, reserved_stock");
476
483
  const availByProductId = new Map();
@@ -499,12 +506,14 @@ export const listProductsForPicker = createServerFn({ method: "GET" })
499
506
  }
500
507
  // Drafts uit de Voorraad Module: koppel via linkedProductId → products.id
501
508
  if (availByDraftId.size > 0) {
502
- const { data: drafts } = await context.supabase
509
+ const { data: drafts } = await supabaseAdmin
503
510
  .from("trade_item_drafts")
504
511
  .select("id, data, floriday_id")
505
512
  .in("id", Array.from(availByDraftId.keys()));
506
513
  for (const d of Array.isArray(drafts) ? drafts : []) {
507
514
  const qty = availByDraftId.get(d.id) ?? 0;
515
+ // Concepten worden ook als product gematerialiseerd op hun eigen id
516
+ addAvail(availByProductId, d.id, qty);
508
517
  const linked = d?.data?.linkedProductId;
509
518
  if (typeof linked === "string" && linked)
510
519
  addAvail(availByProductId, linked, qty);
@@ -513,19 +522,76 @@ export const listProductsForPicker = createServerFn({ method: "GET" })
513
522
  addAvail(availByFloridayId, fid, qty);
514
523
  }
515
524
  }
516
- const { data } = await context.supabase
525
+ // Voorraadoverzicht van de Voorraad Module (view)
526
+ try {
527
+ const { data: viewRows } = await supabaseAdmin
528
+ .from("trade_item_stock_view")
529
+ .select("trade_item_id, stock");
530
+ for (const v of Array.isArray(viewRows) ? viewRows : []) {
531
+ const id = v?.trade_item_id;
532
+ if (typeof id !== "string")
533
+ continue;
534
+ if (!availByFloridayId.has(id) && !availByProductId.has(id)) {
535
+ availByFloridayId.set(id, Number(v?.stock ?? 0));
536
+ }
537
+ }
538
+ }
539
+ catch {
540
+ // view optioneel
541
+ }
542
+ // ── 3. Alle producten teruggeven ────────────────────────────────────────
543
+ const { data } = await supabaseAdmin
517
544
  .from("products")
518
- .select("id, product, unit, plants_per_crate, crates_per_layer, layers_per_cart, custom_fields")
519
- .order("product").range(0, 4999);
520
- const items = (Array.isArray(data) ? data : [])
545
+ .select("id, product, unit, plants_per_crate, crates_per_layer, layers_per_cart, custom_fields, floriday_trade_item_id")
546
+ .order("product")
547
+ .range(0, 9999);
548
+ const dbRows = Array.isArray(data) ? data : [];
549
+ const seen = new Set(dbRows.map((p) => String(p.id)));
550
+ const allRows = [
551
+ ...dbRows,
552
+ ...virtualRows.filter((v) => !seen.has(String(v.id))),
553
+ ];
554
+ const items = allRows
521
555
  .map((p) => {
522
- const fid = p?.custom_fields?.floriday_id;
556
+ const fid = p?.custom_fields?.floriday_id ?? p?.floriday_trade_item_id;
523
557
  const byProd = availByProductId.get(p.id) ?? 0;
524
558
  const byFid = typeof fid === "string" ? (availByFloridayId.get(fid) ?? 0) : 0;
525
- const available_stock = byProd + byFid;
526
- return { ...p, available_stock };
527
- });
528
- return { items };
559
+ return { ...p, available_stock: byProd + byFid };
560
+ })
561
+ .sort((a, b) => String(a.product ?? "").localeCompare(String(b.product ?? "")));
562
+ // ── 4. Rule Engine: mag er doorverkocht worden zonder voorraad? ─────────
563
+ // `logistics_rule_configs.settings.order_verwerking.fields.allow_backorder`
564
+ // bepaalt of artikelen zonder voorraad selecteerbaar blijven. Er is niet
565
+ // altijd een config met is_active = true; val dan terug op de laatst
566
+ // bijgewerkte werkwijze zodat de instelling toch wordt gevolgd.
567
+ let allowBackorder = false;
568
+ try {
569
+ const readFlag = (rows) => {
570
+ const cfg = (Array.isArray(rows) ? rows : [])[0];
571
+ const raw = cfg?.settings?.order_verwerking?.fields?.allow_backorder;
572
+ return raw === undefined ? undefined : raw === true || raw === "true";
573
+ };
574
+ const { data: activeRows } = await supabaseAdmin
575
+ .from("logistics_rule_configs")
576
+ .select("settings, is_active, updated_at")
577
+ .eq("is_active", true)
578
+ .order("updated_at", { ascending: false })
579
+ .limit(1);
580
+ let flag = readFlag(activeRows);
581
+ if (flag === undefined) {
582
+ const { data: latestRows } = await supabaseAdmin
583
+ .from("logistics_rule_configs")
584
+ .select("settings, updated_at")
585
+ .order("updated_at", { ascending: false })
586
+ .limit(1);
587
+ flag = readFlag(latestRows);
588
+ }
589
+ allowBackorder = flag === true;
590
+ }
591
+ catch (e) {
592
+ console.error("[listProductsForPicker] rule engine lezen mislukt:", e);
593
+ }
594
+ return { items, allowBackorder };
529
595
  });
530
596
  export const planAndSaveAutoCarts = createServerFn({ method: "POST" })
531
597
  .middleware([requireSupabaseAuth])
@@ -731,9 +797,14 @@ export const listPickupLocations = createServerFn({ method: "GET" })
731
797
  const label = nameRaw.length > 0
732
798
  ? nameRaw
733
799
  : [addr, city].filter(Boolean).join(", ") || "(naamloos)";
734
- return { id: r.floriday_id, name: label, sort_order: 0 };
800
+ return {
801
+ id: r.floriday_id,
802
+ name: label,
803
+ is_default: r?.data?.isDefault === true,
804
+ sort_order: 0,
805
+ };
735
806
  })
736
- .sort((a, b) => a.name.localeCompare(b.name, "nl"));
807
+ .sort((a, b) => (a.is_default === b.is_default ? a.name.localeCompare(b.name, "nl") : a.is_default ? -1 : 1));
737
808
  return { items };
738
809
  });
739
810
  export const listCarriersForPicker = createServerFn({ method: "GET" })
@@ -741,7 +812,7 @@ export const listCarriersForPicker = createServerFn({ method: "GET" })
741
812
  .handler(async ({ context }) => {
742
813
  const { data } = await context.supabase
743
814
  .from("carriers")
744
- .select("id, name, is_default, sort_order, delivery_days")
815
+ .select("id, name, is_default, sort_order, delivery_days, pickup_weekdays, delivery_weekdays, pickup_moments")
745
816
  .order("sort_order", { ascending: true, nullsFirst: false })
746
817
  .order("name");
747
818
  return { items: data ?? [] };
@@ -766,3 +837,206 @@ function addCalendarDays(base, n) {
766
837
  function toISODate(d) {
767
838
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
768
839
  }
840
+ // ─── Beladingen per product ────────────────────────────────────────────────
841
+ // Zelfde bronnen als de Floriday Verkoop-module: packing-configuraties uit de
842
+ // trade item cache, concept-artikelen, groepsleden en de products-tabel
843
+ // (inclusief de opgeslagen Floriday-payload). Daarnaast worden ALLE in het
844
+ // systeem bekende beladingen meegegeven, ook de niet-gekoppelde.
845
+ export const listBeladingOptionsForProducts = createServerFn({ method: "POST" })
846
+ .middleware([requireSupabaseAuth])
847
+ .inputValidator((input) => z.object({ productIds: z.array(z.string()).max(200) }).parse(input))
848
+ .handler(async ({ data }) => {
849
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
850
+ const ids = Array.from(new Set((data.productIds ?? []).filter(Boolean)));
851
+ const byProduct = {};
852
+ function sortCombos(list) {
853
+ return [...list]
854
+ .sort((a, b) => a.plants_per_crate - b.plants_per_crate ||
855
+ a.crates_per_layer - b.crates_per_layer ||
856
+ a.layers_per_cart - b.layers_per_cart)
857
+ .map((v) => ({
858
+ ...v,
859
+ crates_per_cart: v.crates_per_layer * v.layers_per_cart,
860
+ plants_per_cart: v.plants_per_crate * v.crates_per_layer * v.layers_per_cart,
861
+ }));
862
+ }
863
+ // ── Namen van fusten (VBN-verpakkingscodes) en karren (load carriers) ───
864
+ const CARRIER_NAMES = {
865
+ DANISH_TROLLEY: "Deense container",
866
+ DANISH_CONTAINER: "Deense container",
867
+ DANISH_TROLLEY_NON_CC_RFID: "Deense container (non-CC RFID)",
868
+ DANISH_TROLLEY_WATER_TRAY: "Deense container met waterschaal",
869
+ ONE_THIRD_DANISH_TROLLEY: "1/3 Deense container",
870
+ AUCTION_TROLLEY: "Veilingstapelwagen",
871
+ EURO_TROLLEY: "Euro-stapelwagen",
872
+ CC_CONTAINER: "CC-container",
873
+ PALLET: "Pallet",
874
+ EURO_PALLET: "Europallet",
875
+ BOX: "Doos",
876
+ NONE: "Losse fusten",
877
+ };
878
+ const { data: floricodeRows } = await supabaseAdmin
879
+ .from("floricode_records")
880
+ .select("data")
881
+ .eq("resource", "LoadCarrier")
882
+ .range(0, 4999);
883
+ const crateNameByCode = new Map();
884
+ for (const r of Array.isArray(floricodeRows) ? floricodeRows : []) {
885
+ const d = r?.data;
886
+ if (d?.code == null)
887
+ continue;
888
+ const descr = typeof d?.description === "string" ? d.description.trim() : "";
889
+ if (descr)
890
+ crateNameByCode.set(String(d.code), descr);
891
+ }
892
+ const crateInfo = (p) => {
893
+ const raw = p?.package?.vbnPackageCode ?? p?.vbnPackageCode ?? p?.package?.customPackageId ?? null;
894
+ const code = raw == null || raw === "" ? null : String(raw);
895
+ return { code, name: code ? crateNameByCode.get(code) ?? `Fust ${code}` : null };
896
+ };
897
+ const carrierInfo = (p) => {
898
+ const raw = typeof p?.loadCarrier === "string" ? p.loadCarrier : (p?.loadCarrier?.type ?? null);
899
+ const code = typeof raw === "string" && raw ? raw : null;
900
+ return { code, name: code ? CARRIER_NAMES[code.toUpperCase()] ?? code : null };
901
+ };
902
+ const norm = (v) => Math.max(1, Math.round(Number(v ?? 1)) || 1);
903
+ const emptyMeta = { crate_code: null, crate_name: null, carrier_code: null, carrier_name: null };
904
+ const sameCombo = (x, a, b, c, m) => x.plants_per_crate === a && x.crates_per_layer === b && x.layers_per_cart === c &&
905
+ (x.crate_code ?? null) === (m.crate_code ?? null) &&
906
+ (x.carrier_code ?? null) === (m.carrier_code ?? null);
907
+ const packsById = new Map();
908
+ const push = (key, ppc, cpl, lpc, isDefault, meta = emptyMeta) => {
909
+ const a = norm(ppc), b = norm(cpl), c = norm(lpc);
910
+ const arr = packsById.get(key) ?? [];
911
+ const hit = arr.find((x) => sameCombo(x, a, b, c, meta));
912
+ if (hit) {
913
+ if (isDefault)
914
+ hit.is_default = true;
915
+ return;
916
+ }
917
+ arr.push({ plants_per_crate: a, crates_per_layer: b, layers_per_cart: c, is_default: isDefault, ...meta });
918
+ packsById.set(key, arr);
919
+ };
920
+ const pushPack = (key, p, ppc, isDefault) => {
921
+ const cr = crateInfo(p), ca = carrierInfo(p);
922
+ push(key, ppc, p?.packagesPerLayer, p?.layersPerLoadCarrier, isDefault, {
923
+ crate_code: cr.code, crate_name: cr.name, carrier_code: ca.code, carrier_name: ca.name,
924
+ });
925
+ };
926
+ // ── Alle beladingen in het systeem (ook niet-gekoppeld) ─────────────────
927
+ const globalCombos = new Map();
928
+ const addGlobal = (ppc, cpl, lpc, meta = emptyMeta) => {
929
+ const a = norm(ppc), b = norm(cpl), c = norm(lpc);
930
+ if (a === 1 && b === 1 && c === 1)
931
+ return;
932
+ const k = `${a}x${b}x${c}|${meta.crate_code ?? ""}|${meta.carrier_code ?? ""}`;
933
+ if (globalCombos.has(k))
934
+ return;
935
+ globalCombos.set(k, { plants_per_crate: a, crates_per_layer: b, layers_per_cart: c, is_default: false, ...meta });
936
+ };
937
+ const addGlobalPack = (p, ppc) => {
938
+ const cr = crateInfo(p), ca = carrierInfo(p);
939
+ addGlobal(ppc, p?.packagesPerLayer, p?.layersPerLoadCarrier, {
940
+ crate_code: cr.code, crate_name: cr.name, carrier_code: ca.code, carrier_name: ca.name,
941
+ });
942
+ };
943
+ const addAllPackings = (d, fn) => {
944
+ const packs = Array.isArray(d?.packingConfigurations) ? d.packingConfigurations : [];
945
+ for (const p of packs)
946
+ fn(p);
947
+ };
948
+ const [allTiRes, allDraftRes, allProdRes] = await Promise.all([
949
+ supabaseAdmin.from("floriday_trade_items_cache").select("data").range(0, 4999),
950
+ supabaseAdmin.from("trade_item_drafts").select("data").range(0, 4999),
951
+ supabaseAdmin
952
+ .from("products")
953
+ .select("plants_per_crate, crates_per_layer, layers_per_cart, custom_fields")
954
+ .range(0, 9999),
955
+ ]);
956
+ for (const r of Array.isArray(allTiRes.data) ? allTiRes.data : []) {
957
+ addAllPackings(r?.data, (p) => addGlobalPack(p, p?.piecesPerPackage ?? p?.unitsPerPackage));
958
+ }
959
+ for (const r of Array.isArray(allDraftRes.data) ? allDraftRes.data : []) {
960
+ addAllPackings(r?.data, (p) => addGlobalPack(p, p?.unitsPerPackage ?? p?.piecesPerPackage));
961
+ }
962
+ for (const p of Array.isArray(allProdRes.data) ? allProdRes.data : []) {
963
+ addGlobal(p.plants_per_crate, p.crates_per_layer, p.layers_per_cart);
964
+ addAllPackings(p?.custom_fields?.floriday_payload, (pc) => addGlobalPack(pc, pc?.unitsPerPackage ?? pc?.piecesPerPackage));
965
+ }
966
+ // Verwijder "kale" varianten (zonder fust-/kartype) als dezelfde afmetingen
967
+ // ook met fust- en kartype bekend zijn — anders zie je dubbele regels.
968
+ const dims = (c) => `${c.plants_per_crate}x${c.crates_per_layer}x${c.layers_per_cart}`;
969
+ const dropBare = (list) => {
970
+ const rich = new Set(list.filter((c) => c.crate_code || c.carrier_code).map(dims));
971
+ return list.filter((c) => c.crate_code || c.carrier_code || !rich.has(dims(c)));
972
+ };
973
+ const globalList = dropBare(Array.from(globalCombos.values()));
974
+ if (!ids.length)
975
+ return { byProduct, allBeladingen: sortCombos(globalList) };
976
+ // ── Gekoppelde beladingen per product ───────────────────────────────────
977
+ const { data: giRows } = await supabaseAdmin
978
+ .from("product_group_items")
979
+ .select("product_group_id, trade_item_id")
980
+ .in("product_group_id", ids);
981
+ const membersByGroup = new Map();
982
+ for (const gi of Array.isArray(giRows) ? giRows : []) {
983
+ const gid = gi.product_group_id;
984
+ const arr = membersByGroup.get(gid) ?? [];
985
+ arr.push(gi.trade_item_id);
986
+ membersByGroup.set(gid, arr);
987
+ }
988
+ const lookupIds = Array.from(new Set([...ids, ...Array.from(membersByGroup.values()).flat()]));
989
+ const [tiRes, draftRes, prodRes] = await Promise.all([
990
+ supabaseAdmin.from("floriday_trade_items_cache").select("floriday_id, data").in("floriday_id", lookupIds),
991
+ supabaseAdmin.from("trade_item_drafts").select("id, data").in("id", lookupIds),
992
+ supabaseAdmin
993
+ .from("products")
994
+ .select("id, plants_per_crate, crates_per_layer, layers_per_cart, custom_fields, trade_item_key")
995
+ .in("id", lookupIds),
996
+ ]);
997
+ for (const r of Array.isArray(tiRes.data) ? tiRes.data : []) {
998
+ const key = r.floriday_id;
999
+ addAllPackings(r?.data, (p) => pushPack(key, p, p?.piecesPerPackage ?? p?.unitsPerPackage, !!(p?.primary || p?.isPrimary)));
1000
+ }
1001
+ for (const r of Array.isArray(draftRes.data) ? draftRes.data : []) {
1002
+ const key = r.id;
1003
+ addAllPackings(r?.data, (p) => pushPack(key, p, p?.unitsPerPackage ?? p?.piecesPerPackage, !!(p?.primary || p?.isPrimary)));
1004
+ }
1005
+ for (const p of Array.isArray(prodRes.data) ? prodRes.data : []) {
1006
+ const key = p.id;
1007
+ push(key, p.plants_per_crate, p.crates_per_layer, p.layers_per_cart, true);
1008
+ const fid = p.custom_fields?.floriday_id;
1009
+ if (fid)
1010
+ push(fid, p.plants_per_crate, p.crates_per_layer, p.layers_per_cart, false);
1011
+ const tk = p.trade_item_key;
1012
+ if (typeof tk === "string" && tk.startsWith("group:")) {
1013
+ push(tk.slice("group:".length), p.plants_per_crate, p.crates_per_layer, p.layers_per_cart, true);
1014
+ }
1015
+ addAllPackings(p?.custom_fields?.floriday_payload, (pc) => {
1016
+ const isPrim = !!(pc?.isPrimary || pc?.primary);
1017
+ const ppc = pc?.unitsPerPackage ?? pc?.piecesPerPackage;
1018
+ pushPack(key, pc, ppc, isPrim);
1019
+ if (fid)
1020
+ pushPack(fid, pc, ppc, isPrim);
1021
+ });
1022
+ }
1023
+ for (const id of ids) {
1024
+ const own = packsById.get(id) ?? [];
1025
+ const memberCombos = [];
1026
+ for (const m of membersByGroup.get(id) ?? []) {
1027
+ for (const c of packsById.get(m) ?? [])
1028
+ memberCombos.push(c);
1029
+ }
1030
+ const merged = [];
1031
+ for (const c of [...own, ...memberCombos, ...globalList]) {
1032
+ if (merged.some((x) => sameCombo(x, c.plants_per_crate, c.crates_per_layer, c.layers_per_cart, c)))
1033
+ continue;
1034
+ merged.push({ ...c, is_default: c.is_default && own.includes(c) });
1035
+ }
1036
+ const cleaned = dropBare(merged);
1037
+ if (cleaned.length && !cleaned.some((x) => x.is_default))
1038
+ cleaned[0].is_default = true;
1039
+ byProduct[id] = sortCombos(cleaned);
1040
+ }
1041
+ return { byProduct, allBeladingen: sortCombos(globalList) };
1042
+ });