@flowselections/mailbox-orders 1.0.29 → 1.0.32

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 (61) hide show
  1. package/dist-lib/components/MailboxProcessedPage.js +1 -1
  2. package/dist-lib/components/MailboxProposalsPage.d.ts.map +1 -1
  3. package/dist-lib/components/MailboxProposalsPage.js +202 -122
  4. package/dist-lib/components/MailboxSkippedPage.d.ts +2 -0
  5. package/dist-lib/components/MailboxSkippedPage.d.ts.map +1 -0
  6. package/dist-lib/components/MailboxSkippedPage.js +71 -0
  7. package/dist-lib/components/settings/MailboxFilterSettingsCard.d.ts +2 -0
  8. package/dist-lib/components/settings/MailboxFilterSettingsCard.d.ts.map +1 -0
  9. package/dist-lib/components/settings/MailboxFilterSettingsCard.js +78 -0
  10. package/dist-lib/components/settings/MailboxTestSetCard.d.ts +2 -0
  11. package/dist-lib/components/settings/MailboxTestSetCard.d.ts.map +1 -0
  12. package/dist-lib/components/settings/MailboxTestSetCard.js +69 -0
  13. package/dist-lib/index.d.ts +1 -0
  14. package/dist-lib/index.d.ts.map +1 -1
  15. package/dist-lib/index.js +8 -2
  16. package/dist-lib/integrations/supabase/client.server.js +1 -1
  17. package/dist-lib/integrations/supabase/types.d.ts +864 -1
  18. package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
  19. package/dist-lib/lib/mailbox-ai.server.d.ts +33 -12
  20. package/dist-lib/lib/mailbox-ai.server.d.ts.map +1 -1
  21. package/dist-lib/lib/mailbox-ai.server.js +47 -17
  22. package/dist-lib/lib/mailbox-filter-settings.functions.d.ts +143 -0
  23. package/dist-lib/lib/mailbox-filter-settings.functions.d.ts.map +1 -0
  24. package/dist-lib/lib/mailbox-filter-settings.functions.js +241 -0
  25. package/dist-lib/lib/mailbox-filter.d.ts +34 -0
  26. package/dist-lib/lib/mailbox-filter.d.ts.map +1 -0
  27. package/dist-lib/lib/mailbox-filter.js +121 -0
  28. package/dist-lib/lib/mailbox-ingest.server.d.ts +114 -17
  29. package/dist-lib/lib/mailbox-ingest.server.d.ts.map +1 -1
  30. package/dist-lib/lib/mailbox-ingest.server.js +594 -266
  31. package/dist-lib/lib/mailbox-match.d.ts +28 -0
  32. package/dist-lib/lib/mailbox-match.d.ts.map +1 -0
  33. package/dist-lib/lib/mailbox-match.js +74 -0
  34. package/dist-lib/lib/mailbox-units.d.ts +18 -0
  35. package/dist-lib/lib/mailbox-units.d.ts.map +1 -0
  36. package/dist-lib/lib/mailbox-units.js +80 -0
  37. package/dist-lib/lib/mailbox.functions.d.ts +75 -1
  38. package/dist-lib/lib/mailbox.functions.d.ts.map +1 -1
  39. package/dist-lib/lib/mailbox.functions.js +174 -29
  40. package/package.json +6 -1
  41. package/dist-lib/components/CartAssignmentWizard.d.ts +0 -6
  42. package/dist-lib/components/CartAssignmentWizard.d.ts.map +0 -1
  43. package/dist-lib/components/CartAssignmentWizard.js +0 -199
  44. package/dist-lib/components/MailboxInboxPage.d.ts +0 -2
  45. package/dist-lib/components/MailboxInboxPage.d.ts.map +0 -1
  46. package/dist-lib/components/MailboxInboxPage.js +0 -120
  47. package/dist-lib/components/settings/ImapAccountsCard.d.ts +0 -2
  48. package/dist-lib/components/settings/ImapAccountsCard.d.ts.map +0 -1
  49. package/dist-lib/components/settings/ImapAccountsCard.js +0 -136
  50. package/dist-lib/components/settings/ImapProfilesCard.d.ts +0 -2
  51. package/dist-lib/components/settings/ImapProfilesCard.d.ts.map +0 -1
  52. package/dist-lib/components/settings/ImapProfilesCard.js +0 -101
  53. package/dist-lib/components/settings/MailboxSettingsCard.d.ts +0 -2
  54. package/dist-lib/components/settings/MailboxSettingsCard.d.ts.map +0 -1
  55. package/dist-lib/components/settings/MailboxSettingsCard.js +0 -85
  56. package/dist-lib/lib/imap.functions.d.ts +0 -32852
  57. package/dist-lib/lib/imap.functions.d.ts.map +0 -1
  58. package/dist-lib/lib/imap.functions.js +0 -235
  59. package/dist-lib/lib/mailbox-auto.server.d.ts +0 -9
  60. package/dist-lib/lib/mailbox-auto.server.d.ts.map +0 -1
  61. package/dist-lib/lib/mailbox-auto.server.js +0 -198
@@ -10,6 +10,9 @@
10
10
  // server-only helpers niet direct binnenhalen).
11
11
  import { emailBodyText } from "./mailbox-body.js";
12
12
  import { detectLoadCarrierInText, isLoadCarrierAllowed, loadCarrierLabel, normalizeAllowedLoadCarriers, } from "./load-carriers.js";
13
+ import { classifyMessage, extractLatestPart } from "./mailbox-filter.js";
14
+ import { normalizeUnitKind, toPlants, unitPricePerPlant } from "./mailbox-units.js";
15
+ import { matchLine, rankCandidates } from "./mailbox-match.js";
13
16
  /**
14
17
  * Ladingdragers komen live uit de Logistics Engine — nooit cachen, nooit een
15
18
  * eigen lijst bijhouden.
@@ -126,12 +129,6 @@ const DEFAULT_AI_FIELDS = [
126
129
  field_type: "date",
127
130
  ai_hint: "Alleen invullen als de mail een datum of week noemt. Formaat YYYY-MM-DD.",
128
131
  },
129
- {
130
- key: "reference",
131
- label: "Referentie klant",
132
- field_type: "text",
133
- ai_hint: "Order-, referentie- of PO-nummer van de klant.",
134
- },
135
132
  {
136
133
  key: "notes",
137
134
  label: "Opmerking",
@@ -145,53 +142,104 @@ const DEFAULT_AI_FIELDS = [
145
142
  ai_hint: "Kies uitsluitend een id uit de meegegeven afleverlocaties van deze klant.",
146
143
  },
147
144
  ];
145
+ /** Referentie klant wordt altijd gevraagd, los van het template. */
146
+ const REFERENCE_FIELD = {
147
+ key: "reference",
148
+ label: "Referentie klant",
149
+ field_type: "text",
150
+ ai_hint: "Order-, referentie- of PO-nummer van de klant, letterlijk zoals in de mail.",
151
+ };
148
152
  /** Centrale AI-instellingen van deze module (aanpasbaar via Instellingen). */
149
153
  export async function loadMailboxAiSettings(admin) {
150
154
  const { data } = await admin
151
155
  .from("mailbox_ai_settings")
152
- .select("system_prompt, required_fields")
156
+ .select("system_prompt, required_fields, enabled")
153
157
  .limit(1)
154
158
  .maybeSingle();
155
159
  const req = Array.isArray(data?.required_fields) ? data.required_fields : [];
156
160
  return {
157
161
  systemPrompt: data?.system_prompt ?? null,
158
162
  requiredFields: req.length > 0 ? req : ["customer", "delivery_date", "delivery_location", "lines"],
163
+ enabled: data?.enabled !== false,
159
164
  };
160
165
  }
161
- async function buildAiContext(admin, customerId) {
162
- const [{ data: products }, { data: locations }, { data: tplFields }, { data: plantRows }, settings] = await Promise.all([
163
- admin.from("products").select("id, product").order("product").range(0, 999),
166
+ /** Filter- en limietinstellingen. Fail closed: zonder instellingen geen analyse. */
167
+ export async function loadFilterSettings(admin) {
168
+ const { data, error } = await admin
169
+ .from("mailbox_filter_settings")
170
+ .select("*")
171
+ .eq("singleton", true)
172
+ .maybeSingle();
173
+ if (error)
174
+ throw new Error(`Instellingen van de mailbox niet leesbaar: ${error.message}`);
175
+ if (!data)
176
+ throw new Error("Instellingen van de mailbox ontbreken (mailbox_filter_settings).");
177
+ return data;
178
+ }
179
+ const codeVal = (v) => typeof v === "string" || typeof v === "number" ? String(v).trim() || null : null;
180
+ async function loadProductIndex(admin) {
181
+ const { data } = await admin
182
+ .from("products")
183
+ .select("id, product, article_number, barcode, floriday_trade_item_id, custom_fields, plants_per_crate, crates_per_layer, layers_per_cart")
184
+ .range(0, 9999);
185
+ const list = (Array.isArray(data) ? data : []).map((p) => {
186
+ const cf = p?.custom_fields ?? {};
187
+ const pl = cf?.floriday_payload ?? {};
188
+ const codes = [p.article_number, p.barcode, p.floriday_trade_item_id, cf.articleCode, pl.articleCode, pl.gtin, pl.vbnCode, pl.producerCode]
189
+ .map(codeVal)
190
+ .filter((x) => !!x);
191
+ const packs = Array.isArray(pl?.packingConfigurations) ? pl.packingConfigurations : [];
192
+ const n = (v) => Math.max(1, Math.round(Number(v ?? 1)) || 1);
193
+ return {
194
+ id: String(p.id),
195
+ name: String(p.product ?? ""),
196
+ codes,
197
+ belading: p.plants_per_crate
198
+ ? { plants_per_crate: n(p.plants_per_crate), crates_per_layer: n(p.crates_per_layer), layers_per_cart: n(p.layers_per_cart) }
199
+ : null,
200
+ packings: packs.map((pc) => ({
201
+ plants_per_crate: n(pc?.unitsPerPackage ?? pc?.piecesPerPackage),
202
+ crates_per_layer: n(pc?.packagesPerLayer),
203
+ layers_per_cart: n(pc?.layersPerLoadCarrier),
204
+ })),
205
+ };
206
+ });
207
+ return { list, byId: new Map(list.map((p) => [p.id, p])) };
208
+ }
209
+ const beladingText = (b) => b ? `${b.plants_per_crate} per fust, ${b.crates_per_layer} fust per laag, ${b.layers_per_cart} lagen per kar` : null;
210
+ async function buildAiContext(admin, customerId, mailText, maxCandidates, idx) {
211
+ const [{ data: locations }, { data: tplFields }, { data: plantRows }, settings] = await Promise.all([
164
212
  customerId
165
- ? admin
166
- .from("customer_delivery_locations")
167
- .select("id, name, city")
168
- .eq("customer_id", customerId)
169
- .limit(100)
213
+ ? admin.from("customer_delivery_locations").select("id, name, city, gln").eq("customer_id", customerId).limit(100)
170
214
  : Promise.resolve({ data: [] }),
171
215
  admin.from("mailbox_order_template_fields").select("*").order("sort_order", { ascending: true }),
172
216
  admin.from("products").select("product_type").not("product_type", "is", null).range(0, 999),
173
217
  loadMailboxAiSettings(admin),
174
218
  ]);
175
- const catalog = (Array.isArray(products) ? products : []).map((p) => ({
219
+ // Alleen de beste kandidaten voor deze mail (codes eerst, dan naam).
220
+ const ranked = rankCandidates(mailText, idx.list, maxCandidates);
221
+ const candidates = (ranked.length ? ranked : idx.list.slice(0, Math.max(1, maxCandidates))).map((p) => ({
176
222
  id: p.id,
177
- name: p.product ?? "",
223
+ name: p.name,
224
+ codes: p.codes.slice(0, 6),
225
+ belading: beladingText(p.belading),
178
226
  }));
179
227
  const plantTypes = Array.from(new Set((Array.isArray(plantRows) ? plantRows : [])
180
228
  .map((r) => (typeof r?.product_type === "string" ? r.product_type.trim() : ""))
181
229
  .filter(Boolean))).sort();
182
230
  const deliveryLocations = (Array.isArray(locations) ? locations : []).map((l) => ({
183
231
  id: l.id,
184
- name: [l.name, l.city].filter(Boolean).join(" — ") || "Locatie",
232
+ name: [l.name, l.city, l.gln ? `GLN ${l.gln}` : null].filter(Boolean).join(" — ") || "Locatie",
185
233
  }));
186
234
  const rows = (Array.isArray(tplFields) ? tplFields : []).filter((f) => f.ai_enabled !== false);
187
- const templateFields = rows.length > 0
235
+ const base = rows.length > 0
188
236
  ? rows.map((f) => ({
189
237
  key: f.key,
190
238
  label: f.label ?? f.key,
191
239
  field_type: f.field_type ?? "text",
192
240
  ai_hint: f.ai_hint ?? null,
193
241
  allowed: f.field_type === "product"
194
- ? catalog
242
+ ? candidates.map((c) => ({ id: c.id, name: c.name }))
195
243
  : f.field_type === "delivery_location"
196
244
  ? deliveryLocations
197
245
  : f.field_type === "plant_type"
@@ -202,38 +250,42 @@ async function buildAiContext(admin, customerId) {
202
250
  ...f,
203
251
  allowed: f.field_type === "delivery_location" ? deliveryLocations : undefined,
204
252
  }));
205
- return { catalog, deliveryLocations, templateFields, plantTypes, settings };
253
+ const templateFields = base.some((f) => f.key === "reference") ? base : [...base, { ...REFERENCE_FIELD, allowed: undefined }];
254
+ return { candidates, deliveryLocations, templateFields, plantTypes, settings };
206
255
  }
207
256
  /** Voert de AI-parse uit voor één bericht. Gooit nooit — fouten komen terug in `error`. */
208
- async function aiParseMessage(admin, messageId, customerId) {
257
+ async function aiParseMessage(admin, args) {
209
258
  try {
210
- const { data: msg } = await admin
211
- .from("mailbox_messages")
212
- .select("subject, body_text, body_html")
213
- .eq("id", messageId)
214
- .maybeSingle();
215
- const bodyText = emailBodyText(msg?.body_text, msg?.body_html);
216
- if (!bodyText.trim())
259
+ if (!args.text.trim())
217
260
  throw new Error("Bericht bevat geen tekst om te lezen.");
218
- const ctx = await buildAiContext(admin, customerId);
261
+ const ctx = await buildAiContext(admin, args.customerId, `${args.subject}\n${args.text}`, args.settings.max_candidates, args.idx);
219
262
  const { parseOrderEmail } = await import("./mailbox-ai.server.js");
220
263
  const res = await parseOrderEmail(admin, {
221
- bodyText,
222
- subject: msg?.subject ?? "",
264
+ bodyText: args.text,
265
+ subject: args.subject,
223
266
  templateFields: ctx.templateFields,
224
- productCatalog: ctx.catalog,
267
+ productCatalog: ctx.candidates,
225
268
  deliveryLocations: ctx.deliveryLocations,
226
269
  plantTypes: ctx.plantTypes,
227
270
  requiredFields: ctx.settings.requiredFields,
228
271
  systemPrompt: ctx.settings.systemPrompt,
272
+ now: args.receivedAt,
273
+ maxAttemptsPerModel: args.settings.max_retry_attempts,
229
274
  });
230
- return { parsed: res.parsed, meta: res.meta, error: null, transient: false, requiredFields: ctx.settings.requiredFields };
275
+ return {
276
+ parsed: res.parsed,
277
+ meta: res.meta,
278
+ error: null,
279
+ transient: false,
280
+ requiredFields: ctx.settings.requiredFields,
281
+ candidateIds: new Set(ctx.candidates.map((c) => c.id)),
282
+ };
231
283
  }
232
284
  catch (e) {
233
285
  const msg = String(e?.message ?? e);
234
286
  const { isTransientAiError } = await import("./mailbox-ai.server.js");
235
287
  const transient = e?.name === "AiUnavailableError" ? Boolean(e.transient) : isTransientAiError(msg);
236
- return { parsed: null, meta: null, error: msg.slice(0, 500), transient, requiredFields: [] };
288
+ return { parsed: null, meta: null, error: msg.slice(0, 500), transient, requiredFields: [], candidateIds: new Set() };
237
289
  }
238
290
  }
239
291
  const FIELD_LABELS = {
@@ -246,6 +298,55 @@ const FIELD_LABELS = {
246
298
  reference: "Referentie klant",
247
299
  notes: "Opmerking",
248
300
  };
301
+ /** Koppelt product (code eerst), bepaalt eenheid en rekent om naar planten. */
302
+ function enrichLines(parsed, idx, candidateIds) {
303
+ return parsed.lines.map((l) => {
304
+ const m = matchLine({ code: l.code, raw_text: l.raw_product_text, name_in_mail: l.name_in_mail, product_id: l.product_id, ai_confidence: l.confidence.product }, idx.list, candidateIds);
305
+ const p = m.productId ? idx.byId.get(m.productId) ?? null : null;
306
+ let kind = normalizeUnitKind(l.unit);
307
+ if (kind === "onbekend" && l.unit_text)
308
+ kind = normalizeUnitKind(l.unit_text);
309
+ const warnings = [];
310
+ let bel = p?.belading ?? null;
311
+ const ppu = l.pieces_per_unit && l.pieces_per_unit > 0 ? Math.round(l.pieces_per_unit) : null;
312
+ if (p && ppu && kind === "fust" && bel && ppu !== bel.plants_per_crate) {
313
+ const alt = p.packings.find((x) => x.plants_per_crate === ppu);
314
+ if (alt) {
315
+ bel = alt;
316
+ warnings.push(`Belading met ${ppu} per fust gekozen, zoals genoemd in de mail.`);
317
+ }
318
+ else {
319
+ warnings.push(`De mail noemt ${ppu} per fust, de standaardbelading is ${bel.plants_per_crate} per fust.`);
320
+ }
321
+ }
322
+ if (!p)
323
+ warnings.push(`Onbekend artikel: ${l.name_in_mail || l.raw_product_text || "?"}`);
324
+ if (kind === "onbekend")
325
+ warnings.push("Eenheid onbekend: kies fust, laag, kar of stuks.");
326
+ const plants = toPlants(l.quantity, kind, bel);
327
+ const pricePer = l.price_per ?? (l.price !== null ? "stuk" : null);
328
+ return {
329
+ product_id: p?.id ?? null,
330
+ raw_product_text: l.raw_product_text,
331
+ plant_type: l.plant_type,
332
+ raw_code: l.code,
333
+ name_in_mail: l.name_in_mail,
334
+ qty_in_mail: l.quantity,
335
+ unit_text: l.unit_text,
336
+ unit_kind: kind,
337
+ pieces_per_unit: ppu,
338
+ plants,
339
+ unit_price: unitPricePerPlant(l.price, pricePer, kind, bel),
340
+ currency: l.currency,
341
+ price_per: pricePer,
342
+ mail_price: l.price,
343
+ belading: bel,
344
+ match_source: m.source,
345
+ confidence: { ...l.confidence, product: p ? Math.max(m.confidence, m.source === "code" ? 1 : 0) : 0 },
346
+ warnings,
347
+ };
348
+ });
349
+ }
249
350
  /**
250
351
  * Combineert de zelfcontrole van de AI met een harde servercheck op de
251
352
  * verplichte velden. Ontbrekende of onzekere gegevens worden expliciet
@@ -253,6 +354,7 @@ const FIELD_LABELS = {
253
354
  */
254
355
  export function computeValidation(parsed, requiredFields, extra) {
255
356
  const label = (k) => FIELD_LABELS[k] ?? k;
357
+ const green = extra.confGreen ?? 0.8;
256
358
  const missing = new Set(parsed.validation?.missing ?? []);
257
359
  const uncertain = new Set(parsed.validation?.uncertain ?? []);
258
360
  for (const key of requiredFields) {
@@ -262,7 +364,7 @@ export function computeValidation(parsed, requiredFields, extra) {
262
364
  continue;
263
365
  }
264
366
  if (key === "customer") {
265
- if (!extra.customerId && !parsed.fields?.customer?.value)
367
+ if (!extra.customerId)
266
368
  missing.add(label("customer"));
267
369
  continue;
268
370
  }
@@ -270,29 +372,30 @@ export function computeValidation(parsed, requiredFields, extra) {
270
372
  const empty = f?.value === null || f?.value === undefined || f?.value === "";
271
373
  if (empty)
272
374
  missing.add(label(key));
273
- else if (f && (f.needs_review || f.confidence < 0.8))
375
+ else if (f && (f.needs_review || f.confidence < green))
274
376
  uncertain.add(label(key));
275
377
  }
276
- for (const l of parsed.lines) {
378
+ for (const l of extra.lines ?? []) {
379
+ const name = l.name_in_mail || l.raw_product_text || "onbekend";
277
380
  if (!l.product_id)
278
- uncertain.add(`Artikel niet gekoppeld: ${l.raw_product_text || "onbekend"}`);
279
- if (l.quantity === null || l.quantity <= 0)
280
- missing.add(`Aantal ontbreekt: ${l.raw_product_text || "onbekend"}`);
281
- if (!l.plant_type)
282
- uncertain.add(`Plantsoort onbekend: ${l.raw_product_text || "onbekend"}`);
381
+ uncertain.add(`Onbekend artikel: ${name}`);
382
+ if (l.qty_in_mail === null || l.qty_in_mail <= 0)
383
+ missing.add(`Aantal ontbreekt: ${name}`);
384
+ if (l.unit_kind === "onbekend")
385
+ missing.add(`Eenheid onbekend: ${name}`);
386
+ if (l.unit_price === null)
387
+ uncertain.add(`Geen prijs bekend: ${name}`);
388
+ for (const w of l.warnings)
389
+ if (w.startsWith("De mail noemt"))
390
+ uncertain.add(w);
283
391
  }
284
392
  const m = Array.from(missing).slice(0, 50);
285
393
  const u = Array.from(uncertain).slice(0, 50);
286
- return {
287
- missing: m,
288
- uncertain: u,
289
- notes: parsed.validation?.notes ?? null,
290
- ok: m.length === 0 && u.length === 0,
291
- };
394
+ return { missing: m, uncertain: u, notes: parsed.validation?.notes ?? null, ok: m.length === 0 && u.length === 0 };
292
395
  }
293
396
  /** Maximaal aantal achtergrondpogingen voordat we het voorstel handmatig laten. */
294
- export const AI_MAX_BACKGROUND_RETRIES = 6;
295
- function aiPayloadBlock(attempt, fingerprint, prevAi) {
397
+ export const AI_MAX_BACKGROUND_RETRIES = 3;
398
+ function aiPayloadBlock(attempt, fingerprint, prevAi, maxRetries = AI_MAX_BACKGROUND_RETRIES) {
296
399
  if (attempt.parsed) {
297
400
  return {
298
401
  ok: true,
@@ -313,36 +416,38 @@ function aiPayloadBlock(attempt, fingerprint, prevAi) {
313
416
  error: attempt.error,
314
417
  transient,
315
418
  retry_count: retryCount,
316
- retry_pending: transient && retryCount < AI_MAX_BACKGROUND_RETRIES,
419
+ retry_pending: transient && retryCount < maxRetries,
317
420
  last_attempt_at: new Date().toISOString(),
318
421
  fingerprint,
319
422
  parsed_at: new Date().toISOString(),
320
423
  };
321
424
  }
322
425
  /**
323
- * Vult het afleveradres aan met de hoofdafleverlocatie (is_primary) van de klant
324
- * wanneer de mail/AI géén geldige locatie heeft herkend. Herkent de AI wél een
325
- * locatie van deze klant, dan blijft die staan.
426
+ * Afleveradres: een GLN of locatie uit de mail wint. Anders de vaste locatie
427
+ * (is_primary, maximaal één per klant). Geen vaste locatie → niets kiezen.
326
428
  */
327
- export async function resolveDeliveryLocation(admin, customerId, aiValue) {
429
+ export async function resolveDeliveryLocation(admin, customerId, aiValue, mailText = "") {
328
430
  if (!customerId)
329
431
  return null;
330
432
  const { data: rows } = await admin
331
433
  .from("customer_delivery_locations")
332
- .select("id, is_primary")
434
+ .select("id, is_primary, gln")
333
435
  .eq("customer_id", customerId)
334
- .order("is_primary", { ascending: false })
335
- .limit(100);
436
+ .limit(200);
336
437
  const locations = Array.isArray(rows) ? rows : [];
337
438
  if (locations.length === 0)
338
439
  return null;
440
+ const glns = new Set((mailText.match(/\b\d{13}\b/g) ?? []).map((g) => g));
441
+ const byGln = locations.filter((l) => l.gln && glns.has(String(l.gln)));
442
+ if (byGln.length === 1)
443
+ return { id: String(byGln[0].id), source: "gln" };
339
444
  if (typeof aiValue === "string" && aiValue) {
340
445
  const hit = locations.find((l) => String(l.id) === aiValue);
341
446
  if (hit)
342
- return { id: String(hit.id), primary: false };
447
+ return { id: String(hit.id), source: "mail" };
343
448
  }
344
- const primary = locations.find((l) => l.is_primary === true) ?? locations[0];
345
- return primary ? { id: String(primary.id), primary: true } : null;
449
+ const primary = locations.filter((l) => l.is_primary === true);
450
+ return primary.length === 1 ? { id: String(primary[0].id), source: "vast" } : null;
346
451
  }
347
452
  /**
348
453
  * Afhaallocatie (eigen tuin/warehouse): AI-herkenning wint, anders de hoofdlocatie
@@ -367,8 +472,8 @@ export async function resolvePickupLocation(admin, aiValue) {
367
472
  const main = own.find((r) => r?.data?.isDefault === true) ?? own[0];
368
473
  return main ? { id: String(main.floriday_id), primary: true } : null;
369
474
  }
370
- /** Schrijft velden + regels van een AI-parse weg (na het opschonen van oude rijen). */
371
- async function writeParsedDetails(admin, proposalId, parsed) {
475
+ /** Schrijft velden + regels van een AI-parse weg. */
476
+ async function writeParsedDetails(admin, proposalId, parsed, lines) {
372
477
  const fieldRows = Object.entries(parsed.fields).map(([key, v]) => ({
373
478
  proposal_id: proposalId,
374
479
  field_key: key,
@@ -380,162 +485,338 @@ async function writeParsedDetails(admin, proposalId, parsed) {
380
485
  if (fieldRows.length > 0) {
381
486
  await admin.from("mailbox_proposal_field_values").insert(fieldRows);
382
487
  }
383
- if (parsed.lines.length > 0) {
384
- await admin.from("mailbox_proposal_lines").insert(parsed.lines.map((l, idx) => ({
488
+ if (lines.length > 0) {
489
+ await admin.from("mailbox_proposal_lines").insert(lines.map((l, i) => ({
385
490
  proposal_id: proposalId,
386
491
  product_id: l.product_id,
387
492
  plant_type: l.plant_type,
388
493
  raw_product_text: l.raw_product_text,
389
- quantity: l.quantity,
390
- unit: l.unit,
391
- match_confidence: l.match_confidence,
392
- sort_order: idx,
494
+ quantity: l.plants,
495
+ unit: "planten",
496
+ match_confidence: l.confidence.product,
497
+ sort_order: i,
498
+ raw_code: l.raw_code,
499
+ name_in_mail: l.name_in_mail,
500
+ qty_in_mail: l.qty_in_mail,
501
+ unit_text: l.unit_text,
502
+ unit_kind: l.unit_kind,
503
+ pieces_per_unit: l.pieces_per_unit,
504
+ plants: l.plants,
505
+ unit_price: l.unit_price,
506
+ currency: l.currency,
507
+ price_per: l.price_per,
508
+ belading: l.belading,
509
+ confidence: { ...l.confidence, mail_price: l.mail_price },
510
+ match_source: l.match_source,
511
+ warnings: l.warnings,
393
512
  })));
394
513
  }
395
514
  }
396
- /**
397
- * Maakt exact één voorstel per bericht. Probeert eerst de AI-parse via de
398
- * centrale AI-Authenticatie module; lukt dat niet, dan valt hij terug op een
399
- * leeg voorstel met de reden erbij. Ingest mag nooit stukvallen op AI.
400
- */
401
- export async function createStubProposal(admin, messageId, fromEmail) {
402
- const { data: existing } = await admin
403
- .from("mailbox_order_proposals")
404
- .select("id")
405
- .eq("message_id", messageId)
515
+ async function loadMessage(admin, messageId) {
516
+ const { data } = await admin
517
+ .from("mailbox_messages")
518
+ .select("id, from_email, subject, body_text, body_html, received_at, has_attachments, attachments, filter_outcome")
519
+ .eq("id", messageId)
406
520
  .maybeSingle();
407
- if (existing?.id)
408
- return;
409
- // Val terug op het afzenderadres van het bericht zelf wanneer de event-payload
410
- // geen adres meegaf — anders wordt de klant nooit automatisch herkend.
411
- let senderEmail = normalizeEmail(fromEmail);
412
- if (!senderEmail) {
413
- const { data: msg } = await admin
414
- .from("mailbox_messages")
415
- .select("from_email")
416
- .eq("id", messageId)
417
- .maybeSingle();
418
- senderEmail = normalizeEmail(msg?.from_email ?? null);
419
- }
420
- const matchResult = await matchCustomerByEmail(admin, senderEmail);
421
- const basePayload = {
422
- auto_matched_customer: matchResult.customerId !== null,
423
- auto_match_source: matchResult.source,
424
- auto_match_email: senderEmail || null,
425
- auto_match_warning: matchResult.warning,
426
- auto_match_candidates: matchResult.candidates,
427
- };
428
- // --- AI-parse (best effort) ---------------------------------------------
429
- const { currentAiFingerprint } = await import("./mailbox-ai.server.js");
430
- const fingerprint = await currentAiFingerprint(admin).catch(() => null);
431
- const attempt = await aiParseMessage(admin, messageId, matchResult.customerId);
521
+ return data ?? null;
522
+ }
523
+ /** Bijlagen die geen ingesloten afbeelding zijn worden niet automatisch gelezen. */
524
+ function hasUnreadAttachment(msg) {
525
+ const list = Array.isArray(msg.attachments) ? msg.attachments : [];
526
+ const real = list.filter((a) => !(a?.isInline === true || a?.inline === true || a?.contentId));
527
+ return real.length > 0 || (msg.has_attachments === true && list.length === 0);
528
+ }
529
+ async function senderMatch(admin, email, s) {
530
+ const match = await matchCustomerByEmail(admin, email);
531
+ if (match.customerId || !s.domain_match || !email.includes("@"))
532
+ return match;
533
+ const domain = email.split("@")[1];
534
+ const { data } = await admin.from("customers").select("id, company_name").ilike("email", `%@${domain}`).limit(3);
535
+ const rows = Array.isArray(data) ? data : [];
536
+ if (rows.length === 1) {
537
+ return {
538
+ customerId: rows[0].id,
539
+ source: "customers",
540
+ warning: `Klant herkend op domein (${domain}) — controleer de klant.`,
541
+ candidates: rows.map((c) => ({ id: c.id, name: c.company_name })),
542
+ };
543
+ }
544
+ return match;
545
+ }
546
+ /** Mogelijk dubbel: zelfde klant + zelfde referentie, of vrijwel dezelfde regels. */
547
+ async function findDuplicate(admin, args) {
548
+ if (!args.customerId)
549
+ return null;
550
+ const since = new Date(Date.now() - Math.max(1, args.days) * 86400000).toISOString();
551
+ const { data } = await admin
552
+ .from("mailbox_order_proposals")
553
+ .select("id, message_id, order_number, customer_reference, created_at, mailbox_proposal_lines(product_id, plants)")
554
+ .eq("matched_customer_id", args.customerId)
555
+ .gte("created_at", since)
556
+ .neq("message_id", args.messageId)
557
+ .limit(100);
558
+ const rows = (Array.isArray(data) ? data : []).filter((r) => r.id !== args.excludeProposalId);
559
+ const ref = (args.reference ?? "").trim().toLowerCase();
560
+ if (ref) {
561
+ const hit = rows.find((r) => (r.customer_reference ?? "").trim().toLowerCase() === ref);
562
+ if (hit)
563
+ return { kind: "referentie", label: hit.order_number ?? "een eerder voorstel" };
564
+ const { data: orders } = await admin
565
+ .from("orders")
566
+ .select("order_number")
567
+ .eq("customer_id", args.customerId)
568
+ .gte("created_at", since)
569
+ .eq("custom_fields->>customer_reference", args.reference)
570
+ .limit(1);
571
+ if (Array.isArray(orders) && orders[0])
572
+ return { kind: "referentie", label: orders[0].order_number };
573
+ }
574
+ const sig = (ls) => ls.filter((l) => l.product_id).map((l) => `${l.product_id}:${l.plants ?? 0}`).sort().join("|");
575
+ const mine = sig(args.lines);
576
+ if (mine) {
577
+ const hit = rows.find((r) => sig(Array.isArray(r.mailbox_proposal_lines) ? r.mailbox_proposal_lines : []) === mine);
578
+ if (hit)
579
+ return { kind: "regels", label: hit.order_number ?? "een eerder voorstel" };
580
+ }
581
+ return null;
582
+ }
583
+ /** Klaar om te bevestigen, of controleren. Nooit automatisch bevestigen. */
584
+ function readiness(validation, lines, deliveryLoc, customer) {
585
+ if (!validation || !customer || !deliveryLoc || lines.length === 0)
586
+ return "controleren";
587
+ if (validation.missing.length || validation.uncertain.length)
588
+ return "controleren";
589
+ if (lines.some((l) => !l.product_id || !l.plants || l.unit_price === null || l.warnings.length))
590
+ return "controleren";
591
+ return "klaar";
592
+ }
593
+ async function finishRun(admin, runId, messageId, attempt) {
594
+ if (runId) {
595
+ await admin
596
+ .from("mailbox_ai_runs")
597
+ .update({
598
+ finished_at: new Date().toISOString(),
599
+ ok: !!attempt.parsed,
600
+ input_tokens: attempt.meta?.inputTokens ?? null,
601
+ output_tokens: attempt.meta?.outputTokens ?? null,
602
+ error: attempt.error,
603
+ })
604
+ .eq("id", runId);
605
+ }
606
+ await admin.from("mailbox_messages").update({ analysis_state: "klaar" }).eq("id", messageId);
607
+ }
608
+ /** Legt het bericht vast als "in analyse" (één tegelijk) en bewaakt de limieten. */
609
+ async function claimAnalysis(admin, messageId, force, trigger) {
610
+ const { data, error } = await admin.rpc("mailbox_claim_analysis", {
611
+ p_message_id: messageId,
612
+ p_force: force,
613
+ p_trigger: trigger,
614
+ });
615
+ if (error)
616
+ throw new Error(`Analyse vastleggen mislukt: ${error.message}`);
617
+ return (data ?? {});
618
+ }
619
+ /** Analyseert de mail en vult alle afgeleide velden (locatie, belading, prijs). */
620
+ async function analyzeAndBuild(admin, msg, customerId, text, s) {
621
+ const idx = await loadProductIndex(admin);
622
+ const attempt = await aiParseMessage(admin, {
623
+ customerId,
624
+ subject: msg.subject ?? "",
625
+ text,
626
+ receivedAt: msg.received_at ? new Date(msg.received_at) : new Date(),
627
+ settings: s,
628
+ idx,
629
+ });
432
630
  const parsed = attempt.parsed;
433
- const fieldValue = (key) => {
631
+ const fieldMeta = {};
632
+ const fv = (key) => {
434
633
  const v = parsed?.fields?.[key]?.value;
435
634
  return v === undefined ? null : v;
436
635
  };
437
- const deliveryDate = typeof fieldValue("delivery_date") === "string" ? fieldValue("delivery_date") : null;
438
- const notesValue = typeof fieldValue("notes") === "string" ? fieldValue("notes") : null;
439
- // Afleveradres: AI-herkenning wint, anders de hoofdafleverlocatie van de klant.
440
- const loc = await resolveDeliveryLocation(admin, matchResult.customerId, fieldValue("delivery_location"));
636
+ if (parsed) {
637
+ for (const [k, v] of Object.entries(parsed.fields)) {
638
+ if (v.value !== null && v.value !== undefined && v.value !== "")
639
+ fieldMeta[k] = { confidence: v.confidence, source: "uit mail" };
640
+ }
641
+ }
642
+ const loc = await resolveDeliveryLocation(admin, customerId, fv("delivery_location"), `${msg.subject ?? ""}\n${text}`);
441
643
  if (loc && parsed) {
644
+ const fromMail = loc.source !== "vast";
442
645
  parsed.fields = {
443
646
  ...parsed.fields,
444
- delivery_location: loc.primary
445
- ? { value: loc.id, confidence: 0.5, needs_review: true }
446
- : (parsed.fields?.delivery_location ?? { value: loc.id, confidence: 0.9, needs_review: false }),
647
+ delivery_location: fromMail
648
+ ? { value: loc.id, confidence: Math.max(0.9, parsed.fields?.delivery_location?.confidence ?? 0), needs_review: false }
649
+ : { value: loc.id, confidence: 0.7, needs_review: false },
447
650
  };
651
+ fieldMeta.delivery_location = { confidence: fromMail ? 0.9 : 0.7, source: fromMail ? "uit mail" : "afgeleid" };
448
652
  }
449
- // Afhaallocatie: AI-herkenning wint, anders de hoofd-afhaallocatie.
450
- const pick = await resolvePickupLocation(admin, fieldValue("pickup_location"));
653
+ else if (parsed?.fields?.delivery_location) {
654
+ delete parsed.fields.delivery_location;
655
+ }
656
+ const pick = await resolvePickupLocation(admin, fv("pickup_location"));
451
657
  if (pick && parsed) {
452
658
  parsed.fields = {
453
659
  ...parsed.fields,
454
660
  pickup_location: pick.primary
455
- ? { value: pick.id, confidence: 0.5, needs_review: true }
661
+ ? { value: pick.id, confidence: 0.7, needs_review: false }
456
662
  : (parsed.fields?.pickup_location ?? { value: pick.id, confidence: 0.9, needs_review: false }),
457
663
  };
664
+ fieldMeta.pickup_location = { confidence: pick.primary ? 0.7 : 0.9, source: pick.primary ? "standaard" : "uit mail" };
458
665
  }
459
- // Ladingdrager: alleen wat de Engine toestaat.
460
- const lc = await resolveLoadCarrier(admin, messageId, fieldValue("load_carrier"));
666
+ const lc = await resolveLoadCarrier(admin, msg.id, fv("load_carrier"));
461
667
  if (lc.code && parsed) {
462
- parsed.fields = {
463
- ...parsed.fields,
464
- load_carrier: { value: lc.code, confidence: 0.6, needs_review: false },
465
- };
668
+ parsed.fields = { ...parsed.fields, load_carrier: { value: lc.code, confidence: 0.6, needs_review: false } };
669
+ fieldMeta.load_carrier = { confidence: 0.6, source: "standaard" };
466
670
  }
467
671
  const blockedNote = lc.blocked
468
672
  ? `Handmatig controleren - ladingdrager uitgeschakeld: "${lc.detectedLabel}" staat uit in de Rule Engine.`
469
673
  : null;
674
+ const lines = parsed ? enrichLines(parsed, idx, attempt.candidateIds) : [];
470
675
  const validation = parsed
471
- ? computeValidation(parsed, attempt.requiredFields, { customerId: matchResult.customerId })
676
+ ? computeValidation(parsed, attempt.requiredFields, { customerId, lines, confGreen: Number(s.conf_green) })
472
677
  : null;
678
+ const reference = typeof fv("reference") === "string" && fv("reference").trim() ? String(fv("reference")).trim().slice(0, 120) : null;
679
+ const notesValue = typeof fv("notes") === "string" ? fv("notes") : null;
680
+ const deliveryDate = typeof fv("delivery_date") === "string" && /^\d{4}-\d{2}-\d{2}$/.test(fv("delivery_date")) ? fv("delivery_date") : null;
681
+ const duplicate = await findDuplicate(admin, {
682
+ customerId,
683
+ reference,
684
+ lines,
685
+ days: Number(s.duplicate_days ?? 7),
686
+ messageId: msg.id,
687
+ });
688
+ return {
689
+ attempt,
690
+ lines,
691
+ validation,
692
+ deliveryDate,
693
+ reference,
694
+ customerId,
695
+ notes: blockedNote ? [blockedNote, notesValue].filter(Boolean).join("\n") : notesValue,
696
+ confidence: parsed ? parsed.overall_confidence : customerId ? 1 : 0,
697
+ payloadExtra: {
698
+ load_carrier: lc.code,
699
+ load_carrier_blocked: lc.blocked,
700
+ load_carrier_detected: lc.detectedLabel,
701
+ field_meta: fieldMeta,
702
+ duplicate_of: duplicate,
703
+ attachment_unread: hasUnreadAttachment(msg),
704
+ readiness: readiness(validation, lines, !!loc, !!customerId),
705
+ thresholds: { green: Number(s.conf_green), orange: Number(s.conf_orange) },
706
+ blockedMessageStatus: lc.blocked,
707
+ },
708
+ };
709
+ }
710
+ /**
711
+ * Maakt exact één voorstel per bericht. Eerst het voorfilter (zonder AI), dan
712
+ * het vastleggen van de analyse (één tegelijk + limieten), en pas dan de AI.
713
+ */
714
+ export async function createStubProposal(admin, messageId, fromEmail, opts = {}) {
715
+ const { data: existing } = await admin
716
+ .from("mailbox_order_proposals")
717
+ .select("id")
718
+ .eq("message_id", messageId)
719
+ .maybeSingle();
720
+ if (existing?.id)
721
+ return { status: "exists" };
722
+ const msg = await loadMessage(admin, messageId);
723
+ if (!msg)
724
+ return { status: "skipped", reason: "Bericht niet gevonden" };
725
+ // Al eerder beoordeeld als "geen bestelling" of "wacht": niet opnieuw (kost niets, maar ook geen AI).
726
+ if (!opts.force && msg.filter_outcome)
727
+ return { status: "skipped", reason: "Al beoordeeld" };
728
+ const s = await loadFilterSettings(admin);
729
+ const ai = await loadMailboxAiSettings(admin);
730
+ const body = emailBodyText(msg.body_text, msg.body_html);
731
+ const latest = extractLatestPart(body);
732
+ const senderEmail = normalizeEmail(latest.forwardedFrom ?? fromEmail ?? msg.from_email);
733
+ const matchResult = await senderMatch(admin, senderEmail, s);
734
+ const setMsg = (patch) => admin.from("mailbox_messages").update(patch).eq("id", messageId);
735
+ if (!opts.force) {
736
+ const cls = classifyMessage({ fromEmail: senderEmail, subject: msg.subject ?? "", body: latest.text, knownSender: !!matchResult.customerId }, s);
737
+ await setMsg({ filter_outcome: cls.outcome, filter_reason: cls.reason });
738
+ if (cls.outcome === "geen_bestelling") {
739
+ await setMsg({ status: "skipped" });
740
+ return { status: "skipped", reason: cls.reason };
741
+ }
742
+ if (cls.outcome === "onbekend_met_signalen" && !s.analyze_unknown_with_signals) {
743
+ await setMsg({ analysis_state: "wacht", analysis_note: "Onbekende afzender: automatisch analyseren staat uit" });
744
+ return { status: "waiting" };
745
+ }
746
+ if (!ai.enabled) {
747
+ await setMsg({ analysis_state: "wacht", analysis_note: "Automatisch analyseren staat uit" });
748
+ return { status: "waiting" };
749
+ }
750
+ }
751
+ else {
752
+ await setMsg({ filter_outcome: msg.filter_outcome ?? "analyseren", filter_reason: "Handmatig gestart" });
753
+ }
754
+ const claim = await claimAnalysis(admin, messageId, opts.force === true, opts.trigger ?? "auto");
755
+ if (!claim.ok) {
756
+ return claim.reason === "busy" ? { status: "busy" } : { status: "waiting", reason: claim.reason };
757
+ }
758
+ let built;
759
+ try {
760
+ built = await analyzeAndBuild(admin, msg, matchResult.customerId, latest.text, s);
761
+ }
762
+ catch (e) {
763
+ await finishRun(admin, claim.run_id ?? null, messageId, {
764
+ parsed: null, meta: null, error: String(e?.message ?? e).slice(0, 500), requiredFields: [], candidateIds: new Set(),
765
+ });
766
+ throw e;
767
+ }
768
+ const { currentAiFingerprint } = await import("./mailbox-ai.server.js");
769
+ const fingerprint = await currentAiFingerprint(admin).catch(() => null);
770
+ const parsed = built.attempt.parsed;
473
771
  const { data: proposal, error } = await admin
474
772
  .from("mailbox_order_proposals")
475
773
  .insert({
476
774
  message_id: messageId,
477
775
  matched_customer_id: matchResult.customerId,
478
- confidence: parsed ? parsed.overall_confidence : matchResult.customerId ? 1 : 0,
479
- delivery_date: deliveryDate,
480
- notes: blockedNote ? [blockedNote, notesValue].filter(Boolean).join("\n") : notesValue,
776
+ confidence: built.confidence,
777
+ delivery_date: built.deliveryDate,
778
+ notes: built.notes,
481
779
  status: "pending",
482
- ai_validation: validation,
780
+ customer_reference: built.reference,
781
+ ai_validation: built.validation,
483
782
  parsed_payload: {
484
- ...basePayload,
783
+ auto_matched_customer: matchResult.customerId !== null,
784
+ auto_match_source: matchResult.source,
785
+ auto_match_email: senderEmail || null,
786
+ auto_match_forwarded: !!latest.forwardedFrom,
787
+ auto_match_warning: matchResult.warning,
788
+ auto_match_candidates: matchResult.candidates,
485
789
  stub: parsed === null,
486
790
  reason: parsed === null ? "ai_unavailable" : null,
487
- load_carrier: lc.code,
488
- load_carrier_blocked: lc.blocked,
489
- load_carrier_detected: lc.detectedLabel,
490
- ai: aiPayloadBlock(attempt, fingerprint),
791
+ ai: aiPayloadBlock(built.attempt, fingerprint, undefined, s.max_retry_attempts),
491
792
  fields: parsed?.fields ?? null,
492
- validation,
793
+ validation: built.validation,
794
+ ...built.payloadExtra,
493
795
  },
494
796
  })
495
797
  .select("id")
496
798
  .single();
497
- // 23505 = unique-violation op message_id: een concurrent trigger heeft 'm
498
- // net ingevoegd. Idempotent — negeren. Alle andere fouten laten we bubbelen.
799
+ // Pas na het opslaan van het voorstel de analyse afsluiten, zodat een
800
+ // gelijktijdige trigger het bericht niet opnieuw kan claimen.
801
+ await finishRun(admin, claim.run_id ?? null, messageId, built.attempt);
499
802
  if (error) {
500
803
  if (error.code === "23505")
501
- return;
804
+ return { status: "exists" };
502
805
  throw new Error(error.message);
503
806
  }
504
807
  const proposalId = proposal.id;
505
808
  if (parsed)
506
- await writeParsedDetails(admin, proposalId, parsed);
507
- else if (loc) {
508
- await admin.from("mailbox_proposal_field_values").insert({
509
- proposal_id: proposalId,
510
- field_key: "delivery_location",
511
- value: loc.id,
512
- ai_confidence: 0.5,
513
- ai_filled: false,
514
- needs_review: true,
515
- });
516
- }
517
- if (!parsed && lc.code) {
518
- await admin.from("mailbox_proposal_field_values").upsert({
519
- proposal_id: proposalId,
520
- field_key: "load_carrier",
521
- value: lc.code,
522
- ai_confidence: 0.6,
523
- ai_filled: false,
524
- needs_review: false,
525
- }, { onConflict: "proposal_id,field_key" });
526
- }
527
- if (lc.blocked) {
528
- await admin
529
- .from("mailbox_messages")
530
- .update({ status: "manual_review_load_carrier_disabled" })
531
- .eq("id", messageId);
809
+ await writeParsedDetails(admin, proposalId, parsed, built.lines);
810
+ if (built.payloadExtra.blockedMessageStatus) {
811
+ await setMsg({ status: "manual_review_load_carrier_disabled" });
532
812
  }
813
+ return { status: "created" };
533
814
  }
534
815
  /**
535
816
  * Analyseert een bestaand (nog niet goedgekeurd) voorstel opnieuw met de
536
817
  * huidige AI-agent. De vorige AI-analyse wordt vervangen.
537
818
  */
538
- export async function reparseProposal(admin, proposalId) {
819
+ export async function reparseProposal(admin, proposalId, opts = {}) {
539
820
  const { data: prop } = await admin
540
821
  .from("mailbox_order_proposals")
541
822
  .select("id, message_id, status, matched_customer_id, parsed_payload")
@@ -546,20 +827,37 @@ export async function reparseProposal(admin, proposalId) {
546
827
  if (prop.status !== "pending") {
547
828
  return { ok: false, error: "Voorstel is al verwerkt — niet opnieuw geanalyseerd." };
548
829
  }
549
- // Klantherkenning opnieuw proberen op basis van het afzenderadres.
830
+ const msg = await loadMessage(admin, prop.message_id);
831
+ if (!msg)
832
+ return { ok: false, error: "Bericht niet gevonden." };
833
+ const s = await loadFilterSettings(admin);
834
+ const body = emailBodyText(msg.body_text, msg.body_html);
835
+ const latest = extractLatestPart(body);
836
+ const senderEmail = normalizeEmail(latest.forwardedFrom ?? msg.from_email);
550
837
  let matchedCustomerId = prop.matched_customer_id ?? null;
551
- const { data: msgRow } = await admin
552
- .from("mailbox_messages")
553
- .select("from_email")
554
- .eq("id", prop.message_id)
555
- .maybeSingle();
556
- const senderEmail = normalizeEmail(msgRow?.from_email ?? null);
557
- const rematch = matchedCustomerId ? null : await matchCustomerByEmail(admin, senderEmail);
838
+ const rematch = matchedCustomerId ? null : await senderMatch(admin, senderEmail, s);
558
839
  if (rematch?.customerId)
559
840
  matchedCustomerId = rematch.customerId;
841
+ const claim = await claimAnalysis(admin, msg.id, opts.force !== false, opts.trigger ?? "handmatig");
842
+ if (!claim.ok) {
843
+ return {
844
+ ok: false,
845
+ error: claim.reason === "busy" ? "Deze mail wordt al geanalyseerd." : "De limiet voor AI-analyses is bereikt.",
846
+ };
847
+ }
848
+ let built;
849
+ try {
850
+ built = await analyzeAndBuild(admin, msg, matchedCustomerId, latest.text, s);
851
+ }
852
+ catch (e) {
853
+ await finishRun(admin, claim.run_id ?? null, msg.id, {
854
+ parsed: null, meta: null, error: String(e?.message ?? e).slice(0, 500), requiredFields: [], candidateIds: new Set(),
855
+ });
856
+ return { ok: false, error: String(e?.message ?? e) };
857
+ }
858
+ await finishRun(admin, claim.run_id ?? null, msg.id, built.attempt);
560
859
  const { currentAiFingerprint } = await import("./mailbox-ai.server.js");
561
860
  const fingerprint = await currentAiFingerprint(admin).catch(() => null);
562
- const attempt = await aiParseMessage(admin, prop.message_id, matchedCustomerId);
563
861
  const prevBase = (prop.parsed_payload && typeof prop.parsed_payload === "object" ? prop.parsed_payload : {});
564
862
  const base = rematch
565
863
  ? {
@@ -571,102 +869,53 @@ export async function reparseProposal(admin, proposalId) {
571
869
  auto_match_candidates: rematch.candidates,
572
870
  }
573
871
  : prevBase;
574
- if (!attempt.parsed) {
575
- // Mislukte hernieuwde analyse: bestaande gegevens laten staan, alleen de
576
- // AI-status bijwerken zodat we niet in een lus blijven proberen.
872
+ if (!built.attempt.parsed) {
577
873
  await admin
578
874
  .from("mailbox_order_proposals")
579
- .update({ parsed_payload: { ...base, ai: aiPayloadBlock(attempt, fingerprint, prevBase?.ai) } })
875
+ .update({ parsed_payload: { ...base, ai: aiPayloadBlock(built.attempt, fingerprint, base?.ai, s.max_retry_attempts) } })
580
876
  .eq("id", proposalId);
581
- return { ok: false, error: attempt.error };
877
+ return { ok: false, error: built.attempt.error };
582
878
  }
583
- const parsed = attempt.parsed;
584
- const fv = (key) => {
585
- const v = parsed.fields?.[key]?.value;
586
- return v === undefined ? null : v;
587
- };
588
- // Afleveradres: AI-herkenning wint, anders de hoofdafleverlocatie van de klant.
589
- const rloc = await resolveDeliveryLocation(admin, matchedCustomerId, fv("delivery_location"));
590
- if (rloc) {
591
- parsed.fields = {
592
- ...parsed.fields,
593
- delivery_location: rloc.primary
594
- ? { value: rloc.id, confidence: 0.5, needs_review: true }
595
- : (parsed.fields?.delivery_location ?? { value: rloc.id, confidence: 0.9, needs_review: false }),
596
- };
597
- }
598
- // Afhaallocatie: AI-herkenning wint, anders de hoofd-afhaallocatie.
599
- const rpick = await resolvePickupLocation(admin, fv("pickup_location"));
600
- if (rpick) {
601
- parsed.fields = {
602
- ...parsed.fields,
603
- pickup_location: rpick.primary
604
- ? { value: rpick.id, confidence: 0.5, needs_review: true }
605
- : (parsed.fields?.pickup_location ?? { value: rpick.id, confidence: 0.9, needs_review: false }),
606
- };
607
- }
608
- // Ladingdrager: alleen wat de Engine toestaat.
609
- const rlc = await resolveLoadCarrier(admin, prop.message_id, fv("load_carrier"));
610
- if (rlc.code) {
611
- parsed.fields = {
612
- ...parsed.fields,
613
- load_carrier: { value: rlc.code, confidence: 0.6, needs_review: false },
614
- };
615
- }
616
- else if (parsed.fields) {
617
- delete parsed.fields.load_carrier;
618
- }
619
- const rBlockedNote = rlc.blocked
620
- ? `Handmatig controleren - ladingdrager uitgeschakeld: "${rlc.detectedLabel}" staat uit in de Rule Engine.`
621
- : null;
622
879
  await admin.from("mailbox_proposal_lines").delete().eq("proposal_id", proposalId);
623
880
  await admin.from("mailbox_proposal_field_values").delete().eq("proposal_id", proposalId);
624
- const rvalidation = computeValidation(parsed, attempt.requiredFields, {
625
- customerId: matchedCustomerId,
626
- });
627
- const notesFromAi = typeof fv("notes") === "string" ? fv("notes") : null;
628
881
  const { error: updErr } = await admin
629
882
  .from("mailbox_order_proposals")
630
883
  .update({
631
884
  matched_customer_id: matchedCustomerId,
632
- confidence: parsed.overall_confidence,
633
- delivery_date: typeof fv("delivery_date") === "string" ? fv("delivery_date") : null,
634
- notes: rBlockedNote ? [rBlockedNote, notesFromAi].filter(Boolean).join("\n") : notesFromAi,
635
- ai_validation: rvalidation,
885
+ confidence: built.confidence,
886
+ delivery_date: built.deliveryDate,
887
+ notes: built.notes,
888
+ customer_reference: built.reference,
889
+ ai_validation: built.validation,
636
890
  parsed_payload: {
637
891
  ...base,
638
892
  stub: false,
639
893
  reason: null,
640
894
  reparsed: true,
641
- load_carrier: rlc.code,
642
- load_carrier_blocked: rlc.blocked,
643
- load_carrier_detected: rlc.detectedLabel,
644
- ai: aiPayloadBlock(attempt, fingerprint),
645
- fields: parsed.fields,
646
- validation: rvalidation,
895
+ ai: aiPayloadBlock(built.attempt, fingerprint),
896
+ fields: built.attempt.parsed.fields,
897
+ validation: built.validation,
898
+ ...built.payloadExtra,
647
899
  },
648
900
  })
649
901
  .eq("id", proposalId);
650
902
  if (updErr)
651
903
  return { ok: false, error: updErr.message };
652
- if (rlc.blocked) {
653
- await admin
654
- .from("mailbox_messages")
655
- .update({ status: "manual_review_load_carrier_disabled" })
656
- .eq("id", prop.message_id);
904
+ if (built.payloadExtra.blockedMessageStatus) {
905
+ await admin.from("mailbox_messages").update({ status: "manual_review_load_carrier_disabled" }).eq("id", msg.id);
657
906
  }
658
- await writeParsedDetails(admin, proposalId, parsed);
907
+ await writeParsedDetails(admin, proposalId, built.attempt.parsed, built.lines);
659
908
  return { ok: true, error: null };
660
909
  }
661
910
  /**
662
- * Detecteert een gewijzigde AI-agent: alle openstaande voorstellen waarvan de
663
- * opgeslagen vingerafdruk afwijkt van de huidige worden opnieuw geanalyseerd.
911
+ * Gewijzigde AI-agent: openstaande voorstellen opnieuw analyseren. Alleen via
912
+ * de knop, of automatisch tot het ingestelde maximum (standaard 0 = uit).
664
913
  */
665
914
  export async function reparseChangedAgentProposals(admin, limit = 25) {
666
915
  const { currentAiFingerprint } = await import("./mailbox-ai.server.js");
667
916
  const fingerprint = await currentAiFingerprint(admin).catch(() => null);
668
- if (!fingerprint)
669
- return { checked: 0, reparsed: 0, failed: 0, fingerprint: null };
917
+ if (!fingerprint || limit <= 0)
918
+ return { checked: 0, reparsed: 0, failed: 0, fingerprint };
670
919
  const { data: rows } = await admin
671
920
  .from("mailbox_order_proposals")
672
921
  .select("id, parsed_payload")
@@ -684,7 +933,7 @@ export async function reparseChangedAgentProposals(admin, limit = 25) {
684
933
  checked++;
685
934
  if (reparsed + failed >= limit)
686
935
  break;
687
- const res = await reparseProposal(admin, r.id);
936
+ const res = await reparseProposal(admin, r.id, { force: false, trigger: "agent" });
688
937
  if (res.ok)
689
938
  reparsed++;
690
939
  else
@@ -692,13 +941,28 @@ export async function reparseChangedAgentProposals(admin, limit = 25) {
692
941
  }
693
942
  return { checked, reparsed, failed, fingerprint };
694
943
  }
944
+ /** Telt openstaande voorstellen met een verouderde AI-analyse (voor de knop). */
945
+ export async function countOutdatedAgentProposals(admin) {
946
+ const { currentAiFingerprint } = await import("./mailbox-ai.server.js");
947
+ const fingerprint = await currentAiFingerprint(admin).catch(() => null);
948
+ if (!fingerprint)
949
+ return 0;
950
+ const { data: rows } = await admin
951
+ .from("mailbox_order_proposals")
952
+ .select("parsed_payload")
953
+ .eq("status", "pending")
954
+ .limit(1000);
955
+ return (Array.isArray(rows) ? rows : []).filter((r) => {
956
+ const ai = r?.parsed_payload?.ai;
957
+ return ai?.ok === true && ai?.fingerprint && ai.fingerprint !== fingerprint;
958
+ }).length;
959
+ }
695
960
  /**
696
961
  * Achtergrondherstel: voorstellen waarvan de AI-analyse door een tijdelijke
697
- * storing (bijv. model overbelast) mislukte, worden bij een volgende run
698
- * automatisch opnieuw geanalyseerd — tot AI_MAX_BACKGROUND_RETRIES pogingen.
699
- * Blijft strikt idempotent: alleen 'pending' voorstellen worden aangeraakt.
962
+ * storing mislukte, worden bij een volgende run opnieuw geanalyseerd — begrensd
963
+ * per run en per voorstel (instellingen), en binnen de dag-/maandlimiet.
700
964
  */
701
- export async function retryFailedAiProposals(admin, limit = 10) {
965
+ export async function retryFailedAiProposals(admin, limit = 3, maxRetries = AI_MAX_BACKGROUND_RETRIES) {
702
966
  const { data: rows } = await admin
703
967
  .from("mailbox_order_proposals")
704
968
  .select("id, parsed_payload")
@@ -715,13 +979,13 @@ export async function retryFailedAiProposals(admin, limit = 10) {
715
979
  continue;
716
980
  if (ai.retry_pending !== true)
717
981
  continue;
718
- if (Number(ai.retry_count ?? 0) >= AI_MAX_BACKGROUND_RETRIES)
982
+ if (Number(ai.retry_count ?? 0) >= maxRetries)
719
983
  continue;
720
984
  if (retried >= limit)
721
985
  break;
722
986
  retried++;
723
987
  try {
724
- const res = await reparseProposal(admin, r.id);
988
+ const res = await reparseProposal(admin, r.id, { force: false, trigger: "herstel" });
725
989
  if (res.ok)
726
990
  recovered++;
727
991
  else
@@ -876,11 +1140,12 @@ export async function runIngest(trigger) {
876
1140
  errorSample = `mark consumed: ${markErr.message}`.slice(0, 500);
877
1141
  }
878
1142
  }
879
- // 5. Backfill: bestaande messages zonder proposal krijgen alsnog een stub.
1143
+ // 5. Backfill: alleen berichten die nog nooit door het voorfilter zijn gegaan.
880
1144
  const { data: orphans } = await supabaseAdmin
881
1145
  .from("mailbox_messages")
882
1146
  .select("id, from_email, mailbox_order_proposals(id)")
883
1147
  .eq("connection_id", connectionId)
1148
+ .is("filter_outcome", null)
884
1149
  .order("received_at", { ascending: false })
885
1150
  .limit(200);
886
1151
  for (const m of (orphans ?? [])) {
@@ -888,7 +1153,7 @@ export async function runIngest(trigger) {
888
1153
  if (props.length > 0)
889
1154
  continue;
890
1155
  try {
891
- await createStubProposal(supabaseAdmin, m.id, m.from_email ?? null);
1156
+ await createStubProposal(supabaseAdmin, m.id, m.from_email ?? null, { trigger });
892
1157
  }
893
1158
  catch (e) {
894
1159
  errors++;
@@ -896,23 +1161,26 @@ export async function runIngest(trigger) {
896
1161
  errorSample = `orphan-stub: ${String(e?.message ?? e)}`.slice(0, 500);
897
1162
  }
898
1163
  }
899
- // 6. Gewijzigde AI-agent? Openstaande voorstellen opnieuw analyseren.
900
- try {
901
- await reparseChangedAgentProposals(supabaseAdmin);
902
- }
903
- catch (e) {
904
- errors++;
905
- if (!errorSample)
906
- errorSample = `reparse: ${String(e?.message ?? e)}`.slice(0, 500);
907
- }
908
- // 7. Herstelronde: mislukte AI-analyses door tijdelijke storingen opnieuw proberen.
909
- try {
910
- await retryFailedAiProposals(supabaseAdmin);
911
- }
912
- catch (e) {
913
- errors++;
914
- if (!errorSample)
915
- errorSample = `reparse: ${String(e?.message ?? e)}`.slice(0, 500);
1164
+ // 6 + 7. Gewijzigde AI-agent en herstelrondes: alleen vanuit cron en begrensd.
1165
+ if (trigger === "cron") {
1166
+ const s = await loadFilterSettings(supabaseAdmin);
1167
+ try {
1168
+ if (Number(s.max_agent_reparse) > 0)
1169
+ await reparseChangedAgentProposals(supabaseAdmin, Number(s.max_agent_reparse));
1170
+ }
1171
+ catch (e) {
1172
+ errors++;
1173
+ if (!errorSample)
1174
+ errorSample = `reparse: ${String(e?.message ?? e)}`.slice(0, 500);
1175
+ }
1176
+ try {
1177
+ await retryFailedAiProposals(supabaseAdmin, Number(s.max_retries_per_run), Number(s.max_retry_attempts));
1178
+ }
1179
+ catch (e) {
1180
+ errors++;
1181
+ if (!errorSample)
1182
+ errorSample = `retry: ${String(e?.message ?? e)}`.slice(0, 500);
1183
+ }
916
1184
  }
917
1185
  }
918
1186
  finally {
@@ -931,3 +1199,63 @@ export async function runIngest(trigger) {
931
1199
  }
932
1200
  return { ok: true, runId, processed, inserted, errors, errorSample, connectionId };
933
1201
  }
1202
+ // ─── Testset: filter + extractie zonder voorstel of order ───────────────────
1203
+ export async function dryRunAnalysis(admin, input) {
1204
+ const s = await loadFilterSettings(admin);
1205
+ const latest = extractLatestPart(input.body);
1206
+ const sender = normalizeEmail(latest.forwardedFrom ?? input.fromEmail);
1207
+ const match = await senderMatch(admin, sender, s);
1208
+ const known = !!match.customerId || input.simulateKnownSender === true;
1209
+ const cls = classifyMessage({ fromEmail: sender, subject: input.subject, body: latest.text, knownSender: known }, s);
1210
+ const analyze = cls.outcome === "analyseren" || (cls.outcome === "onbekend_met_signalen" && s.analyze_unknown_with_signals);
1211
+ if (!analyze) {
1212
+ return { filter: cls, sender, forwarded: !!latest.forwardedFrom, analyzed: false, tokens: { input: 0, output: 0 }, result: null };
1213
+ }
1214
+ const { data: run } = await admin.from("mailbox_ai_runs").insert({ trigger: "testset", is_test: true }).select("id").single();
1215
+ const idx = await loadProductIndex(admin);
1216
+ const attempt = await aiParseMessage(admin, {
1217
+ customerId: match.customerId,
1218
+ subject: input.subject,
1219
+ text: latest.text,
1220
+ receivedAt: new Date(),
1221
+ settings: s,
1222
+ idx,
1223
+ });
1224
+ if (run?.id) {
1225
+ await admin.from("mailbox_ai_runs").update({
1226
+ finished_at: new Date().toISOString(),
1227
+ ok: !!attempt.parsed,
1228
+ input_tokens: attempt.meta?.inputTokens ?? null,
1229
+ output_tokens: attempt.meta?.outputTokens ?? null,
1230
+ error: attempt.error,
1231
+ }).eq("id", run.id);
1232
+ }
1233
+ const lines = attempt.parsed ? enrichLines(attempt.parsed, idx, attempt.candidateIds) : [];
1234
+ const f = (k) => attempt.parsed?.fields?.[k]?.value ?? null;
1235
+ return {
1236
+ filter: cls,
1237
+ sender,
1238
+ forwarded: !!latest.forwardedFrom,
1239
+ analyzed: true,
1240
+ tokens: { input: attempt.meta?.inputTokens ?? 0, output: attempt.meta?.outputTokens ?? 0 },
1241
+ result: {
1242
+ error: attempt.error,
1243
+ customer_known: !!match.customerId,
1244
+ delivery_date: typeof f("delivery_date") === "string" ? f("delivery_date") : null,
1245
+ reference: typeof f("reference") === "string" ? f("reference") : null,
1246
+ attachment_unread: input.hasAttachment,
1247
+ lines: lines.map((l) => ({
1248
+ text: l.name_in_mail || l.raw_product_text,
1249
+ code: l.raw_code,
1250
+ product_id: l.product_id,
1251
+ product_known: !!l.product_id,
1252
+ match_source: l.match_source,
1253
+ quantity: l.qty_in_mail,
1254
+ unit: l.unit_kind,
1255
+ plants: l.plants,
1256
+ unit_price: l.unit_price,
1257
+ warnings: l.warnings,
1258
+ })),
1259
+ },
1260
+ };
1261
+ }