Commit graph

305 commits

Author SHA1 Message Date
DEls
8e72acc8a5 chore(assets): update application icons and logo with new designs 2026-09-11 20:54:44 +02:00
DEls
d5e3b2ce2b docs(spec): Workers/Timesheets split and worker QR badges
Workers becomes a lean roster (name, employee number, position, type,
site, rate) with filters unchanged. Everything currently cluttering the
list row -- total hours, cost, the 8-week sparkline, per-employee entry
history -- moves to a new worker detail page that doesn't exist yet
(today's "detail" is a dialog). Same pattern already used for Suppliers
and Vehicles/Equipment this session: lean list, detail page carries
depth.

Timesheets keeps its flat entries table but gains a genuinely separate
upload-status section -- the timesheets document collection already has
this data, it's just currently squeezed into a per-row badge instead of
being its own thing. The duplicated Import-timesheets button (currently
on both pages) moves here only.

QR badges: encodes employee_no directly, no new PocketBase field --
every worker already has a valid code the moment they exist, with no
generation step. Explicitly a deliberate tradeoff (guessable numbers,
no revocation) the user chose for now, pending seeing how the separate
timekeeping PWA wants identity handled -- moving to a stored opaque
token later is additive, not a rework. Card layout confirmed via the
visual companion (centered, QR-forward). Both single-worker and
batch-sheet print confirmed in scope. Rendering via the qrcode npm
package + the browser's own print dialog (@media print), not a PDF
library -- CSS handles real-world card dimensions more reliably than
fighting a PDF engine's coordinate system.

The timekeeping PWA itself is explicitly out of scope -- a separate
project this spec only needs to stay compatible with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 19:04:34 +02:00
DEls
5f4adcba6c fix(ui): clip table headers to their card's rounded corners
Card is rounded-xl with no overflow-hidden. TableHeader is sticky with
its own opaque background (bg-card/95) that sits flush against the
card's edge -- with nothing clipping it, the header's square background
corners poke past the card's rounded ones. Visible on every page built
with the shared <Card><CardContent className="p-0"><Table> pattern:
Suppliers, Supplier Detail, Uploads, Delivery Notes, Fuel Logs,
Timesheets. Purchase Orders is unaffected -- it uses a card-per-row
layout, not an HTML table.

Added overflow-hidden to each affected Card. Standard, deterministic
Tailwind behavior -- reliably clips child content to the parent's
border-radius, no visual regression risk.

Also added click-to-sort on the Suppliers list (Supplier/POs/Ordered/
Delivered/Outstanding, toggling asc/desc) -- the page previously only
supported a fixed server-side default (outstanding descending) with no
way to change it. No existing sort-header convention exists elsewhere
in the app, so this is self-contained rather than a premature shared
abstraction for what is still a single use site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 18:36:42 +02:00
DEls
b454621ecf feat(suppliers): add Suppliers to the Financial sidebar group 2026-09-11 18:20:34 +02:00
DEls
ca6ecd5815 feat(suppliers): add the supplier detail page 2026-09-11 18:19:58 +02:00
DEls
2af21eb117 feat(suppliers): add the Suppliers list page 2026-09-11 18:19:33 +02:00
DEls
cdaa4493cf feat(suppliers): show resolved supplier name on the PO list page 2026-09-11 18:18:37 +02:00
DEls
1c3adede0d feat(suppliers): PO detail page picks a real supplier instead of free text 2026-09-11 18:17:46 +02:00
DEls
50bcf405a9 feat(suppliers): add create/edit dialog 2026-09-11 18:16:40 +02:00
DEls
7921befa00 feat(suppliers): repoint PO supplier assignment at real supplier records 2026-09-11 18:16:21 +02:00
DEls
87cb86b642 feat(suppliers): add create/edit API routes 2026-09-11 18:15:09 +02:00
DEls
854599b691 feat(suppliers): match extracted supplier name on PO ingest 2026-09-11 18:14:47 +02:00
DEls
5fa8edfc3e feat(suppliers): migrate existing PO supplier text onto supplier records 2026-09-11 18:14:05 +02:00
DEls
5ecbd6eded feat(suppliers): add computeSupplierLedger, computed never stored 2026-09-11 18:13:43 +02:00
DEls
73f7e500ee feat(suppliers): add normalizeSupplierName and matchSupplier 2026-09-11 18:13:20 +02:00
DEls
8025d297e6 feat(schema): add suppliers collection and purchase_orders.supplier_ref 2026-09-11 18:12:30 +02:00
DEls
dc85da3f36 docs(plan): suppliers implementation plan
13 tasks across 6 phases: additive schema, pure matching/ledger
functions (TDD, tested in isolation), the one irreversible migration
(guarded, dry-run default), OCR wiring, API routes, and UI.

Grounded in the actual codebase rather than the spec's prose: the
existing "unknown supplier" inline-editor on the PO detail page
(SupplierEditor + POST /api/purchase-orders/[id]/supplier) is upgraded
in place rather than duplicated, following the same principle applied
to the sidebar restructure's delivery-note review queue. Every code
sample is written against files actually read during planning, not
guessed -- site-form-dialog.tsx, the /api/sites routes, matchSite,
po-fulfilment.ts, and the live purchase_orders.supplier data.

Self-review caught a real gap before committing: pb-bootstrap-schema.py
and pb-types.ts were named in the file-structure table but no task
actually touched them -- exactly the foot-gun docs/data-model.md
section 7 warns about. Added as Task 1 steps 5-8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 18:08:30 +02:00
DEls
c73a2aad6c docs(spec): correct the suppliers migration's expected merge count
19 -> ~16 was wrong. Ran the actual normalizeSupplierName() (trim +
whitespace-collapse + uppercase) against the three example pairs cited
as evidence: only the case-variant pair (Builders Warehouse) actually
collapses. The Ikhasi pair keeps a stray space after a hyphen that
differs between variants; the Global Precast pair differs by the
"(Pty) Ltd" suffix, not case or whitespace. Neither collapses under
exact-normalized matching.

This is not a defect -- it's the conservative-matching principle in
section 3 working as designed. The correct expectation against
production is 19 raw strings -> 18 groups, one merge. Both pairs that
don't collapse are exactly the kind of ambiguous case the spec already
says should stay separate for a human to resolve, not the kind that
should be forced together by loosening the normalizer.

Caught while grounding the implementation plan in real data, before
writing a single line of the plan itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 18:02:18 +02:00
DEls
861cb25f0f Merge branch 'feat/sidebar-restructure' into main 2026-09-11 17:48:07 +02:00
DEls
1d706e1985 feat(dashboard): grouped sidebar navigation with landing pages
Implements docs/superpowers/specs/2026-09-11-sidebar-restructure-design.md.
Reorganizes the flat 7-item sidebar into Workforce/Plant/Financial groups,
each with a landing dashboard and sub-pages split out of the former
combined Employees and Purchasing pages. Old routes (/dashboard/employees,
/fuel, /fuel/[vehicleId], /plant/[vehicleId], /purchasing,
/purchasing/[poId]) now redirect to their new locations so existing
bookmarks and links keep working.

New pages: Workforce, Plant, Financial landing dashboards; Timesheets
(entries table + upload, surfacing document status that was previously
invisible); Fuel Logs (flat table across both vehicle types); Delivery
Notes (the page docs/superpowers/specs/2026-09-11-ingestion-restructure-design.md
section 8 deliberately deferred); Uploads (combined PO/DN ingestion status).

Moved-unchanged pages (Workers, Vehicles, Equipment) verified
byte-identical to their prior content via direct diff against main --
relocated only, nothing altered.

Reviewed the initial implementation and found two issues, both fixed
here before commit:
  - The delivery-note review queue (NeedsReviewDns) was duplicated onto
    the Purchase Orders page instead of moving to the new Delivery Notes
    page as specified -- same workflow rendered in two places from two
    independent queries. Removed from Purchase Orders; it now lives
    exclusively on Delivery Notes.
  - The new Uploads page's timestamp column fell back to a `created`
    field that doesn't exist on this PocketBase instance, so it could
    never help. All 24 real purchase_orders predate date_uploaded being
    backfilled and would have shown a blank column forever. Dropped the
    dead fallback; the view already degrades to an em-dash for missing
    timestamps.

Typecheck and full suite verified clean before and after both fixes
(583/583).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:47:27 +02:00
DEls
cfefb61c1b docs(spec): suppliers — new collection, PO relation, live AP ledger
New suppliers collection replacing purchase_orders.supplier (free text)
as the authoritative value, kept alongside it as OCR provenance rather
than dropped -- it genuinely is the supplier name, just inconsistently
formatted, unlike project_code which never held a real project code at
all.

Grounded in production: 24 POs, 19 distinct raw supplier strings, at
least 3 confirmed duplicates under a formatting variant. Migration
auto-merges only exact-normalized matches; anything ambiguous (e.g. two
"Vula" entries that may or may not be the same company) stays separate
rather than guessed.

The accounts-payable rollup (ordered/delivered/outstanding per supplier,
in Rand) is computed live from purchase_order_items and
delivery_note_items every read, never stored -- same reasoning as
po-fulfilment.ts: a stored balance has to be kept correct on every PO,
delivery, and correction, and PocketBase has no transactions to
guarantee that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:31:43 +02:00
DEls
f63cf598a7 Merge branch 'feat/ingestion-restructure' into main 2026-09-11 16:39:44 +02:00
DEls
12639e98f0 docs(spec): sidebar restructure — grouped nav with landing pages
Reorganizes the flat 7-item sidebar into Workforce/Plant/Financial
groups, each with a landing dashboard and sub-pages split out of
today's combined Employees and Purchasing pages. Delivers the Delivery
Notes page and ingestion-status Uploads view that
2026-09-11-ingestion-restructure-design.md section 8 deliberately left
for later, now that the data model is settled.

Suppliers (new collection, PO relation, free-text-to-record matching
migration) is explicitly out of scope -- split into its own spec per
user request, same rigor as the ingestion restructure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:37:59 +02:00
DEls
f3d433160b chore: untrack compiled Python bytecode, fix missed Task 17 checkboxes
.pyc files are generated, not source -- machine/version-specific,
produce spurious diffs, and a routine __pycache__ cleanup can delete
tracked files as a side effect (hit this earlier in the session). Added
__pycache__/ to .gitignore.

Also fixed a boundary error in the plan's checkbox sweep: Task 17's own
6 steps were skipped when checking off Task 18's range. Task 17 has been
done and independently verified since commit 29ee2cc.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 16:18:06 +02:00
DEls
9b9d7097bb docs(plan): check off Task 20 — all 20 tasks complete 2026-09-11 16:17:41 +02:00
DEls
8eb256360b docs(schema): bring bootstrap and data-model docs back to truth
pb-bootstrap-schema.py -- the script a fresh PocketBase instance would
run against -- had fallen ten migrations behind and still described the
pre-restructure world. Rewrote it field-by-field against a live
production dump (17 collections, every field/required-flag/relation/
index verified). Found and fixed two pre-existing gaps unrelated to this
restructure while verifying: fuel_prices and income_claims were entirely
absent from the bootstrap script.

pb-relax-rules.py's collection list updated to match.

No local PocketBase binary is available in this environment, so the
plan's "bootstrap fresh and diff against production" verification step
was replaced with a manual field-by-field comparison against the live
dump -- same goal, different mechanism, documented as a substitution
rather than silently skipped.

Added site scoping to the one agent tool that still lacked it
(list_imports, backed by loadImportBatches) -- every other list_* tool
already had it. Preparation for the future per-user site-allowlist work,
not part of this restructure itself.

Rewrote docs/data-model.md and docs/data-flows.md end to end: the
site-attribution table, the collection reference, the five ingestion
pipelines, and the schema-drift section that was describing the exact
problem this task fixes. Every remaining data_import_batches reference
in both docs is now historical context, not a current description.

This is the last task of the ingestion restructure
(docs/superpowers/plans/2026-09-11-ingestion-restructure.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 16:17:41 +02:00
DEls
530d837bd6 docs(plan): check off Task 19 2026-09-11 16:08:34 +02:00
DEls
13f76ac894 perf(schema): index the columns every per-site query filters on
timesheet_entries, fuel_logs and delivery_notes had no indexes at all --
fine at today's row counts, not once site count grows toward the 18-20
target. Added 8 indexes across fuel_logs, timesheet_entries,
delivery_notes, purchase_orders and plant_hours_log, unioned into each
collection's existing index array rather than replacing it.

Verified both pre-existing UNIQUE indexes (idx_po_number, load-bearing
for the placeholder-PO-number scheme; idx_plant_hours_log_qr_start,
prevents duplicate weekly imports) are byte-for-byte unchanged.
Idempotent: a second run touches nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 16:08:34 +02:00
DEls
31e4df30a9 refactor(schema): drop project_code, qty_outstanding, PLANT and data_import_batches
The irreversible step of the ingestion restructure. Guards proved clean
(0 no-site rows across all 8 target collections, PLANT empty, all 27
migrated documents present, no row still depending on the old batch
link), full schema + row-data backups taken to /tmp/pb-backup-2026-09-15
first, then applied.

Dropped project_code from employees, vehicles, fixed_costs,
income_claims, purchase_orders, delivery_notes, fuel_logs and
plant_hours_log. Dropped qty_outstanding from purchase_order_items.
Deleted the PLANT collection (0 rows, 0 code references — a deprecated
alias for vehicles) and DATA_IMPORT_BATCHES (86 rows, superseded by the
five document collections built across Tasks 6-15).

PocketBase refused the first attempt: it will not delete a collection
while any relation still points at it, and fuel_logs, plant_hours_log
and timesheet_entries all still carried a batch_reference relation to
data_import_batches that Task 7's migration made obsolete but never
removed. Confirmed live that nothing in src/ reads or writes that field
any more (only three stale type declarations in pb-types.ts), extended
the drop script to remove it from all three collections, re-ran, and the
deletion succeeded.

Cleaned up the now-dead DataImportBatchRecord interface and BatchType
union from pb-types.ts; BatchStatus survives as the shared type every
document/queue collection's processing_status now uses independently.

Final collection count: 17, down from a peak of 20 mid-restructure (16
original + 3 new document collections - PLANT - data_import_batches -
2 renames counted once each). Every remaining collection's fields match
what code actually reads and writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:05:42 +02:00
DEls
a3e4702159 refactor(schema): resolve project_code through site, not a stored copy
project_code was a text copy of the parent site's code, denormalised
onto 8 child collections that each already have a real site relation.
Written once at insert, never updated -- docs/data-model.md documents
the drift risk. On purchase_orders it never even held a real project
code: production had requisition numbers, site names, and the PO's own
number in that field.

Every read site now resolves the code through site/expand.site/siteById
instead, reusing each page's existing site map where one already
existed. Every create() call stops writing the column. The chat agent's
list_imports tool no longer reads data_import_batches -- it now reads
timesheets, fuel_sheets, plant_hours_sheets, purchase_orders and
delivery_notes filtered by processing_status, which was necessary before
the pool can be dropped (Task 20 would otherwise 404 the agent).

Also closed a Task 17 gap found while doing this: the agent tools' vehicle
and fuel-log handlers still resolved project code from the now-removed
vehicles.project_code column.

Wrote scripts/migrations/2026-09-15-drop-legacy-columns.py, the one
irreversible step in this whole restructure. Its guard against orphaning
fuel_logs rows checked the wrong condition -- "fuel_sheet is empty" --
which produced a false abort: 73 fuel_logs rows have no fuel_sheet
because they predate batch tracking entirely and never had a
batch_reference to begin with, so dropping data_import_batches costs
them nothing. Fixed the guard to check what it actually claims to check:
a row that HAD a batch_reference and was never relinked. Verified live
that no such row exists on any of the three affected collections.

Script run in dry-run only; not yet applied.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:01:14 +02:00
DEls
29ee2ccd7d fix(purchasing): compute outstanding from delivery lines, drop the stored column
qty_outstanding was written once at ingest as qty_outstanding: item.qty
and never decremented on receipt, so it permanently reported nothing
delivered. Replace every read/write of it with computeOutstanding()
(src/lib/po-fulfilment.ts), a pure sum over delivery_note_items that
cannot drift the way a stored counter can — it stays correct even
after OCR re-runs delete and recreate a note's items.

Also fixes src/app/dashboard/purchasing/[poId]/page.tsx, which computed
its own outstanding figure locally and clamped over-delivery to zero;
it now shares computeOutstanding and shows over-delivery as negative,
matching the rest of the app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 15:37:49 +02:00
DEls
f007b5e8fb feat(schema): require site on every ingested collection
Tightened site from optional to required on fuel_logs, timesheet_entries,
delivery_notes and purchase_orders now that every route writes it and
every historical row has been backfilled. plant_hours_log was already
required (matches docs/data-model.md's documented invariant, confirmed
against production). timesheets (the document collection) was already
required from Task 6 -- a no-op here, not a regression.

fuel_sheets.site and plant_hours_sheets.site deliberately stay optional:
those sheets can legitimately span multiple sites.

Verified: creating a fuel_logs row with no site now gets a hard 400
(validation_required) from PocketBase itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 15:32:48 +02:00
DEls
f184776148 docs(n8n): repoint PO ingest workflow at purchase_orders
Task 15 of the ingestion restructure. Edited the live n8n workflow
(005iI4jX5mq9DsTR) directly via MCP and published the new version.

Repointed Watchdog: List Stuck, Watchdog: Reset Stuck Rows, Fetch Pending
Batches, Atomic Claim, Claim Successful?, Download File and Escalate
Failure from data_import_batches/status onto purchase_orders/
processing_status. Verified zero in-flight rows would be orphaned: all
26 old purchase_orders-type batches were already processed or failed,
and all 24 real PO rows had processing_status unset (won't match the new
pending filter).

Two things found on the live workflow that the plan didn't anticipate:

- Atomic Claim had no ?filter=processing_status="pending" guard at all --
  an unconditional PATCH, despite the workflow's own name and this doc's
  prior description both calling it "atomic". Added while repointing the
  same node.
- Build Callback Payload sent the extracted PO number as `po_number`.
  process-batch's validator requires the field named exactly
  `purchase_order_number` -- the old field name would have failed
  validation on every single real upload, a total silent outage. Fixed
  before publishing, verified against the route's actual validateExtracted().

Also removed the n8n-side duplicate short-circuit: the old
Build Callback Payload queried purchase_orders and PATCHed the batch row
directly from n8n code to pre-empt process-batch's own dedup logic.
process-batch's merge branch (Task 14) already does this correctly,
server-side, tested. Keeping both meant the same decision made twice in
two places that could disagree. Is Duplicate? node removed; Build
Callback Payload connects straight to Send Callback.

Published as the active version and verified by reading the workflow
back in full -- every repointed node confirmed, connections confirmed,
zero validation warnings.

Rewrote docs/n8n/purchase-order-ingest-implementation.md end to end to
describe the deployed shape rather than the retired one. Flagged (not
fixed, separate decision) a plaintext PB superuser password baked into
the Auth PB Admin node.

Marked Tasks 1-15 complete in the plan's own checkboxes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 14:46:57 +02:00
DEls
1559f9e51f fix(purchasing): restore PO-number normalization in process-batch
Task 14 rewrote process-batch to fill in the placeholder PO row instead
of creating a new one, but dropped normalizePoNumber along the way. The
route it replaced normalized (uppercase, collapse whitespace, strip a
leading PO prefix) before both the dedup lookup and storage; the rewrite
used the raw OCR string for both.

That's a real regression, not a style choice: two OCR runs of the same
real order that extracted slightly different formatting -- "PO-1234" vs
"PO 1234" -- would both pass the exact-string dedup filter as
non-matches, silently defeating the one check that exists to catch
exactly that. purchase_order_number carries a UNIQUE index but that only
blocks an identical string, not a format variant of the same PO.

Confirmed src/app/api/purchase-orders/ingest/route.ts (the only other
caller of purchase_order_number in this shape) is dead -- deprecated in
docs/superpowers/specs/2026-08-19-po-import-queue-redesign.md, superseded
by process-batch, kept only in case anything still hits it. process-batch
is the one route n8n actually calls, so this was live-path drift, not a
cosmetic inconsistency.

Normalizes once, in validateExtracted(), so every downstream use (dedup
filter, stored value, response body) sees the same canonical form. Also
restores the old route's guard against a PO number that strips to empty
once normalized (e.g. a raw value of just "PO") -- non-empty raw input
that would otherwise sail through as a legitimate-looking fill-in.

Full suite: 580/580 passing, 0 failures -- the pre-existing 404-vs-500
test bug that predated this entire restructure was also resolved as a
side effect of Task 14's rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 14:29:06 +02:00
DEls
0124c612d3 feat(purchasing): process-batch fills in the PO, merges on dedup
Task 13 moved PO creation to upload time (placeholder PENDING- number,
real site chosen by the uploader). process-batch now fills in that
existing row instead of creating a second one, with three outcomes:

1. Fill in — the extracted number is new: update the placeholder in
   place (number, supplier, dates, line items, processing_status).
   `site` is never part of this update — the uploader's choice is far
   more reliable than OCR's guess at a project reference (an audit
   found OCR-derived site matching succeeds on ~8% of real documents).
   The OCR's project guess lands in raw_ocr_data.extracted_project_ref,
   not in a schema column.
2. Merge on dedup — the number already belongs to a different,
   already-processed PO: delete the placeholder (it would otherwise
   strand as an unresolved PENDING- row) and point the caller at the
   real PO.
3. Invalid extraction — mark the placeholder `failed` with
   `last_error` set, but keep it. A stranded failed row is visible and
   recoverable in the admin UI; a deleted one silently loses the file.

Contract changes from { batch_id } to { purchase_order_id }; matchSite
and its import are dropped (site is no longer OCR's job in this
route). Rewrote route.test.ts for the new contract, which also
resolves the pre-existing "404 when batch does not exist" test bug
(the old mock error had no .status, so the route's status-based 404
check fell through to 500) since that test's shape no longer exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:23:56 +02:00
DEls
4f9af4a2a6 fix(purchasing): restore the 30-day PO dedup window with a real timestamp
Task 13 moved PO creation to upload time and needed a hash-dedup window
matching the spec's "same 30-day dedup window as today" (design doc
section 4.1). purchase_orders has no working created/updated autodate
field on this PocketBase instance, and unlike delivery_notes it had no
other timestamp field either -- the first cut of Task 13 correctly found
this, but responded by dropping the time window entirely rather than
adding one. That's a real behaviour change from spec, not just a
workaround: a PO file legitimately re-sent long after the original would
have been flagged a duplicate forever, with no way to age out.

Added purchase_orders.date_uploaded (additive, mirrors how
delivery_notes already uses its own date_received) and wired it through:
stamped at create, filtered on in the dedup query.

Also brought PurchaseOrderRecord in pb-types.ts in line with what Task 1
already put on the live schema and what the upload route now writes --
processing_status/attempts/last_error/next_retry_at/last_attempt_at/
file_hash/date_uploaded were live in production but absent from the
TypeScript interface. docs/data-model.md section 7 documents this exact
gap as a standing foot-gun; closing it now while it's fresh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 14:17:42 +02:00
DEls
fa725f096c feat(purchasing): create the PO row at upload with a placeholder number
purchase_orders.purchase_order_number carries a UNIQUE index
(idx_po_number, confirmed live) so two unprocessed POs can no longer
share "". The upload route now creates the purchase_orders row
directly (dropping the shared data_import_batches pool for POs),
stamping it with a placeholder minted by src/lib/po-placeholder.ts
that n8n's extraction overwrites once OCR completes. Also requires
`site` at upload instead of waiting on OCR-derived site matching,
which only succeeds on ~8% of real documents per the project's audit
- the uploader already knows which site they're on.

Dedup now checks purchase_orders by file_hash directly. This
instance's purchase_orders collection has no working created/updated
autodate field (confirmed live - sort/filter by `created` 400s) and
no other timestamp field, so the dedup query drops the 30-day window
used by the old batch-based dedup and instead excludes only rows
whose last attempt failed.

process-batch/route.ts is intentionally untouched - it still creates
a fresh purchase_orders row from a batch, which is now inconsistent
with this upload shape. That inconsistency is expected until the
follow-up task rewrites process-batch to fill in the row born here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:14:19 +02:00
DEls
819678fb06 fix(timesheets): document-first import, site stamped per entry
Cuts the timesheet importer over to the document-first pattern already
proven for fuel logs and plant hours: creates a `timesheets` document
pending (site required, unlike the other two sheet types) before any
parsing happens, guards every early-exit path with a failDoc() helper
that writes a real `failed` outcome, and links entries via the
`timesheet` relation instead of `batch_reference`.

The actual point of this task: each timesheet_entries row now stamps
its own `site` from that row's employee (falling back to the
document's site only when the employee has none), rather than
inheriting the upload's site. Site attribution for payroll history is
resolved once, at write time, so moving a worker to a different site
later no longer rewrites their historical labour cost at read time.

Also updates ImportTimesheetsButton to the documentId response shape
(previously batchId), matching the Task 10/11 button updates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:07:01 +02:00
DEls
49f119ab5c fix(fuel): document-first import with honest status
The fuel-logs import route created its data_import_batches audit row
with status "processed" hardcoded before a single row was inserted —
the same production bug fixed for plant-hours in the prior commit.

Cut the route over to fuel_sheets (Task 6/7): the document is created
`pending` with the source file attached before any parsing, and the
real outcome is computed from actual insert counts via
resolveDocumentOutcome() and written back before the response
returns. A sheet whose vehicles match nothing now comes back `failed`
with rows_created: 0, not `processed`. Every fuel_logs row now links
via the `fuel_sheet` relation instead of `batch_reference`.

`fuel_sheets.site` is optional (a pump sheet can span sites; the
row-level site resolved from "Current Site" stays authoritative), so
it's only passed to the document create when the form supplied one.

Also renames ImportResult.batchId -> documentId in
import-fuel-logs-button.tsx to match the new response shape. No
exception-report route or separate orchestrator lib exists for fuel
logs (unlike plant-hours), so there was nothing else to rename.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:02:02 +02:00
DEls
610426776a fix(plant-hours): document-first import with honest status
The plant-hours import route used to create its audit row with
status "processed" hardcoded before a single insert ran. Production
ended up with 19 such batches, 18 of which created zero rows while
claiming success.

Cut the route over to plant_hours_sheets (Task 6/7): the document is
created `pending` with the source file attached before any parsing,
and the real outcome is computed from actual insert counts via
resolveDocumentOutcome() (Task 8) and written back before the
response returns. A sheet whose vehicles match nothing now comes back
`failed` with rows_created: 0, not `processed`.

Also renames plant_hours_log's `batch_reference` writes to the new
`plant_hours_sheet` relation (src/lib/plant-hours-import.ts), and
renames the exception-report route folder from [batchId] to [sheetId]
to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 13:56:11 +02:00
DEls
928ed26f7c refactor(delivery-notes): own the slip and the hash, drop the shadow batch
delivery_notes already creates its own row at upload time with the slip
attached, and n8n's OCR poller already reads delivery_notes directly by
processing_status=pending — it never looked at data_import_batches. The
batch row created alongside every upload was therefore a pure duplicate
write: same file, uploaded twice, read by nothing downstream. Move dedup
onto the DN row itself (it has file_hash from Task 1) and delete the
batch-tracking code paths in both the upload route and the OCR-result
webhook.

The plan's suggested dedup filter/sort used `created`, but this PB
instance has no usable created/updated autodate fields on delivery_notes
(sort=-created 400s here, as already noted for purchase_orders and
flagged by Task 6). Used date_received instead, matching the field the
rest of the app already sorts this collection by (src/lib/agent/load.ts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 13:48:30 +02:00
DEls
8654c65e6f feat(ingest): add honest document status resolution
The three synchronous importers (timesheets, fuel, plant hours) used to
write status: "processed" on their audit row before a single insert ran,
and never updated it. Production had 21 such phantom-processed batches
across payroll and plant hours. resolveDocumentOutcome() computes the
real status from actual insert counts, so no route can claim success it
didn't earn.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 13:43:15 +02:00
DEls
539ec93d2a feat(migration): move batch files into document collections
Migrates the 27 batches of type timesheets/fuel_logs/plant_hours out of
data_import_batches into the three document collections created in Task
6, preserving every source file and relinking every child row.

Status is derived from actual child count, not copied from the batch
row. Production proved the stored status cannot be trusted: every one of
these batches was written status=processed at creation time, before any
insert ran.

The bug is worse than the plan's own audit found. It predicted 18
phantom-processed batches, all plant_hours. Production has 21 -- three
timesheets batches are also phantom, including two byte-identical
re-uploads of the same wage sheet that both reported success and created
nothing. This hit payroll, not just plant-hour smoke tests.

Also: computed file_hash on migrated documents while the bytes were
already in hand (closes the same dedup gap Task 1 closed on delivery
notes and purchase orders), and split the schema-additive step
(--apply-fields) from the row migration (--apply) so the reversible part
can run independently of the irreversible one. The plan claimed
idempotency via a migrated_from_batch field that doesn't exist; replaced
with an explicit refuse-if-already-populated guard rather than pretend
resumability that isn't there.

27/27 migrated. 6/6 post-migration invariants verified: 3 fuel_sheets,
19 plant_hours_sheets, 5 timesheets, 1367/247/1 children relinked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:06:40 +02:00
DEls
3a71e7e884 feat(schema): add timesheets, fuel_sheets, plant_hours_sheets documents
Three document collections replacing data_import_batches for the
spreadsheet importers. Each owns its source file and carries honest
outcome counters (rows_total/created/failed, row_errors) — the counts the
current importers compute and then discard.

site is required on timesheets (a wage sheet belongs to one site) and
optional on the other two, which legitimately span sites since the fuel
importer resolves a site per row. cascadeDelete is false on all three, so
deleting a site cannot destroy its source spreadsheets.

Widened source_file mimeTypes beyond the plan's three entries to match
the pool being replaced. Browsers label CSVs text/csv, application/csv or
text/plain interchangeably and the importers branch on file extension,
not MIME — so the narrower list would have parsed a CSV successfully then
rejected it on write. The 2026-08-24 migration exists because that
already happened once.

Plan corrected: its TypeScript edit added a second `expand` property to
an interface that already had one, which does not compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:09:14 +02:00
DEls
0cdf385df2 refactor(schema): rename received_items and timesheets to their real names
RECEIVED_ITEMS -> DELIVERY_NOTE_ITEMS (55 rows): they are the lines on a
delivery note; the old name described an event, not a thing.

TIMESHEETS -> TIMESHEET_ENTRIES (1367 rows): this collection is the time
log, not a timesheet. The name is freed for the uploaded-document
collection created in Task 6.

PocketBase stores relations by collection id, so every relation survived
untouched; row counts asserted intact on both renames.

Two call sites the plan missed: src/app/api/timesheets/import/route.ts
and src/lib/ocr-result-tier.ts. Deliberately preserved the "timesheets"
string literals in pb-types.ts and the import route — those are
data_import_batches.batch_type select values, not collection names, and
changing them would have violated the select constraint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:01:40 +02:00
DEls
a0d335b235 fix(timesheets): stamp site on log rows, ending read-time drift
The timesheet log carried neither site nor project_code; attribution was
synthesised at read time from employees.site, so moving a worker to
another site silently migrated their entire payroll history with them.
docs/data-model.md flags this as the schema's worst drift risk.

Adds an optional site relation (maxSelect 1, cascadeDelete false so
deleting a site cannot destroy payroll history) and stamps all 1367 rows
from the employee's current site. 1367/1367 written, 0 orphans.

Tightened to required in Task 16, once the import route writes it on
every new row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:48:51 +02:00
DEls
9117b73cd4 fix(fuel): backfill site on 147 orphaned fuel logs
147 of 320 fuel_logs had no site relation; their only attribution was the
denormalised project_code text, which Task 18 deletes. Backfilled before
that column goes, resolving each row twice (by project_code and via the
vehicle's own site) and writing only on agreement.

147/147 written, 0 skipped, 0 remaining.

Recorded a caveat the dry run exposed: with one site in production both
resolvers draw from a candidate set of size one, so "both agree" is
guaranteed rather than evidence. The write is still the only possible
correct value, but the guard only bites once more sites exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:44:19 +02:00
DEls
5274ac4981 fix(migration): separate download failures from missing files
Review found a transient 500 during download landed in the "no stored
file" bucket, so a broken run printed as a clean one and exited 0. Now
counted separately and exits non-zero.

Also: 404 is no longer swallowed on mutations (a PATCH against a deleted
record was counted as written), the target host and mode are printed, the
apply path reports a final written count, and filenames quote with
safe="".

Plan Task 1 Step 4 still carried the superseded copy-from-batch code
block under the rewritten prose, contradicting it. Replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:39:54 +02:00
DEls
147ce7e77a feat(schema): hash entity files directly instead of copying batch hashes
Production disproved the copy-from-batch approach in two ways:
- 10 delivery-note batches carry no file_hash at all (they predate
  78fa8da), and all 10 sit inside the 30-day dedup window, so copying
  would have left a live gap rather than a historical one
- one PO row was produced from two different files with different
  hashes via the dedup-by-PO-number branch, so copying gives
  last-write-wins on an arbitrary one

Hashing the stored bytes has neither problem and does not depend on the
collection we are about to delete. 56/56 records covered, none skipped.

Immediately surfaced a real duplicate the old path missed: two delivery
notes numbered IN132202 with identical bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 10:32:41 +02:00
DEls
b1d06c1aa7 feat(schema): add file_hash and queue fields to DNs and POs 2026-09-11 10:29:10 +02:00