The write service puts the stock_ledger row first and then patches
current_qty. When that second write failed, the ledger row was left
behind: the ledger sat one change ahead of the quantity, and a retry
wrote a second row for the same change, so SUM(change_qty) no longer
rebuilt current_qty — the premise a drift-repair job rests on.
The failed write is now compensated with a ledger row for the same
item carrying the opposite change_qty, stamped with the same actor
and noted as a reversal of the adjustment. If the compensation itself
fails, the row is still reported as failed with a message that says
the ledger could not be reversed. The failed row's message says when
the ledger was compensated, so a retry is safe.
The ledger-first ordering is unchanged — it is what makes a crash
between the two writes leave the audit trail ahead of the cached
total.
A write that did not complete now has its own status, "failed", and
its own summary count. Reporting it as "unmatched" sent the operator
looking for a typo in the SKU when the real cause was a PocketBase
error — two different problems with two different responses, so they
no longer share a status. "unmatched" keeps its meaning: the line
never resolved to an active stock item.
The five counts still add up to "total".
The price-list mirror reads one 200-row page. The catalogue holds
about 2,900 rows over about 2,700 SKUs, so a SKU has one row per
supplier and a handful at most: the page is unbounded by design at
this data volume, not an oversight.
Resolves pasted lines, computes the signed delta, and hands the write
to updateStockItemsQuantities so the ledger row is written first and
stamped with the acting user. A negative result is a normal outcome,
not an error — the till oversells on purpose.
Mirrors the new quantity onto price_list.stock_qty, which is a
denormalised snapshot of our own quantity. The catalogue holds one row
per SKU per supplier, so one item can touch several rows; the count is
reported per line.
Three changes from the Task 2 review.
The SKU string gets a second chance at matching by how the item is
named. When the exact SKU misses, the line's description AND its SKU
are joined and scored as text, so a line carrying only a SKU still
finds its item. The match method stays "description_fuzzy" - it is a
text match either way, and the threshold and the top-unless-tied rule
are unchanged.
An inactive best match now names itself. The text step scores every
candidate, active or inactive, exactly as the SKU step does, so an
inactive row is no longer filtered out before scoring - it is reported
as unmatched with its name and the reason, instead of vanishing and
leaving a bare "no match" behind.
The resolver truncates the quantity. Quantities are whole units, so
the dry-run plan and the stored value now agree instead of the plan
reporting 12.7 for a write that stores 12.
Pure matching, no PocketBase, so the rules can be tested on their own.
Exact SKU first (normalised), then a tokenised Jaccard description
match against the item's name and description.
The description match takes the top candidate unless two are tied at
the top — a single threshold alone would report 'ambiguous' for almost
every description and defeat the feature.
The /stock page keeps its non-negative floor — the operator types a
physical count there. The agent has to be able to write an absolute
count that sits below zero, because the till oversells on purpose and
the stocktake is what corrects it.
Also make the ledger note say where the edit came from, so an agent
write is not mislabelled as a /stock edit.
The date was kept in the registry as an authoring reference and never
rendered, but it was still a date sitting in the notes file, and a
date-shaped value can always find its way to a screen. The ReleaseNote
type has no date field at all now, so one cannot be added without a
deliberate change to the contract.
A test walks the registry and fails on any date — a stray property, a
headline or a line of copy — so the popup carries a release number and
a commit and nothing else.
Release 6 also says what it is: Payments and Approvals is in beta. The
schema and the enforcement hooks are live on the PocketBase, but the
backend behind the queue is still being worked on.
The payments and approvals queue, the theme rework and the release
number on the popup all landed since release 5, so the office is told
about them in one place.
The entry is the release number and the commit, never a date, and the
popup still renders no date of any kind.
The item supplier payments and approvals queue: a payment hold on order
items, the Payments and Approvals page, supporting documents and proof
of payment, and the PB hook that stops an unpaid item becoming ready for
collection.
The theme registry carried a hand-maintained set of hex swatches for
every palette, and they had drifted away from the CSS the app actually
paints. The picker previewed colours the product never showed.
The registry now holds only what cannot be read from CSS — id, label,
tagline, fonts, radius — and the picker, the Settings swatches and the
preview cards resolve --background, --primary and --foreground off the
theme itself. A preview can no longer disagree with the screen it
previews.
themes.css is rewritten so every palette carries a real dark surface,
and the palettes that are dark by nature stay dark whichever way the
mode switch is set. The pre-hydration script validates the stored theme
id against the registry, so a retired id falls back to WFDASH instead of
painting an unstyled page, and the old "warm" mode migrates to Light.
The legacy text-{warn,success,info}-foreground classes now resolve to
the semantic text colour and switch to the filled ink only on an exact
solid fill, so one class reads correctly on both a tinted badge and a
filled button. A solid focus outline replaces the faint ring, and the
charts and quote status badges take their colours from tokens rather
than hard-coded dark strokes.
Tests: theme-bootstrap.test.tsx covers saved-theme recovery and the
storage-blocked path; e2e/themes.spec.ts walks every palette in both
modes checking surface contrast, tinted and solid semantic pairs, focus
colour, chart tokens and the destructive hover, and asserts that the
picker preview matches the applied theme.
Remove payment amount, reference, notes and manual combining forms. Link supplier POs automatically and record paid from supplier evidence plus proof, keeping readiness and attachment safeguards.
Keep active supplier/company views current and retire stale supplier evidence on routing changes. Verified 5167 tests with only the two existing script-import errors.
Keep payment holds independent of workflow and margin approval. Require proof before collection readiness; deduplicate uploads transactionally in PocketBase and preserve payment history.
Includes schema migration, deployment runbook, browser checks and isolated PocketBase regression coverage. Full suite has 5156 passing tests and retains two pre-existing script-import errors.
The popup rendered the day each release went out, which gave the office
a way to ask what was done on a given day. Each release now shows its
number and the short SHA it shipped with, and the date-shaped version
string is gone from the registry.
The date formatter is deleted rather than hidden, so no code path can
put a date on screen, and a test fails the build if one comes back.
The 'seen' marker moves from the version string to the release number,
so the badge lights up once for everyone on this deploy and then
settles.
The version strings are dates ("2026.11.01"), so a release has no
identity that is not a date. Every entry now also carries a release
number and the short SHA of the commit it went out with.
The date-shaped version string is left in place for now — the popup
switches over and drops it in the next commit, so this one stays
green on its own.
A test holds the numbering unique, contiguous and newest-first, and
holds every commit to a real SHA.
The sidebar's bottom block gets two new controls:
- A "What's New" button that opens the release-note popup, with a badge
until the operator has read the latest release.
- A Dark Mode section that replaces the old one-click toggle with an
explicit Light/Dark switch, so the current mode is always visible
instead of being inferred from the button's label.
The theme colour picker stays in Settings -> Appearance; the sidebar
carries the mode switch only, so the nav rail is not a strip of 16
coloured squares.
Add a per-company quote letterhead lock backed by `companies.confirmed_letterhead`. The quote process flow prompts for a default when unset, permits only the confirmed letterhead with a triple-click per-quote override, and enforces the rule during PDF generation and email sending. Also add company settings controls and API, email-ingest defaults, migration and backfill tooling, and unit coverage.
Owner's rule: the POS counter only carries priced stock.
- POS catalogue lists only active stock items with selling_price > 0
(POS_CATALOG_FILTER); checkout refuses any line priced at R0 or less.
- New stock order now gives a new stock item the PO line's cost and the
default 80% markup. It used to drop the cost, which is how 157 stock
items reached the till at R0.
- Collection Confirm "Add to POS" becomes "Add to price list": customer
lines go to price_list at cost + 80% (needs SKU, cost and supplier),
never into stock_items. Route renamed to add-to-price-list.
- scripts/2026-10-30-pos-zero-price-cleanup.ts: priced 79 items from their
PO line cost, switched off 48 with no cost and no stock, left 30 stocked
ones active for costing (already applied on live PB).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New /delivery-pool page, a clone of the Collection Pool for deliveries
with no pending stage. Invoiced lines are grouped per company, one card
per customer invoice; a card expands to its lines. Assign a driver and
day per invoice (or bulk per company); uploading the signed copy adds it
to every line, marks the lines completed + delivered_at, and closes a
delivery note once none of its lines is still open.
- Driver lives in new order_items.delivery_driver / delivery_assigned_at
(npm run pb:migrate:delivery-pool), never drv_id (collection driver).
- Pool starts at DELIVERY_POOL_START (2026-10-30); lines whose DN is
already completed/cancelled (POS counter sales) are left out.
- Service + /api/v1/delivery-pool routes (browser session); the service
503s until the migration has run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sendQuoteEmail now stamps the resolved To address on quotes.email_to.
The n8n send workflow mails it (falling back to customer.email), so the
rep's To override and the sender fallback are honoured, and the audit
hook shows the recipient in the quote history. Needs the live step
`npm run pb:migrate:quote-email-to`.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A scheduled check (n8n, every 2 hours, POST /api/v1/collection-pool/ready-dates/tick)
marks pending customer stops ready once their expected-ready date is today or
earlier (South African time). The stop stays unassigned, keeps its pool age,
and gets an audit note. Stops with a driver problem flag are left alone.
Stock lines are not covered: they have no date field.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
On PO202610005TEL the AI read a 15% discount that was not on the invoice
and cut a line's cost from R146.47 to R124.50. It no longer writes a cost
to order or stock lines. A cost that differs from the invoice's discounted
price shows as a red Price tick, which also keeps the card out of
auto-push, so a person settles it. Earlier cost changes stay visible on
the line. Tests now assert no cost is ever written; runbook and AGENTS.md
updated.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
After each AI tick, every Collection Confirm card the page shows purple
(cardAiState verified) is confirmed without a person. Customer lines go
to ready for invoicing with the invoice number and date the AI read; stock
orders go to stock booking once every line is collected. Cards with a
problem flag, a photo for another PO, or no readable invoice number/date
stay for review.
Each push is recorded in ai_settings.collection_auto_push_log and shown at
the top of the page for today and yesterday (PO, supplier, items, value).
Off by default behind a Settings > AI switch; needs the manual live step
npm run pb:migrate:collection-auto-push before it will run.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The AI order assistant's shortlist filtered candidate quotes to the order's
customer (or blank). A different contact at the same company is common, so
the right quote was dropped and the AI was never asked (M24 nylock order
74288 vs Q202610684). The scorer still rewards a customer match.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A hidden, installable PWA: one link, opened on a phone, showing where
every order is. New / Processing / Completed, with a tap-to-preview
popup and the invoice/line detail Gary needs. Read-only, and NOT in
the sidebar — the route lives outside `(app)`, so `<Sidebar />` never
renders there.
The preview popup is reused, not rebuilt: the service emits
`UniversalHit`s and the page hands them to the existing
`SearchPreviewDialog`, so the manager gets the desk's quick-overview
verbatim.
Also fixes the /orders Completed tab, which was matching ZERO orders.
`delivered_at` is stamped on only 2 of 12,957 delivered lines and the
business stopped writing `delivered` on 2026-08-14 (the live pipeline
now ends at `invoiced` / `checked_in`), so the old filter never
matched. "Settled" is now defined once — `SETTLED_STATUSES` +
`SETTLED_DATE_FIELDS` — and dated per status: `invoiced_date` for
invoiced (the only properly stamped field, 2,199 lines), `delivered_at`
for delivered, `updated` only where no dedicated column exists.
`delivery_note` and `checked_in` move into `completed` as instructed,
and the standalone `post_processing` tab is retired (a DN is not a
stage of its own). `invoiced` stays in `collected` as well, so
invoiced orders don't vanish from the workflow tabs.
Completed tab: 0 -> 341 orders over 30 days, 112 over 7.
- confirmPosMatch: an AI "exact" whose parsed sizes really conflict
(thread M22 vs M16, length 65 vs 60) becomes "closest", so no price
is written; a field the POS item just doesn't state is not a conflict
- ranking, checked read-only against the live POS: an item that doesn't
name its type ("M12 X 40 ALLEN CAP") no longer drops behind wrong
lengths; a same-thread item with no length (washers) sits behind
same-kind items that have one; non-fasteners rank on their dimensions
(150mm) with a conflicting size below none
- allen cap / socket cap / cap head read as cap screw, WSHR as washer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The quote assistant anchored the line's intel on the POS row it
picked, but did not record the pick itself — so the price check could
not tell an exact pick from a fuzzy match. It now stores the pick and
match_kind becomes "pos_pick".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The blue badge read the same whether the AI had matched the item
exactly or only found the nearest one, so a line priced from an M22
bolt looked like any other line.
Now an exact pick reads as before, a closest pick leads with
"Closest: {name} ({differs})" and raises a closest_only flag, and a
fuzzy match keeps its "Similar items:" wording. The popover shows the
pick and the differences at the top.
A refresh is still a no-AI recompute, but it keeps the stored pick
instead of throwing it away.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The order fill copied the quote's price onto the line and then looked
the POS item up by SKU — so the price check could still show the wrong
size next to the right price.
Now the fill anchors the line's intel on the POS row the AI actually
picked: the quote line's own pick when it has one (exact, or closest
with its differences), the SKU otherwise, and for the lines nothing
else anchors it runs ONE POS pick for the whole order.
The pick is stored on the intel blob as pos_pick, and match_kind
becomes "pos_pick". When the AI is switched off the lines keep the
fuzzy price check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A near-miss used to be priced as if it were the item the customer
asked for. Now only an exact match writes a price; a closest match
writes none and records the nearest item with what differs, so the
rep can see it and decide.
The "AI: no match" badge reads "Closest: {name} ({differs})" when
there is a closest item, and the popover shows its price and the
differences.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The AI could only say "this is the item" or "no idea" — it had no way
to say "this is the nearest one, but it is 5mm too short". So a
near-miss either got priced as if it were right, or was thrown away
with nothing shown.
The submit_line_matches tool now takes a match of "exact", "closest"
or "none" plus the list of what differs. The user text shows the
line's parsed size and each candidate's parsed size with the
differences we computed, and the prompt spells out when to use each
kind.
A response with no match field still reads as "exact" when it named a
candidate — the old tool had no match, so those responses keep
working.
pickPosItems() in pos-match.ts runs the same shortlist and the same
tool for any list of lines, one AI call per 15 lines.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The POS shortlist ranked by shared words only, so "BOLT HEX M16X60
GR8.8 ZP" put the M22x65, the M27x170 and the M20x160 all on 0.75 —
they share "BOLT 8.8 ZP" — and the M16 the customer asked for never
reached the AI.
pos-match.ts is now the shared POS matcher: the word pull plus a
size-targeted pull (name ~ "M16" || description ~ "M16"), ranked by
the size comparison and then by word score. A line with no size
information keeps today's word-only behaviour.
- shortlistStockItems delegates to it, so the quote assistant gets
the better list for free.
- the intel popover's fuzzy stock section uses the same ranking, so
the right size wins there too.
- tokenizeIntel / informativeTokens / combinedScore moved to
item-match-text.ts so the matcher and the intel service can share
one implementation without an import cycle.
PocketBase has no IN operator: every filter here is an OR-chain of
~ and = clauses, escaped with escapeForPbString, capped at 300 rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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 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 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>