From 7ab55703b4041c41bcda378b74bd56306498b0d4 Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 18 Sep 2026 18:43:52 +0200 Subject: [PATCH] docs: plan hybrid delivery note ingestion --- ...26-09-18-hybrid-delivery-note-ingestion.md | 732 ++++++++++++++++++ 1 file changed, 732 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-18-hybrid-delivery-note-ingestion.md diff --git a/docs/superpowers/plans/2026-09-18-hybrid-delivery-note-ingestion.md b/docs/superpowers/plans/2026-09-18-hybrid-delivery-note-ingestion.md new file mode 100644 index 0000000..3cb5fde --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-hybrid-delivery-note-ingestion.md @@ -0,0 +1,732 @@ +# Hybrid Delivery-Note Ingestion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax for tracking. + +**Goal:** Upgrade the existing delivery-note inbox into a photo-first staged pipeline that preserves bulk source files, persists page reads, assembles documents, resolves supplier and PO within the selected site, and exposes only genuine ambiguity for review. + +**Architecture:** A batch owns one or more immutable source files; each source produces normalised page images; each page gets one cached vision extraction; pages are assembled into candidate documents; candidates resolve supplier, PO, and items before idempotent promotion into the existing ledger. PocketBase stores every stage, application code owns matching and posting, and n8n remains only the scheduler. + +**Tech Stack:** Next.js 16 App Router, TypeScript, React 19, Vitest, PocketBase, MiniMax through the OpenAI-compatible client, Poppler, pdf-lib, sharp, Tailwind, existing UI primitives. + +**Spec:** docs/superpowers/specs/2026-09-18-hybrid-delivery-note-ingestion-design.md + +## Global Constraints + +- Implement Increment A only. Do not introduce the receipt/invoice three-way-match model or change month-end payable semantics. +- Read node_modules/next/dist/docs/01-app/01-getting-started/15-route-handlers.md and node_modules/next/dist/docs/01-app/01-getting-started/05-server-and-client-components.md before changing Next.js routes or pages. +- Preserve the original bytes of every source file. Vision only receives decoded, auto-rotated, correctly labelled images. +- Treat the confirmed batch site as authoritative. Coordinates are evidence, never an automatic override. +- Auto-resolve only deterministic unique matches. Fuzzy supplier, PO, or item matches remain review candidates. +- An unmatched line contributes zero financial quantity until a person links it. +- One failed or ambiguous candidate must not block clear sibling candidates. +- Every stage and retry must be idempotent. +- Keep the legacy runner available behind DN_STAGED_INGESTION_ENABLED until shadow comparison and one-site rollout pass. +- Do not modify or revert concurrent Clock/photo-app work in src/app/clock/**, src/lib/image-quality*, or src/lib/nearest-site*. +- Use apply_patch for manual edits. Preserve all unrelated working-tree changes. + +## File Structure + +| File | Responsibility | +| --- | --- | +| scripts/pb-add-dn-staged-ingestion.py | Additive PocketBase schema and indexes. | +| src/lib/dn-pipeline/stages.ts | Legacy/new status mapping and terminal-state predicates. | +| src/lib/dn-pipeline/normalise-source.ts | PDF/raster input to canonical PNG pages. | +| src/lib/dn-pipeline/page-store.ts | Idempotent source/page persistence. | +| src/lib/dn-pipeline/extract.ts | One canonical page to a validated rich PageRead. | +| src/lib/dn-pipeline/assemble.ts | Pure page-read to document-group assembly. | +| src/lib/dn-pipeline/resolve-entities.ts | Pure site-scoped supplier and PO resolution. | +| src/lib/dn-pipeline/lock.ts | Atomic PocketBase processing lease. | +| src/lib/dn-pipeline/run-staged.ts | Stage orchestration and independent candidate processing. | +| src/lib/dn-pipeline/promote.ts | Idempotent validated candidate promotion. | +| src/app/api/delivery-notes/upload/route.ts | Backward-compatible multi-source intake. | +| src/app/api/dn-batches/[id]/assembly/route.ts | Manual split/join revision endpoint. | +| src/app/dashboard/financial/delivery-notes/inbox/** | Batch board and split review. | +| src/app/dashboard/financial/delivery-notes/work-queue/** | Grouped candidate queue and line linker. | + +--- + +### Task 1: Add staged-ingestion schema and status vocabulary + +**Files:** +- Create: scripts/pb-add-dn-staged-ingestion.py +- Create: src/lib/dn-pipeline/stages.ts +- Test: src/lib/dn-pipeline/stages.test.ts +- Modify: src/lib/pb-types.ts + +**Interfaces:** +- Produces BatchStage, CandidateStage, displayBatchStage(), displayCandidateStage(), and isCandidateTerminal(). +- Produces PocketBase collections dn_batch_sources, dn_batch_pages, supplier_document_aliases, dn_review_events, and dn_pipeline_locks. + +- [ ] **Step 1: Write the failing status compatibility tests** + + import { describe, expect, it } from "vitest"; + import { + displayBatchStage, + displayCandidateStage, + isCandidateTerminal, + } from "./stages"; + + describe("stage compatibility", () => { + it("maps legacy states without rewriting live rows", () => { + expect(displayBatchStage("analysing", "")).toBe("reading"); + expect(displayCandidateStage("blocked", "")).toBe("needs_review"); + expect(displayCandidateStage("promoted", "")).toBe("posted"); + }); + + it("prefers an explicit stage", () => { + expect(displayBatchStage("needs_attention", "split_review")).toBe("split_review"); + expect(displayCandidateStage("blocked", "matching")).toBe("matching"); + }); + + it("recognises terminal candidates", () => { + expect(isCandidateTerminal("posted")).toBe(true); + expect(isCandidateTerminal("dismissed")).toBe(true); + expect(isCandidateTerminal("needs_review")).toBe(false); + }); + }); + +- [ ] **Step 2: Run the test and verify the missing-module failure** + +Run: npx vitest run src/lib/dn-pipeline/stages.test.ts + +Expected: FAIL because ./stages does not exist. + +- [ ] **Step 3: Implement the pure status module** + + export type BatchStage = + | "queued" | "preparing" | "reading" + | "split_review" | "complete" | "failed"; + + export type CandidateStage = + | "matching" | "needs_review" | "ready_to_post" + | "posted" | "dismissed"; + + export function displayBatchStage(legacy: string, explicit: string): BatchStage { + if (["queued", "preparing", "reading", "split_review", "complete", "failed"].includes(explicit)) { + return explicit as BatchStage; + } + if (legacy === "analysing") return "reading"; + if (legacy === "processed") return "complete"; + if (legacy === "needs_attention") return "split_review"; + return "queued"; + } + + export function displayCandidateStage(legacy: string, explicit: string): CandidateStage { + if (["matching", "needs_review", "ready_to_post", "posted", "dismissed"].includes(explicit)) { + return explicit as CandidateStage; + } + if (legacy === "promoted") return "posted"; + if (legacy === "dismissed") return "dismissed"; + if (legacy === "blocked") return "needs_review"; + return "ready_to_post"; + } + + export const isCandidateTerminal = (stage: CandidateStage) => + stage === "posted" || stage === "dismissed"; + +- [ ] **Step 4: Add the schema script and record types** + +The idempotent script must support --dry-run, reuse scripts/pb_env.py, and create these fields: + + dn_batch_sources: + batch, file, file_hash, ordinal, mime, original_name, + capture_session_id, delivery_session_id, captured_at, + latitude, longitude, location_accuracy_m, created, updated + + dn_batch_pages: + batch, source, source_page, region, image, image_hash, + mime, width, height, rotation, read_status, extraction, + schema_version, model, prompt_version, read_error, + manual_group, created, updated + + supplier_document_aliases: + supplier, raw_value, normalised_key, source, approved_by, + created, updated + + dn_review_events: + batch, note, action, before, after, reason, actor, created, updated + + dn_pipeline_locks: + batch, expires_at, worker, created, updated + +Extend dn_batches with stage, source_count, and processing_completed_at. +Extend dn_batch_notes with stage, assembly_revision, pages, supplier, +supplier_name_raw, match_method, match_evidence, and posted_at. +Add optional relation batch_note to delivery_notes. + +Add unique indexes on source(batch,file_hash), page(source,source_page,region), +alias(normalised_key), lock(batch), and delivery_notes(batch_note). Add exact +matching TypeScript record interfaces to src/lib/pb-types.ts. + +Also add a partial unique capture-session index for non-empty mobile +capture_session_id values so an offline retry can find, rather than duplicate, +its original batch. + +- [ ] **Step 5: Verify and commit** + +Run: + + npx vitest run src/lib/dn-pipeline/stages.test.ts + npm run typecheck + python3 scripts/pb-add-dn-staged-ingestion.py --dry-run + +Expected: tests and typecheck pass; dry-run prints changes and writes nothing. + +Commit: + + git add scripts/pb-add-dn-staged-ingestion.py src/lib/dn-pipeline/stages.ts src/lib/dn-pipeline/stages.test.ts src/lib/pb-types.ts + git commit -m "feat(dn): add staged ingestion schema" + +--- + +### Task 2: Normalise PDFs and photographs into canonical pages + +**Files:** +- Create: src/lib/dn-pipeline/normalise-source.ts +- Test: src/lib/dn-pipeline/normalise-source.test.ts +- Modify: src/lib/dn-pipeline/split.ts +- Modify: package.json +- Modify: package-lock.json + +**Interfaces:** +- Produces normaliseSource(input, declaredMime): Promise. +- NormalisedPage is { sourcePage, png, mime: "image/png", width, height, rotation }. + +- [ ] **Step 1: Install sharp and write failing tests** + +Run: npm install sharp + +Test a generated JPEG and a two-page PDF: + + import sharp from "sharp"; + import { PDFDocument } from "pdf-lib"; + import { expect, it } from "vitest"; + import { normaliseSource } from "./normalise-source"; + + it("returns a real PNG for JPEG input", async () => { + const jpeg = await sharp({ + create: { width: 80, height: 120, channels: 3, background: "white" }, + }).jpeg().toBuffer(); + const [page] = await normaliseSource(jpeg, "image/jpeg"); + expect(page.mime).toBe("image/png"); + expect((await sharp(page.png).metadata()).format).toBe("png"); + }); + + it("renders every PDF page", async () => { + const pdf = await PDFDocument.create(); + pdf.addPage([200, 300]); + pdf.addPage([200, 300]); + expect(await normaliseSource(Buffer.from(await pdf.save()), "application/pdf")) + .toHaveLength(2); + }); + +- [ ] **Step 2: Verify failure** + +Run: npx vitest run src/lib/dn-pipeline/normalise-source.test.ts + +Expected: FAIL because normalise-source.ts is missing. + +- [ ] **Step 3: Implement normalisation** + +Raster path: + + sharp(buffer) + .rotate() + .resize({ width: 2200, height: 2200, fit: "inside", withoutEnlargement: true }) + .png() + +PDF path renders every page through Poppler and sends each result through the +same raster function. Detect PDF magic bytes and actual decoder metadata rather +than trusting extension/MIME. Reject undecodable HEIC/HEIF/TIFF with +unsupported_image_codec. Preserve originals outside this function. + +Keep splitPages() as an adapter: + + export async function splitPages(buffer: Buffer, mime: string): Promise { + const pages = await normaliseSource(buffer, mime); + return pages.map((p) => ({ page: p.sourcePage, png: p.png })); + } + +- [ ] **Step 4: Add regression tests** + +Test mismatched declared MIME, invalid bytes, EXIF orientation, bounded +dimensions, and no accidental enlargement. + +- [ ] **Step 5: Verify and commit** + +Run: + + npx vitest run src/lib/dn-pipeline/normalise-source.test.ts src/lib/dn-pipeline/split.test.ts + npm run typecheck + +Commit dependency, implementation, tests, and split adapter. + +--- + +### Task 3: Store many immutable sources in one batch + +**Files:** +- Modify: src/app/api/delivery-notes/upload/route.ts +- Test: src/app/api/delivery-notes/upload/route.test.ts +- Modify: src/components/upload-delivery-note-button.tsx + +**Interfaces:** +- Consumes dn_batch_sources from Task 1. +- Produces one response with batch_id, source_count, results, and status while retaining legacy single-file fields. + +- [ ] **Step 1: Write new multi-file tests** + +Assert one dn_batches create and three dn_batch_sources creates for three +files. Test repeated capture_session_id returns the existing batch, duplicate +hashes are reported per file, and one invalid file does not erase valid +sources. + +- [ ] **Step 2: Verify the old behaviour fails** + +Run: npx vitest run src/app/api/delivery-notes/upload/route.test.ts + +Expected: FAIL because the route currently creates one batch per file. + +- [ ] **Step 3: Implement one-batch intake** + +Create the batch, then one source row per accepted file using FormData. +Preserve the 20 MB per-file and 50-file limits. Accept source, +capture_session_id, delivery_session_id, captured_at, latitude, longitude, +and location_accuracy_m. A single-file request keeps siteId, projectCode, +created, and batch_id in its response. + +- [ ] **Step 4: Update the upload dialog** + +Report "N files added to one processing batch" with per-file duplicate/error +details. Do not change site selection or Clock scanner files. + +- [ ] **Step 5: Verify and commit** + +Run route tests and npm run typecheck, then commit route, tests, and component. + +--- + +### Task 4: Persist and cache one rich vision read per page + +**Files:** +- Create: src/lib/dn-pipeline/page-store.ts +- Test: src/lib/dn-pipeline/page-store.test.ts +- Modify: src/lib/dn-pipeline/types.ts +- Modify: src/lib/dn-pipeline/extract.ts +- Test: src/lib/dn-pipeline/extract.test.ts + +**Interfaces:** +- Produces readOrReusePage(pb, input): Promise. +- Extends PageRead with supplier/site text, PO candidates, printed pagination, + vehicle registration, raw transcription, and evidence. + +- [ ] **Step 1: Add failing parser tests** + +Parse a full JSON fixture and defaults for absent optional fields. Assert a +model parse failure throws instead of becoming an empty successful page. + +- [ ] **Step 2: Verify failure** + +Run: npx vitest run src/lib/dn-pipeline/extract.test.ts + +- [ ] **Step 3: Extend type, schema, and prompt** + +Add: + + supplier_name_raw: string | null; + supplier_registration_raw: string | null; + site_name_raw: string | null; + po_number_candidates: string[]; + printed_page_number: number | null; + printed_page_count: number | null; + vehicle_registration: string | null; + raw_text: string; + evidence: Record; + +Always send canonical data:image/png bytes. Preserve the current commercial +fields. Never infer from neighbouring pages. + +- [ ] **Step 4: Implement cached persistence** + +readOrReusePage returns extraction immediately when read_status is read and +schema/prompt versions match. Otherwise it marks reading, calls the injected +extractor, stores validated extraction/model/version on success, or stores +failed plus a bounded error and rethrows. Tests use fake PocketBase calls and +prove success is reused. + +- [ ] **Step 5: Verify and commit** + +Run extractor/page-store tests and typecheck, then commit. + +--- + +### Task 5: Assemble candidates and support manual revisions + +**Files:** +- Create: src/lib/dn-pipeline/assemble.ts +- Test: src/lib/dn-pipeline/assemble.test.ts +- Create: src/app/api/dn-batches/[id]/assembly/route.ts +- Test: src/app/api/dn-batches/[id]/assembly/route.test.ts +- Modify: src/lib/dn-pipeline/segment.ts + +**Interfaces:** +- Produces assemblePages(reads, hints): AssemblyResult. +- AssemblyResult contains groups, openPages, and requiresReview. +- Each PageGroup contains key, pages, reason, and confidence. + +- [ ] **Step 1: Write failing assembly tests** + +Cover ten distinct numbers, a two-page note with printed pagination, a header +plus continuation, conflicting numbers, a multi-document page, and a mobile +delivery_session_id that never overrides conflicting printed numbers. + +- [ ] **Step 2: Verify failure** + +Run: npx vitest run src/lib/dn-pipeline/assemble.test.ts + +- [ ] **Step 3: Implement deterministic assembly** + +Keep segmentPages() as a compatibility adapter. Every page must appear exactly +once in a group or openPages; duplicate or missing coverage throws. + +- [ ] **Step 4: Implement the revision route test-first** + +Accept: + + { + revision: number, + groups: { pages: number[] }[], + dismissedPages: { page: number, reason: string }[], + reason: string + } + +Return 409 for stale revisions or posted candidates and 400 for missing or +duplicated pages. On success rewrite only unposted candidate groups, increment +assembly_revision, append a review event, and do not call vision. + +- [ ] **Step 5: Verify and commit** + +Run assembly and route tests plus typecheck, then commit. + +--- + +### Task 6: Resolve supplier and PO within the confirmed site + +**Files:** +- Create: src/lib/dn-pipeline/resolve-entities.ts +- Test: src/lib/dn-pipeline/resolve-entities.test.ts +- Modify: src/lib/dn-pipeline/run.ts +- Modify: src/lib/po-matching.ts + +**Interfaces:** +- Produces resolveEntities(input): EntityResolution. +- EntityResolution contains supplierId, purchaseOrderId, method, autoResolved, + and ordered candidates with evidence. + +- [ ] **Step 1: Write failing resolver tests** + +Cover exact PO deriving supplier with blank supplier text, exclusion of another +site's matching PO, approved aliases, ambiguous fuzzy PO results, and no safe +winner margin. + +- [ ] **Step 2: Verify failure** + +Run: npx vitest run src/lib/dn-pipeline/resolve-entities.test.ts + +- [ ] **Step 3: Implement ordered evidence resolution** + +Receive already site-filtered POs, suppliers, aliases, and lines. Match unique +normalised PO first, then registration identifier, approved alias, exact +normalised supplier name, and finally ranked suggestions. Only unique +deterministic results set autoResolved true. + +- [ ] **Step 4: Replace global PO loading** + +Load POs with a batch-site filter, derive supplier from the PO relation, and +persist match_method plus match_evidence. Remove the global fuzzy auto-pick +from run.ts. + +- [ ] **Step 5: Verify and commit** + +Run resolver, PO matching, and pipeline tests plus typecheck, then commit. + +--- + +### Task 7: Add atomic leases and staged orchestration + +**Files:** +- Create: src/lib/dn-pipeline/lock.ts +- Test: src/lib/dn-pipeline/lock.test.ts +- Create: src/lib/dn-pipeline/run-staged.ts +- Test: src/lib/dn-pipeline/run-staged.test.ts +- Modify: src/app/api/dn-batches/tick/route.ts +- Test: src/app/api/dn-batches/tick/route.test.ts + +**Interfaces:** +- Produces withBatchLease(pb, batchId, worker, fn). +- Produces runStagedBatch(pb, batchId): Promise. + +- [ ] **Step 1: Write lease race and expiry tests** + +Make a second create of the deterministic lock id fail. Assert only the winner +calls fn, expired locks reclaim once, and finally releases the winner's lock. + +- [ ] **Step 2: Verify failure** + +Run: npx vitest run src/lib/dn-pipeline/lock.test.ts + +- [ ] **Step 3: Implement atomic create-based leases** + +Claim by creating a deterministic valid PocketBase id, never by +read/update/reread. Existing unexpired lock returns acquired false. Expired +lock is deleted and create retried once. + +- [ ] **Step 4: Build staged runner test-first** + +Prove source normalisation creates/reuses pages, successful reads cache, +stages advance, clear notes process despite a blocked sibling, failed pages +remain counted, and reruns duplicate nothing. Tick keeps secret/attempt guards +and reports not_claimed to losing workers. When +DN_STAGED_INGESTION_ENABLED is not exactly true, tick continues calling the +legacy runBatch path; when true, it calls runStagedBatch. + +- [ ] **Step 5: Verify and commit** + +Run lock, staged runner, tick, existing runner tests, and typecheck; commit. + +--- + +### Task 8: Harden promotion and duplicate identity + +**Files:** +- Modify: src/lib/dn-pipeline/promote.ts +- Test: src/lib/dn-pipeline/promote.test.ts +- Modify: src/lib/dn-pipeline/checks.ts +- Test: src/lib/dn-pipeline/checks.test.ts + +**Interfaces:** +- Uses batchNoteId as the permanent promotion key. +- Uses supplier plus document number as duplicate identity when supplier exists. + +- [ ] **Step 1: Write failing safety tests** + +Assert repeated promotion updates one delivery note, different suppliers may +share a document number, same supplier/document blocks, and image candidates +retain actual MIME/name. + +- [ ] **Step 2: Verify failures** + +Run promote and checks tests. + +- [ ] **Step 3: Implement candidate-keyed promotion** + +Set delivery_notes.batch_note. Look up by it before create. Preserve actual +file type. Mark posted only after all line writes succeed. On partial failure +leave a review event and reconcile the same candidate on retry. + +- [ ] **Step 4: Scope duplicate identity** + +Implement: + + documentIdentity(supplierId, number) { + return (supplierId ?? "unknown") + ":" + normaliseDocumentNumber(number); + } + +Unknown-supplier duplicates remain review candidates rather than automatic +global blocks. + +- [ ] **Step 5: Verify and commit** + +Run promotion/check/pipeline tests and typecheck; commit. + +--- + +### Task 9: Build batch stages and split review + +**Files:** +- Modify: src/app/dashboard/financial/delivery-notes/inbox/page.tsx +- Modify: src/app/dashboard/financial/delivery-notes/inbox/inbox-board.tsx +- Modify: src/app/dashboard/financial/delivery-notes/inbox/[id]/page.tsx +- Modify: src/app/dashboard/financial/delivery-notes/inbox/[id]/batch-detail-view.tsx +- Modify: src/app/api/dn-batches/[id]/pages/[n]/route.ts +- Test: src/app/api/dn-batches/[id]/pages/[n]/route.test.ts +- Create: src/app/dashboard/financial/delivery-notes/inbox/split-review.tsx +- Test: src/app/dashboard/financial/delivery-notes/inbox/split-review.test.tsx + +**Interfaces:** +- Consumes stage helpers and assembly route. +- Produces Queued, Reading photos, Check page grouping, Complete, Failed. + +- [ ] **Step 1: Write component tests** + +Render four pages grouped [1,2], [3], with open page 4. Assert page labels, +Join previous, Split here, Dismiss page, and Save grouping. Internal check ids +are hidden until technical details opens. + +- [ ] **Step 2: Verify failure** + +Run the split-review component test. + +- [ ] **Step 3: Implement board mapping** + +Use displayBatchStage and show source/page/document counts plus plain-language +actions. Legacy rows continue to render. + +- [ ] **Step 4: Implement split review** + +Update the existing page route to return a persisted dn_batch_pages image when +available and fall back to legacy on-demand rendering. Render those thumbnails, +edit groups locally, send one revision-checked patch, show a reload message on +409, and require a dismissal reason. Component tests should use +react-dom/server unless the repository gains a browser component-test library. + +- [ ] **Step 5: Verify and commit** + +Run component tests and typecheck; commit. + +--- + +### Task 10: Build the supplier/PO-grouped work queue + +**Files:** +- Create: src/app/dashboard/financial/delivery-notes/work-queue/page.tsx +- Create: src/app/dashboard/financial/delivery-notes/work-queue/work-queue-view.tsx +- Test: src/app/dashboard/financial/delivery-notes/work-queue/work-queue.test.tsx +- Modify: src/app/dashboard/financial/suppliers/page.tsx +- Modify: src/app/dashboard/financial/suppliers/suppliers-view.tsx + +**Interfaces:** +- Consumes candidate stages and expanded supplier/PO/site relations. +- Produces site, supplier, purchase_order, stage, reason, and document_type filters. + +- [ ] **Step 1: Write grouping tests** + +Use Vula/PO A, Vula/unknown PO, Raumix/PO B, and unknown supplier fixtures. +Assert supplier and PO groups, exception groups, counts, and filtered links. + +- [ ] **Step 2: Verify failure** + +Run the work-queue component test. + +- [ ] **Step 3: Implement the server query** + +Load candidates with expanded site, supplier, PO, and delivery note. Apply site +scope and pass serialisable view models to the client. + +- [ ] **Step 4: Implement grouping and supplier links** + +Cards show document identity, type/date, batch, match method, and one primary +action. Supplier rows show unresolved counts linking to the queue without +changing ledger totals. + +- [ ] **Step 5: Verify and commit** + +Run queue tests and typecheck; commit. + +--- + +### Task 11: Build correction actions and line-item linking + +**Files:** +- Create: src/app/api/dn-batches/[id]/notes/[noteId]/resolve-entities/route.ts +- Test: src/app/api/dn-batches/[id]/notes/[noteId]/resolve-entities/route.test.ts +- Modify: src/app/api/dn-batches/[id]/notes/[noteId]/resolve-item/route.ts +- Test: src/app/api/dn-batches/[id]/notes/[noteId]/resolve-item/route.test.ts +- Create: src/app/dashboard/financial/delivery-notes/work-queue/line-linker.tsx +- Test: src/app/dashboard/financial/delivery-notes/work-queue/line-linker.test.tsx + +**Interfaces:** +- Entity route accepts supplierId, purchaseOrderId, reason, and + rememberSupplierAlias. +- Item route accepts lineIndex, purchaseOrderItemId, reason, and rememberAlias. + +- [ ] **Step 1: Write API audit tests** + +Assert selected PO belongs to site and supplier, cross-site selection returns +400, accepted changes append review events, rememberSupplierAlias upserts one +approved supplier_document_aliases row, and item rememberAlias upserts one +dn_item_aliases row without duplicates. + +- [ ] **Step 2: Verify failure** + +Run both route test files. + +- [ ] **Step 3: Implement server-validated corrections** + +Reload all relationships from PocketBase. Re-run deterministic checks. Set +ready_to_post only when gates pass; otherwise keep needs_review. + +- [ ] **Step 4: Build responsive linker test-first** + +Desktop uses left extracted/right PO columns with candidate buttons. Narrow +layouts stack cards. Show codes, quantities, units, conversion, and rate +evidence. Do not draw SVG spider lines. Confirm replacement of manual links. + +- [ ] **Step 5: Verify and commit** + +Run route/linker tests and typecheck; commit. + +--- + +### Task 12: Add posting action, shadow comparison, and final verification + +**Files:** +- Create: src/app/api/dn-batches/[id]/notes/[noteId]/post/route.ts +- Test: src/app/api/dn-batches/[id]/notes/[noteId]/post/route.test.ts +- Create: scripts/compare-dn-pipelines.ts +- Modify: docs/operations.md + +**Interfaces:** +- Posting returns deliveryNoteId, itemsCreated, and stage posted. +- Comparison reports page coverage, candidate count, PO/item match, units, + quantity, and computed value without ledger writes. + +- [ ] **Step 1: Write posting-gate tests** + +Assert needs_review returns 409 with reasons, ready_to_post posts once, retry +returns the same id, and posted candidates cannot be regrouped or re-extracted. + +- [ ] **Step 2: Verify failure** + +Run the post route test. + +- [ ] **Step 3: Implement posting and audit** + +Reload checks and relations server-side, call promotion, append a posted audit +event, and return the existing row on retry. + +- [ ] **Step 4: Add dry-run comparison and operations documentation** + +The comparison script accepts a batch id or --all and never promotes. Document +schema dry-run/apply, required environment, tick/lock recovery, feature flag, +one-site rollout, and rollback to the legacy runner. + +- [ ] **Step 5: Run complete verification** + + npm run typecheck + npm run test:run + npm run build + git diff --check + python3 scripts/pb-add-dn-staged-ingestion.py --dry-run + +Do not mutate production PocketBase during automated verification. + +- [ ] **Step 6: Commit** + +Commit posting route, tests, comparison script, and operations documentation. + +--- + +## Final Review Checklist + +- [ ] Originals remain downloadable. +- [ ] JPEG bytes are never mislabeled as PNG or PDF. +- [ ] Every page is grouped, open, failed, or dismissed. +- [ ] Exact site-scoped POs can derive supplier. +- [ ] Fuzzy matches never auto-post. +- [ ] Blocked siblings do not block valid notes. +- [ ] Duplicate identity is supplier-scoped. +- [ ] Retries create no duplicate values. +- [ ] Manual actions create audit events. +- [ ] Existing PO, supplier, and delivery-note pages work. +- [ ] Concurrent Clock/photo-app changes remain untouched.