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();