@flowselections/mailbox-orders 1.0.30 → 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.
- package/dist-lib/components/MailboxProcessedPage.js +1 -1
- package/dist-lib/components/MailboxProposalsPage.d.ts.map +1 -1
- package/dist-lib/components/MailboxProposalsPage.js +202 -122
- package/dist-lib/components/MailboxSkippedPage.d.ts +2 -0
- package/dist-lib/components/MailboxSkippedPage.d.ts.map +1 -0
- package/dist-lib/components/MailboxSkippedPage.js +71 -0
- package/dist-lib/components/settings/MailboxFilterSettingsCard.d.ts +2 -0
- package/dist-lib/components/settings/MailboxFilterSettingsCard.d.ts.map +1 -0
- package/dist-lib/components/settings/MailboxFilterSettingsCard.js +78 -0
- package/dist-lib/components/settings/MailboxTestSetCard.d.ts +2 -0
- package/dist-lib/components/settings/MailboxTestSetCard.d.ts.map +1 -0
- package/dist-lib/components/settings/MailboxTestSetCard.js +69 -0
- package/dist-lib/index.d.ts +1 -0
- package/dist-lib/index.d.ts.map +1 -1
- package/dist-lib/index.js +8 -2
- package/dist-lib/integrations/supabase/types.d.ts +864 -1
- package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
- package/dist-lib/lib/mailbox-ai.server.d.ts +33 -12
- package/dist-lib/lib/mailbox-ai.server.d.ts.map +1 -1
- package/dist-lib/lib/mailbox-ai.server.js +47 -17
- package/dist-lib/lib/mailbox-filter-settings.functions.d.ts +143 -0
- package/dist-lib/lib/mailbox-filter-settings.functions.d.ts.map +1 -0
- package/dist-lib/lib/mailbox-filter-settings.functions.js +241 -0
- package/dist-lib/lib/mailbox-filter.d.ts +34 -0
- package/dist-lib/lib/mailbox-filter.d.ts.map +1 -0
- package/dist-lib/lib/mailbox-filter.js +121 -0
- package/dist-lib/lib/mailbox-ingest.server.d.ts +114 -17
- package/dist-lib/lib/mailbox-ingest.server.d.ts.map +1 -1
- package/dist-lib/lib/mailbox-ingest.server.js +594 -266
- package/dist-lib/lib/mailbox-match.d.ts +28 -0
- package/dist-lib/lib/mailbox-match.d.ts.map +1 -0
- package/dist-lib/lib/mailbox-match.js +74 -0
- package/dist-lib/lib/mailbox-units.d.ts +18 -0
- package/dist-lib/lib/mailbox-units.d.ts.map +1 -0
- package/dist-lib/lib/mailbox-units.js +80 -0
- package/dist-lib/lib/mailbox.functions.d.ts +74 -0
- package/dist-lib/lib/mailbox.functions.d.ts.map +1 -1
- package/dist-lib/lib/mailbox.functions.js +170 -25
- package/package.json +6 -1
|
@@ -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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
?
|
|
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
|
-
|
|
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,
|
|
257
|
+
async function aiParseMessage(admin, args) {
|
|
209
258
|
try {
|
|
210
|
-
|
|
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:
|
|
264
|
+
bodyText: args.text,
|
|
265
|
+
subject: args.subject,
|
|
223
266
|
templateFields: ctx.templateFields,
|
|
224
|
-
productCatalog: ctx.
|
|
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 {
|
|
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
|
|
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 <
|
|
375
|
+
else if (f && (f.needs_review || f.confidence < green))
|
|
274
376
|
uncertain.add(label(key));
|
|
275
377
|
}
|
|
276
|
-
for (const l of
|
|
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(`
|
|
279
|
-
if (l.
|
|
280
|
-
missing.add(`Aantal ontbreekt: ${
|
|
281
|
-
if (
|
|
282
|
-
|
|
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 =
|
|
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 <
|
|
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
|
-
*
|
|
324
|
-
*
|
|
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
|
-
.
|
|
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),
|
|
447
|
+
return { id: String(hit.id), source: "mail" };
|
|
343
448
|
}
|
|
344
|
-
const primary = locations.
|
|
345
|
-
return primary ? { id: String(primary.id),
|
|
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
|
|
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 (
|
|
384
|
-
await admin.from("mailbox_proposal_lines").insert(
|
|
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.
|
|
390
|
-
unit:
|
|
391
|
-
match_confidence: l.
|
|
392
|
-
sort_order:
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
const
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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:
|
|
445
|
-
? { value: loc.id, confidence: 0.
|
|
446
|
-
:
|
|
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
|
-
|
|
450
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
479
|
-
delivery_date: deliveryDate,
|
|
480
|
-
notes:
|
|
776
|
+
confidence: built.confidence,
|
|
777
|
+
delivery_date: built.deliveryDate,
|
|
778
|
+
notes: built.notes,
|
|
481
779
|
status: "pending",
|
|
482
|
-
|
|
780
|
+
customer_reference: built.reference,
|
|
781
|
+
ai_validation: built.validation,
|
|
483
782
|
parsed_payload: {
|
|
484
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
498
|
-
//
|
|
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
|
-
|
|
508
|
-
await
|
|
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
|
-
|
|
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
|
|
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,
|
|
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:
|
|
633
|
-
delivery_date:
|
|
634
|
-
notes:
|
|
635
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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 (
|
|
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
|
-
*
|
|
663
|
-
*
|
|
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
|
|
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
|
|
698
|
-
*
|
|
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 =
|
|
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) >=
|
|
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:
|
|
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
|
|
900
|
-
|
|
901
|
-
await
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
+
}
|