L3 Auftragsimport: Extraktion, Plausibilität, Dubletten, Bestätigung (Services + Tests)
- Claude-Extraktion (PDF nativ/Bild, Structured Output, Konfidenzen, Volltext), FakeProvider - Plausibilitätsprüfung, Mapping Extraktion → Formular, Korrektur-Diff - Services Upload/Verarbeitung/Bestätigung/Verwerfen/Neu verarbeiten, Processor import-extraction - Stubs: storeFile (§4.3), findDuplicateCustomers (L1), createWorkOrder (L2) - Tests test-import-rules/-flow/-live, Beispiel-PDFs + Generator Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
%PDF-1.4
|
||||
%âãÏÓ
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [6 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Length 2221 >>
|
||||
stream
|
||||
BT /F2 16 Tf 56 790 Td (Kranich Haustechnik GmbH) Tj ET
|
||||
BT /F1 8 Tf 56 777 Td (Deichweg 4 \267 21029 Hamburg \267 Tel. 040 0000 1000 \267 info@kranich-haustechnik.example.org) Tj ET
|
||||
BT /F1 10 Tf 56 737 Td (Musterbau GmbH) Tj ET
|
||||
BT /F1 10 Tf 56 722 Td (z. Hd. Frau Jana K\366hler) Tj ET
|
||||
BT /F1 10 Tf 56 707 Td (Hafenstra\337e 12) Tj ET
|
||||
BT /F1 10 Tf 56 692 Td (20457 Hamburg) Tj ET
|
||||
BT /F2 14 Tf 56 656 Td (Auftragsbest\344tigung) Tj ET
|
||||
BT /F1 10 Tf 56 636 Td (Auftragsnummer: AB-2026-0815 Datum: 02.09.2026) Tj ET
|
||||
BT /F1 10 Tf 56 621 Td (Ihr Angebot: ANG-2026-0342 Kundennummer: K-10042) Tj ET
|
||||
BT /F1 10 Tf 56 606 Td (Bauvorhaben: Neubau B\374rogeb\344ude Speicherhof, Am Kaiserkai 30, 20457 Hamburg) Tj ET
|
||||
BT /F1 10 Tf 56 591 Td (Ansprechpartnerin vor Ort: Jana K\366hler, Tel. 040 0000 2233, j.koehler@musterbau.example.org) Tj ET
|
||||
BT /F2 10 Tf 56 576 Td (Ausf\374hrungszeitraum: 12.10.2026 bis 16.10.2026) Tj ET
|
||||
BT /F1 10 Tf 56 552 Td (Sehr geehrte Frau K\366hler,) Tj ET
|
||||
BT /F1 10 Tf 56 537 Td (vielen Dank f\374r Ihren Auftrag. Wir best\344tigen die Ausf\374hrung folgender Leistungen:) Tj ET
|
||||
BT /F2 10 Tf 56 513 Td (Pos. Bezeichnung Art.-Nr. Menge Einheit) Tj ET
|
||||
BT /F1 10 Tf 56 498 Td (1 Montage W\344rmepumpe Luft/Wasser 12 kW 1 Stk) Tj ET
|
||||
BT /F1 10 Tf 56 483 Td (2 W\344rmepumpe Aerotherm 12 kW WP-AT-12 1 Stk) Tj ET
|
||||
BT /F1 10 Tf 56 468 Td (3 Pufferspeicher 500 l PS-500 1 Stk) Tj ET
|
||||
BT /F1 10 Tf 56 453 Td (4 Kupferrohr 22 mm CU-22 24 m) Tj ET
|
||||
BT /F1 10 Tf 56 438 Td (5 Inbetriebnahme und Einweisung 4 Std) Tj ET
|
||||
BT /F2 10 Tf 56 414 Td (Gesamtbetrag netto: 18.450,00 \200 zzgl. 19 % MwSt.: 3.505,50 \200 Gesamt: 21.955,50 \200) Tj ET
|
||||
BT /F1 10 Tf 56 390 Td (Hinweise: Zufahrt \374ber Tor 2, Anmeldung beim Bauleiter. Kran ist bauseits zu stellen.) Tj ET
|
||||
BT /F1 10 Tf 56 375 Td (Referenz: Ihre Bestellung BE-7781 vom 28.08.2026) Tj ET
|
||||
BT /F1 10 Tf 56 345 Td (Mit freundlichen Gr\374\337en) Tj ET
|
||||
BT /F1 10 Tf 56 330 Td (Kranich Haustechnik GmbH) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
6 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents 5 0 R >>
|
||||
endobj
|
||||
xref
|
||||
0 7
|
||||
0000000000 65535 f
|
||||
0000000015 00000 n
|
||||
0000000064 00000 n
|
||||
0000000121 00000 n
|
||||
0000000218 00000 n
|
||||
0000000320 00000 n
|
||||
0000002593 00000 n
|
||||
trailer
|
||||
<< /Size 7 /Root 1 0 R >>
|
||||
startxref
|
||||
2729
|
||||
%%EOF
|
||||
@@ -0,0 +1,54 @@
|
||||
%PDF-1.4
|
||||
%âãÏÓ
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [6 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Length 1425 >>
|
||||
stream
|
||||
BT /F2 16 Tf 56 790 Td (Kranich Haustechnik GmbH) Tj ET
|
||||
BT /F1 8 Tf 56 777 Td (Deichweg 4 \267 21029 Hamburg \267 Tel. 040 0000 1000 \267 info@kranich-haustechnik.example.org) Tj ET
|
||||
BT /F1 10 Tf 56 737 Td (Elbblick Wohnen eG) Tj ET
|
||||
BT /F1 10 Tf 56 722 Td (Hausverwaltung) Tj ET
|
||||
BT /F1 10 Tf 56 707 Td (Neum\374hlen 7) Tj ET
|
||||
BT /F1 10 Tf 56 692 Td (22763 Hamburg) Tj ET
|
||||
BT /F2 14 Tf 56 656 Td (Auftrag Wartung Heizungsanlagen) Tj ET
|
||||
BT /F1 10 Tf 56 636 Td (Auftrag Nr. W-26-117 \267 Kunden-Nr. K-20017 \267 Hamburg, den 01.09.2026) Tj ET
|
||||
BT /F1 10 Tf 56 621 Td (Objekt: Wohnanlage Elbhang, \326velg\366nne 45, 22605 Hamburg) Tj ET
|
||||
BT /F1 10 Tf 56 606 Td (Ansprechpartner: Herr Timo Brandt, Hausmeister, Tel. +49 40 0000 4545) Tj ET
|
||||
BT /F1 10 Tf 56 586 Td (Termin: KW 41 nach Absprache mit dem Hausmeister) Tj ET
|
||||
BT /F2 10 Tf 56 564 Td (Leistungsumfang:) Tj ET
|
||||
BT /F1 10 Tf 56 549 Td (J\344hrliche Wartung von 2 Gas-Brennwertkesseln inkl. Abgasmessung und Protokoll.) Tj ET
|
||||
BT /F1 10 Tf 56 534 Td (1 Wartung Gas-Brennwertkessel 2 Stk) Tj ET
|
||||
BT /F1 10 Tf 56 519 Td (2 Wartungsset Dichtungen 2 Satz) Tj ET
|
||||
BT /F1 10 Tf 56 504 Td (3 Abgasmessung mit Protokoll 2 Stk) Tj ET
|
||||
BT /F1 10 Tf 56 482 Td (Besondere Hinweise: Heizungsraum im Keller, Schl\374ssel beim Hausmeister.) Tj ET
|
||||
BT /F1 10 Tf 56 467 Td (R\374ckfragen bitte an verwaltung@elbblick.example.org) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
6 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents 5 0 R >>
|
||||
endobj
|
||||
xref
|
||||
0 7
|
||||
0000000000 65535 f
|
||||
0000000015 00000 n
|
||||
0000000064 00000 n
|
||||
0000000121 00000 n
|
||||
0000000218 00000 n
|
||||
0000000320 00000 n
|
||||
0000001797 00000 n
|
||||
trailer
|
||||
<< /Size 7 /Root 1 0 R >>
|
||||
startxref
|
||||
1933
|
||||
%%EOF
|
||||
@@ -0,0 +1,51 @@
|
||||
%PDF-1.4
|
||||
%âãÏÓ
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [6 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Length 1061 >>
|
||||
stream
|
||||
BT /F2 16 Tf 56 790 Td (Kranich Haustechnik GmbH) Tj ET
|
||||
BT /F1 8 Tf 56 777 Td (Deichweg 4 \267 21029 Hamburg \267 Tel. 040 0000 1000 \267 info@kranich-haustechnik.example.org) Tj ET
|
||||
BT /F1 10 Tf 56 737 Td (Frau) Tj ET
|
||||
BT /F1 10 Tf 56 722 Td (Petra Sommer) Tj ET
|
||||
BT /F1 10 Tf 56 707 Td (Lindenallee 9a) Tj ET
|
||||
BT /F1 10 Tf 56 692 Td (2148 Hamburg) Tj ET
|
||||
BT /F2 14 Tf 56 656 Td (Auftragsbest\344tigung Reparatur) Tj ET
|
||||
BT /F1 10 Tf 56 636 Td (Auftrag: AB-2026-0901 Datum: 03.09.2026) Tj ET
|
||||
BT /F1 10 Tf 56 621 Td (Einsatzort: wie oben) Tj ET
|
||||
BT /F1 10 Tf 56 606 Td (Telefon: 0170 0000 987 E-Mail: petra.sommer\(at\)example.org) Tj ET
|
||||
BT /F1 10 Tf 56 591 Td (Geplanter Termin: 18.09.2026, Ende 17.09.2026) Tj ET
|
||||
BT /F1 10 Tf 56 569 Td (Leistung: Austausch defekter Thermostatkopf im Bad, Pr\374fung Heizk\366rperventile.) Tj ET
|
||||
BT /F1 10 Tf 56 554 Td (1 Thermostatkopf Standard TK-100 1 Stk) Tj ET
|
||||
BT /F1 10 Tf 56 539 Td (2 Arbeitszeit Monteur 1,5 Std) Tj ET
|
||||
BT /F2 10 Tf 56 517 Td (Gesamt brutto: 189,40 \200) Tj ET
|
||||
endstream
|
||||
endobj
|
||||
6 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents 5 0 R >>
|
||||
endobj
|
||||
xref
|
||||
0 7
|
||||
0000000000 65535 f
|
||||
0000000015 00000 n
|
||||
0000000064 00000 n
|
||||
0000000121 00000 n
|
||||
0000000218 00000 n
|
||||
0000000320 00000 n
|
||||
0000001433 00000 n
|
||||
trailer
|
||||
<< /Size 7 /Root 1 0 R >>
|
||||
startxref
|
||||
1569
|
||||
%%EOF
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Generates fictitious sample order confirmations for the import lane (L3) as text PDFs —
|
||||
* handwritten minimal PDF writer (Helvetica, WinAnsiEncoding), no dependencies.
|
||||
*
|
||||
* Lauf: npx tsx scripts/make-sample-pdfs.ts → docs/craftvia/samples/*.pdf
|
||||
* All companies, people, addresses and numbers are invented (example.org, 0000-numbers).
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const OUT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "docs", "craftvia", "samples");
|
||||
|
||||
type Line = { text: string; size?: number; bold?: boolean; x?: number; gap?: number };
|
||||
|
||||
/** Latin-1/WinAnsi string literal for a PDF content stream. */
|
||||
function pdfString(s: string): string {
|
||||
let out = "";
|
||||
for (const ch of s) {
|
||||
if (ch === "€") out += "\\200";
|
||||
else if (ch === "–") out += "\\226";
|
||||
else if (ch === "(" || ch === ")" || ch === "\\") out += `\\${ch}`;
|
||||
else {
|
||||
const code = ch.charCodeAt(0);
|
||||
out += code < 128 ? ch : `\\${code.toString(8).padStart(3, "0")}`;
|
||||
}
|
||||
}
|
||||
return `(${out})`;
|
||||
}
|
||||
|
||||
export function buildPdf(pages: Line[][]): Buffer {
|
||||
const objects: string[] = [];
|
||||
const add = (body: string) => {
|
||||
objects.push(body);
|
||||
return objects.length;
|
||||
};
|
||||
const catalogId = add("");
|
||||
const pagesId = add("");
|
||||
const fontId = add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>");
|
||||
const boldId = add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>");
|
||||
const pageIds: number[] = [];
|
||||
|
||||
for (const lines of pages) {
|
||||
let y = 800;
|
||||
const ops: string[] = [];
|
||||
for (const l of lines) {
|
||||
const size = l.size ?? 10;
|
||||
y -= l.gap ?? size + 5;
|
||||
ops.push(`BT /${l.bold ? "F2" : "F1"} ${size} Tf ${l.x ?? 56} ${y} Td ${pdfString(l.text)} Tj ET`);
|
||||
}
|
||||
const stream = ops.join("\n");
|
||||
const contentId = add(`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`);
|
||||
pageIds.push(
|
||||
add(`<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 ${fontId} 0 R /F2 ${boldId} 0 R >> >> /Contents ${contentId} 0 R >>`),
|
||||
);
|
||||
}
|
||||
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
|
||||
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(" ")}] /Count ${pageIds.length} >>`;
|
||||
|
||||
let pdf = "%PDF-1.4\n%\xe2\xe3\xcf\xd3\n";
|
||||
const offsets: number[] = [];
|
||||
objects.forEach((body, i) => {
|
||||
offsets.push(Buffer.byteLength(pdf, "latin1"));
|
||||
pdf += `${i + 1} 0 obj\n${body}\nendobj\n`;
|
||||
});
|
||||
const xref = Buffer.byteLength(pdf, "latin1");
|
||||
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||
for (const o of offsets) pdf += `${String(o).padStart(10, "0")} 00000 n \n`;
|
||||
pdf += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
||||
return Buffer.from(pdf, "latin1");
|
||||
}
|
||||
|
||||
const letterhead = (): Line[] => [
|
||||
{ text: "Kranich Haustechnik GmbH", size: 16, bold: true, gap: 10 },
|
||||
{ text: "Deichweg 4 · 21029 Hamburg · Tel. 040 0000 1000 · info@kranich-haustechnik.example.org", size: 8 },
|
||||
];
|
||||
|
||||
const SAMPLES: Record<string, Line[][]> = {
|
||||
"01-musterbau-auftragsbestaetigung.pdf": [
|
||||
[
|
||||
...letterhead(),
|
||||
{ text: "Musterbau GmbH", gap: 40 },
|
||||
{ text: "z. Hd. Frau Jana Köhler" },
|
||||
{ text: "Hafenstraße 12" },
|
||||
{ text: "20457 Hamburg" },
|
||||
{ text: "Auftragsbestätigung", size: 14, bold: true, gap: 36 },
|
||||
{ text: "Auftragsnummer: AB-2026-0815 Datum: 02.09.2026", gap: 20 },
|
||||
{ text: "Ihr Angebot: ANG-2026-0342 Kundennummer: K-10042" },
|
||||
{ text: "Bauvorhaben: Neubau Bürogebäude Speicherhof, Am Kaiserkai 30, 20457 Hamburg" },
|
||||
{ text: "Ansprechpartnerin vor Ort: Jana Köhler, Tel. 040 0000 2233, j.koehler@musterbau.example.org" },
|
||||
{ text: "Ausführungszeitraum: 12.10.2026 bis 16.10.2026", bold: true },
|
||||
{ text: "Sehr geehrte Frau Köhler,", gap: 24 },
|
||||
{ text: "vielen Dank für Ihren Auftrag. Wir bestätigen die Ausführung folgender Leistungen:" },
|
||||
{ text: "Pos. Bezeichnung Art.-Nr. Menge Einheit", bold: true, gap: 24 },
|
||||
{ text: "1 Montage Wärmepumpe Luft/Wasser 12 kW 1 Stk" },
|
||||
{ text: "2 Wärmepumpe Aerotherm 12 kW WP-AT-12 1 Stk" },
|
||||
{ text: "3 Pufferspeicher 500 l PS-500 1 Stk" },
|
||||
{ text: "4 Kupferrohr 22 mm CU-22 24 m" },
|
||||
{ text: "5 Inbetriebnahme und Einweisung 4 Std" },
|
||||
{ text: "Gesamtbetrag netto: 18.450,00 € zzgl. 19 % MwSt.: 3.505,50 € Gesamt: 21.955,50 €", bold: true, gap: 24 },
|
||||
{ text: "Hinweise: Zufahrt über Tor 2, Anmeldung beim Bauleiter. Kran ist bauseits zu stellen.", gap: 24 },
|
||||
{ text: "Referenz: Ihre Bestellung BE-7781 vom 28.08.2026" },
|
||||
{ text: "Mit freundlichen Grüßen", gap: 30 },
|
||||
{ text: "Kranich Haustechnik GmbH" },
|
||||
],
|
||||
],
|
||||
"02-elbblick-wartungsauftrag.pdf": [
|
||||
[
|
||||
...letterhead(),
|
||||
{ text: "Elbblick Wohnen eG", gap: 40 },
|
||||
{ text: "Hausverwaltung" },
|
||||
{ text: "Neumühlen 7" },
|
||||
{ text: "22763 Hamburg" },
|
||||
{ text: "Auftrag Wartung Heizungsanlagen", size: 14, bold: true, gap: 36 },
|
||||
{ text: "Auftrag Nr. W-26-117 · Kunden-Nr. K-20017 · Hamburg, den 01.09.2026", gap: 20 },
|
||||
{ text: "Objekt: Wohnanlage Elbhang, Övelgönne 45, 22605 Hamburg" },
|
||||
{ text: "Ansprechpartner: Herr Timo Brandt, Hausmeister, Tel. +49 40 0000 4545" },
|
||||
{ text: "Termin: KW 41 nach Absprache mit dem Hausmeister", gap: 20 },
|
||||
{ text: "Leistungsumfang:", bold: true, gap: 22 },
|
||||
{ text: "Jährliche Wartung von 2 Gas-Brennwertkesseln inkl. Abgasmessung und Protokoll." },
|
||||
{ text: "1 Wartung Gas-Brennwertkessel 2 Stk" },
|
||||
{ text: "2 Wartungsset Dichtungen 2 Satz" },
|
||||
{ text: "3 Abgasmessung mit Protokoll 2 Stk" },
|
||||
{ text: "Besondere Hinweise: Heizungsraum im Keller, Schlüssel beim Hausmeister.", gap: 22 },
|
||||
{ text: "Rückfragen bitte an verwaltung@elbblick.example.org" },
|
||||
],
|
||||
],
|
||||
"03-privatkunde-reparatur.pdf": [
|
||||
[
|
||||
...letterhead(),
|
||||
{ text: "Frau", gap: 40 },
|
||||
{ text: "Petra Sommer" },
|
||||
{ text: "Lindenallee 9a" },
|
||||
{ text: "2148 Hamburg" },
|
||||
{ text: "Auftragsbestätigung Reparatur", size: 14, bold: true, gap: 36 },
|
||||
{ text: "Auftrag: AB-2026-0901 Datum: 03.09.2026", gap: 20 },
|
||||
{ text: "Einsatzort: wie oben" },
|
||||
{ text: "Telefon: 0170 0000 987 E-Mail: petra.sommer(at)example.org" },
|
||||
{ text: "Geplanter Termin: 18.09.2026, Ende 17.09.2026" },
|
||||
{ text: "Leistung: Austausch defekter Thermostatkopf im Bad, Prüfung Heizkörperventile.", gap: 22 },
|
||||
{ text: "1 Thermostatkopf Standard TK-100 1 Stk" },
|
||||
{ text: "2 Arbeitszeit Monteur 1,5 Std" },
|
||||
{ text: "Gesamt brutto: 189,40 €", bold: true, gap: 22 },
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
for (const [name, pages] of Object.entries(SAMPLES)) {
|
||||
const file = join(OUT_DIR, name);
|
||||
writeFileSync(file, buildPdf(pages));
|
||||
console.log(`✔ ${file}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// Lane L3 (Auftragsimport) — service flow against the local DB:
|
||||
// upload/validation, processing (fake provider), duplicate + site candidates, AiGeneration,
|
||||
// "kein Auftrag ohne Bestätigung", confirm (existing vs. new customer) incl. document link,
|
||||
// corrections and audit, double confirm, provider error → failed + retry, no provider → manual,
|
||||
// discard, tenant isolation (B cannot read/change A) and roles (technician → forbidden).
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-import-flow.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import type { WorkOrderExtraction } from "../src/server/ai/providers";
|
||||
import { dbForTenant, prisma } from "../src/server/db";
|
||||
import { ROLE_DEFS } from "../src/server/rbac";
|
||||
import { ServiceError, type ServiceCtx } from "../src/server/services/context";
|
||||
import { createImport } from "../src/server/services/imports/upload";
|
||||
import { processImport } from "../src/server/services/imports/process";
|
||||
import { confirmImport, discardImport, retryImport } from "../src/server/services/imports/confirm";
|
||||
import { getImportDetail, getImportFile, listImports, searchCustomers } from "../src/server/services/imports/queries";
|
||||
import { FakeExtractionProvider } from "../src/server/ai/extraction/fake";
|
||||
import { emptyExtraction } from "../src/lib/imports/extraction";
|
||||
import { extractionToForm } from "../src/lib/imports/review";
|
||||
import { buildPdf } from "./make-sample-pdfs";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (cond: boolean, msg: string) => {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
async function code(p: Promise<unknown>): Promise<string> {
|
||||
try {
|
||||
await p;
|
||||
return "ok";
|
||||
} catch (e) {
|
||||
return e instanceof ServiceError ? e.code : `error:${(e as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
const SLUG_A = "zz-import-a";
|
||||
const SLUG_B = "zz-import-b";
|
||||
|
||||
async function cleanup() {
|
||||
const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true } });
|
||||
const ids = tenants.map((t) => t.id);
|
||||
if (!ids.length) return;
|
||||
const w = { tenantId: { in: ids } };
|
||||
await prisma.auditLog.deleteMany({ where: w });
|
||||
await prisma.aiGeneration.deleteMany({ where: w });
|
||||
await prisma.materialPlan.deleteMany({ where: w });
|
||||
await prisma.workOrderStatusChange.deleteMany({ where: w });
|
||||
await prisma.document.updateMany({ where: w, data: { workOrderId: null, siteId: null, customerId: null } });
|
||||
await prisma.workOrder.deleteMany({ where: w });
|
||||
await prisma.importJob.deleteMany({ where: w });
|
||||
await prisma.document.deleteMany({ where: w });
|
||||
await prisma.site.deleteMany({ where: w });
|
||||
await prisma.contact.deleteMany({ where: w });
|
||||
await prisma.customer.deleteMany({ where: w });
|
||||
await prisma.numberSequence.deleteMany({ where: w });
|
||||
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
|
||||
const ctxFor = (tenantId: string, role: keyof typeof ROLE_DEFS, userId: string): ServiceCtx => ({
|
||||
db: dbForTenant(tenantId),
|
||||
tenantId,
|
||||
userId,
|
||||
permissions: new Set(ROLE_DEFS[role].permissions),
|
||||
});
|
||||
|
||||
const PDF = buildPdf([[{ text: "Auftragsbestätigung AB-2026-0815" }, { text: "Musterbau GmbH, Hafenstraße 12, 20457 Hamburg" }]]);
|
||||
const loadBytes = async () => PDF;
|
||||
const noDispatch = async () => undefined;
|
||||
|
||||
function musterbauExtraction(): Partial<WorkOrderExtraction> {
|
||||
return {
|
||||
orderNumber: { value: "AB-2026-0815", confidence: 0.97 },
|
||||
customerNumber: { value: "K-10042", confidence: 0.95 },
|
||||
companyName: { value: "Musterbau GmbH", confidence: 0.96 },
|
||||
customerAddress: { value: { street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", country: "DE" }, confidence: 0.93 },
|
||||
siteName: { value: "Speicherhof", confidence: 0.7, source: "Bauvorhaben: Speicherhof" },
|
||||
siteAddress: { value: { street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg" }, confidence: 0.9 },
|
||||
plannedStart: { value: "12.10.2026", confidence: 0.9 },
|
||||
plannedEnd: { value: "16.10.2026", confidence: 0.9 },
|
||||
title: { value: "Montage Wärmepumpe", confidence: 0.85 },
|
||||
positions: {
|
||||
value: [
|
||||
{ name: "Wärmepumpe Aerotherm 12 kW", articleNumber: "WP-AT-12", quantity: 1, unit: "Stk", isMaterial: true },
|
||||
{ name: "Montage", quantity: 8, unit: "Std", isMaterial: false },
|
||||
],
|
||||
confidence: 0.88,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await cleanup();
|
||||
const tA = await prisma.tenant.create({ data: { name: "ZZ Import A", slug: SLUG_A } });
|
||||
const tB = await prisma.tenant.create({ data: { name: "ZZ Import B", slug: SLUG_B } });
|
||||
const boA = ctxFor(tA.id, "backoffice", "zz-user-bo-a");
|
||||
const techA = ctxFor(tA.id, "technician", "zz-user-tech-a");
|
||||
const leadA = ctxFor(tA.id, "team-lead", "zz-user-lead-a");
|
||||
const boB = ctxFor(tB.id, "backoffice", "zz-user-bo-b");
|
||||
|
||||
// Existing master data in A (duplicate + site candidate).
|
||||
const existing = await prisma.customer.create({
|
||||
data: { tenantId: tA.id, customerNumber: "K-10042", companyName: "Musterbau GmbH", street: "Hafenstrasse", houseNumber: "12", postalCode: "20457", city: "Hamburg" },
|
||||
});
|
||||
const existingSite = await prisma.site.create({
|
||||
data: { tenantId: tA.id, customerId: existing.id, name: "Speicherhof", street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg" },
|
||||
});
|
||||
await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-99999", companyName: "Andere Firma AG", postalCode: "80331", city: "München" } });
|
||||
const foreignCustomer = await prisma.customer.create({ data: { tenantId: tB.id, customerNumber: "K-10042", companyName: "Musterbau GmbH", postalCode: "20457" } });
|
||||
|
||||
// ---------- upload ----------
|
||||
const dispatched: unknown[] = [];
|
||||
const job1 = await createImport(boA, { bytes: PDF, fileName: "../AB 2026-0815.pdf", mimeType: "application/pdf" }, { dispatch: async (p) => void dispatched.push(p) });
|
||||
const doc1 = await prisma.document.findUnique({ where: { id: job1.documentId } });
|
||||
ok(job1.status === "uploaded" && job1.importedById === boA.userId, "(U1) upload creates ImportJob uploaded with importing user");
|
||||
ok(doc1?.category === "order_confirmation" && doc1.visibility === "backoffice_only" && doc1.mimeType === "application/pdf", "(U2) document category/visibility/type");
|
||||
ok(doc1?.fileName === "AB 2026-0815.pdf" && doc1.checksum.length === 64, "(U3) file name normalised, SHA-256 stored");
|
||||
ok(dispatched.length === 1 && (dispatched[0] as { tenantId: string; entityId: string }).entityId === job1.id && (dispatched[0] as { tenantId: string }).tenantId === tA.id, "(U4) extraction job dispatched with tenant + import id");
|
||||
ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "import_job", entityId: job1.id, action: "import" } })) === 1, "(U5) upload audited");
|
||||
|
||||
ok((await code(createImport(boA, { bytes: Buffer.from("MZ not a pdf"), fileName: "x.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }))) === "invalid", "(U6) wrong magic bytes rejected");
|
||||
const big = Buffer.concat([Buffer.from("%PDF-1.4\n"), Buffer.alloc(25 * 1024 * 1024)]);
|
||||
ok((await code(createImport(boA, { bytes: big, fileName: "big.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }))) === "invalid", "(U7) file > 25 MB rejected");
|
||||
ok((await code(createImport(boA, { bytes: PDF, fileName: "x.png", mimeType: "image/png" }, { dispatch: noDispatch }))) === "invalid", "(U8) declared type must match content");
|
||||
ok((await code(createImport(boA, { bytes: PDF, fileName: "x.docx", mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }, { dispatch: noDispatch }))) === "invalid", "(U9) non-allowlisted type rejected");
|
||||
|
||||
// ---------- processing ----------
|
||||
const provider = new FakeExtractionProvider({ extraction: musterbauExtraction(), text: "Auftragsbestätigung AB-2026-0815 Musterbau GmbH" });
|
||||
const processed = await processImport(boA, job1.id, { provider, loadBytes, now: new Date("2026-09-14T10:00:00Z") });
|
||||
ok(processed.status === "review_required", "(X1) processing → review_required");
|
||||
ok(provider.calls.length === 1 && provider.calls[0].mimeType === "application/pdf", "(X2) provider received the stored PDF");
|
||||
ok(processed.extractedText?.includes("AB-2026-0815") === true && processed.provider === "fake" && processed.extractionModel === "fake-extraction-1" && processed.extractionVersion !== null, "(X3) text, provider, model and version stored");
|
||||
const dups = processed.duplicateCandidates as Array<{ customerId: string; score: number; reasons: string[] }>;
|
||||
ok(dups[0]?.customerId === existing.id && dups[0].reasons.includes("customer_number") && dups[0].reasons.includes("address"), "(X4) existing customer found via number + address (Straße/Strasse)");
|
||||
ok(!dups.some((d) => d.customerId === foreignCustomer.id), "(X5) duplicate check never returns customers of another tenant");
|
||||
const stored = processed.extraction as { siteCandidates: Array<{ siteId: string }>; fields: WorkOrderExtraction };
|
||||
ok(stored.siteCandidates[0]?.siteId === existingSite.id, "(X6) site candidate at same address found");
|
||||
ok(stored.fields.plannedStart.value === "2026-10-12", "(X7) plausibility normalised dates before storing");
|
||||
ok((await prisma.aiGeneration.count({ where: { tenantId: tA.id, entityId: job1.id, kind: "import_extraction" } })) === 1, "(X8) AiGeneration recorded");
|
||||
ok((await prisma.workOrder.count({ where: { tenantId: tA.id } })) === 0, "(X9) no work order without confirmation");
|
||||
const again = await processImport(boA, job1.id, { provider, loadBytes });
|
||||
ok(again.status === "review_required" && provider.calls.length === 1, "(X10) processing is idempotent");
|
||||
|
||||
// ---------- roles ----------
|
||||
ok((await code(listImports(techA))) === "forbidden", "(R1) technician cannot list imports");
|
||||
ok((await code(getImportDetail(techA, job1.id))) === "forbidden", "(R2) technician cannot read an import");
|
||||
ok((await code(getImportFile(leadA, job1.id))) === "forbidden", "(R3) team lead cannot load the original document");
|
||||
ok((await code(createImport(techA, { bytes: PDF, fileName: "a.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch }))) === "forbidden", "(R4) technician cannot upload");
|
||||
ok((await code(confirmImport(techA, job1.id, extractionToForm(stored.fields)))) === "forbidden", "(R5) technician cannot confirm");
|
||||
ok((await code(discardImport(techA, job1.id))) === "forbidden", "(R6) technician cannot discard");
|
||||
ok((await code(searchCustomers(techA, "Muster"))) === "forbidden", "(R7) technician cannot use the import customer search");
|
||||
|
||||
// ---------- tenant isolation ----------
|
||||
ok((await code(getImportDetail(boB, job1.id))) === "not_found", "(T1) tenant B cannot read A's import");
|
||||
ok((await code(getImportFile(boB, job1.id))) === "not_found", "(T2) tenant B cannot load A's document");
|
||||
ok((await code(confirmImport(boB, job1.id, extractionToForm(stored.fields)))) === "not_found", "(T3) tenant B cannot confirm A's import");
|
||||
ok((await code(discardImport(boB, job1.id))) === "not_found", "(T4) tenant B cannot discard A's import");
|
||||
ok((await code(retryImport(boB, job1.id, { dispatch: noDispatch }))) === "not_found", "(T5) tenant B cannot retry A's import");
|
||||
ok((await code(processImport(boB, job1.id, { provider, loadBytes }))) === "not_found", "(T6) tenant B context cannot process A's import");
|
||||
ok(!(await listImports(boB)).some((i) => i.id === job1.id), "(T7) A's import not listed for B");
|
||||
ok(!(await searchCustomers(boB, "Muster")).some((c) => c.id === existing.id), "(T8) customer search stays in tenant");
|
||||
ok((await prisma.importJob.findUnique({ where: { id: job1.id } }))?.status === "review_required", "(T9) A's import unchanged after B's attempts");
|
||||
|
||||
// B's own import may not reference A's customer/site.
|
||||
const jobB = await createImport(boB, { bytes: PDF, fileName: "b.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch });
|
||||
await processImport(boB, jobB.id, { provider: new FakeExtractionProvider({ extraction: musterbauExtraction() }), loadBytes });
|
||||
const formB = extractionToForm((await getImportDetail(boB, jobB.id)).extraction.fields, { customerId: existing.id, siteId: existingSite.id });
|
||||
ok((await code(confirmImport(boB, jobB.id, formB))) === "not_found", "(T10) B cannot assign A's customer to its import");
|
||||
const detailB = await getImportDetail(boB, jobB.id);
|
||||
ok(detailB.customerCandidates.every((c) => c.customerId === foreignCustomer.id), "(T11) B's candidates only from B");
|
||||
|
||||
// ---------- confirm: existing customer ----------
|
||||
const detail1 = await getImportDetail(boA, job1.id);
|
||||
ok(detail1.customerCandidates[0]?.customer.companyName === "Musterbau GmbH" && detail1.customerCandidates[0].customer.sites.length === 1, "(K1) detail enriches candidates with customer and sites");
|
||||
const form1 = extractionToForm(detail1.extraction.fields, { customerId: existing.id, siteId: existingSite.id });
|
||||
form1.order!.title = "Wärmepumpe Speicherhof montieren";
|
||||
ok((await code(confirmImport(boA, job1.id, { ...form1, customerId: "" }))) === "invalid", "(K2) invalid form rejected (existing customer without id)");
|
||||
const customersBefore = await prisma.customer.count({ where: { tenantId: tA.id } });
|
||||
const res1 = await confirmImport(boA, job1.id, form1);
|
||||
const wo1 = await prisma.workOrder.findUnique({ where: { id: res1.workOrderId }, include: { statusHistory: { orderBy: { createdAt: "asc" } }, materialPlans: true } });
|
||||
ok(wo1?.status === "planned" && wo1.sourceImportId === job1.id && wo1.customerId === existing.id && wo1.siteId === existingSite.id, "(K3) work order planned, linked to import, customer and site");
|
||||
ok(wo1?.number.startsWith("A-") === true && wo1.externalOrderNumber === "AB-2026-0815" && wo1.plannedStart?.toISOString().startsWith("2026-10-12") === true, "(K4) number allocated, document order number and dates taken over");
|
||||
ok(wo1?.statusHistory.map((s) => s.toStatus).join(">") === "review_required>planned", "(K5) status history review_required → planned");
|
||||
ok(wo1?.materialPlans.length === 1 && wo1.materialPlans[0].articleNumber === "WP-AT-12", "(K6) only positions marked as material become material plan");
|
||||
ok((await prisma.customer.count({ where: { tenantId: tA.id } })) === customersBefore, "(K7) no new customer when existing one is used");
|
||||
const docAfter = await prisma.document.findUnique({ where: { id: job1.documentId } });
|
||||
ok(docAfter?.workOrderId === wo1?.id && docAfter?.customerId === existing.id && docAfter?.siteId === existingSite.id, "(K8) original document linked to work order, customer and site");
|
||||
const job1After = await prisma.importJob.findUnique({ where: { id: job1.id } });
|
||||
const corr = (job1After?.corrections as { fields: Record<string, { from: unknown; to: unknown }> }).fields;
|
||||
ok(job1After?.status === "confirmed" && job1After.confirmedById === boA.userId && job1After.confirmedAt !== null, "(K9) import confirmed with user and time");
|
||||
ok(corr["order.title"]?.to === "Wärmepumpe Speicherhof montieren" && corr["order.title"]?.from === "Montage Wärmepumpe", "(K10) corrections stored as diff");
|
||||
ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "import_job", entityId: job1.id, action: "import", after: { path: ["status"], equals: "confirmed" } } })) === 1, "(K11) confirmation audited as import");
|
||||
ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "work_order", entityId: res1.workOrderId, action: "create" } })) === 1, "(K12) work order creation audited");
|
||||
ok((await code(confirmImport(boA, job1.id, form1))) === "conflict", "(K13) second confirmation → conflict (no duplicate order)");
|
||||
ok((await prisma.workOrder.count({ where: { tenantId: tA.id } })) === 1, "(K14) still exactly one work order");
|
||||
ok((await code(discardImport(boA, job1.id))) === "conflict", "(K15) confirmed import cannot be discarded");
|
||||
|
||||
// ---------- confirm: new customer ----------
|
||||
const job2 = await createImport(boA, { bytes: PDF, fileName: "neu.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch });
|
||||
const ex2 = {
|
||||
...musterbauExtraction(),
|
||||
customerNumber: { value: null, confidence: 0 },
|
||||
companyName: { value: "Neubau Kraft KG", confidence: 0.94 },
|
||||
customerAddress: { value: { street: "Ringstraße", houseNumber: "5", postalCode: "22761", city: "Hamburg" }, confidence: 0.9 },
|
||||
contactName: { value: "Olaf Kraft", confidence: 0.9 },
|
||||
email: { value: "o.kraft@example.org", confidence: 0.9 },
|
||||
siteName: { value: "Lagerhalle Nord", confidence: 0.9 },
|
||||
siteAddress: { value: { street: "Nordkanalstraße", houseNumber: "1", postalCode: "20097", city: "Hamburg" }, confidence: 0.9 },
|
||||
};
|
||||
await processImport(boA, job2.id, { provider: new FakeExtractionProvider({ extraction: ex2 }), loadBytes });
|
||||
const detail2 = await getImportDetail(boA, job2.id);
|
||||
ok(detail2.customerCandidates.length === 0, "(N1) unknown customer → no duplicate candidates");
|
||||
const form2 = extractionToForm(detail2.extraction.fields);
|
||||
ok(form2.customerMode === "new" && form2.siteMode === "new", "(N2) form proposes new customer and new site");
|
||||
const res2 = await confirmImport(boA, job2.id, form2);
|
||||
const cust2 = await prisma.customer.findUnique({ where: { id: res2.customerId }, include: { contacts: true, sites: true } });
|
||||
ok(cust2?.companyName === "Neubau Kraft KG" && cust2.customerNumber?.startsWith("K-") === true && cust2.tenantId === tA.id, "(N3) new customer created in tenant with K- number");
|
||||
ok(cust2?.contacts[0]?.name === "Olaf Kraft" && res2.contactId === cust2.contacts[0].id, "(N4) contact created");
|
||||
ok(cust2?.sites[0]?.name === "Lagerhalle Nord" && res2.siteId === cust2.sites[0].id && cust2.sites[0].contactId === res2.contactId, "(N5) site created with contact");
|
||||
ok(Object.keys((await prisma.importJob.findUnique({ where: { id: job2.id } }))!.corrections as object).length > 0, "(N6) decisions/corrections stored");
|
||||
ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "customer", entityId: res2.customerId, action: "create" } })) === 1, "(N7) customer creation audited");
|
||||
|
||||
// New customer with a customer number that already exists → conflict, nothing created.
|
||||
const job3 = await createImport(boA, { bytes: PDF, fileName: "dup.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch });
|
||||
await processImport(boA, job3.id, { provider: new FakeExtractionProvider({ extraction: musterbauExtraction() }), loadBytes });
|
||||
const form3 = extractionToForm((await getImportDetail(boA, job3.id)).extraction.fields);
|
||||
const woCount = await prisma.workOrder.count({ where: { tenantId: tA.id } });
|
||||
ok((await code(confirmImport(boA, job3.id, form3))) === "conflict", "(N8) new customer with taken customer number → conflict");
|
||||
ok((await prisma.workOrder.count({ where: { tenantId: tA.id } })) === woCount && (await prisma.importJob.findUnique({ where: { id: job3.id } }))?.status === "review_required", "(N9) nothing created, import stays reviewable");
|
||||
|
||||
// ---------- discard ----------
|
||||
await discardImport(boA, job3.id);
|
||||
ok((await prisma.importJob.findUnique({ where: { id: job3.id } }))?.status === "discarded", "(D1) discard → discarded");
|
||||
ok((await code(confirmImport(boA, job3.id, form3))) === "conflict", "(D2) discarded import cannot be confirmed");
|
||||
ok((await prisma.document.findUnique({ where: { id: job3.documentId } })) !== null, "(D3) original document kept after discard");
|
||||
|
||||
// ---------- provider failure + retry ----------
|
||||
const job4 = await createImport(boA, { bytes: PDF, fileName: "fail.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch });
|
||||
const failed = await processImport(boA, job4.id, { provider: new FakeExtractionProvider({ fail: new Error("Claude API error 529 (OverloadedError)") }), loadBytes });
|
||||
ok(failed.status === "failed" && failed.errorMessage?.includes("529") === true, "(F1) provider error → failed with message");
|
||||
ok((await prisma.auditLog.count({ where: { tenantId: tA.id, entityId: job4.id, after: { path: ["status"], equals: "failed" } } })) === 1, "(F2) failure audited");
|
||||
ok((await code(confirmImport(boA, job4.id, form3))) === "conflict", "(F3) failed import cannot be confirmed");
|
||||
const missing = await createImport(boA, { bytes: PDF, fileName: "missing.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch });
|
||||
const noBytes = await processImport(boA, missing.id, { provider, loadBytes: async () => null });
|
||||
ok(noBytes.status === "failed" && noBytes.errorMessage === "file_unavailable", "(F4) unavailable file → failed (file_unavailable)");
|
||||
const redispatched: unknown[] = [];
|
||||
await retryImport(boA, job4.id, { dispatch: async (p) => void redispatched.push(p) });
|
||||
ok((await prisma.importJob.findUnique({ where: { id: job4.id } }))?.status === "uploaded" && redispatched.length === 1, "(F5) retry → uploaded + dispatched again");
|
||||
const retried = await processImport(boA, job4.id, { provider: new FakeExtractionProvider({ extraction: musterbauExtraction() }), loadBytes });
|
||||
ok(retried.status === "review_required" && retried.errorMessage === null, "(F6) retried processing succeeds");
|
||||
ok((await code(retryImport(boA, job4.id, { dispatch: noDispatch }))) === "conflict", "(F7) only failed imports can be retried");
|
||||
const dispatchFail = await createImport(boA, { bytes: PDF, fileName: "q.pdf", mimeType: "application/pdf" }, { dispatch: async () => { throw new Error("redis down"); } });
|
||||
ok((await prisma.importJob.findUnique({ where: { id: dispatchFail.id } }))?.status === "failed", "(F8) dispatch failure → failed (retryable)");
|
||||
|
||||
// ---------- no provider ----------
|
||||
const job5 = await createImport(boA, { bytes: PDF, fileName: "manual.pdf", mimeType: "application/pdf" }, { dispatch: noDispatch });
|
||||
const manual = await processImport(boA, job5.id, { provider: null, loadBytes });
|
||||
const manualStored = manual.extraction as { hints: Array<{ code: string }>; fields: WorkOrderExtraction };
|
||||
ok(manual.status === "review_required" && manualStored.hints.some((h) => h.code === "manual_entry"), "(M1) without provider → review_required with manual-entry hint");
|
||||
const emptyKeys = Object.keys(emptyExtraction());
|
||||
ok(
|
||||
emptyKeys.every((k) => {
|
||||
const f = (manualStored.fields as Record<string, { value: unknown; confidence: number }>)[k];
|
||||
return f?.value === null && f.confidence === 0;
|
||||
}) && manual.provider === null,
|
||||
"(M2) empty extraction, no provider recorded",
|
||||
);
|
||||
ok((await prisma.aiGeneration.count({ where: { tenantId: tA.id, entityId: job5.id } })) === 0, "(M3) no AiGeneration without AI use");
|
||||
|
||||
await cleanup();
|
||||
if (failures === 0) console.log("\nOK — Importablauf, Mandantentrennung und Rollen erfüllt.");
|
||||
else console.log(`\n${failures} FEHLER.`);
|
||||
await prisma.$disconnect();
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
console.error(e);
|
||||
await cleanup().catch(() => {});
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// Lane L3 (Auftragsimport) — optional live test of the Claude extraction against a generated
|
||||
// sample order confirmation. Runs ONLY when ANTHROPIC_API_KEY is set (costs tokens); otherwise
|
||||
// it skips with exit 0 so the default gate stays offline.
|
||||
//
|
||||
// Lauf: ANTHROPIC_API_KEY=… npx tsx scripts/test-import-live.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { getExtractionProvider } from "../src/server/ai/extraction/anthropic";
|
||||
import { checkPlausibility } from "../src/lib/imports/plausibility";
|
||||
import { buildPdf } from "./make-sample-pdfs";
|
||||
|
||||
async function main() {
|
||||
if (!process.env.ANTHROPIC_API_KEY?.trim()) {
|
||||
console.log("↷ übersprungen: kein ANTHROPIC_API_KEY gesetzt (Live-Extraktion gegen Claude).");
|
||||
process.exit(0);
|
||||
}
|
||||
const provider = getExtractionProvider();
|
||||
if (!provider) {
|
||||
console.log("↷ übersprungen: AI_EXTRACTION_PROVIDER ist nicht anthropic.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
const ok = (cond: boolean, msg: string) => {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
const pdf = buildPdf([
|
||||
[
|
||||
{ text: "Kranich Haustechnik GmbH", size: 16, bold: true },
|
||||
{ text: "Musterbau GmbH", gap: 40 },
|
||||
{ text: "Hafenstraße 12" },
|
||||
{ text: "20457 Hamburg" },
|
||||
{ text: "Auftragsbestätigung", size: 14, bold: true, gap: 30 },
|
||||
{ text: "Auftragsnummer: AB-2026-0815 Datum: 02.09.2026 Kundennummer: K-10042", gap: 20 },
|
||||
{ text: "Bauvorhaben: Speicherhof, Am Kaiserkai 30, 20457 Hamburg" },
|
||||
{ text: "Ausführung: 12.10.2026 bis 16.10.2026" },
|
||||
{ text: "1 Wärmepumpe Aerotherm 12 kW WP-AT-12 1 Stk", gap: 20 },
|
||||
{ text: "2 Kupferrohr 22 mm CU-22 24 m" },
|
||||
],
|
||||
]);
|
||||
|
||||
const started = Date.now();
|
||||
const result = await provider.extract({ bytes: pdf, mimeType: "application/pdf", fileName: "live-sample.pdf" });
|
||||
const { extraction } = checkPlausibility(result.extraction);
|
||||
console.log(` Modell ${result.meta.model}, ${result.meta.inputTokens} in / ${result.meta.outputTokens} out, ${((Date.now() - started) / 1000).toFixed(1)} s`);
|
||||
|
||||
ok(result.text.includes("AB-2026-0815"), "(L1) full text contains the order number");
|
||||
ok(extraction.companyName.value?.includes("Musterbau") === true, "(L2) customer company recognised (not the letterhead)");
|
||||
ok(extraction.orderNumber.value === "AB-2026-0815" && extraction.orderNumber.confidence > 0.5, "(L3) order number with confidence");
|
||||
ok(extraction.plannedStart.value === "2026-10-12", "(L4) German date normalised to ISO");
|
||||
ok(extraction.siteAddress.value?.street?.includes("Kaiserkai") === true, "(L5) site address separated from customer address");
|
||||
ok((extraction.positions.value ?? []).some((p) => p.quantity === 24 && p.isMaterial === true), "(L6) positions with quantity and material flag");
|
||||
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
// Lane L3 (Auftragsimport) — pure logic without DB: plausibility rules, extraction parsing,
|
||||
// JSON schema for structured outputs, mapping extraction → form, form validation,
|
||||
// correction diff, duplicate normalisation, file sniffing.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-import-rules.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import type { WorkOrderExtraction } from "../src/server/ai/providers";
|
||||
import { emptyExtraction, extractionJsonSchema, parseExtraction, EXTRACTION_FIELDS } from "../src/lib/imports/extraction";
|
||||
import { checkPlausibility, parseDate, toIsoDate, VIOLATION_CONFIDENCE } from "../src/lib/imports/plausibility";
|
||||
import { computeCorrections, extractionToForm, formFieldMeta, reviewFormSchema } from "../src/lib/imports/review";
|
||||
import { normalizeCompany, normalizeStreet } from "../src/server/services/imports/duplicates-stub";
|
||||
import { normalizeFileName, sniffMime } from "../src/server/services/imports/document-store-stub";
|
||||
import { buildPdf } from "./make-sample-pdfs";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (cond: boolean, msg: string) => {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
const NOW = new Date("2026-09-14T10:00:00Z");
|
||||
|
||||
function sample(): WorkOrderExtraction {
|
||||
return {
|
||||
...emptyExtraction(),
|
||||
orderNumber: { value: "AB-2026-0815", confidence: 0.97, source: "Auftragsnummer: AB-2026-0815" },
|
||||
customerNumber: { value: "K-10042", confidence: 0.95 },
|
||||
companyName: { value: "Musterbau GmbH", confidence: 0.96 },
|
||||
customerAddress: { value: { street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", country: "DE" }, confidence: 0.93 },
|
||||
siteName: { value: "Neubau Bürogebäude Speicherhof", confidence: 0.7, source: "Bauvorhaben: Neubau Bürogebäude" },
|
||||
siteAddress: { value: { street: "Am Kaiserkai", houseNumber: "30", postalCode: "20457", city: "Hamburg" }, confidence: 0.9 },
|
||||
contactName: { value: "Jana Köhler", confidence: 0.9 },
|
||||
phone: { value: "040 0000 2233", confidence: 0.9 },
|
||||
email: { value: "j.koehler@musterbau.example.org", confidence: 0.92 },
|
||||
orderDate: { value: "2026-09-02", confidence: 0.95 },
|
||||
plannedStart: { value: "12.10.2026", confidence: 0.9 },
|
||||
plannedEnd: { value: "2026-10-16", confidence: 0.9 },
|
||||
title: { value: "Montage Wärmepumpe", confidence: 0.85 },
|
||||
description: { value: "Montage Wärmepumpe inkl. Inbetriebnahme", confidence: 0.9 },
|
||||
positions: {
|
||||
value: [
|
||||
{ position: "1", name: "Montage Wärmepumpe", quantity: 1, unit: "Stk", isMaterial: false },
|
||||
{ position: "2", name: "Wärmepumpe Aerotherm 12 kW", articleNumber: "WP-AT-12", quantity: 1, unit: "Stk", isMaterial: true },
|
||||
{ position: "4", name: "Kupferrohr 22 mm", articleNumber: "CU-22", quantity: 24, unit: "m", isMaterial: true },
|
||||
],
|
||||
confidence: 0.88,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- plausibility ----------
|
||||
{
|
||||
const { extraction, hints } = checkPlausibility(sample(), NOW);
|
||||
ok(hints.length === 0, "(P1) plausible extraction → no hints");
|
||||
ok(extraction.plannedStart.value === "2026-10-12", "(P2) German date 12.10.2026 normalised to ISO");
|
||||
ok(extraction.plannedStart.confidence === 0.9, "(P3) confidence unchanged without violation");
|
||||
|
||||
const bad = sample();
|
||||
bad.customerAddress.value = { ...bad.customerAddress.value!, postalCode: "2045" };
|
||||
bad.email.value = "j.koehler(at)musterbau";
|
||||
bad.phone.value = "call me";
|
||||
bad.plannedEnd.value = "2026-10-01";
|
||||
bad.orderDate.value = "31.02.2026";
|
||||
bad.documentDate.value = "1999-01-01";
|
||||
bad.documentDate.confidence = 0.99;
|
||||
const r = checkPlausibility(bad, NOW);
|
||||
const codes = new Set(r.hints.map((h) => `${h.field}:${h.code}`));
|
||||
ok(codes.has("customerAddress:postal_code_invalid"), "(P4) 4-digit German postal code flagged");
|
||||
ok(codes.has("email:email_invalid"), "(P5) invalid e-mail flagged");
|
||||
ok(codes.has("phone:phone_invalid"), "(P6) invalid phone flagged");
|
||||
ok(codes.has("plannedEnd:end_before_start"), "(P7) end before start flagged");
|
||||
ok(codes.has("orderDate:date_invalid"), "(P8) impossible date 31.02. flagged");
|
||||
ok(codes.has("documentDate:date_implausible"), "(P9) date 27 years back flagged as implausible");
|
||||
ok(r.extraction.documentDate.confidence <= VIOLATION_CONFIDENCE && r.extraction.email.confidence <= VIOLATION_CONFIDENCE, "(P10) violations lower confidence ≤ 0.4");
|
||||
ok(bad.email.confidence === 0.92, "(P11) input extraction is not mutated");
|
||||
|
||||
const foreign = sample();
|
||||
foreign.customerAddress.value = { street: "Hauptstrasse", postalCode: "8001", city: "Zürich", country: "CH" };
|
||||
ok(checkPlausibility(foreign, NOW).hints.length === 0, "(P12) non-German postal codes are not checked against the 5-digit rule");
|
||||
ok(parseDate("2026-02-29") === null && parseDate("29.02.2028") !== null, "(P13) leap-year aware date parsing");
|
||||
ok(toIsoDate("1.3.26") === "2026-03-01", "(P14) short German date 1.3.26 → 2026-03-01");
|
||||
}
|
||||
|
||||
// ---------- extraction parsing + schema ----------
|
||||
{
|
||||
const raw = {
|
||||
...Object.fromEntries(EXTRACTION_FIELDS.map((k) => [k, { value: null, confidence: 0.3, source: null }])),
|
||||
companyName: { value: " Musterbau GmbH ", confidence: 1.7, source: null },
|
||||
email: { value: "", confidence: 0.9, source: "E-Mail:" },
|
||||
positions: { value: [{ position: null, name: "Rohr", articleNumber: null, quantity: 3, unit: "m", isMaterial: true }], confidence: 0.8, source: null },
|
||||
};
|
||||
let parsed: WorkOrderExtraction | null = null;
|
||||
try {
|
||||
parsed = parseExtraction(raw);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
ok(parsed !== null, "(E1) provider output with nulls parses");
|
||||
ok(parsed?.companyName.value === "Musterbau GmbH", "(E2) values are trimmed");
|
||||
ok(parsed?.companyName.confidence === 0, "(E3) out-of-range confidence is rejected (→ 0, i.e. uncertain)");
|
||||
ok(parsed?.email.value === null && parsed?.email.confidence === 0, "(E4) empty string counts as not found");
|
||||
ok(parsed?.positions.value?.[0].articleNumber === undefined && parsed?.positions.value?.[0].quantity === 3, "(E5) nested nulls → undefined");
|
||||
let threw = false;
|
||||
try {
|
||||
parseExtraction({ foo: 1 });
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
ok(threw, "(E6) structurally invalid output throws");
|
||||
|
||||
const schema = extractionJsonSchema();
|
||||
const strict = (node: unknown): boolean => {
|
||||
if (!node || typeof node !== "object") return true;
|
||||
const n = node as Record<string, unknown>;
|
||||
if (n.type === "object") {
|
||||
const props = Object.keys((n.properties as object) ?? {});
|
||||
const req = (n.required as string[]) ?? [];
|
||||
if (n.additionalProperties !== false || props.length !== req.length || !props.every((p) => req.includes(p))) return false;
|
||||
}
|
||||
return Object.values(n).every((v) => (Array.isArray(v) ? v.every(strict) : strict(v)));
|
||||
};
|
||||
ok(strict(schema), "(E7) JSON schema: every object has additionalProperties:false and all properties required");
|
||||
const exProps = ((schema.properties as Record<string, { properties: object }>).extraction.properties);
|
||||
ok(EXTRACTION_FIELDS.every((k) => k in exProps), "(E8) JSON schema covers all 22 extraction fields");
|
||||
ok(!JSON.stringify(schema).match(/"(minimum|maximum|minLength|maxLength|pattern)"/), "(E9) no unsupported constraint keywords");
|
||||
}
|
||||
|
||||
// ---------- mapping + validation + corrections ----------
|
||||
{
|
||||
const ex = checkPlausibility(sample(), NOW).extraction;
|
||||
const form = extractionToForm(ex);
|
||||
ok(form.customerMode === "new" && form.customer.companyName === "Musterbau GmbH", "(M1) customer fields mapped, default new customer");
|
||||
ok(form.siteMode === "new" && form.site.street === "Am Kaiserkai" && form.site.houseNumber === "30", "(M2) site address mapped");
|
||||
ok(form.contact.name === "Jana Köhler" && form.contact.email === "j.koehler@musterbau.example.org", "(M3) contact mapped");
|
||||
ok(form.order.externalOrderNumber === "AB-2026-0815" && form.order.plannedStart === "2026-10-12", "(M4) order number and dates mapped");
|
||||
ok(form.positions?.length === 3 && form.positions[0].asMaterial === false && form.positions[1].asMaterial === true, "(M5) positions mapped, material preselected");
|
||||
ok(extractionToForm(ex, { customerId: "c1", siteId: "s1" }).customerMode === "existing", "(M6) existing customer preselectable");
|
||||
|
||||
const noTitle = { ...ex, title: { value: null, confidence: 0 } };
|
||||
ok(extractionToForm(noTitle).order.title === "Montage Wärmepumpe inkl. Inbetriebnahme", "(M7) title falls back to first description line");
|
||||
ok(extractionToForm(emptyExtraction()).siteMode === "none", "(M8) empty extraction → no site");
|
||||
|
||||
const meta = formFieldMeta(ex);
|
||||
ok(meta["site.name"].uncertain === true && meta["site.name"].source?.startsWith("Bauvorhaben") === true, "(M9) confidence 0.7 → uncertain with source snippet");
|
||||
ok(meta["customer.companyName"].uncertain === false, "(M10) confidence 0.96 → not uncertain");
|
||||
ok(meta["order.offerNumber"].uncertain === false, "(M11) missing value is not flagged as uncertain");
|
||||
|
||||
const valid = reviewFormSchema.safeParse(form);
|
||||
ok(valid.success, "(V1) mapped form validates");
|
||||
const invalid = reviewFormSchema.safeParse({
|
||||
...form,
|
||||
customer: { ...form.customer, email: "nope", postalCode: "123" },
|
||||
order: { ...form.order, title: " ", plannedEnd: "2026-10-01" },
|
||||
positions: [{ name: "Rohr", quantity: "abc" }],
|
||||
});
|
||||
const paths = invalid.success ? [] : invalid.error.issues.map((i) => `${i.path.join(".")}:${i.message}`);
|
||||
ok(paths.includes("customer.email:email_invalid"), "(V2) invalid e-mail rejected");
|
||||
ok(paths.includes("customer.postalCode:postal_code_invalid"), "(V3) invalid postal code rejected");
|
||||
ok(paths.includes("order.title:title_required"), "(V4) empty title rejected");
|
||||
ok(paths.includes("order.plannedEnd:end_before_start"), "(V5) end before start rejected");
|
||||
ok(paths.includes("positions.0.quantity:quantity_invalid"), "(V6) non-numeric quantity rejected");
|
||||
const comma = reviewFormSchema.safeParse({ ...form, positions: [{ name: "Rohr", quantity: "2,5", unit: "m", asMaterial: true }] });
|
||||
ok(comma.success && comma.data.positions[0].quantity === 2.5, "(V7) German decimal comma accepted");
|
||||
const existingWithout = reviewFormSchema.safeParse({ ...form, customerMode: "existing", customerId: "" });
|
||||
ok(!existingWithout.success, "(V8) existing customer requires a selection");
|
||||
|
||||
const unchanged = reviewFormSchema.parse(form);
|
||||
ok(Object.keys(computeCorrections(ex, unchanged)).length === 0, "(C1) unchanged form → no corrections");
|
||||
const edited = reviewFormSchema.parse({
|
||||
...form,
|
||||
order: { ...form.order, title: "Wärmepumpe montieren" },
|
||||
site: { ...form.site, postalCode: "20459" },
|
||||
positions: form.positions!.slice(1),
|
||||
});
|
||||
const corr = computeCorrections(ex, edited);
|
||||
ok(corr["order.title"]?.from === "Montage Wärmepumpe" && corr["order.title"]?.to === "Wärmepumpe montieren", "(C2) edited title recorded as from/to");
|
||||
ok(corr["site.postalCode"]?.to === "20459", "(C3) edited postal code recorded");
|
||||
ok("positions" in corr && Object.keys(corr).length === 3, "(C4) removed position recorded, nothing else");
|
||||
const onlyMaterialToggle = reviewFormSchema.parse({ ...form, positions: form.positions!.map((p) => ({ ...p, asMaterial: !p.asMaterial })) });
|
||||
ok(!("positions" in computeCorrections(ex, onlyMaterialToggle)), "(C5) material toggle is a decision, not a data correction");
|
||||
}
|
||||
|
||||
// ---------- duplicates normalisation + files ----------
|
||||
{
|
||||
ok(normalizeCompany("Musterbau GmbH") === "musterbau" && normalizeCompany("MUSTERBAU G.m.b.H.") !== "", "(D1) legal form stripped from company names");
|
||||
ok(normalizeCompany("Elbblick Wohnen eG") === normalizeCompany("elbblick wohnen eg"), "(D2) company normalisation is case-insensitive");
|
||||
ok(normalizeStreet("Hafenstraße") === normalizeStreet("Hafenstr.") && normalizeStreet("Hafenstrasse") === normalizeStreet("Hafenstraße"), "(D3) street variants normalise equally");
|
||||
|
||||
const pdf = buildPdf([[{ text: "Auftragsbestätigung Größe 5 €" }]]);
|
||||
ok(sniffMime(pdf) === "application/pdf", "(F1) generated sample is a PDF by magic bytes");
|
||||
ok(sniffMime(Buffer.from([0xff, 0xd8, 0xff, 0xe0])) === "image/jpeg", "(F2) JPEG magic bytes");
|
||||
ok(sniffMime(Buffer.from("MZ\x90\x00 fake exe", "latin1")) === null, "(F3) executable rejected");
|
||||
ok(normalizeFileName("../../etc/pa | ||||