docs: plan hybrid delivery note ingestion

This commit is contained in:
DEls 2026-09-18 18:43:52 +02:00
parent 5bf8036210
commit 7ab55703b4

View file

@ -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[]>.
- 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<PageImage[]> {
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<PageRead>.
- 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<string, string>;
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<StagedRunResult>.
- [ ] **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.