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]

### Added
- ERP export sends the reviewed positions (description, quantity, unit, material, dimensions) with each
approved request – ERP contract 1.1.0, additive and optional. A position value the ERP would refuse
blocks the approval so the clerk can still correct it.
- Visual design for the pilot UI, implemented from the Claude Design prototype "RequestFlow A": warm
neutral palette with one blue accent, IBM Plex Sans/Mono (self-hosted via `@fontsource`, no
third-party requests), a header with company, name and role, a start page with open work, and a
Expand Down
38 changes: 37 additions & 1 deletion contracts/erp-export.openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
openapi: 3.1.0
info:
title: RequestFlow ERP export
version: 1.0.0
version: 1.1.0
description: |
Port between RequestFlow and the customer's ERP (ADR-0001 D9). In the pilot the ERP mock
(`/api/erp-mock`, active only with `ERP_MOCK_ENABLED=true`) implements it.

Idempotency: `Idempotency-Key` is the RequestFlow request id. A repeated call with the same key
and the same body returns the SAME `erpReference` (status 200 instead of 201) – never a second
record. The same key with a different body is refused with 409.

1.1.0 (#46): optional `lineItems` – the reviewed positions in document order. Additive and backwards
compatible: a request without positions sends exactly the 1.0.0 body (the field is omitted).
servers:
- url: /api/erp-mock
paths:
Expand Down Expand Up @@ -114,6 +117,39 @@ components:
description: ISO date (YYYY-MM-DD) when the value is a date, else the reviewed text.
type: [string, "null"]
maxLength: 500
lineItems:
description: Reviewed positions in document order (1.1.0). Omitted when the request has none.
type: array
minItems: 1
maxItems: 200
items:
$ref: "#/components/schemas/LineItem"
LineItem:
type: object
additionalProperties: false
required: [position, description, quantity, unit, material, dimensions]
properties:
position:
description: 1-based position in the document order.
type: integer
minimum: 1
description:
type: [string, "null"]
maxLength: 500
quantity:
description: Plain decimal with a dot, no grouping ("1250", "2.5"), else the reviewed text.
type: [string, "null"]
maxLength: 500
unit:
description: mm, cm, m, kg, t or pcs when known, else the unit as written.
type: [string, "null"]
maxLength: 500
material:
type: [string, "null"]
maxLength: 500
dimensions:
type: [string, "null"]
maxLength: 500
QuoteRequestReceipt:
type: object
additionalProperties: false
Expand Down
6 changes: 6 additions & 0 deletions docs/technical/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ flag or without `ERP_TOKEN` the route answers 404. `Authorization: Bearer <ERP_T
`Idempotency-Key: <requestId>` (UUID, must equal `requestId` in the body), JSON body ≤ 64 KiB with a
`Content-Length` (411/413 otherwise).

Contract 1.1.0 (#46) adds the optional `lineItems` array (1–200 positions: `position` ≥ 1 plus
`description`, `quantity`, `unit`, `material`, `dimensions`, each `string | null` ≤ 500 characters). It is
sent only when the request has positions, so a 1.0.0 receiver sees an unchanged body for requests without
positions. Values that would break these limits (or push the body over 64 KiB) block the approval, where
the clerk can still correct them – nothing is cut silently.

| Status | Meaning |
|---|---|
| 201 | stored; body `{ erpReference, requestId, receivedAt }` |
Expand Down
1 change: 1 addition & 0 deletions src/app/requests/[id]/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const ERROR_MESSAGES: Record<string, string> = {
reason_missing: "Bitte einen Grund für die Ablehnung angeben.",
reason_too_long: "Der Grund ist zu lang (höchstens 1000 Zeichen).",
value_too_long: "Ein Wert ist zu lang für den ERP-Export (höchstens 500 Zeichen) – bitte zuerst korrigieren.",
export_too_large: "Die Anfrage ist zu umfangreich für den ERP-Export (höchstens 200 Positionen, 64 KiB) – bitte ablehnen und direkt im ERP erfassen.",
duplicate_undecided: "Mögliches Duplikat – bitte zuerst entscheiden, ob es eine eigenständige Anfrage ist.",
not_a_possible_duplicate: "Für diese Anfrage steht keine Duplikat-Entscheidung an.",
forbidden: "Keine Berechtigung.",
Expand Down
18 changes: 17 additions & 1 deletion src/features/export/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,26 @@ import type { components } from "./erp-export.contract";
// generated types and these schemas drift apart.
export type QuoteRequest = components["schemas"]["QuoteRequest"];
export type QuoteRequestReceipt = components["schemas"]["QuoteRequestReceipt"];
export type QuoteRequestLineItem = components["schemas"]["LineItem"];

const text = (max: number) => z.string().max(max).nullable();

export const lineItemSchema = z.strictObject({
position: z.int().min(1),
description: text(500),
quantity: text(500),
unit: text(500),
material: text(500),
dimensions: text(500),
});

export const quoteRequestSchema = z.strictObject({
requestId: z.uuid(),
subject: text(300),
approvedAt: z.iso.datetime({ offset: true }),
fields: z.strictObject({ company: text(500), contactPerson: text(500), requestedDeliveryDate: text(500) }),
// 1.1.0 (#46): optional and omitted when there are no positions – older receivers see the 1.0.0 body.
lineItems: z.array(lineItemSchema).min(1).max(200).optional(),
});

export const receiptSchema = z.strictObject({
Expand All @@ -27,4 +39,8 @@ export const errorSchema = z.strictObject({ error: z.strictObject({ code: z.enum

// Compile-time drift guard: parsed values must be assignable to the generated contract types.
type Assignable<A, B> = A extends B ? true : never;
export const contractGuards: [Assignable<z.infer<typeof quoteRequestSchema>, QuoteRequest>, Assignable<z.infer<typeof receiptSchema>, QuoteRequestReceipt>] = [true, true];
export const contractGuards: [
Assignable<z.infer<typeof quoteRequestSchema>, QuoteRequest>,
Assignable<z.infer<typeof receiptSchema>, QuoteRequestReceipt>,
Assignable<z.infer<typeof lineItemSchema>, QuoteRequestLineItem>,
] = [true, true, true];
13 changes: 13 additions & 0 deletions src/features/export/erp-export.contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ export interface components {
/** @description ISO date (YYYY-MM-DD) when the value is a date, else the reviewed text. */
requestedDeliveryDate: string | null;
};
/** @description Reviewed positions in document order (1.1.0). Omitted when the request has none. */
lineItems?: components["schemas"]["LineItem"][];
};
LineItem: {
/** @description 1-based position in the document order. */
position: number;
description: string | null;
/** @description Plain decimal with a dot, no grouping ("1250", "2.5"), else the reviewed text. */
quantity: string | null;
/** @description mm, cm, m, kg, t or pcs when known, else the unit as written. */
unit: string | null;
material: string | null;
dimensions: string | null;
};
QuoteRequestReceipt: {
erpReference: string;
Expand Down
6 changes: 4 additions & 2 deletions src/features/export/export-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { logEvent } from "@/features/observability";
import { canTransition, lockRequest, recordExportRetry, transitionRequest } from "@/features/requests";
import type { Tenancy } from "@/features/tenancy";
import { ErpExportError, type ErpExporter } from "./erp-client";
import { buildQuoteRequest, ExportNotPossible, type FieldValues } from "./payload";
import { buildQuoteRequest, ExportNotPossible, type FieldValues, type LineItemValues } from "./payload";
import { ensureExportRecord, markExportSucceeded, recordExportAttemptFailure } from "./repository";

// Export handler (ADR-0001 D9). Exactly once from three guards together:
Expand All @@ -17,6 +17,8 @@ export interface ExportDeps {
tenancy: Tenancy;
erp: ErpExporter;
fieldValues: FieldValues;
/** Reviewed positions (#46) – required, so no caller drops them silently. */
lineItemValues: LineItemValues;
}

export interface ExportDrainDeps extends ExportDeps {
Expand Down Expand Up @@ -48,7 +50,7 @@ export async function exportRequestJob(deps: ExportDeps, job: Job): Promise<"exp
return "skipped";
}
await ensureExportRecord(tx, requestId);
const payload = await buildQuoteRequest(tx, request, deps.fieldValues);
const payload = await buildQuoteRequest(tx, request, deps.fieldValues, deps.lineItemValues);
const { receipt, replay } = await deps.erp.submit(payload);
await markExportSucceeded(tx, requestId, receipt.erpReference);
await transitionRequest(tx, request, "export.succeeded", { errorStage: null, errorMessage: null, nextRetryAt: null });
Expand Down
4 changes: 2 additions & 2 deletions src/features/export/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Public API of the `export` module: ERP port + REST adapter, idempotency.
// Other modules import only from this file (dependency-cruiser, ADR-0001 D1).
export { errorCodes, errorSchema, quoteRequestSchema, receiptSchema, type QuoteRequest, type QuoteRequestReceipt } from "./contract";
export { errorCodes, errorSchema, lineItemSchema, quoteRequestSchema, receiptSchema, type QuoteRequest, type QuoteRequestLineItem, type QuoteRequestReceipt } from "./contract";
export { createErpClient, ErpExportError, type ErpExporter, type ErpSettings } from "./erp-client";
export { describeExportFailure, drainExports, exportRequestJob, type ExportDeps, type ExportDrainDeps, type ExportDrainOptions, type ExportDrainResult } from "./export-job";
export { buildQuoteRequest, ERP_LIMITS, ExportNotPossible, exportLimitViolations, type FieldValues } from "./payload";
export { buildQuoteRequest, ERP_LIMITS, ExportNotPossible, exportLimitViolations, type FieldValues, type LineItemValues } from "./payload";
export { getExportRecord, listExportRecords, type ExportRecord } from "./repository";
30 changes: 30 additions & 0 deletions src/features/export/payload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { ERP_LIMITS, exportLimitViolations } from "./payload";

const header = { company: "Musterbau Beispiel GmbH", contact_person: "Erika Beispiel", requested_delivery_date: "2026-10-15" };
const item = (description: string | null = "Flansch DN 100") => ({ description, quantity: "1250", unit: "pcs", material: null, dimensions: null });

describe("exportLimitViolations (ERP contract 1.1.0, #46)", () => {
it("accepts a request with and without positions inside the limits", () => {
expect(exportLimitViolations("Anfrage", header)).toEqual([]);
expect(exportLimitViolations("Anfrage", header, [item(), item("Dichtung DN 100")])).toEqual([]);
});

it("names an overlong position value by position and field", () => {
expect(exportLimitViolations("Anfrage", header, [item(), item("x".repeat(ERP_LIMITS.field + 1))])).toEqual(["item 2 description"]);
});

it("refuses more positions than the ERP accepts", () => {
const items = Array.from({ length: ERP_LIMITS.lineItems + 1 }, () => item("F"));
expect(exportLimitViolations("Anfrage", header, items)).toContain("lineItems");
});

it("refuses a body the ERP would reject as too large, even when every value is within its own limit", () => {
const items = Array.from({ length: ERP_LIMITS.lineItems }, () => ({ description: "d".repeat(400), quantity: "q".repeat(400), unit: null, material: null, dimensions: null }));
expect(exportLimitViolations("Anfrage", header, items)).toEqual(["body"]);
});

it("still ignores header fields the ERP never receives", () => {
expect(exportLimitViolations("Anfrage", { ...header, additional_requirements: "x".repeat(ERP_LIMITS.field + 1) })).toEqual([]);
});
});
55 changes: 44 additions & 11 deletions src/features/export/payload.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,78 @@
import { listAuditEvents } from "@/features/audit";
import type { RequestRow } from "@/features/requests";
import type { TenantTx } from "@/features/tenancy";
import { quoteRequestSchema, type QuoteRequest } from "./contract";
import { quoteRequestSchema, type QuoteRequest, type QuoteRequestLineItem } from "./contract";

/** Reviewed values of a request (the latest correction, else the extraction) – injected by the caller. */
export type FieldValues = (tx: TenantTx, requestId: string) => Promise<Record<string, string | null>>;

/** Reviewed positions in document order, one record of item fields each – injected by the caller (#46). */
export type LineItemValues = (tx: TenantTx, requestId: string) => Promise<Array<Record<string, string | null>>>;

export class ExportNotPossible extends Error {
constructor(message: string) {
super(message);
this.name = "ExportNotPossible";
}
}

/** ERP limits of the contract (maxLength of subject and fields). */
export const ERP_LIMITS = { subject: 300, field: 500 } as const;
/** ERP limits of the contract: maxLength of subject and fields, maxItems of positions, body size. */
export const ERP_LIMITS = { subject: 300, field: 500, lineItems: 200, bodyBytes: 64 * 1024 } as const;

/** Header fields that go to the ERP (contracts/erp-export.openapi.yaml). */
const EXPORTED_FIELDS = ["company", "contact_person", "requested_delivery_date"] as const;
/** Position fields that go to the ERP (contract 1.1.0, #46). */
const EXPORTED_ITEM_FIELDS = ["description", "quantity", "unit", "material", "dimensions"] as const;

function toLineItems(items: Array<Record<string, string | null>>): QuoteRequestLineItem[] {
return items.map((item, index) => ({
position: index + 1,
description: item.description ?? null,
quantity: item.quantity ?? null,
unit: item.unit ?? null,
material: item.material ?? null,
dimensions: item.dimensions ?? null,
}));
}

/**
* Header fields whose reviewed value would break the ERP contract (too long). Checked at approval, so
* the clerk can still correct the value – after approval corrections are closed (#9 review).
* Values whose reviewed state would break the ERP contract (too long, too many positions, body too
* large). Checked at approval, so the clerk can still correct them – after approval corrections are
* closed (#9 review). Returns field keys, `item N <field>` for positions, `lineItems` or `body`.
*/
export function exportLimitViolations(subject: string | null, values: Record<string, string | null>): string[] {
// Only the fields the ERP receives (contract v1) – a long free text that is never exported must
// not block the approval (#22 review).
const fields = EXPORTED_FIELDS.filter((key) => {
export function exportLimitViolations(subject: string | null, values: Record<string, string | null>, items: Array<Record<string, string | null>> = []): string[] {
// Only the fields the ERP receives – a long free text that is never exported must not block the
// approval (#22 review).
const violations: string[] = EXPORTED_FIELDS.filter((key) => {
const value = values[key];
return value != null && value.length > ERP_LIMITS.field;
});
return subject !== null && subject.length > ERP_LIMITS.subject ? ["subject", ...fields] : fields;
if (subject !== null && subject.length > ERP_LIMITS.subject) violations.unshift("subject");
items.forEach((item, index) => {
for (const key of EXPORTED_ITEM_FIELDS) {
const value = item[key];
if (value != null && value.length > ERP_LIMITS.field) violations.push(`item ${index + 1} ${key}`);
}
});
if (items.length > ERP_LIMITS.lineItems) violations.push("lineItems");
// The ERP refuses bodies over 64 KiB before reading them; a generous estimate of the envelope
// (ids, timestamps, keys) keeps the check on the safe side.
const estimate = JSON.stringify({ subject, values: EXPORTED_FIELDS.map((key) => values[key] ?? null), items: toLineItems(items) });
if (Buffer.byteLength(estimate, "utf8") + 1024 > ERP_LIMITS.bodyBytes) violations.push("body");
return violations;
}

/**
* The ERP payload. Deterministic for an approved request (values are frozen after approval, the
* approval time comes from its audit event), so a retry sends the same body under the same key.
* Positions are sent only when there are any – a request without positions keeps the 1.0.0 body.
* A payload outside the contract (e.g. an overlong value) is refused here – never cut silently.
*/
export async function buildQuoteRequest(tx: TenantTx, request: RequestRow, fieldValues: FieldValues): Promise<QuoteRequest> {
export async function buildQuoteRequest(tx: TenantTx, request: RequestRow, fieldValues: FieldValues, lineItemValues: LineItemValues): Promise<QuoteRequest> {
const approval = (await listAuditEvents(tx, "request", request.id)).filter((event) => event.action === "request.approved").at(-1);
if (!approval) throw new ExportNotPossible("approval event missing");
const values = await fieldValues(tx, request.id);
const items = await lineItemValues(tx, request.id);
const payload: QuoteRequest = {
requestId: request.id,
subject: request.subject ?? null,
Expand All @@ -51,7 +82,9 @@ export async function buildQuoteRequest(tx: TenantTx, request: RequestRow, field
contactPerson: values.contact_person ?? null,
requestedDeliveryDate: values.requested_delivery_date ?? null,
},
...(items.length > 0 ? { lineItems: toLineItems(items) } : {}),
};
if (!quoteRequestSchema.safeParse(payload).success) throw new ExportNotPossible("payload outside the ERP contract");
if (Buffer.byteLength(JSON.stringify(payload), "utf8") > ERP_LIMITS.bodyBytes) throw new ExportNotPossible("payload larger than the ERP accepts");
return payload;
}
1 change: 1 addition & 0 deletions src/features/review/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export {
correctField,
correctionHistory,
currentFieldValues,
currentLineItemValues,
FIELD_LABELS,
loadReview,
REJECTION_REASON_MAX,
Expand Down
Loading
Loading