From 5a266ff6dd4732b690de3657e8627d83d44e39dd Mon Sep 17 00:00:00 2001 From: Fluory Date: Mon, 28 Sep 2026 20:15:37 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(review):=20store=20a=20unit=20correcti?= =?UTF-8?q?on=20like=20the=20extraction=20=E2=80=93=20"Stk."=20as=20pcs=20?= =?UTF-8?q?(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review screen reads pcs as "Stk." (#77), so clerks type "Stk." – which was stored as written and exported as a non-canonical unit. A unit correction now folds known spellings exactly like the AI service's canonical_unit (parity test against its table); unknown units stay as typed. The audit event keeps what was typed next to what was stored. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 ++ src/app/requests/[id]/page.tsx | 4 +-- src/app/requests/[id]/value-label.ts | 4 +-- src/features/review/review.ts | 15 +++++++-- src/features/review/unit.test.ts | 33 +++++++++++++++++++ src/features/review/unit.ts | 23 ++++++++++++++ tests/integration/review.test.ts | 47 +++++++++++++++++++++++++++- 7 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 src/features/review/unit.test.ts create mode 100644 src/features/review/unit.ts 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..d4a0e4f 100644 --- a/src/app/requests/[id]/page.tsx +++ b/src/app/requests/[id]/page.tsx @@ -291,10 +291,10 @@ 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.} + {selected.key === "unit" && Übliche Schreibweisen wie „Stück“ oder „Meter“ werden einheitlich gespeichert (Stk., m).} 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..212cc50 --- /dev/null +++ b/src/features/review/unit.test.ts @@ -0,0 +1,33 @@ +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 exactly the spellings of the AI service, so a correction and an extraction never differ", () => { + 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 [, canonical, spellings] = /^\s*"(\w+)": \((.*)\),$/.exec(line) ?? []; + return [canonical, [...(spellings ?? "").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..c6ced91 --- /dev/null +++ b/src/features/review/unit.ts @@ -0,0 +1,23 @@ +// Units as the ERP expects them (#96). A unit correction folds a known spelling to the canonical unit +// exactly like the AI service does for extracted values (`canonical_unit` in +// services/ai/src/requestflow_ai/grounding/values.py); a parity test keeps both tables in step. +// 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(); From eb5e8c37286ac564790bc25fd130f022767f72cf Mon Sep 17 00:00:00 2001 From: Fluory Date: Mon, 28 Sep 2026 20:32:19 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(review):=20address=20the=20#100=20revie?= =?UTF-8?q?w=20=E2=80=93=20honest=20parity=20wording,=20clear=20parser=20e?= =?UTF-8?q?rror,=20described=20hints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - unit.ts/test: "same spellings and normalisation steps", not "exactly"; exotic control or line-break characters may differ from Python - parity test fails with a clear message when the Python table is reformatted - the correction field is described by its hints (aria-describedby); the unit hint says that other units stay as typed Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/requests/[id]/page.tsx | 18 +++++++++++++++--- src/features/review/unit.test.ts | 8 +++++--- src/features/review/unit.ts | 8 +++++--- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/app/requests/[id]/page.tsx b/src/app/requests/[id]/page.tsx index d4a0e4f..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" && Übliche Schreibweisen wie „Stück“ oder „Meter“ werden einheitlich gespeichert (Stk., m).} - 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/features/review/unit.test.ts b/src/features/review/unit.test.ts index 212cc50..0e01227 100644 --- a/src/features/review/unit.test.ts +++ b/src/features/review/unit.test.ts @@ -17,14 +17,16 @@ describe("canonicalUnit", () => { expect(canonicalUnit("")).toBeNull(); }); - it("uses exactly the spellings of the AI service, so a correction and an extraction never differ", () => { + 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 [, canonical, spellings] = /^\s*"(\w+)": \((.*)\),$/.exec(line) ?? []; - return [canonical, [...(spellings ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1])]; + 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])]; }), ); diff --git a/src/features/review/unit.ts b/src/features/review/unit.ts index c6ced91..28d8d8d 100644 --- a/src/features/review/unit.ts +++ b/src/features/review/unit.ts @@ -1,7 +1,9 @@ // Units as the ERP expects them (#96). A unit correction folds a known spelling to the canonical unit -// exactly like the AI service does for extracted values (`canonical_unit` in -// services/ai/src/requestflow_ai/grounding/values.py); a parity test keeps both tables in step. -// An unknown unit stays as entered – the ERP contract accepts it as written. +// 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> = {