diff --git a/CHANGELOG.md b/CHANGELOG.md index 52eea8a..604d906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ This file records what changes **in the product** – process and session state ## [Unreleased] ### Changed +- A unit correction is stored like the extraction stores units: „Stk.“, „Stück“ or „Meter“ become the ERP units + `pcs` and `m`; other units stay as typed. The correction field shows „Stk.“, and the audit event keeps what + was typed (#96). - Request list leads with the next work decision: customer, need for review (the same count as the detail page) and the next action ("Prüfen", "Duplikat entscheiden", "Fehler ansehen", "Export läuft"); attempts, last error and next retry move into an expandable diagnosis per row; no sideways scrolling at 1280 px. The start diff --git a/src/app/requests/[id]/page.tsx b/src/app/requests/[id]/page.tsx index 56e237c..7653335 100644 --- a/src/app/requests/[id]/page.tsx +++ b/src/app/requests/[id]/page.tsx @@ -291,11 +291,23 @@ export default async function RequestPage({ Neuer Wert für Position {selected.itemIndex + 1}, {selected.label}
- +
- {selected.key === "unit" && Einheit als ERP-Kürzel: pcs (= Stk.), mm, cm, m, kg oder t.} - Wird mit altem und neuem Wert, Person und Zeit protokolliert. + {selected.key === "unit" && ( + + Übliche Schreibweisen wie „Stück“ oder „Meter“ werden einheitlich gespeichert (Stk., m); andere Einheiten bleiben, wie getippt. + + )} + + Wird mit altem und neuem Wert, Person und Zeit protokolliert. + )}
diff --git a/src/app/requests/[id]/value-label.ts b/src/app/requests/[id]/value-label.ts index 4690288..815760a 100644 --- a/src/app/requests/[id]/value-label.ts +++ b/src/app/requests/[id]/value-label.ts @@ -1,6 +1,6 @@ // How a value reads on the review screen (#77). Units are stored canonical – mm, cm, m, kg, t, pcs – because -// the ERP contract expects them; only "pcs" reads differently in German. The stored value (and the -// correction input) stays canonical, so a correction never changes what the ERP receives by accident. +// the ERP contract expects them; only "pcs" reads differently in German. The stored value stays canonical: +// the correction input may show „Stk.“, because a correction folds it back to pcs (#96). const UNIT_LABEL: Record = { pcs: "Stk." }; export function displayValue(field: { key: string; value: string | null }): string | null { diff --git a/src/features/review/review.ts b/src/features/review/review.ts index 4455c75..abbfb8a 100644 --- a/src/features/review/review.ts +++ b/src/features/review/review.ts @@ -10,6 +10,7 @@ import { canTransition, getRequest, lockRequest, recordDuplicateDecision, transi import { tenantOf, type Tenancy, type TenantTx } from "@/features/tenancy"; import { buildSourceView, type SourceView, type StoredSegment } from "./source-view"; import { applicableCorrection, correctionKey, latestCorrections, summarizeReview, type ReviewSummary, type SummaryField } from "./summary"; +import { canonicalUnit } from "./unit"; export const FIELD_LABELS: Record = { company: "Firma", @@ -200,7 +201,8 @@ async function lockForReview(tx: TenantTx, requestId: string): Promise { await lockForReview(tx, requestId); const run = await latestRun(tx, requestId); @@ -229,7 +232,13 @@ export async function correctField( action: "field.corrected", entityType: "request", entityId: requestId, - data: itemIndex === null ? { field: fieldKey, oldValue, newValue: value } : { field: fieldKey, item: itemIndex, oldValue, newValue: value }, + data: { + field: fieldKey, + ...(itemIndex === null ? {} : { item: itemIndex }), + oldValue, + newValue: value, + ...(entered === value ? {} : { entered }), + }, }); }); } diff --git a/src/features/review/unit.test.ts b/src/features/review/unit.test.ts new file mode 100644 index 0000000..0e01227 --- /dev/null +++ b/src/features/review/unit.test.ts @@ -0,0 +1,35 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { canonicalUnit, UNIT_SPELLINGS } from "./unit"; + +// A unit correction reads like the extraction (#96): known spellings become the ERP unit. +describe("canonicalUnit", () => { + it("folds the German and English spellings to the ERP unit", () => { + for (const typed of ["Stk.", "Stk", "stück", "Stueck", "St.", "PCS", " Stck. "]) expect(canonicalUnit(typed), typed).toBe("pcs"); + expect(canonicalUnit("Meter")).toBe("m"); + expect(canonicalUnit("Kilogramm")).toBe("kg"); + expect(canonicalUnit("mm")).toBe("mm"); + }); + + it("leaves an unknown unit to the caller", () => { + expect(canonicalUnit("Rolle")).toBeNull(); + expect(canonicalUnit("Stk..")).toBeNull(); + expect(canonicalUnit("")).toBeNull(); + }); + + it("uses the same spellings as the AI service, so a correction and an extraction fold units alike", () => { + const source = readFileSync(new URL("../../../services/ai/src/requestflow_ai/grounding/values.py", import.meta.url), "utf8"); + const block = /_UNIT_SPELLINGS: dict\[str, tuple\[str, \.\.\.\]\] = \{\n([\s\S]*?)\n\}/.exec(source)?.[1]; + expect(block, "the unit table moved – update this test and src/features/review/unit.ts").toBeDefined(); + const python = Object.fromEntries( + block!.split("\n").map((line) => { + const parsed = /^\s*"(\w+)": \((.*)\),$/.exec(line); + // One entry per line; a reformatted table (e.g. wrapped by ruff) must fail clearly, not as a diff of undefined. + if (!parsed) throw new Error(`unexpected line in _UNIT_SPELLINGS – adapt this parser: ${JSON.stringify(line)}`); + return [parsed[1], [...parsed[2]!.matchAll(/"([^"]+)"/g)].map((match) => match[1])]; + }), + ); + + expect(UNIT_SPELLINGS).toEqual(python); + }); +}); diff --git a/src/features/review/unit.ts b/src/features/review/unit.ts new file mode 100644 index 0000000..28d8d8d --- /dev/null +++ b/src/features/review/unit.ts @@ -0,0 +1,25 @@ +// Units as the ERP expects them (#96). A unit correction folds a known spelling to the canonical unit +// like the AI service does for extracted values (`canonical_unit` in +// services/ai/src/requestflow_ai/grounding/values.py): the same spellings – a parity test keeps both +// tables in step – and the same normalisation steps for what a single-line field can hold (exotic +// control or line-break characters may differ). An unknown unit stays as entered – the ERP contract +// accepts it as written. + +/** Canonical unit → spellings, compared normalised and without a trailing dot. */ +export const UNIT_SPELLINGS: Readonly> = { + mm: ["mm", "millimeter", "millimetre"], + cm: ["cm", "zentimeter", "centimeter", "centimetre"], + m: ["m", "meter", "metre"], + kg: ["kg", "kilogramm", "kilogram"], + t: ["t", "tonne", "tonnen", "tonnes"], + pcs: ["pcs", "pc", "piece", "pieces", "stk", "stck", "st", "stück", "stueck"], +}; + +const UNITS = new Map(Object.entries(UNIT_SPELLINGS).flatMap(([canonical, spellings]) => spellings.map((spelling) => [spelling, canonical] as const))); + +/** The canonical unit for a known spelling, else null. */ +export function canonicalUnit(text: string): string | null { + // As normalize_text: compatibility forms, soft hyphens, whitespace, case; then one trailing dot. + const key = text.normalize("NFKC").replaceAll("­", "").replace(/\s+/g, " ").trim().toLowerCase().replace(/\.$/, ""); + return UNITS.get(key) ?? null; +} diff --git a/tests/integration/review.test.ts b/tests/integration/review.test.ts index 226cbd1..b7a49e1 100644 --- a/tests/integration/review.test.ts +++ b/tests/integration/review.test.ts @@ -6,12 +6,23 @@ import { loadConfig } from "@/config/env"; import { createJobQueue } from "@/db/job-queue-client"; import { listAuditEvents } from "@/features/audit"; import { insertDocuments } from "@/features/documents"; +import { buildQuoteRequest } from "@/features/export"; import { persistExtractionRun } from "@/features/extraction"; import { syntheticExtractResponse } from "@/features/extraction/fixtures"; import { AuthorizationError, getActor, type Actor } from "@/features/identity"; import { QUEUES } from "@/features/jobs"; import { createRequest, getRequest, lockRequest, transitionRequest } from "@/features/requests"; -import { approveRequest, correctField, correctionHistory, listReviewSummaries, loadReview, rejectRequest, ReviewRefused } from "@/features/review"; +import { + approveRequest, + correctField, + correctionHistory, + currentFieldValues, + currentLineItemValues, + listReviewSummaries, + loadReview, + rejectRequest, + ReviewRefused, +} from "@/features/review"; import { createTenancy, type Tenancy } from "@/features/tenancy"; import { companyWithAdmin, createStack, invitedUser, type Stack } from "./helpers/stack"; @@ -199,6 +210,40 @@ describe("review: fields beside their source, corrections, approve or reject", ( await expect(insertCorrection(1)).resolves.toBeDefined(); }); + it("stores a unit correction like the extraction – „Stk.“ as the ERP unit pcs – and audits what was typed (#96)", async () => { + const none = { value: null, status: "missing" as const, evidence: null, modelStatus: "missing" as const, reason: null }; + const item = (index: number) => ({ index, description: { ...none }, quantity: { ...none }, unit: { ...none }, material: { ...none }, dimensions: { ...none } }); + const requestId = randomUUID(); + const documentId = randomUUID(); + await tenancy.withTenant(clerk.companyId, async (tx) => { + await createRequest(tx, { id: requestId, createdBy: clerk.userId }); + await insertDocuments(tx, [ + { id: documentId, requestId, filename: "anfrage.eml", contentType: "message/rfc822", kind: "eml", sizeBytes: 10, sha256: "u".repeat(64), storageKey: `${clerk.companyId}/${requestId}/${documentId}` }, + ]); + const row = await transitionRequest(tx, (await lockRequest(tx, requestId))!, "processing.started", { attempts: 1 }); + await persistExtractionRun(tx, { requestId, jobId: randomUUID(), outcomes: [{ documentId, response: syntheticExtractResponse(documentId, {}, [item(0), item(1)]) }] }); + await transitionRequest(tx, row, "processing.succeeded"); + }); + + await correctField(tenancy, clerk, requestId, "unit", " Stk. ", 0); + await correctField(tenancy, clerk, requestId, "unit", "Rolle", 1); + // Typed again as "Stück": the same ERP unit – nothing new to store or audit. + await correctField(tenancy, clerk, requestId, "unit", "Stück", 0); + + const units = (await loadReview(tenancy, clerk, requestId))!.lineItems.map((line) => line.fields.find((field) => field.key === "unit")?.value); + expect(units).toEqual(["pcs", "Rolle"]); + const corrections = (await auditOf(clerk, requestId)).filter((event) => event.action === "field.corrected").map((event) => event.data); + expect(corrections).toEqual([ + { field: "unit", item: 0, oldValue: null, newValue: "pcs", entered: "Stk." }, + { field: "unit", item: 1, oldValue: null, newValue: "Rolle" }, + ]); + + await approveRequest({ tenancy, boss }, clerk, requestId); + const payload = await tenancy.withTenant(clerk.companyId, async (tx) => buildQuoteRequest(tx, (await getRequest(tx, requestId))!, currentFieldValues, currentLineItemValues)); + expect(payload.lineItems?.map((line) => line.unit)).toEqual(["pcs", "Rolle"]); + await stack.database.pool.query("delete from pgboss.job where name = $1 and singleton_key = $2", [QUEUES.exportRequest, requestId]); + }); + it("keeps failed attachments and skipped OCR pages per document for the review (#23)", async () => { const requestId = randomUUID(); const documentId = randomUUID();