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>
428 lines
22 KiB
Markdown
428 lines
22 KiB
Markdown
# Data flows
|
|
|
|
> End-to-end ingestion pipelines with sequence diagrams and the exact site-attribution strategy each one uses.
|
|
|
|
Reflects the shape produced by the 2026-09-11 ingestion restructure
|
|
(`docs/superpowers/plans/2026-09-11-ingestion-restructure.md`). The shared
|
|
`data_import_batches` pool is gone. Two pipeline shapes replaced it:
|
|
|
|
- **The three sheet imports** (timesheets, fuel logs, plant hours) are
|
|
still synchronous, but each now creates its own typed "uploaded document"
|
|
row (`timesheets`, `fuel_sheets`, `plant_hours_sheets`) instead of a
|
|
generic batch row, and computes its real outcome via
|
|
`resolveDocumentOutcome()` (`src/lib/document-status.ts`) once parsing is
|
|
done — never hardcoding `status: processed` before a single row exists.
|
|
- **Purchase orders** moved from synchronous-upload-then-async-batch to
|
|
fully async against `purchase_orders` itself: the row is born at upload
|
|
time with a placeholder number and `processing_status: pending`, and an
|
|
n8n cron claims, extracts, and fills it in. There is no batch row at any
|
|
point in this pipeline any more.
|
|
- **Delivery notes** keep their existing shape (site + file stamped at
|
|
upload, OCR result posted back by webhook) minus the batch row.
|
|
|
|
---
|
|
|
|
## 1. Timesheet import (synchronous, document-first)
|
|
|
|
**Route:** `POST /api/timesheets/import` (`src/app/api/timesheets/import/route.ts`)
|
|
**Input:** `multipart/form-data` with `file` (xlsx / xls / csv), **required** `siteId`, and optional `createMissingEmployees`
|
|
**Output:** `{ documentId, totalRows, imported, skipped, errors, detectedFormat, createdEmployees }`
|
|
**Triggers:** `ImportTimesheetsButton` on the Workforce page
|
|
|
|
Unlike the fuel/plant-hours sheets, a wage sheet always belongs to exactly
|
|
one site — `siteId` is required and the upload is rejected before anything
|
|
is parsed if it's missing.
|
|
|
|
**Format auto-detection** (the route walks every sheet, first match wins):
|
|
|
|
1. **Hills Casual Matrix** — `looksLikeCasualMatrix(rows)` (`src/lib/timesheet-casual-parse.ts`) → one long row per (employee, day). Hours are `HH:MM`, "Absent" cells are skipped, names with leading commas are cleaned.
|
|
2. **Permanent wide** — `parseHillsTimesheet` (`src/lib/timesheet-grid-parse.ts`) → one row per employee with daily hours across columns.
|
|
3. **Long format** — header row `employee_no, date, hours_worked` for both CSV and XLSX fallback.
|
|
|
|
**Pipeline:**
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant U as User (browser)
|
|
participant R as /api/timesheets/import
|
|
participant P as PocketBase
|
|
U->>R: multipart { file, siteId (required), createMissingEmployees? }
|
|
R->>R: parseFile() → uniform long rows
|
|
R->>P: create(timesheets, site=siteId, source_file, file_hash, processing_status=pending)
|
|
alt createMissingEmployees
|
|
R->>P: getFullList(employees, fields="id,employee_no")
|
|
R->>P: create(missing employees, casual, site=siteId)
|
|
end
|
|
R->>P: getFullList(employees, fields="id,employee_no,site")
|
|
loop for each parsed row
|
|
R->>P: create(timesheet_entries, employee=lookup[row.employee_no], site=employeeSite.get(employeeId) || siteId)
|
|
end
|
|
R->>R: resolveDocumentOutcome({ rowsTotal, rowsCreated, rowErrors }, now)
|
|
R->>P: update(timesheets doc, processing_status, rows_total, rows_created, rows_failed, row_errors, last_error)
|
|
R-->>U: { documentId, totalRows, imported, skipped, errors, ... }
|
|
```
|
|
|
|
**Site attribution.** `timesheet_entries` (the collection that used to be
|
|
called `timesheets` before the rename) now carries a **required** `site`
|
|
relation, stamped from the employee's site at the moment of import — not
|
|
joined at read time. This is a deliberate change: the old at-read-time join
|
|
through `employees.site` meant a worker moving sites silently rewrote the
|
|
site attribution of every historical hour they'd ever logged. Stamping at
|
|
write time freezes each entry's site to what it actually was that day.
|
|
|
|
**Failure behaviour.**
|
|
|
|
- Unknown `employee_no` (and `createMissingEmployees` not set) → row errored, import continues.
|
|
- Per-row `pb.collection("timesheet_entries").create` failure → row errored, import continues.
|
|
- File parse failure → 400 with a top-level `error` and `details`.
|
|
- `resolveDocumentOutcome()` marks the document `failed` (not `processed`)
|
|
if the file had zero data rows, or if parsing produced rows but zero of
|
|
them landed — see `src/lib/document-status.ts`. A partial import (some
|
|
rows landed, some failed) is `processed` with `rows_failed > 0` and
|
|
`last_error` summarising the count; it is not silently reported clean.
|
|
|
|
---
|
|
|
|
## 2. Fuel log import (synchronous, document-first)
|
|
|
|
**Route:** `POST /api/fuel-logs/import` (`src/app/api/fuel-logs/import/route.ts`)
|
|
**Input:** `multipart/form-data` with `file` (xlsx or csv), optional `siteId`
|
|
**Output:** `{ documentId, totalRows, imported, skipped, errors, qrMatched, descMatched }`
|
|
**Triggers:** `ImportFuelLogsButton` on the Fleet page
|
|
|
|
**Expected columns** (case-insensitive, alias-resolved by `resolveColumn` in `src/lib/fuel-import-parse.ts`):
|
|
|
|
| Primary | Legacy aliases |
|
|
| --- | --- |
|
|
| `Date Logged` | `date`, `date_logged` |
|
|
| `Vehicle QR` | `vehicle_qr`, `qr`, `qr_code_id` |
|
|
| `Vehicle` | `vehicle_description`, `description` |
|
|
| `Km's/ Hours` | `odo_reading`, `odometer`, `hour_meter` |
|
|
| `Litres Pumped` | `litres`, `liters` |
|
|
| `Current Site` | `site`, `current_site` |
|
|
| `Latest Consumption` | `latest_consumption`, `consumption` (optional) |
|
|
|
|
**Pipeline:**
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant U as User (browser)
|
|
participant R as /api/fuel-logs/import
|
|
participant P as PocketBase
|
|
U->>R: multipart { file, siteId? }
|
|
R->>R: SheetJS parse → rowsRaw
|
|
R->>P: create(fuel_sheets, site=siteId?, source_file, file_hash, processing_status=pending)
|
|
R->>P: getFullList(vehicles, fields="id,description,site,qr_code_id")
|
|
R->>P: getFullList(sites, fields="id,name")
|
|
R-->>R: vehicleByQr, vehicleByDesc, siteByName maps
|
|
loop for each row
|
|
R->>R: parseDate, toNumber
|
|
R->>R: vehicle = vehicleByQr[qr] ?? vehicleByDesc[desc]
|
|
R->>R: rowSiteId = siteByName[row.site.toLowerCase()] ?? vehicle.site
|
|
R->>P: create(fuel_logs, site=rowSiteId, vehicle, fuel_sheet=doc.id, ...)
|
|
end
|
|
R->>R: resolveDocumentOutcome({ rowsTotal, rowsCreated, rowErrors }, now)
|
|
R->>P: update(fuel_sheets doc, processing_status, rows_total, rows_created, rows_failed, row_errors, last_error)
|
|
R-->>U: { documentId, totalRows, imported, skipped, errors, qrMatched, descMatched }
|
|
```
|
|
|
|
**Site attribution strategy.** Two-tier match, same shape as before the
|
|
restructure, but the destination is a real required `site` relation on
|
|
`fuel_logs` instead of a denormalised `project_code` fallback:
|
|
|
|
1. The row's `Current Site` cell is matched case-insensitive against
|
|
`sites.name`. If found, that site is stamped on the row.
|
|
2. If the row has no site cell (legacy CSVs) or no match, the row falls
|
|
back to the matched vehicle's current `site`.
|
|
|
|
This is still the seam where **site attribution goes wrong** as the fleet
|
|
grows — see [multi-site-assessment §4](./multi-site-assessment.md#4-site-resolution-by-name-string).
|
|
Two sites with near-identical names, a renamed site, or a vehicle moved
|
|
between sites all still produce silently wrong attribution; what changed is
|
|
that there is no separate `project_code` fallback path to reason about any
|
|
more — one relation, one resolution order.
|
|
|
|
**Failure behaviour.**
|
|
|
|
- Unknown vehicle (no QR match, no description match) → row errored, import continues.
|
|
- Invalid date / odo / litres → row errored, import continues.
|
|
- PB insert failure → row errored, import continues; the importer extracts
|
|
the field-level validation message via `formatPbError`.
|
|
- `resolveDocumentOutcome()` governs the document's final status exactly as
|
|
in the timesheet pipeline (§1) — zero rows parsed or zero rows landed is
|
|
`failed`, never `processed`.
|
|
|
|
---
|
|
|
|
## 3. Plant hours import (synchronous, document-first)
|
|
|
|
**Route:** `POST /api/plant-hours/import` (`src/app/api/plant-hours/import/route.ts`)
|
|
**Input:** `multipart/form-data` with `file` (xlsx or csv, max 10 MB), optional `siteId`
|
|
**Output:** `{ documentId, totalRows, imported, skipped, mismatched, warnings, errors }`
|
|
**Orchestrator:** `importPlantHours` in `src/lib/plant-hours-import.ts`
|
|
**Triggers:** `ImportPlantHoursButton` on the Plant page
|
|
|
|
**Pipeline:**
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant U as User
|
|
participant R as /api/plant-hours/import
|
|
participant O as importPlantHours (pure)
|
|
participant P as PocketBase
|
|
U->>R: multipart { file, siteId? }
|
|
R->>R: SheetJS parse
|
|
R->>P: create(plant_hours_sheets, site=siteId?, source_file, file_hash, processing_status=pending)
|
|
R->>P: getFullList(vehicles, fields="id,description,qr_code_id,unit_type,site,cost_per_hour", perPage=5000)
|
|
R->>P: getFullList(sites, fields="id,name", perPage=5000)
|
|
R->>O: importPlantHours(rowsRaw, { vehicles, sites })
|
|
O->>O: resolve each row by qr_code_id (or desc fallback)
|
|
O->>O: compute hours, value, over_budget, hours_mismatch
|
|
O->>O: snapshot vehicle_unit_type_at_import
|
|
loop for each prepped row
|
|
R->>P: create(plant_hours_log, site=vehicle.site, plant_hours_sheet=doc.id, ...)
|
|
alt duplicate (vehicle_qr, start_date)
|
|
P-->>R: 400 validation_not_unique
|
|
R->>R: record as "duplicate: vehicle_qr=X start_date=Y"
|
|
end
|
|
end
|
|
R->>R: resolveDocumentOutcome({ rowsTotal, rowsCreated, rowErrors }, now)
|
|
R->>P: update(plant_hours_sheets doc, processing_status, rows_total, rows_created, rows_failed, row_errors, last_error)
|
|
R-->>U: { documentId, totalRows, imported, skipped, mismatched, warnings, errors }
|
|
```
|
|
|
|
**Site attribution.** The orchestrator pulls the site from the matched
|
|
vehicle (`vehicle.site`) and stamps it on the row. This remains the
|
|
**only collection where `site` is required (`minSelect=1`)** on the log
|
|
itself — every `plant_hours_log` row has a non-null site.
|
|
|
|
**Composite unique index.** `idx_plant_hours_log_qr_start` on
|
|
`(vehicle_qr, start_date)` blocks accidental double-imports of the same
|
|
week for the same vehicle. The route surfaces the PB-level rejection as
|
|
a friendly `duplicate: ...` error per row, not a document-level failure.
|
|
See [multi-site-assessment §5](./multi-site-assessment.md#5-vehicles-plant-and-employees-moving-between-sites) for the cross-site implication
|
|
of this index.
|
|
|
|
**Failure behaviour.** Per-row. The orchestrator collects row-level
|
|
errors and warnings; the route appends PB insert errors. The document
|
|
itself is never aborted by one bad row — `resolveDocumentOutcome()` decides
|
|
the final status the same way as §1/§2.
|
|
|
|
**Exception report.** `GET /api/plant-hours/exception-report/[sheetId]`
|
|
reconstructs a per-row exception CSV by re-parsing the source file
|
|
from the document's `source_file` and matching against the imported rows
|
|
in `plant_hours_log.filter('plant_hours_sheet = "<sheetId>"')`. Used to
|
|
re-export a corrected sheet when the operator needs to fix and re-import.
|
|
|
|
---
|
|
|
|
## 4. Purchase order upload → ingest (async, no batch row)
|
|
|
|
Two routes cooperate; the browser never talks to n8n directly. There is no
|
|
longer a poll-for-status route, a separate batch collection, or a "move the
|
|
file onto the new row" step — the `purchase_orders` row *is* the queue item
|
|
from the moment it's created.
|
|
|
|
**Routes:**
|
|
- `POST /api/purchase-orders/upload` — `src/app/api/purchase-orders/upload/route.ts`
|
|
- `POST /api/purchase-orders/process-batch` — called by n8n — `src/app/api/purchase-orders/process-batch/route.ts`
|
|
|
|
Full implementation detail, including the n8n node map, hardcoded values,
|
|
and a manual smoke-test script, lives in
|
|
`docs/n8n/purchase-order-ingest-implementation.md` (rewritten for this
|
|
shape in Task 15 of the restructure) — this section is the short version.
|
|
|
|
**Auth model.** Both routes are server-side; `process-batch` additionally
|
|
requires the shared `N8N_INGEST_SECRET` header.
|
|
|
|
**Pipeline:**
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant U as User
|
|
participant W as /api/purchase-orders/upload
|
|
participant P as PocketBase
|
|
participant N as n8n (PO ingest cron, every ~3 min)
|
|
participant B as /api/purchase-orders/process-batch
|
|
U->>W: multipart { file (PDF/image), siteId }
|
|
W->>W: SHA-256 the bytes
|
|
W->>P: getFullList(purchase_orders, file_hash=X && date_uploaded >= 30d_ago)
|
|
alt duplicate
|
|
W-->>U: 200 { duplicate: true, existing_id, ... }
|
|
else new
|
|
W->>P: create(purchase_orders, site=siteId, purchase_order_number="PENDING-...", processing_status=pending, file_hash, purchase_order_file)
|
|
W-->>U: 202 { purchase_order_id, status: queued }
|
|
end
|
|
N->>P: PATCH purchase_orders/{id}?filter=processing_status="pending" (atomic claim → processing, attempts+1)
|
|
N->>P: GET /files/purchase_orders/{id}/{purchase_order_file}
|
|
N->>N: AI Agent extracts PO header + line items
|
|
N->>B: POST { purchase_order_id, extracted } + x-ingest-secret
|
|
B->>P: getOne(purchase_orders, id) — must still be 'processing'
|
|
B->>P: getFullList(purchase_orders, filter=purchase_order_number=extracted.number) — dedup
|
|
alt fill in (no existing PO with that number)
|
|
B->>P: update(this row, purchase_order_number=extracted.number, processing_status=processed, raw_ocr_data, is_verified=false)
|
|
loop for each line item
|
|
B->>P: create(purchase_order_items, purchase_order=this row.id)
|
|
end
|
|
else merge (a real PO with that number already exists)
|
|
B->>P: update(existing PO with any extracted fields it was missing)
|
|
B->>P: delete(this placeholder row)
|
|
else invalid extraction
|
|
B->>P: update(this row, processing_status=failed, last_error) // never deleted — stays visible and recoverable
|
|
end
|
|
B-->>N: { ok: true } or 4xx/5xx
|
|
```
|
|
|
|
**Three-outcome `process-batch` contract.** This is the core of the async
|
|
design — one route, three possible endings for the *same* placeholder row,
|
|
never a fourth row created:
|
|
|
|
1. **Fill in.** No existing PO shares the extracted number — the
|
|
placeholder row is updated in place with the real number, OCR payload,
|
|
and line items. This is the common case.
|
|
2. **Merge / dedup / delete placeholder.** A real PO with that number
|
|
already exists (a genuine duplicate upload, or two near-simultaneous
|
|
uploads of the same document). The existing PO is patched with anything
|
|
useful the new extraction found that it was missing, and *this*
|
|
placeholder row is deleted. The caller-visible id becomes the winner's.
|
|
3. **Mark failed.** The extraction was invalid (no PO number, garbage
|
|
output) — the row moves to `processing_status=failed` with `last_error`
|
|
set. It is **never deleted** on this path, so it stays visible and
|
|
recoverable in the dashboard, and the n8n-side escalation logic can
|
|
retry or eventually mark it `dead` after repeated failures.
|
|
|
|
**Dedup layers, in order:**
|
|
|
|
1. **Hash dedup** at upload time — SHA-256 of the file bytes, matched
|
|
against `purchase_orders` rows created in the last 30 days. Returns
|
|
`200 { duplicate: true }` before any new placeholder row is written.
|
|
2. **Extracted-number dedup** at process time — outcome 2 above.
|
|
3. **PB unique index** `idx_po_number` on
|
|
`purchase_orders(purchase_order_number)` — the final backstop. If the
|
|
dedup logic misses, this rejects the insert.
|
|
|
|
**Site attribution strategy.** `matchSite(pb, siteId or project code hint)`
|
|
in `src/lib/purchase-order-schema.ts` resolves the site once, at upload
|
|
time, before OCR ever runs — `purchase_orders.site` is a required field, so
|
|
there is no unmatched/null-site PO state to reason about downstream the way
|
|
there used to be.
|
|
|
|
**Failure behaviour.** Per the `process-batch` route's own contract, it
|
|
distinguishes `retryable: true` (5xx with a transient signal — n8n retries)
|
|
from `retryable: false` (400 — n8n marks the row `dead` after enough
|
|
attempts and surfaces it in the dashboard). Network blips and PB write
|
|
conflicts are retryable; validation failures are not.
|
|
|
|
---
|
|
|
|
## 5. Delivery note upload → OCR result (async via n8n)
|
|
|
|
**Routes:**
|
|
- `POST /api/delivery-notes/upload` — `src/app/api/delivery-notes/upload/route.ts`
|
|
- `GET /api/delivery-notes/[id]/slip` — authenticated file proxy for n8n
|
|
- `POST /api/delivery-notes/[id]/ocr-result` — webhook from n8n — `src/app/api/delivery-notes/[id]/ocr-result/route.ts`
|
|
- `POST /api/delivery-notes/[id]/assign-po` — manual fallback
|
|
- `POST /api/records/[collection]/[id]/verify` — manual verify toggle
|
|
|
|
**Pipeline:**
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant U as User
|
|
participant U2 as /api/delivery-notes/upload
|
|
participant P as PocketBase
|
|
participant N as n8n (DN OCR workflow)
|
|
participant S as /api/delivery-notes/:id/slip
|
|
participant R as /api/delivery-notes/:id/ocr-result
|
|
participant A as /api/delivery-notes/:id/assign-po
|
|
U->>U2: multipart { file, site, delivery_note_number? }
|
|
U2->>P: create(delivery_notes, slip_image=file, site, processing_status=pending, file_hash)
|
|
U2-->>U: 201 { id, siteId, processingStatus: pending }
|
|
N->>S: GET (x-ingest-secret)
|
|
S->>P: getOne(delivery_notes, fields="slip_image")
|
|
S->>P: GET /api/files/delivery_notes/:id/<filename>
|
|
S-->>N: file bytes
|
|
N->>N: AI Agent OCR: po_number, dn_number, items, confidence
|
|
N->>R: POST { po_number, delivery_note_number, date_received, received_by_name, confidence, items, raw_ocr } + x-ingest-secret
|
|
R->>P: getOne(delivery_notes, fields="id,purchase_order,...")
|
|
R->>R: PO match (exact → normalised → Levenshtein)
|
|
R->>P: getFullList(purchase_order_items, filter=purchase_order='<poId>')
|
|
R->>R: resolveOcrTier(confidence, poResolved, itemsVerifiable)
|
|
alt tier = auto
|
|
R->>P: update(delivery_notes, processing_status=auto-verified, is_verified=true, ocr_confidence, ocr_completed_at, purchase_order, raw_ocr_data, ...)
|
|
loop for each item
|
|
R->>P: create(delivery_note_items, delivery_note, purchase_order_item=validatedId, qty_received, condition_notes)
|
|
end
|
|
else tier = needs-review
|
|
R->>P: update(delivery_notes, processing_status=needs-review, is_verified=false, ...)
|
|
R->>P: delete + recreate delivery_note_items (idempotent)
|
|
else tier = manual
|
|
R->>P: update(delivery_notes, processing_status=needs-review, ...) — no delivery_note_items
|
|
end
|
|
opt operator fixes
|
|
U->>A: POST { po: <poId> } (manual fallback)
|
|
A->>P: getOne(purchase_orders, id)
|
|
A->>P: update(delivery_notes, purchase_order=poId, processing_status=manual-verified, is_verified=true)
|
|
end
|
|
```
|
|
|
|
**Three-tier OCR result** (rules in `src/lib/ocr-result-tier.ts`):
|
|
|
|
| Confidence | PO resolved | Items verifiable | Tier | Effect |
|
|
| --- | --- | --- | --- | --- |
|
|
| ≥ 0.8 | ✅ | ✅ | `auto` | `is_verified=true`, `processing_status=auto-verified`, delivery_note_items created with PO links |
|
|
| ≥ 0.8 | ❌ | n/a | `needs-review` (downgraded) | `is_verified=false`, no auto-link — orphan DNs are never auto-verified |
|
|
| ≥ 0.8 | ✅ | ❌ (local fetch empty but n8n supplied ids) | `needs-review` (downgraded) | We don't claim auto when we can't cross-check the links |
|
|
| 0.5..0.79 | any | any | `needs-review` | `is_verified=false`, delivery_note_items created without PO links |
|
|
| < 0.5 | any | any | `manual` | `is_verified=false`, **no delivery_note_items** — the operator must capture manually |
|
|
|
|
**PO match priority:** exact `purchase_order_number` → normalised
|
|
(strip non-alnum, lowercase) → Levenshtein (single-char edit, threshold
|
|
0.75). The chosen path + score is recorded in
|
|
`raw_ocr_data.po_match_method` / `po_match_score` / `po_resolved_number`
|
|
so the exception inbox can show "matched by Levenshtein, 0.9" rather
|
|
than a silent "no match".
|
|
|
|
**Site attribution strategy.** Site is stamped at upload time from the
|
|
form field (`src/app/api/delivery-notes/upload/route.ts`), a required
|
|
field on the row; the OCR callback does not change it. For DNs uploaded
|
|
without a site, the route 400s.
|
|
|
|
**Idempotency.** If the OCR webhook is replayed, the route
|
|
**deletes** the existing `delivery_note_items` for this DN and recreates
|
|
them within the same call. This avoids stale matches if the OCR rerun finds
|
|
different items. The DN update is patch-style so missing fields
|
|
(delivery_note_number, date_received) are never overwritten by missing
|
|
data.
|
|
|
|
**Failure behaviour.** If the DN update itself fails, the route marks the
|
|
DN's `processing_status` as `failed` so the operator dashboard reflects the
|
|
broken state instead of leaving it pending forever. If a single
|
|
`delivery_note_items` create fails with a 400 (FK race — the PO item was
|
|
deleted between fetch and create), the route retries with the link dropped
|
|
and annotates the row as `link dropped: orphan id`.
|
|
|
|
---
|
|
|
|
## 6. Cross-cutting behaviours
|
|
|
|
- **Multipart limits.** All upload routes check `file.size` before parsing.
|
|
PO upload enforces mime allowlist via `ALLOWED_MIME`; DN upload accepts
|
|
any image/pdf. Plant hours import caps at 10 MB.
|
|
- **Auth headers.** `N8N_INGEST_SECRET` is checked at the top of every
|
|
n8n-callbackable route (`process-batch`, `ocr-result`, `slip` proxy).
|
|
The browser-facing routes do not check any auth.
|
|
- **PB ID validation.** All `[id]` path params use the PB ID pattern
|
|
(`/^[a-z0-9]{15}$/i`) to close the filter-injection sink before the
|
|
route does `filter: "<field>='<id>'"`.
|
|
- **Audit trail.** Every document/queue collection (`timesheets`,
|
|
`fuel_sheets`, `plant_hours_sheets`, `purchase_orders`) keeps its own
|
|
original file on its own row via `source_file` (or `purchase_order_file`
|
|
for POs) — there is no separate batch collection to clear a file from any
|
|
more. `file_hash` on each of those, plus `delivery_notes`, drives
|
|
upload-time dedup.
|
|
- **No `created`/`updated` autodate fields.** This PocketBase instance
|
|
never enabled them on any collection. Several routes have comments
|
|
noting that sorting or filtering by `created`/`updated` 400s here — use
|
|
the collection's own `date_uploaded` / `date_logged` / `date_received`
|
|
field instead.
|