Commit graph

982 commits

Author SHA1 Message Date
37933ccdc0 feat(orders): quote matching is AI-only
The rule-based matcher no longer writes or shows suggestions: on
2026-10-24 it had left 2,888 on unlinked orders, one quote suggested
to 309 of them.

- createOrder runs the AI order assistant (matchOrder) straight after
  intake when it is switched on, instead of the rule-based suggestion
- order page: Re-match / Try again and the rule-based suggestion card
  are gone; Match with AI is the action, and "Match by reference" saves
  the reference then runs the AI (the run route takes { reference })
- the unauthenticated /orders/[id]/quote-match route is removed
- the sweep route and MCP orders.quote_match_sweep run the AI batch
  (matchUnlinkedOrders, shared with the tick)
- scripts/2026-10-24-clear-rule-quote-suggestions.ts clears the old
  suggestions (dry run unless --apply); replaces the rematch script,
  which would have written rule-based ones again
- also fixes the setState-in-effect lint error on the order page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:49:26 +02:00
e9c7dd0e30 Merge branch 'feat/ai-order-assistant': AI order assistant
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:18:48 +02:00
0294015ba2 fix(orders): clamp the AI fill's markup at 0 like every other order line
A line that sells below the quote's cost produced a negative markup.
Every other write to an order line's markup stores
Math.max(0, computedMarkup), so the fill now does the same. The margin
chip is unaffected — it works from cost and selling, not from this
field, so a loss-making line still shows its real margin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:44:19 +02:00
34d3422586 docs(orders): order assistant runbook final pass
The runbook now matches the code as built.

- New section: filling the lines from the quote — which lines may be
  filled, what is written, the 5% price doubt, the purple review and
  how it ends, and the board badge.
- The "Fill lines from the quote" switch is no longer described as
  reserved for a later phase, and the tick's bounds now say what the
  code does: 10 orders matched, 10 orders filled, and an order with
  nothing to do does not use up that budget.
- Reading order_items.ai_fill is documented alongside orders.ai_quote_match.
- Live steps confirmed against the code: the migration is
  pb:migrate:order-assistant, ai_quote_match and ai_fill are both in
  IGNORED_FIELDS, the n8n tick is POST /api/v1/orders/assistant/tick,
  and the settings labels are "Order assistant enabled", "Run every
  (minutes)", "Auto-link when sure" and "Fill lines from the quote".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:34:00 +02:00
d3a52d97f2 feat(orders): purple review for AI-filled order lines
A line the AI filled now shows up purple until a person checks it, so
nothing the assistant priced is ever taken on trust.

- order-assistant-review.ts: the four client helpers — a line is
  pending only while it is filled, unreviewed and still priced from
  the quote; a human save keeps "ai_quote" / "quote" / "catalogue" and
  turns anything else into "manual"; the review patch records who
  checked the line and whether the prices were accepted unchanged.
- order-assistant-save.ts: the save-time merge, the order-side twin of
  quote-assistant-save.ts. The value the operator committed wins over
  the one the page was loaded with, so the one-click "use this cost"
  copy really does end the AI's provenance.
- The Process page applies the purple row class, shows the bot marker
  with the quote it was filled from, the reason and the doubts, and
  routes every line save through the merge.
- The Orders board shows an "AI x/y" badge: how many lines the AI
  filled, and how many still need a review. One query per page, and
  never fatal — the board has to render whether or not the assistant's
  columns exist yet.
- AiFillMarker grows an "order" variant instead of assuming a POS item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:25:11 +02:00
89e52bbd9d feat(orders): AI fills order lines from the matched quote
The assistant now prices the order's lines from the quote they are
paired with, as purple drafts a person then reviews.

- order-assistant-core.ts: isOrderLineWritable + buildOrderLineFillPatch.
  Only a pending line with no cost of its own, no person-set pricing
  source and no reviewed fill may be written. Supplier / SKU /
  matched_price_list_id only when the line has none; selling_price only
  when the line has none; markup + markup_percent written together.
  pricing_source becomes "ai_quote". A customer price that differs
  from the quoted price by more than 5% becomes a doubt.
- order-assistant-lines.ts (new): the shared, cycle-free helpers —
  idFilter, aiQuotePairs, writeAiPairs, clearAiPairs and
  fillOrderFromQuote. It lives in its own module so orders-service.ts
  can use it without an import cycle, since order-assistant-service.ts
  already imports setOrderQuote.
- The fill re-reads every line right before writing it, so a cost a
  person typed in the meantime wins. It stays quiet until the migration
  has added order_items.ai_fill — PB rejects a projection that names a
  column the collection does not have.
- setOrderQuote writes the AI's line pairs before pairOrderLines runs,
  so the AI's pairing wins over word matching, then fills the lines.
  A failure there is logged and never fails the link.
- The tick gains a second pass that fills the lines of orders already
  linked to a quote. Only orders the fill actually wrote to count
  towards the budget, so a skipped order cannot block the run.
- The run route fills after the match and reports the counts.

PocketBase filters have no IN operator, so every id list is an
OR-chain. fetchQuoteItemsForQuotesAdmin no longer tries an IN filter
first — that was a guaranteed 400 on every call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:24:56 +02:00
379c978cc5 fix(orders): order assistant works against real PocketBase
- id lists are OR-chains: PB filters have no IN operator, so the
  shortlist and supplier lookups would 400 on every real run
- AI line pairs are written before the auto-link (and undone if it is
  refused), so setOrderQuote's word pairing can't replace them
- the tick looks past orders it skips; otherwise the same oldest
  already-handled orders filled every run and newer ones were never reached

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:29:24 +02:00
6fe09f7752 feat(orders): Settings → AI order assistant section
Task 9 of the AI order assistant plan.

- new "Order assistant" card in Settings → AI: enabled switch, run
  every (minutes, 5-1440), auto-link when sure, fill lines from the
  quote, the prompt with show/reset default, and the last-run summary
- parseSettingsForm reads oa_* and validates the interval; the last-run
  fields are never written by the form
- the page passes the new settings through as the initial state

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:16:09 +02:00
00d09b3b12 feat(orders): show AI quote match on the order page
Task 8 of the AI order assistant plan.

- new AiQuoteMatchPanel: "Linked by AI" / "AI suggests Q…", the
  reason, the doubts and the line-pair table (order line -> quote line
  with same_item)
- the quote match card gains a "Match with AI" button in every state,
  posts to /api/v1/orders/{id}/assistant/run with the PB token, toasts
  the 409 message verbatim, then reloads the order
- new card state: the AI suggested a quote -> the AI panel + Accept
  (links the AI's quote) / Match with AI / Dismiss
- the AI suggestion marks the order as quote_matching_attempted so the
  deterministic sweep cannot overwrite it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:13:28 +02:00
85533245bf feat(orders): order assistant routes, MCP tool and runbook
Task 7 of the AI order assistant plan.

- POST /api/v1/orders/assistant/tick (n8n, assertAgentAuth)
- POST /api/v1/orders/{id}/assistant/run (browser session; 409 for
  "AI is disabled" and "not set up yet")
- MCP orders.assistant_match { orderId } -> matchOrder(force) with a
  TOOL_HINTS entry; deliberately NOT in READ_TOOLS
- docs/order-assistant.md runbook and the AGENTS.md section

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:06:51 +02:00
a04d58b781 feat(orders): AI order assistant matches orders to quotes
Task 6 of the AI order assistant plan.

- matchOrder(orderId): load order + lines, isOrderEligible, shortlist
  from computeQuoteSuggestion (best + alternatives, first 5), empty
  shortlist writes ai_no_match with no AI call, otherwise ask the AI in
  batches of 30 lines against the same shortlist
- auto_link only through setOrderQuote inside
  runWithAuditActor({ name: "AI order assistant" }); a 409 falls back to
  a suggestion, never an error
- suggest writes potential_quote + potential_quote_confidence
  (0.9 / 0.7 / 0.5) + quote_match_method "ai_suggest"
- always writes orders.ai_quote_match (lines_hash, model, run_at); an
  AI failure is recorded with status "error" and never thrown out
- runOrderAssistantTick: same due/lease/stamp logic as the quote
  assistant, oldest eligible order first, max 10 per tick

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:03:45 +02:00
8eb64f2b2d feat(orders): AI call that matches an order to a quote
Task 5 of the AI order assistant plan.

- DEFAULT_ORDER_ASSISTANT_PROMPT: a quote match needs matching items,
  not just the same customer; quantities may differ; prices may differ
  slightly; a line may have no quote line; answer only with given ids
- SUBMIT_ORDER_QUOTE_MATCH_TOOL (forced tool) + MatchInput
- buildMatchUserText — ids and line facts only, never the email body
- parseOrderQuoteMatch — unknown ids dropped/nulled, a quote line
  claimed twice gives the second order line null, skipped order lines
  come back unpaired, bad confidence -> low
- matchOrderWithAi — throws on a cut-off response or a missing tool call

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 13:00:03 +02:00
d5c677ad4e feat(orders): order assistant eligibility and auto-link rule
Task 4 of the AI order assistant plan.

Pure core, no I/O:
- PLACEHOLDER_LINE_PATTERNS, isRealOrderLine
- orderLinesHash (sha1 over sorted id|description|quantity)
- isOrderEligible (no quote, >=1 real pending line, not already matched
  on the same lines unless forced, not in the AI-error cooldown)
- decideOutcome — the auto-link rule: high confidence, every real line
  paired "sure" on the chosen quote, same company, auto-link on
- confidenceToScore (0.9 / 0.7 / 0.5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:58:58 +02:00
4dbe28147d feat(orders): fields and settings for the AI order assistant
Task 3 of the AI order assistant plan.

- types: OrderAiQuoteMatch, OrderAiLinePair, OrderItemAiFill,
  OrderAssistantRunSummary; Order.ai_quote_match, OrderItem.ai_fill,
  OrderItem.quote_item
- ai_settings: order_assistant_enabled (off), _interval_minutes (10,
  5-1440), _auto_link (on), _fill_lines (on), _prompt, _last_run_at,
  _last_run_summary; recordOrderAssistantRun()
- migration script + npm run pb:migrate:order-assistant (not run)
- audit_log_utils IGNORED_FIELDS: ai_quote_match, ai_fill

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:56:51 +02:00
c0a720387a fix(orders): drop linked-elsewhere quotes before deciding to widen the window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:49:19 +02:00
09b9c0f280 feat(orders): read-only quote suggestion and stale-suggestion clean-up script
suggestQuoteForOrder is split into computeQuoteSuggestion (all the
logic, no writes) and suggestQuoteForOrder (compute + the existing
writeSuggestion). Behaviour is otherwise unchanged: the same order
ends up with the same suggestion, written the same way.

computeQuoteSuggestion also drops candidate quotes that another order
already hard-links. A quote belongs to one order, and setOrderQuote
refuses a second link with a 409, so offering one invites a dead end.
The check is one query on `orders` (fields id,quote), chunked at 50
ids per OR-chain to stay under PB's filter-length limit, and it
applies to the exact-match citation too — an exact hit linked
elsewhere falls through to the fuzzy pass instead of being suggested.

scripts/2026-10-24-rematch-quote-suggestions.ts re-runs the matcher
over every order with a soft suggestion and no hard link, which is
where the stale 16x/11x repeats live. Dry run unless --apply; the dry
run only reads.

The script has NOT been run — the owner runs the dry run against live
PocketBase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:45:56 +02:00
5bacd048c6 fix(orders): never suggest a quote that shares no line with the order
scoreOrderVsQuote awarded up to 0.5 confidence from the header alone
(same company +3, same customer +2), which clears the 0.4 threshold
with zero paired lines. One quote was suggested to 16 unrelated
orders that way.

A candidate now needs at least one paired order/quote line; with no
pairs the confidence is 0. The header points and raw score stay in
the breakdown so the UI can still show why a quote looked close.

No existing test relied on the header-only confidence, so no fixture
needed changing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:31:48 +02:00
e0058e02d9 docs(plan): AI order assistant implementation plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:41:21 +02:00
d6feda066f docs(spec): AI order assistant — match orders to quotes, pre-fill lines
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:37:34 +02:00
e4fbcf3748 feat(orders): label lines split off a short collection
Shows "Short delivered · on <PO>" next to the description on /orders and
the order page so the buyer doesn't re-order units already on back order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 07:59:57 +02:00
7fceb47005 chore(scripts): one-off repair of 3 part-invoiced short pickups
Applied 2026-10-22 against live PB: 6632, MSP18104, ZA10PO26012735.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 04:05:55 +02:00
2e55d21937 feat(collection): split short pickups on confirm, stop re-billing
Collection Confirm cuts a short line to what was collected (that part
invoices and closes) and creates the rest as a new pending line on the
same order, with Back order (same PO / straight to pool) on the process
page. Invoicing subtracts qty_invoiced so a part-invoiced line can't bill
twice. Supplier-side lines close at confirm when their order is billed,
and stragglers get a close button on Invoicing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 04:04:50 +02:00
33aa56cbf8 feat(driver): match an invoice photo to the driver's assigned lines
POST /api/v1/driver/invoice-match returns exact matches (same product,
invoice qty = ordered qty) and lines to review. Read-only for the lines.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:58:33 +02:00
5c46152df7 feat(collection-confirm): never hold collected stock lines back for outstanding ones
Confirm stock now sends the collected lines to stock booking and moves
lines not yet collected to a follow-on order (<PO>-2, -3, ...). The
invoice matcher follows a closed PO to its open follow-on.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 19:53:21 +02:00
9dd2da3e99 feat(driver): read-only invoice check endpoint for the driver app
POST /api/v1/driver/collection-check runs the collection AI check for a PO
and returns a per-line verdict (right item, right quantity) for drivers.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 19:38:38 +02:00
511f5dac6e feat(collection): return mistaken pickups to pool 2026-10-21 03:37:01 +02:00
42a20d7e72 fix(build): self-host the app fonts so the build doesn't need Google
next/font/google downloads every font from fonts.googleapis.com during
`next build`, and the Coolify build kept failing on it (Nunito/Outfit on
2026-10-17, Lato and JetBrains Mono on 2026-10-20).

The ten families (Geist, Geist Mono, Inter, Space Grotesk, DM Sans,
Playfair Display, Lato, Nunito, Outfit, JetBrains Mono) are now Latin
.woff2 files in src/app/fonts (344 KB, downloaded from Google Fonts),
loaded with next/font/local under the same CSS variable names and
weights. Verified: `npm run build` succeeds with no network access, and
the login page renders in the local Inter with no font errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:35:27 +02:00
70ee567215 fix(collection-confirm): show the stock line's cost on the card
Stock rows on Collection Confirm were built without cost_price, so the
Cost column was blank (card total R0) and the AI price tick red ("no
cost") on every stock line, even when the line had a cost (e.g. all ten
lines of PO26100702STK after the B&R invoice was checked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:35:25 +02:00
9a28e99520 fix(collection-ai): read iPhone HEIC photos (they were skipped)
The first iPhone driver photos (Thabo, 2026-10-20, PO202610003TEL and
PO26100702STK) arrived as HEIC. The AI only takes JPEG/PNG/WebP/GIF, so
the reads were saved as "skipped — Not an image (.heic)" and Check with
AI kept answering 0 files read, 0 lines checked; skipped reads were never
retried.

- heic-to-jpeg.ts: HEIC/HEIF → JPEG with heic-convert (pure JS/WASM;
  sharp's prebuilt binaries can't decode HEVC HEIC), downscaled to 1600px
  like the PB thumb. defaultFetchFile uses it (PB has no HEIC thumbs).
- HEIC/HEIF count as readable images; a read an older version "skipped"
  for a now-readable type is re-read, and the tick treats it as unread.
- next.config: heic-convert / heic-decode / libheif-js are server
  externals (libheif's WASM loader uses a dynamic require).

Verified on the live photo: 1 MB HEIC → 315 KB JPEG in ~1 s, readable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:12:29 +02:00
d3132ab7f6 feat(collection-confirm): collected stock orders go through Collection Confirm
The driver app records the full quantity as received when it collects a
stock pickup, and Collection Confirm only listed stock lines that were
not fully received — so collected stock orders skipped it and sat at
"sent", outside the accountant's booking queue, until someone clicked
Mark received (29 stuck on 2026-10-19).

- STOCK_ON_PAGE_FILTER / stockLineOnPage: a collected stock line stays on
  Collection Confirm until its stock order is received; cancelled orders
  never show. Shared by the list and the AI check (so those lines get
  photos read and ticks too).
- Confirm stock: markStockOrdersReceived flips each stock order to
  "received" once every line is collected/booked (→ booking queue);
  orders with lines still out stay open, reported as stillOpen. The
  route returns { updatedIds, received, stillOpen }; the toast says so.
- Stock photos: `npm run pb:migrate:stock-photos` makes
  stock_purchases.additional_attachments multi-file;
  addStockPurchaseAttachment, Move photo and Delete photo add/remove with
  `+`/`-` instead of replacing the single slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:03:33 +02:00
f017a7f985 fix: number customer POs one at a time (duplicate PO numbers)
PO numbers were "read the month's highest, add one", so POs for several
suppliers on one order created together got the same number — 34 numbers
by 2026-10-19 (e.g. PO202610008LIF for BarloWorld CAT and John Ratcliffe,
created 71 ms apart).

- createPurchaseOrderWithNumber (po-number.ts): numbers and creates POs
  one at a time in the server, retries (up to 5) when the unique index
  rejects a clash; ignores any po_number passed in.
- buyer-tasks / MCP generate_po use it.
- The Process page's Send PO and in-stock PO buttons now call
  POST /api/v1/orders/[id]/purchase-orders (browser session) instead of
  numbering in the browser; the client generatePONumber is removed from
  pb-helpers.
- npm run pb:migrate:po-number-unique adds a partial unique index on
  purchase_orders.po_number for POs created from then on; the 34 existing
  duplicates are left as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:08:12 +02:00
924f7b764e fix: uploads to an order line add files instead of replacing them
A PocketBase file PATCH under the bare field name replaces the whole
list. On 2026-10-19 a buyer's "Add supplier invoice" on PO202610008LIF
deleted the driver's delivery-note photo (uploaded 14:38, read by the AI
14:40, replaced 14:41). Generating or regenerating a delivery-note PDF
did the same to every line on the DN.

- New src/lib/server/order-item-files.ts: addAttachmentForm() adds with
  `order_item_attachements+` and removes (`-`) only older stored copies
  of the same document; pickStoredCopy() finds a file by its uploaded
  name (PB stores "DN26101913.pdf" as "dn26101913_<10>.pdf").
- Used by the Collection Confirm invoice upload, DN create, DN regenerate
  (replaces only its own previous PDF) and /api/invoicing/delivery-note.
- /api/invoicing/delivery-note-pdf finds the DN PDF by name instead of
  taking the line's first file (falls back to the old behaviour for
  legacy names).
- AGENTS.md: always add, never replace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:46:37 +02:00
e66ea11845 feat(collection-confirm): Delete photo; drop the Run agent scan button
- Delete photo next to "Add supplier invoice" removes the photo open in
  the previewer (wrong upload, or one of several for the same PO) after an
  inline "Delete …? Yes, delete" confirm; the confirm resets when you
  switch photos. Not offered for the PO PDF.
- POST /api/v1/collection-confirm/delete-photo (browser session) →
  deleteCollectionPhoto: takes the file off every order line carrying it
  (or the stock purchase's photo slot) and deletes the AI's reads of it
  plus the checks built on them (shared forgetReads with Move photo).
- Removed the header's blue "Run agent scan" button and its results panel
  (not in use); the /api/v1/agent/scan route stays for the agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:26:10 +02:00
af4cdc66da feat(collection-confirm): spot a photo on the wrong PO and move it
Drivers photograph invoices per item and can't see which order a photo
lands on. B&R invoice 00282979 (stock order PO26100702STK) ended up on
the thinners line of PO202610017BLY; the AI read "PO26100702STK" off it
but the card only said "not found".

- The card list now carries each read's po_refs and source file, and sets
  ai_doc.other_po when the document names a PO that exists and isn't the
  row's PO (none named, or this card's PO among them: no hint).
- PO card: "Photo may be for <PO>" in the header; when open, "Invoice <no>
  is for <PO>, not this PO" with a Move photo button. Nothing moves on its
  own.
- POST /api/v1/collection-confirm/move-photo (browser session) →
  movePhotoToPo: copies the photo to the target (a collected line of a
  customer PO, or the stock purchase's single photo slot — refused when
  taken), removes it from the source, deletes the AI's read of it and the
  checks built on it so it is re-read where it now belongs.
- normalizePoReference moves to the client-safe src/lib/po-reference.ts
  (re-exported from the matching service).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:08:47 +02:00
802565d2d8 feat(collection-confirm): check printed prices on delivery notes; show AI reading notes
Plumblink hands out delivery notes with prices on them. The Price tick
was always grey on a delivery note, so a card could go purple without
its prices ever being checked, and the AI's reading notes ("delivery
note, not a tax invoice", "year not printed") were never shown.

- Price tick: compare whenever the document line has a price, invoice
  or delivery note; grey only when no price is printed. The AI still
  only corrects a cost from a tax invoice.
- ai_doc now carries the read's notes; cardNotes() unions them per card
  and the AI findings box lists them under "Reading notes" (the box now
  shows when there are notes but no findings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:50:27 +02:00
dade4592be docs(mcp): collection_confirm.ai_check may set a discounted cost
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:40:22 +02:00
a6455376e4 fix(collection-confirm): purple card only when the AI found nothing off
- The collapsed PO card showed white instead of purple: it always carried
  `bg-card`, which beat `bg-ai/10` in the CSS (but lost to the green).
  Now the card gets exactly one background.
- Purple now means "the AI checked every line and no tick is red or
  amber" (grey "couldn't check" ticks are fine). A card the AI checked
  with a line off, or with unchecked lines, goes amber and says
  "AI checked N of M lines · K need a look". Green still means "photo,
  not checked yet".
- cardAiState() in collection-ai-ticks.ts works this out from the same
  computeLineTicks the table uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:20:36 +02:00
d73fb6f0ab feat(collection-ai): read invoice discounts and set the discounted cost
HYCO (I009753) prints full prices on the lines and "Discount 25.00%" in
the totals; Swagelok (622831) has a "Disc %" column on each line. The AI
compared our cost (already the discounted price) with the printed price,
so the Price tick went red on both.

- The read now captures a line's discount_percent and a whole-document
  doc_discount_percent (prompt rule 5b) and stores net_unit_price on each
  line (both discounts stacked).
- The Price tick compares the cost with the price after discount; the
  tooltip and the document-lines panel spell it out
  ("R15.10 less 25% invoice discount = R11.33").
- Owner's call: when a "sure" tax-invoice line has a discount and our
  cost isn't the discounted price, the AI sets the cost to it (customer
  lines through updateOrderItemCost, which adds a cost note; stock lines
  through updateStockPurchaseItem) and records why in ai_check.cost_changed,
  shown in purple under the line. No discount, a delivery note or a
  "likely" match: the cost is never touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:18:21 +02:00
9ddb811afd feat(collection-confirm): AI checks on/off switch in the page header
The n8n tick was calling in every 5 minutes but WFDASH answered
"disabled" because Collection checks was off in Settings → AI, and
nothing on Collection Confirm said so.

- New "AI checks" switch next to Refresh, the same setting as Settings →
  AI → Collection checks. Purple and "On" when running, with the last
  run in the tooltip; a gear links to /settings/ai.
- GET/PUT /api/v1/collection-confirm/ai/enabled (browser session). Only
  reads/writes collection_ai_enabled and the last run — no key, model or
  prompt goes through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:18:13 +02:00
86020ed48d feat(collection-confirm): purple for cards and lines the AI checked
A collapsed PO card turned green as soon as the driver uploaded a photo,
whether or not the AI had read it, so "everything is green" said nothing
about the agent. Now:

- A collapsed card with at least one AI-checked line is purple (the AI
  colour) instead of green; green still means "photo, not checked yet".
- The header says "AI checked N of M lines" with the robot icon, open
  or closed.
- Each line the AI checked gets a light purple wash in the items table.

Lines whose documents couldn't be paired (pair_failed, grey ticks) don't
count as checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:13:45 +02:00
4eb4a24b84 fix(collection-confirm): show AI ticks under the description; always fill the SKU
- The AI and POS columns sat at the far right of the items table, hidden
  behind the document preview in the split view. Ticks (now labelled SKU /
  Qty / Desc / Price) and the POS status render under each description,
  and the table is back to its original six columns and width.
- Owner's call: fill an empty SKU on a sure pairing even if the code may
  be misread; the rep edits it and a near-miss still helps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:45:34 +02:00
a4345c1f85 fix(collection-ai): day-first dates, only fill SKUs read with certainty, tolerant supplier names
- Dates: the model also returns the date as printed; code parses SA
  day-first formats (16-10-26 -> 2026-10-16) and drops dates more than a
  year old or a month ahead (the Bestobell read gave 2016-10-26).
- SKU: a new code_certain flag per line; the AI only fills an empty SKU
  when it read every character clearly (it filled 0515315 for 051315).
- Supplier: names sharing a distinctive word (Bestobell, Belting) no
  longer raise a 'supplier differs' finding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:36:58 +02:00
79d54ce3fd fix(collection-ai): re-read sideways invoice photos turned upright
A sideways Bestobell invoice was read as doc 10045064 / a bank-details
line; turned 270 deg the same photo reads correctly (10245084,
PO202610010MRE, rubber insertion @ R365). When no printed PO number
resolves, retry the photo at 270 and 90 deg and keep the first read whose
PO resolves (within the tick's time budget).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:07:26 +02:00
f8294b4e1d feat(pocketbase): switch on the audit-log hooks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:41:20 +02:00
8c99381aa5 docs(pocketbase): note how to publish hooks in DEPLOY
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:37:07 +02:00
0890835e91 chore(pocketbase): publish hooks to WFDASH-PBHOOKS for the VPS sync
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:35:54 +02:00
6b99f52dac chore(pocketbase): track live quotes hook and add git-based hook sync
quotes.pb.js is copied byte-for-byte from the live WFDASH PocketBase.
DEPLOY lists the hooks the VPS cron syncs into the hooks volume; the
audit hooks stay undeployed for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:34:41 +02:00
810f0d7350 Merge branch 'feat/collection-ai-checks': Collection Confirm AI checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:07:42 +02:00
cba2d84549 docs(collection-ai): runbook tick table, audit scope; spec content_hash index is non-unique
F2: tick table (SKU green via the code match incl. AI fills, grey per tick,
pairing failure -> all grey, different product -> red), stock lines use the
line SKU, Add to POS needs a SKU, SKU fills audited for order lines only.
F3: content_hash is a non-unique index (identical uploads share a hash,
skipped PDFs have an empty hash) - matches the migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:21:25 +02:00
8efee9db42 fix(collection-ai): pre-fill only from the chip's tax invoice, AI column only with ai_check
M2: the confirm dialog pre-fill uses the card's newest document (as the
chip) and only when it is a tax invoice.
M3: the AI ticks column (and its min-width) shows only when a row has
ai_check; the POS column stays whenever in_pos is known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:20:46 +02:00