Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions src/app/requests/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -291,11 +291,23 @@ export default async function RequestPage({
Neuer Wert<span className="visually-hidden"> für Position {selected.itemIndex + 1}, {selected.label}</span>
</label>
<div className="correction-row">
<input id="item-correction" name="value" defaultValue={selected.value ?? ""} maxLength={500} />
<input
id="item-correction"
name="value"
defaultValue={displayValue(selected) ?? ""}
maxLength={500}
aria-describedby={selected.key === "unit" ? "unit-hint item-correction-hint" : "item-correction-hint"}
/>
<button type="submit">Speichern</button>
</div>
{selected.key === "unit" && <span className="field-hint">Einheit als ERP-Kürzel: pcs (= Stk.), mm, cm, m, kg oder t.</span>}
<span className="field-hint">Wird mit altem und neuem Wert, Person und Zeit protokolliert.</span>
{selected.key === "unit" && (
<span id="unit-hint" className="field-hint">
Übliche Schreibweisen wie „Stück“ oder „Meter“ werden einheitlich gespeichert (Stk., m); andere Einheiten bleiben, wie getippt.
</span>
)}
<span id="item-correction-hint" className="field-hint">
Wird mit altem und neuem Wert, Person und Zeit protokolliert.
</span>
</form>
)}
<div className="divider" />
Expand Down
4 changes: 2 additions & 2 deletions src/app/requests/[id]/value-label.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = { pcs: "Stk." };

export function displayValue(field: { key: string; value: string | null }): string | null {
Expand Down
15 changes: 12 additions & 3 deletions src/features/review/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
company: "Firma",
Expand Down Expand Up @@ -200,7 +201,8 @@ async function lockForReview(tx: TenantTx, requestId: string): Promise<RequestRo

/**
* Stores a correction and its audit event (old value, new value, user, time) in ONE transaction – for a
* header field, or with `itemIndex` for a field of an existing line item (#25).
* header field, or with `itemIndex` for a field of an existing line item (#25). A unit is stored like
* the extraction stores it (#96): „Stk.“ becomes `pcs`; the audit event keeps what was typed.
*/
export async function correctField(
tenancy: Tenancy,
Expand All @@ -213,7 +215,8 @@ export async function correctField(
authorize(actor, "requests.process");
const known = itemIndex === null ? (HEADER_FIELDS as readonly string[]) : (ITEM_FIELDS as readonly string[]);
if (!known.includes(fieldKey) || (itemIndex !== null && (!Number.isInteger(itemIndex) || itemIndex < 0))) throw new ReviewRefused("unknown_field");
const value = newValue === null ? null : newValue.trim().slice(0, 500) || null;
const entered = newValue === null ? null : newValue.trim().slice(0, 500) || null;
const value = fieldKey === "unit" && entered !== null ? (canonicalUnit(entered) ?? entered) : entered;
await tenancy.withTenant(actor.companyId, async (tx) => {
await lockForReview(tx, requestId);
const run = await latestRun(tx, requestId);
Expand All @@ -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 }),
},
});
});
}
Expand Down
35 changes: 35 additions & 0 deletions src/features/review/unit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
25 changes: 25 additions & 0 deletions src/features/review/unit.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, readonly string[]>> = {
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;
}
47 changes: 46 additions & 1 deletion tests/integration/review.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
Expand Down
Loading