Integration: Stubs von L2/L3/L4/L5 gegen echte Services getauscht

- Aufträge: createWorkOrder/transitionWorkOrder/getCompletionBlockers aus L2
- Dokumente: storeFile aus L1; neu services/documents/read.ts (readStoredBytes,
  readDocumentBytes mit Prüfsummenprüfung); L2-Upload nutzt zentrale Ablage
- Dubletten aus L1 (findDuplicateCustomers(ctx)); Objekt-Kandidaten als
  imports/site-candidates.ts; Dateityp-Erkennung mobil als field/mime.ts
- Objekt-Historie mobil als Adapter auf L1 getSiteHistory, PDF über /api/v1/reports/:id/pdf
- L4-Upload-Idempotenz: fester Upload-Lineage nach storeFile, Race → Soft-Delete + Replay
- PDF-Worker-Kontext: document:write zum Ablegen des Berichts-PDF
- Import: doppeltes Work-Order-Audit entfernt; bestätigte Aufträge starten planned
- Dateilinks in Auftragsdetail auf /files/[documentId]
- proxy: /api/v1 ohne Session → 401 JSON statt Login-Redirect
- ARCHITEKTUR §2: Objekt-Historie für Feldrollen (freigegebene Einsätze aller Teams)

Gate: tsc, lint, build, 42/42 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:49:19 +02:00
co-authored by Claude Opus 5
parent 2984cc18d3
commit 3883296300
37 changed files with 216 additions and 798 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ import { rejectReport } from "../src/server/services/reports/reject";
import { createNewVersion } from "../src/server/services/reports/new-version";
import { requireVisibleReport } from "../src/server/services/reports/common";
import { listReports } from "../src/server/services/reports/queries";
import { storeFile } from "../src/server/services/reports/_stubs/documents";
import { storeFile } from "../src/server/services/documents/store";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
+3 -2
View File
@@ -15,7 +15,8 @@ import { submitReport } from "../src/server/services/reports/submit";
import { approveReport } from "../src/server/services/reports/approve";
import { generateReportPdf } from "../src/server/services/reports/pdf";
import { openReportFile } from "../src/server/services/reports/files";
import { readFileBytes, sha256Hex, storeFile } from "../src/server/services/reports/_stubs/documents";
import { readStoredBytes } from "../src/server/services/documents/read";
import { sha256Hex, storeFile } from "../src/server/services/documents/store";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
@@ -101,7 +102,7 @@ async function main() {
ok(doc.fileSize > 0 && doc.mimeType === "application/pdf", `Dokument gespeichert (${doc.fileSize} Bytes)`);
ok(doc.category === "daily_report" && doc.visibility === "customer_report" && doc.workOrderId === wo.id, "Kategorie daily_report, Sichtbarkeit customer_report, am Auftrag");
ok(stored.pdfChecksum === doc.checksum && /^[0-9a-f]{64}$/.test(stored.pdfChecksum ?? ""), "SHA-256-Prüfsumme am Bericht gespeichert");
const bytes = await readFileBytes(doc.storageKey);
const bytes = await readStoredBytes(doc.storageKey);
if (bytes) {
ok(Buffer.from(bytes).subarray(0, 4).toString() === "%PDF", "Datei beginnt mit %PDF");
ok(sha256Hex(bytes) === stored.pdfChecksum, "Prüfsumme entspricht den gespeicherten Bytes");
+1 -1
View File
@@ -13,7 +13,7 @@ import { upsertMaterialUsage } from "../src/server/services/field/materials";
import { toggleChecklistItem } from "../src/server/services/field/checklist";
import { createNote } from "../src/server/services/field/notes";
import { getFieldOrderDetail, listFieldOrders, listTodayOrders, getFieldBundle } from "../src/server/services/field/queries";
import { transitionWorkOrder } from "../src/server/services/field/stubs/work-order-transition";
import { transitionWorkOrder } from "../src/server/services/work-orders/transition";
import { createFixture, expectCode, failures, ok } from "./lib/einsatz-fixture";
const min = (n: number) => n * 60 * 1000;
+2 -1
View File
@@ -180,7 +180,8 @@ async function main() {
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");
// The review happens on the ImportJob ("Entwurf – Prüfung erforderlich"); the confirmed order starts planned.
ok(wo1?.statusHistory.map((s) => s.toStatus).join(">") === "planned", "(K5) confirmed order starts with status history → 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 } });
+4 -3
View File
@@ -9,8 +9,9 @@ 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 { normalizeCompanyName as normalizeCompany, normalizeStreet } from "../src/lib/customers/duplicates";
import { detectMime as sniffMime } from "../src/server/services/documents/scanner";
import { normalizeFileName } from "../src/server/services/documents/store";
import { buildPdf } from "./make-sample-pdfs";
let failures = 0;
@@ -191,7 +192,7 @@ function sample(): WorkOrderExtraction {
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/pass<wd>.pdf") === "pa_ss_wd_.pdf", "(F4) file name without path and control characters");
ok(normalizeFileName("../../etc/pass<wd>.pdf") === "pass_wd_.pdf", "(F4) file name without path, control characters removed, reserved characters replaced");
const raw = pdf.toString("latin1");
ok(raw.includes("(Auftragsbest\\344tigung Gr\\366\\337e 5 \\200)") && raw.trimEnd().endsWith("%%EOF"), "(F5) PDF writer encodes umlauts/€ as WinAnsi octal escapes and closes the file");
}