Commit graph

1006 commits

Author SHA1 Message Date
3eea87ec03 feat(collection-confirm): auto-push purple cards on to invoicing, with a 2-day log
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>
2026-10-28 11:36:23 +02:00
6c83282888 feat(settings): link to AI settings from the Settings page
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 11:36:23 +02:00
66f23a34e9 fix(orders): quote shortlist no longer requires the same customer contact
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>
2026-10-28 10:38:36 +02:00
16743f87ed docs: driver invoice photo handover - widen the filter, use confirm_collected
Tested the handover against live PB on 2026-10-27. The read path works as
written; four defects fixed:

- The filter only caught item_status = 'assigned'. The collection_pool
  tab group is [collection_pool, assigned, ready_delivery] and
  order_items.confirm_collected accepts collection_pool or assigned, so
  a line stuck in either was invisible to the sweep.
- Stock POs were not covered at all. Stock lines live in
  stock_purchase_items and their photos on the parent
  stock_purchases.additional_attachments.
- The write path hand-rolled order_items.update + update_status, which
  skips the eligibility check, the over-collect guard, drv_collected_at
  and the actor-stamped audit note. Now uses
  order_items.confirm_collected / stock_purchase_items.mark_collected.
- No de-duplication: the NSS M24 line is still 'assigned' with a photo,
  so every run would re-report it. Added a 'Waiting on a person' list.

Also noted that no cron runs this yet, that order_items_attachements is
multi-file (read every filename), and added a supplier check.
2026-10-27 07:07:40 +02:00
b212d0fcd8 feat(manager): a mobile orders board for the managers at /manager
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.
2026-10-26 23:09:01 +02:00
55cc7fa2f6 Merge branch 'feat/ai-only-quote-match': AI-only quote matching, order-card tint, blue price check, size-aware POS matcher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:46:46 +02:00
fa68b14388 fix(pos-match): our size check backs the AI's "exact"; rank fixes from the live POS
- 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>
2026-10-24 23:22:47 +02:00
33813b4264 docs: the size-aware POS matcher and the exact/closest rule
The runbook and AGENTS.md described the old word-only shortlist and
did not mention the shared matcher. Both now say how the size is used,
that a price is written only for an exact match, and what
ai_fill.closest holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 23:09:53 +02:00
2213bd36dd feat(quote-assistant): the quote line's intel records the AI's pick
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>
2026-10-24 23:07:20 +02:00
a6d53e3b2b feat(price-check): say whether the POS item is exact or only closest
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>
2026-10-24 23:05:09 +02:00
c5cfca1955 feat(order-fill): the filled line's intel follows the AI's POS pick
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>
2026-10-24 23:02:26 +02:00
b7e80e7f85 feat(quote-assistant): price only an exact match, show the closest
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>
2026-10-24 22:59:25 +02:00
a705215f82 feat(quote-assistant): the AI answers exact, closest or none
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>
2026-10-24 22:57:32 +02:00
16a1cec562 feat(pos-match): one size-aware shortlist for the whole app
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>
2026-10-24 22:52:48 +02:00
19f1bc080b feat(item-size): parse the size out of a description
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>
2026-10-24 22:47:37 +02:00
5e1aaa27f2 fix(price-check): say "Similar items" for fuzzy matches; leave out the line's own quote/order
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>
2026-10-24 22:23:13 +02:00
8ef10f9f75 fix(price-check): keep the badge in step with the line's stored intel
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>
2026-10-24 21:59:56 +02:00
9b3f30a01f feat(process): show the blue price check on both Process pages
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>
2026-10-24 21:45:46 +02:00
3248e3aa03 feat(ui): blue price-check badge with its popover
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>
2026-10-24 21:33:50 +02:00
13220c3c43 feat(price-check): pure buildPriceCheck helper for the blue badge
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>
2026-10-24 21:31:02 +02:00
42a683aec1 feat(orders): compute item intel right after the AI fills an order line
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>
2026-10-24 21:28:04 +02:00
bf4968875b feat(quotes): compute item intel right after the AI fills a line
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>
2026-10-24 21:22:06 +02:00
110332402e feat(intel): anchor item intel to the exact POS item the AI picked
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>
2026-10-24 21:18:05 +02:00
793c9ea215 feat(orders): tint New-tab cards by the AI quote match
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>
2026-10-24 20:52:59 +02:00
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