The POS shortlist ranked by shared words only, so "BOLT HEX M16X60
GR8.8 ZP" put the M22x65 above the M16x60 — every bolt shares
"BOLT 8.8 ZP" and scores 0.75. The right size never reached the AI
and the price check showed the wrong item.
parseItemSize pulls the type, thread, length, grade, finish and other
dimensions out of free text; compareSizes says whether a candidate is
the same size and, when it is not, in plain words what differs.
Pure and client-safe: no PocketBase, no AI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seen on the TEST order: an M16x60 line with no POS item got an M22x65
bolt as its POS price, and "ordered before" listed the order itself.
- the badge summary starts with "Similar items:" when the intel is a
fuzzy match, so it no longer reads like this item's own price
- quoted/ordered before skip the line's own quote / order
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The line's ai_item_insights can change under the badge (the assistant
fills the line, the operator saves it) and router.refresh() keeps the
component mounted, so the badge would keep showing the intel it read
at mount. Mirror the prop into local state instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The blue price-check badge now appears under the purple AI marker on
every line the AI filled (reviewed or not):
- quote Process page, mobile card: its own row under the header, so
the one-line summary is readable on a phone
- quote Process page, desktop table: inside the Description cell
rather than the cramped 32-px "#" column, which has no room for a
summary
- order Process page: its own full-width row under the markers, so
the summary is readable on a phone
The summary truncates rather than wrapping — the popover always shows
it in full.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The blue badge that sits under the purple AI marker: a tag icon plus the
one-line summary, with an orange dot on the icon when the price check
found something worth a look.
The popover shows POS stock (on hand, status, cost, sell, supplier), the
top three price-list matches, what we last paid each supplier, what we
quoted and ordered before, and how the match was made ("POS item the AI
picked" / "Matched by SKU" / "Similar items"). Refresh re-computes the
intel through the existing /api/v1/items/{collection}/{id}/intel/refresh
route and reads the line back afterwards.
Also closes the colour-token gap: globals.css mapped --color-info to
var(--info), but --info / --info-foreground were never defined, so
text-info rendered nothing. They now have a light and a dark fallback
next to the --ai tokens; themes that define their own --info still win.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
buildPriceCheck(intel, line) turns the Item Intel payload into the two
things the blue price-check badge needs:
- a one-line summary, e.g. "POS R 0,45 · 120 in stock · last bought
R 0,42 Fowkes (12 days ago) · 2 other suppliers"
- the flags worth a look: cost_above_last_paid, cheaper_supplier,
in_stock, price_list_cheaper — each with a plain-English sentence
Pure and client-safe: no PocketBase, no server imports. Money is
formatted the way the rest of the app formats it (R 0,45).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An order line the AI filled from its quote now gets its Item Intel
immediately, so the blue price-check marker on the order Process page
has something to show.
The anchor is the SKU the line ended up with. The quote assistant
deliberately keeps the POS SKU off the line's own `sku` column — it
lives on the quote line's `ai_fill` — so that is the fallback when the
order line has no SKU of its own. `quote_items.ai_fill` is only
projected once the schema shows the column exists, because PocketBase
rejects a projection naming a column the collection does not have.
The step is additive and never throws: a failure is logged and the line
still counts as filled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A line the AI filled now gets its Item Intel immediately, so the blue
price-check marker on the Process page has something to show instead of
"No intel available" until someone opens the ⓘ popover.
The intel is anchored to the POS item the AI actually picked
(ai_fill.stock_item_id / ai_fill.stock_item_sku), so it points at that
exact catalogue row rather than a description-fuzzy guess.
The step is strictly additive: it runs inside the same audit actor as
the fill, never throws (a failure is logged and the fill still counts
as filled), and it is skipped once the run's TICK_TIME_BUDGET_MS is
spent — the remaining lines keep their fills and get their intel on the
next ⓘ / Refresh.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
populateItemIntel now accepts an optional anchor ({ stockItemId?, sku? }).
When it resolves, the anchored stock_items row is forced to the top of
stock_list and its purchase history always feeds supplier_history, so a
line filled by the AI assistant points at the catalogue row it actually
came from instead of whatever the description-fuzzy ranking happened to
surface.
match_kind records how it resolved: "stock_item" (found by PB id),
"sku" (found by SKU — exact, then canonical form via the SKU's
alphabetic stem) or "fuzzy" when nothing anchored or the anchor no
longer resolves. The field is optional so blobs written before this
change stay readable.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Purple when the order is linked to the quote the AI chose and every
line was paired "sure"; yellow when the AI matched a quote but not
every line for sure, or it is only a suggestion. New tab only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
- 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>
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>
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>
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>