WFDASH-WEBAPP/src/lib/types.ts
Dave e6cac6e447 feat(collection-ai): data model, settings, migration
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 01:18:10 +02:00

2553 lines
94 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export interface BaseRecord {
id: string;
created: string;
updated: string;
collectionId: string;
collectionName: string;
}
export interface Customer extends BaseRecord {
customer: string;
email: string;
alternative_email: string;
buyer_email: string;
number: string;
/**
* PB relation → `companies`. Stored as the related record's ID
* string. UI fetches with `expand: "company"` to render the company
* name. A customer works for a company but may deliver to a
* different address — delivery coordinates live on the customer
* (`coordinates` below), not the company.
*/
company: string;
dateadded: string;
lastvisiteddate: string;
/**
* PB relation → `users` (auth collection). Stored as user ID. UI
* fetches with `expand: "rep"` to render the user name. Sales rep
* handling this customer; the order form auto-fills order.rep from
* here when a customer is selected.
*/
rep: string;
/**
* Geographic location for the customer's delivery point (shaft /
* site address — may differ from the parent company's location).
* PB geoPoint shape: `{ lon: number, lat: number }`. Zero default
* `{ lon: 0, lat: 0 }` ("Null Island") must be treated as empty in
* the UI — it's PB's auto-applied fallback for an unset geoPoint.
*/
coordinates?: { lon: number; lat: number };
/**
* Free-text mine/shaft tag (e.g. "Harmony - Kusasalethu",
* "South Deep Shaft 3"). Lets the office batch-pin every customer on
* one shaft in a single operation instead of placing each one
* individually — see `/visits/pin-tool/batch`. Deliberately a plain
* string rather than a relation to a sites collection: the office
* types the name freehand, so the batch tool offers autocomplete
* against distinct values already in use (`listDistinctSites`) to
* curb drift, but nothing enforces one canonical spelling.
*
* Operator-added 2026-09 alongside `site_location` below. The two
* fields coexist intentionally: `site` is the legacy free-text
* shaft tag (also used by the pin tool to drop pins), while
* `site_location` is the newer site/location name keyed off the
* customer's company. They're not the same concept, so we don't
* auto-backfill one from the other.
*/
site?: string;
/**
* Free-text site/location name (e.g. "Kusasalethu shaft",
* "South Deep - surface workshop"). Operator-added in PB admin on
* 2026-09 because each company operates from multiple shafts/sites
* and the office wants to label the customer with the shaft, not
* just the company. Storage is plain text on purpose — there's no
* `sites` collection and we don't want one for this; the UI
* autocomplete surfaces distinct values already typed by other
* customers (see `listDistinctSiteLocations` in
* `customers-service.ts`).
*/
site_location?: string;
/**
* Whether the customer is currently an active account. Added in
* the PB admin on 2026-09 because a customer can stop working for
* a mine without the office wanting to lose the historical
* orders/quotes attached to their row.
*
* True → active customer (the default for new customers).
* False → inactive: same row, but the office wants the customer
* to disappear from the active-customer pickers and the
* rep dashboards. The historical record is preserved.
*
* Pre-existing rows written before this field landed don't have
* the value at all (`undefined` in PB → falsy in JS), so every
* read site treats `undefined` as `true` via `?? true` on read —
* we don't want the migration date to silently turn the entire
* legacy customer base "inactive".
*/
status?: boolean;
description: string;
expand?: {
company?: Company;
rep?: User;
};
}
/**
* Minimal user shape used for expand joins on the customer (`rep`)
* field and for the user-search combobox. PB's `users` collection is
* the auth collection; `id` is the user ID, `name` is display name,
* `email` is login.
*/
export interface User {
id: string;
name: string;
email: string;
}
export interface Company extends BaseRecord {
company: string;
address: string;
vendor: string;
company_email: string;
company_tel: string;
/**
* Geographic location for future routing / delivery features.
*
* PB geoPoint shape: `{ lon: number, lat: number }`. Zero default
* `{ lon: 0, lat: 0 }` ("Null Island") must be treated as empty in
* the UI — it's PB's auto-applied fallback for an unset geoPoint.
*
* Customers + suppliers use a combined `coordinates: string` field
* (separate convention). Companies diverge to enable PB's `within`
* radius filter without re-parsing.
*/
coordinates?: { lon: number; lat: number };
}
export interface CompanyDetails extends BaseRecord {
company_name: string;
address: string;
number: string;
email: string;
vat_number: string;
registration_number: string;
}
/**
* Admin-managed categories that bucket quote_items on the /sprint
* page (JEI / TOOLS / FASTENERS / OIL / HUSQVARNA - GARDEN & FORESTRY
* / UNKNOWN). Each category carries three match signals:
*
* match_brands → highest priority — checks price_list.brand
* match_supplier_tags → medium — checks supplier.supply_tags
* match_keywords → fallback — scans description + SKU
*
* Items that match no category land in the UNKNOWN bucket — the
* "needs review" pile.
*/
export interface Category extends BaseRecord {
name: string;
slug: string;
match_keywords: string;
match_supplier_tags: string;
match_brands: string;
color: string;
display_order: number;
is_active: boolean;
}
export interface Supplier extends BaseRecord {
suppliername: string;
email: string;
suppliernumber: string;
supplieraddress: string;
supply_tags: string;
coordinates: string;
closingtime: string;
}
export type QuoteStatus =
| "new"
| "claimed"
| "processed"
| "emailed"
| "approval_pending"
| "approved"
| "rejected"
| "problem"
| "complete"
| "cancelled"
| "follow_up"
| "email_failed"
| "unmatched_bounce"
| "template"
/**
* Transient: the operator clicked Approve & Send Email. Next.js
* set this; n8n is about to (or currently) attempting the SMTP
* send. Cleared on success (→ emailed) or failure (→ mail_failed).
* Visible in the UI as a spinner pill. If the quote sits here for
* more than ~2 min the UI flags it as stuck.
*/
| "sending"
/**
* Terminal: the outbound send was attempted and failed. The
* reason is on `email_error`. Operator can retry from the modal,
* which flips status back to `sending`. Distinct from
* `email_failed` (which is the inbound bounce path).
*/
| "mail_failed"
/**
* This quote is pricing a delivery note — operator opens the quote,
* fills in prices, sends the quote + delivery note to the customer.
* When the customer sends back their PO, the operator types it on
* the delivery note and creates the invoice from the quote. The
* quote then transitions through the regular lifecycle (e.g. sent,
* approved) as the invoice / order flow takes over.
*/
| "delivery_note"
/**
* Partial approval — added 2026-07-24 alongside the sprint + item-level
* pricing redesign. Means: the operator submitted a quote for approval
* but only SOME items are priced (e.g. 5 of 15). The rep can review
* and approve the priced subset, which will email a partial PDF to
* the customer and (on success) clone the unpriced items into a
* continuation quote. Distinct from `approval_pending` which requires
* every item to be priced.
*/
| "partial_approval_pending"
/**
* Terminal (for this batch). The rep approved a partial quote and
* the email was sent. The parent quote is sealed — no new items can
* be added. If a continuation quote was created, it's a separate
* `new`-status quote linked via `parent_quote`. The parent itself
* stays at this status for audit. If the email failed, the rep can
* retry; the status does not flip to `mail_failed` for partials (we
* keep the audit-visible "this was a partial" flag).
*/
| "partial_approved";
export interface Quote extends BaseRecord {
quote_number: string;
quote_reference: string;
external_number: string;
rfq_number: string;
markup: number;
customer: string;
company: string;
claimed_by: string;
description: string;
status: QuoteStatus;
approved_by: string;
/** Identity of the operator / agent that flipped the quote to
* `approval_pending`. Stored conditionally — if the PB schema
* doesn't have the column yet, the value is silently dropped
* during the update. See `submitQuote` in `quotes-service.ts`. */
submitted_by?: string;
/** ISO timestamp of the `new → approval_pending` transition. Same
* soft-write semantics as `submitted_by`. */
submitted_at?: string;
notes: string;
/**
* Operator-facing free-text note attached to the quote. Rendered on
* the customer PDF (in a blue callout below the totals) and editable
* from the process page. Deliberately separate from `notes` above,
* which is reserved for the rejection audit log written by
* `review-approve/route.ts` and parsed by `parseLatestRejection` on
* the detail page.
*
* Added 2026-08-17. Optional in the type so pre-existing rows that
* pre-date the schema migration don't break the renderer.
*/
quote_notes?: string;
email_sender: string;
email_subject: string;
email_body: string;
has_attachments: boolean;
company_letterhead: string;
email_delivered: boolean;
email_delivered_at: string;
/** When the original inbound email was received (Date header / IMAP envelope date). */
email_received_at: string;
email_opened: boolean;
email_opened_at: string;
email_tracking_id: string;
has_new_reply: boolean;
last_reply_at: string;
email_failed_at: string;
pdf_generated_at: string;
email_message_id: string;
/**
* Snapshot of the AI's analysis of this quote, written by the n8n
* agent on creation. Free-form JSON — the shape is decided per
* record by what the model found (duplicate detection, margin
* warnings, supplier suggestions, etc.). Always has at least a
* `summary` string at the top level. See
* `components/ai-insights-renderer.tsx` for the rendering rules.
* Optional in the type so pre-existing rows / non-AI code paths
* don't need to populate it.
*/
ai_insights?: unknown;
/** Date printed on the PDF (the quote's "official" date). Blank → fall back to `created`. */
quote_date: string;
/** Validity period in days (e.g. 30, 60). Blank/null → default to QUOTE_VALID_DAYS in the PDF. */
validity_days: number | null;
/** True for templates — reusable scaffolding, not a real customer quote. */
is_template: boolean;
/**
* True for "no-quote" / decline-letter records — a formal quote the
* company cannot price. Items render with "No quote" cells; the
* reason is carried on `quote_notes` and titled "Why we cannot quote"
* in the PDF. Distinct from `is_template` (reusable scaffolding).
* Optional: existing rows pre-date this column.
*/
is_no_quote?: boolean;
/** If this template was cloned from a real quote, the source quote id. */
template_source_id: string;
/** If this quote was generated from a template, the template quote id. */
generated_from_template_id: string;
/**
* Customer-facing attachments on the quote — datasheets, drawings,
* anything the operator explicitly wants sent to the customer. PB
* stores these as a list of file tokens (one per upload) on the
* quote row itself (NOT in the `quote_attachments` collection —
* see `quote_attachments` JSDoc for the historical inbox).
*
* PB returns file fields with `maxSelect > 1` as `string[]` on a
* normal `getOne`, but it can collapse to a single `string` when
* the array has one element. The `| string` here keeps existing
* consumers honest; helper `normaliseAttachmentArray` in
* `quotes-service.ts` collapses both shapes.
*/
outbound_attachments?: string[] | string;
/**
* Operator-only attachments on the quote — supplier quotes,
* pricing evidence, internal drawings. NEVER sent to the customer.
* Same shape as `outbound_attachments`.
*/
internal_attachments?: string[] | string;
/**
* Back-reference to the delivery note this quote prices. Set when
* the quote is created with status=`delivery_note` (the
* "delivery-note pricing" status). Lets the quote navigate back to
* the delivery note it prices — and lets the delivery note navigate
* to the quote that prices it. Optional: a regular sales quote has
* no delivery note.
*/
delivery_note?: string;
/**
* Parent quote (for continuation quotes created by the partial-approval
* split). Set on the child quote (the continuation), pointing back to
* the original. The original is sealed at `partial_approved` once
* the split lands. UI surfaces a "Continuation of Q-2026-07-001"
* banner and a link back to the parent. Empty for top-level quotes.
*
* Added 2026-07-24 alongside the partial-approval flow.
*/
parent_quote?: string;
/** User-set follow-up reminder date. Dashboard widget flags overdue. */
follow_up_due: string;
/**
* Email-composition fields persisted by the Approve & Send modal
* (added 2026-06-23). Each is the operator's last-used value for
* this quote — so opening the modal a second time shows what they
* sent before, instead of starting from scratch.
*
* `email_subject_outbound` overrides the default `Re: {email_subject}`;
* `email_message` overrides the default "please find attached" body.
* Both empty falls back to the defaults at send time (n8n side).
*
* `attach_auth_letters` defaults true — toggles the per-letterhead
* supplier authorization letter (CNL or ISHERWOOD). Requires the
* `letterhead_settings.auth_letter` field to be uploaded; otherwise
* it's a no-op and the modal surfaces a note.
*/
email_cc: string;
email_bcc: string;
email_subject_outbound: string;
email_message: string;
attach_auth_letters: boolean;
/**
* RFC 5322 Message-ID stamped on the outbound email by the SMTP
* send. Lets the inbound monitor match future bounce / DSN
* messages back to this specific outbound attempt. Empty until
* the first send completes.
*/
email_outbound_message_id: string;
/**
* JSON array of `quote_attachments` record IDs that were attached
* to the outbound send. Captured at send time so retry sends the
* same set (not whatever is attached at retry time). Empty until
* the first send completes.
*/
email_outbound_attachment_ids: string;
/**
* Last failure reason from the outbound send. Empty on success.
* Cleared on every retry attempt. Visible in the modal banner
* and the quote card timeline.
*/
email_error: string;
/**
* Wall-clock when status flipped to `sending`. Lets the UI flag
* "stuck for >2 min" sends and operators measure SMTP turnaround.
*/
sending_started_at: string;
expand?: {
customer?: Customer;
company?: Company;
/**
* The parent quote this one is a continuation of. Only populated
* for continuation quotes. Surfaced on the quote detail page as
* a banner + back-link.
*/
parent_quote?: Quote;
};
}
export type QuoteAttachmentKind =
"pdf" | "image" | "doc" | "other" | "delivery_note";
/**
* Where a `quote_attachments` row came from / where it's going.
* - "inbound" — file arrived with the original RFQ email
* - "outbound" — operator uploaded it on the quote detail page to
* be sent with the quote email
* - "generated" — PDF rendered by `/api/v1/quotes/[id]/pdf`
*
* `direction` is null/undefined for rows that pre-date the
* 2026-06-23 migration; the UI must tolerate that and treat them
* as "inbound" by default (the migration backfills new rows, but
* very old rows may still be null in dev).
*/
export type QuoteAttachmentDirection =
"inbound" | "outbound" | "internal" | "generated";
export interface QuoteAttachment extends BaseRecord {
quote: string;
/**
* User-facing filename, e.g. "Quote-CNL-Q009638.pdf" — what we set
* when uploading. NOT what PB stores on disk; PB normalises to a
* slugged version in `file`.
*/
filename: string;
/**
* PB's stored file tokens. For a single-file field PB returns an
* array (it allows multiple per record); we treat it as an array
* here. The first element is the token to use in `/api/files/...` URLs.
*/
file: string[];
content_type: string;
size_bytes: number;
email_message_id: string;
/**
* Classification used by the dashboard to pick the right icon / preview.
* Older attachments (and a few mid-2026 imports) were uploaded without
* `kind` being set — the UI must tolerate `null` / `undefined`.
*/
kind?: QuoteAttachmentKind | null;
uploaded_at?: string;
/**
* See `QuoteAttachmentDirection`. Tolerate null/undefined for
* rows created before the 2026-06-23 direction-field migration.
*/
direction?: QuoteAttachmentDirection | null;
expand?: {
quote?: Quote;
};
}
/**
* Per-item pricing-proof attachment. Lives in the
* `quote_item_attachments` collection; a quote_item can have many of
* these (one for the AI agent's price-list proof, one for the
* operator's manual screenshot, etc.). Different from
* `QuoteAttachment` which is per-**quote** (the original RFQ email,
* the generated PDF, etc.) — those are context for the whole
* conversation; these are audit-trail for a specific price.
*
* `source` is the audit tag — added 2026-07-02 when the AI agent
* started attaching its own price-list proof to each row it
* priced. The operator's manual uploads use `operator_proof`.
*/
export type QuoteItemAttachmentSource =
"agent_proof" | "operator_proof" | "manual";
export interface QuoteItemAttachment extends BaseRecord {
quote_item: string;
filename: string;
file: string[];
content_type: string;
size_bytes: number;
source: QuoteItemAttachmentSource;
notes: string;
uploaded_at: string;
/**
* Identity of whoever uploaded (operator email from the
* dev-identity cookie/header, or "agent" for AI uploads).
* Audit only — not used for filtering.
*/
uploaded_by?: string;
}
export interface QuoteReply extends BaseRecord {
quote: string;
from_email: string;
from_name: string;
subject: string;
body_plain: string;
body_html: string;
received_at: string;
email_message_id: string;
in_reply_to: string;
quoted_text: string;
expand?: {
quote?: Quote;
};
}
export type AiFillStatus = "filled" | "no_match" | "no_candidates" | "error";
export type AiFillConfidence = "high" | "medium" | "low";
export interface AiFillCandidate {
id: string;
name: string;
sku: string;
cost_price: number;
selling_price: number;
}
/**
* What the AI quote assistant recorded on a quote line. Lives in
* `quote_items.ai_fill` (json). The POS SKU stays here only — it is
* never copied onto the line's own `sku`.
*/
export interface QuoteItemAiFill {
status: AiFillStatus;
stock_item_id: string | null;
stock_item_name: string;
stock_item_sku: string;
cost_price: number;
selling_price: number;
markup: number;
supplier_id: string;
confidence: AiFillConfidence | null;
reason: string;
doubts: string[];
candidates: AiFillCandidate[];
description_snapshot: string;
model: string;
attempted_at: string;
price_from_default_markup?: boolean;
reviewed_at?: string;
reviewed_by?: string;
accepted_unchanged?: boolean;
supplier_changed?: boolean;
}
export interface QuoteAssistantRunSummary {
started_at: string;
finished_at: string;
quotes_seen: number;
lines_filled: number;
lines_unmatched: number;
lines_skipped: number;
errors: string[];
}
export type CollectionDocType = "tax_invoice" | "delivery_note" | "other";
export interface CollectionDocLine {
line_no: number;
code: string;
description: string;
quantity: number | null;
unit: string;
/** Per single unit, already normalised from "per 100" etc. */
unit_price: number | null;
line_total: number | null;
to_follow: number | null;
price_note: string;
}
export interface CollectionDocRead {
id: string;
source_collection: string;
source_id: string;
filename: string;
content_hash: string;
status: "read" | "failed" | "skipped";
doc_type: CollectionDocType | "";
document_no: string;
doc_date: string;
supplier_name: string;
po_refs: string[];
lines: CollectionDocLine[];
notes: string[];
findings: string[];
model: string;
error: string;
read_at: string;
}
export type SameItem = "sure" | "likely" | "different";
export interface CollectionAiCheck {
read_id: string;
document_no: string;
doc_date: string;
doc_type: CollectionDocType;
doc_line: CollectionDocLine | null;
same_item: SameItem | null;
reason: string;
sku_filled_value: string;
findings: string[];
checked_at: string;
model: string;
}
export interface CollectionAiRunSummary {
started_at: string;
finished_at: string;
files_read: number;
cards_checked: number;
errors: string[];
}
export interface QuoteItem extends BaseRecord {
quote: string;
description: string;
/**
* Operator's cleaned-up description that goes on the PDF. Often
* differs from `description` (the customer's original RFQ
* language) — e.g. "M20 FLW" → "M20 FLW Hot Dip Galvanised, M10
* thread, 50mm length". Empty = fall back to `description` at
* render time. Added 2026-07-02 alongside the sprint redesign.
* Optional in the type so pre-existing rows / non-sprint code
* paths don't need to populate it.
*/
quote_description?: string;
quote_number: string;
quote_reference: string;
quantity: number;
supplier: string;
cost_price: number;
selling_price: number;
notes: string;
is_processed: boolean;
markup: number;
quote_pdf_id: string;
/**
* Pack / measure unit (EA, BOX, M, L, KG). Optional in the type —
* pre-2026 rows have no unit. Populated by the n8n email extractor
* and the operator on the process page.
*/
unit?: string;
/**
* Flipped to true the first time the user saves the item from the
* process page or quote-form. Gates the AI rewrite feature — we
* don't let the LLM rewrite an item the user hasn't put their own
* touch on yet (the AI-extracted descriptions from the email
* pipeline are treated as drafts until a human confirms them).
*/
user_edited: boolean;
/**
* SKU / part number the buyer asked for. Operators type this on
* the process page (or the n8n email extractor populates it on
* the way in). "Check pricing" uses it as the join key against
* `price_list.sku_normalised`.
*/
sku: string;
/**
* Audit tag for how the `cost_price` / `selling_price` values on
* this row were populated. Free-text (not a select) so the set of
* values can grow without a schema change. Known values:
* - "manual" — operator typed the numbers directly
* - "catalogue" — auto-filled from the matched price_list row
* on a Check-pricing run
* - "ai" — populated by the AI agent (n8n Email Monitor
* or future AI workflow) reading the price list
* and matching items. Surfaces a "Priced by AI"
* badge on the /sprint row so the operator
* knows to review before saving.
* - "" (empty) — not yet decided / no match found
*/
pricing_source: string;
/**
* The `price_list.id` we matched against on the last Check-pricing
* run. Lets the UI resolve the supplier, spec, and pricing
* provenance without re-running the matcher. Stored as a plain
* text id (not a relation) on purpose so the helper can resolve
* by id without a second round-trip to PB.
*/
matched_price_list_id: string;
/**
* Free-text audit trail for the last pricing decision. Examples:
* "Auto-matched from JEISA - WESTSCOTT GROUP on 2026-06-30"
* "No match in catalogue — operator to follow up"
* "3 candidates; picked newest match"
*/
pricing_notes: string;
/**
* Snapshot of the AI's "Item History" analysis for this line:
* last quoted/sold price + dates, stock status, supplier history.
* Free-form JSON, written by the n8n agent on quote creation. See
* `components/ai-insights-renderer.tsx`.
*/
ai_item_insights?: unknown;
/**
* AI quote assistant's POS match / fill record for this line. Null or
* absent = the assistant has not looked at it. A line is "pending
* review" only when `status === "filled" && !reviewed_at &&
* pricing_source === "ai_pos"`.
*/
ai_fill?: QuoteItemAiFill | null;
/**
* Sprint page category bucket. Relation to the `categories`
* collection. NULL → lands in the UNKNOWN tab in the UI.
*
* Optional on the type — pre-sprint code paths (PDF render,
* dashboard stats, etc.) never read this and shouldn't be forced
* to populate it in test fixtures.
*/
category?: string;
/**
* Sprint claim lifecycle:
* unclaimed → visible on /sprint, free to claim
* claimed → someone has the 30-min lock
* priced → priced + saved → leaves the sprint list
* awaiting_supplier → email sent to supplier, waiting on reply
* send_failed → outbound email send failed; needs retry
*
* `priced` is set in tandem with `is_processed = true`; the sprint
* list filter is `is_processed = false` so priced items disappear
* from view. Optional — defaults to "unclaimed" via migration
* backfill.
*/
claim_status?: ClaimStatus;
/** Email of the user who currently has the 30-min claim. */
claimed_by?: string;
/** When the claim was made. */
claimed_at?: string;
/** Soft TTL — claim auto-releases at this time. 30 min default. */
claim_expires_at?: string;
/**
* RFC 5322 Message-ID of the outbound RFQ email we sent to the
* supplier. The Supplier RFQ Monitor (n8n) matches inbound replies
* by `In-Reply-To: <message-id>` against this field.
*/
outbound_message_id?: string;
/** When the RFQ email was sent. */
outbound_sent_at?: string;
/** Set by the inbound monitor when a supplier reply lands. */
supplier_replied_at?: string;
/** Incremented on each inbound reply. 0 = no replies yet. */
supplier_reply_count?: number;
/**
* Partial-approval flag — added 2026-07-24. Flipped to true by the
* "Approve & send partial" action when this item is included in the
* PDF/email the rep just approved. Stays true after the email
* succeeds. The /sprint list filter (is_processed = false) hides
* these so the operator doesn't re-price them.
*
* Distinct from `is_processed`: a manually-priced item that's
* waiting for the rep's approval has is_processed=true +
* approved_in_batch=false. The split endpoint treats
* `is_processed=true && approved_in_batch=false` as "still in this
* batch" and `is_processed=true && approved_in_batch=true` as
* "already sent in a previous batch".
*/
approved_in_batch?: boolean;
/**
* Set by the partial-approval split endpoint to the id of the
* continuation quote this item was moved to. NULL on items that
* belong to the current quote. UI hides items with
* `moved_to_quote != null` from the active item list (they're
* audit history, no longer "this quote's items").
*
* The split preserves the original row rather than deleting it, so
* the audit trail is intact. Added 2026-07-24.
*/
moved_to_quote?: string | null;
expand?: {
supplier?: Supplier;
category?: Category;
};
}
/**
* Sprint claim lifecycle states. Stored as a `select` enum on
* `quote_items.claim_status` — see the migration that adds it.
*/
export type ClaimStatus =
"unclaimed" | "claimed" | "priced" | "awaiting_supplier" | "send_failed";
/**
* Per-line state for an `order_items` record.
*
* This is the only authoritative status in the orders model — orders
* themselves carry no stored status field. The order header "Complete"
* badge in the UI is derived from this (every item === `delivered`).
*
* Lifecycle:
* pending → item created, not yet sent to a supplier
* approval_needed → below-22% pricing saved; blocked pending manager review
* approval_rejected → manager rejected the below-threshold pricing
* processed → operator has reviewed/edited the line
* po_generated → PO created, in_stock check happens here
* collection_pool → queued for collection (in_stock=false path)
* assigned → a driver has picked it up
* collected → back at the office (in_stock=true skips the pool)
* stock_verified → verified against stock count
* picking → being pulled for delivery
* ready_delivery → packed, on the delivery vehicle (legacy)
* delivered → signed for at the customer (terminal for delivery; legacy path)
* cancelled → cancelled (terminal)
* with_driver → on the truck / out with the driver (post-invoice physical flow)
* checked_in → operator typed the paper's reference number; verified
* completed → reserved for the n8n signed-copy auto-complete flow
*/
export type ItemStatus =
| "pending"
| "approval_needed"
| "approval_rejected"
| "processed"
| "po_generated"
| "collection_pool"
| "assigned"
| "collected"
| "stock_verified"
| "picking"
| "ready_delivery"
| "delivered"
| "cancelled"
| "ready_invoice"
| "invoiced"
| "stock_booked"
| "with_driver"
| "checked_in"
| "completed"
| "removed"
| "delivery_note";
export interface Order extends BaseRecord {
order_number: string;
company: string;
customer: string;
quote: string;
notes: string;
quote_matching_attempted: boolean;
quote_match_confidence: number;
potential_quote: string;
potential_quote_confidence: number;
/**
* Repeat-order detection — relation to a past delivery note whose
* items exactly matched this order's. Set by
* suggestDeliveryNoteForOrder at intake (see
* order-delivery-note-link-service.ts). Visibility only — a wrong
* flag costs nothing; the operator just ignores the pill.
*/
potential_delivery_note?: string;
/** Denormalized copy of the matched DN's delivery_note_number.
* Written alongside potential_delivery_note so the list pill can
* render the number without a second `expand` (see
* orders-service.ts:253). delivery_note_number is immutable once
* assigned, so copying carries no staleness risk. */
potential_delivery_note_number?: string;
/**
* Relation to another order sharing the exact same `order_number`,
* of any status, no time limit. Set by
* suggestOrderNumberDuplicate (order-number-duplicate-link-service.ts).
* Visibility only — free-text custom order numbers (customer PO
* refs, "NO-REF" placeholders) are allowed to repeat by design; this
* flag exists so the operator can eyeball whether a given repeat was
* intentional.
*/
order_number_duplicate?: string;
/** True once suggestOrderNumberDuplicate has run for this order —
* distinguishes "checked, no match" from "never checked." */
order_number_duplicate_checked?: boolean;
/**
* Relation to another order whose line items (description +
* quantity) exactly match this order's, for the same company,
* within the last 60 days, regardless of either order's status. Set
* by suggestOrderDuplicate (order-order-duplicate-link-service.ts).
* Visibility only.
*/
potential_duplicate_order?: string;
/** Denormalized copy of the matched order's order_number — same
* reasoning as potential_delivery_note_number: lets the list pill
* render without a second expand. */
potential_duplicate_order_number?: string;
/** 1 if potential_duplicate_order is set, 0 otherwise. */
duplicate_order_match_confidence?: number;
/** "exact_items_same_company" | "no_match" */
duplicate_order_match_method?: string;
/** True once suggestOrderDuplicate has run for this order. */
duplicate_order_matching_attempted?: boolean;
is_repair_order: boolean;
/**
* Sales rep handling this order. Free-text — auto-populated from the
* customer's `rep` field when a customer is selected, but always
* editable. Empty for company-only orders until the operator types one.
*/
rep: string;
/**
* True once an invoice has been generated for this order. Independent
* of item lifecycle — an order can be marked invoiced before, during,
* or after the items have all been delivered. Set by the invoicing
* action; never derived.
*/
invoiced: boolean;
/**
* The reference the operator sees on the customer's PO — typically the
* quote number we issued, but it can also be a customer's own ref,
* an RFQ id, or an external PO reference. Drives the in-house matcher:
* we try exact matches against `quote_number` / `quote_reference` /
* `rfq_number` / `external_number` first, then fall back to a fuzzy
* company + customer + description + price match.
*/
quote_reference_text: string;
/**
* Audit trail for the `potential_quote` suggestion. One of:
* - "exact_quote_number"
* - "exact_quote_reference"
* - "exact_rfq_number"
* - "exact_external_number"
* - "company_customer_desc_price"
* - "no_match"
* Free-text on purpose — the set may grow (LLM-based later) and we
* don't want a schema migration per addition.
*/
quote_match_method: string;
/**
* Snapshot of the AI's analysis of this order, written by the n8n
* agent on creation. Free-form JSON, same shape rules as
* `Quote.ai_insights`. See `components/ai-insights-renderer.tsx`.
*/
ai_insights?: unknown;
/**
* Source attachments for the order. Multi-file field on the orders
* collection (`maxSelect: 10`, accepts any MIME). Replaces the
* previous `quote_attachments` collection rows for orders that were
* migrated on 2026-07-29. The PB SDK returns this as a bare
* `string[]` of filenames (no token); callers compose the file URL
* with `recordId` + filename.
*
* Note: the field name is the PB-side misspelling ("attachements")
* — keep it in sync with the live schema.
*/
order_attachements?: string[];
/**
* Subject line of the customer email that originated this order.
* Populated by the n8n agent on order creation from the
* `orders.email_subject` PB column (added to the live schema
* after the local `pb_schema-2.json` snapshot was taken — the
* snapshot is reference-only per AGENTS.md, so the drift is
* expected).
*/
email_subject?: string;
/**
* Sender (From:) of the customer email that originated this order.
* Lives on the order itself so the review modal can show the
* sender without joining to the related quote.
*/
email_sender?: string;
/**
* Plain-text body of the customer email that originated this order.
* Distinct from `quotes.email_body` (which is the operator's
* outbound email body for the related quote) — this is the
* inbound email the customer sent with their RFQ, snapshotted
* onto the order so the review modal can display the source of
* truth without depending on the quote link.
*/
email_body?: string;
/**
* RFC 5322 Message-ID of the customer email that originated this
* order. Mirrored from the inbound reply tracking on
* `quote_replies` for orders that flowed through the RFQ
* monitoring pipeline; null for orders created without one.
*/
email_message_id?: string;
/**
* Customer's own RFQ reference (e.g. their internal "RFQ-1234"
* ticket). Populated by the n8n email agent when the inbound
* request mentions one, and inherited verbatim by `splitOrder`
* when an order is split into a continuation. Drives the
* `exact_rfq_number` matcher.
*/
rfq_number?: string;
/** Free-text order description captured at intake. */
description?: string;
/**
* Free-text person the order is for. Sometimes the customer
* record is just the company (legal entity), and the actual
* contact captured during the email-intake lives here. Distinct
* from `customer` (the relation) and `email_sender` (the email
* From: address) — this is the operator-readable contact name
* shown on the order card so the office can see "this goes to
* Dreyer, Mr. Petrus" without opening the detail page.
*/
contact_name?: string;
expand?: {
company?: Company;
customer?: Customer;
quote?: Quote;
potential_quote?: Quote;
};
}
export interface OrderItem extends BaseRecord {
/**
* Relation to the parent `orders` record. Optional as of the
* 2026-07-19-delivery-notes-v3 migration: ad-hoc delivery
* notes (counter pickups, "customer took items off the
* shelf") create `order_items` rows with no parent order —
* the items are linked back to the delivery note via
* `delivery_note_number` instead. All pre-existing order
* items have a parent order; only ad-hoc rows have `null`.
*/
order?: string;
description: string;
po_description: string;
/**
* Operator-entered quantity. Nullable — an order row can be saved with
* no quantity yet (operator intends to fill it in on the process page).
* Defaults to `null` in the UI; the `canSubmit` gate on /orders/new
* rejects save attempts where every line is missing a quantity, so a
* blank-but-not-empty record can survive the round trip to PB without
* the form silently writing `0`/`1` on the operator's behalf.
*
* Note: the type code path is `quantity: number | null` from here on;
* older order rows stored as `0` still load as `0`.
*/
quantity: number | null;
/**
* What WE order from the SUPPLIER for this line — the number that
* prints on the purchase order, the PO email and the PO total. The
* process page labels this field "what we tell the supplier".
*
* **This is not the customer's quantity.** Use `quantity` for anything
* customer-facing: the invoice, the delivery note, the "ordered"
* column on a picking slip, and the surplus-to-stock calculation.
* Helpers for both live in `src/lib/order-item-qty.ts` — prefer those
* over reading either field directly.
*
* The two are written identically at creation and diverge in exactly
* two situations, both deliberate:
*
* - **Supplier MOQ** — customer wants 4, supplier won't sell fewer
* than 10. `quantity = 4`, `order_qty = 10`. We bill 4 and book
* the other 6 into stock via `/api/v1/stock/surplus-book`.
* - **Shorting** — customer wants 100, supplier A can only supply
* 40. The operator sets `order_qty = 40` and splits the remaining
* 60 onto a new line with `splitOrderItemRemainder`, which then
* reduces this line's `quantity` to 40 so the two reconcile.
*
* Reading this as the customer quantity over-bills MOQ lines and
* under-bills shorted ones. See
* `docs/superpowers/specs/2026-08-30-quantity-handling-audit.md`.
*/
order_qty: number;
/**
* Customer-facing unit price. Nullable — many orders are priced after
* the operator processes the line (cost copy from quote + margin
* decision). `$0` is a valid intentional value (freebie / TBD),
* which is why we keep `null` separate from `0`.
*/
selling_price: number | null;
supplier: string;
cost_price: number;
markup: number;
purchase_order: string;
item_status: ItemStatus;
/**
* Operator flag set on the process page. When true, the line
* bypasses the driver collection pool entirely after PO generation:
* the item is treated as stock-on-hand and jumps straight to
* `item_status = "collected"` (or `stock_verified`) on PO send,
* skipping `collection_pool` / `assigned` / `en_route`.
*
* Default false. Invoices and delivery notes ignore this flag — the
* item is still part of the order.
*/
in_stock: boolean;
/**
* Free-form JSON blob shared across features — invoicing writes
* `invoice` / `print` / `deliveryNote`, the split flow writes
* `split`. Stored as a JSON string in PB; read via the feature
* view-model parsers (e.g. `readSplitMarker` on the process page).
*/
order_item_details?: string | Record<string, unknown> | null;
/** Customer invoice number stamped by `reconcileOrderItemInvoicedStatus`. */
customer_invoice_number?: string;
invoiced_date?: string;
qty_invoiced?: number;
drv_status?: "unassigned" | "assigned" | "en_route" | "collected" | "failed";
drv_id?: string;
/**
* Server-side ISO timestamp of the most recent assignment. Set on
* the order_item row by the assignment page when an operator picks
* a driver (or the agent updates via the driver app — same row
* write either way). Do not use this field to derive
* "currently assigned" — check `item_status === "assigned"` (or
* `drv_status`) for that. `assigned_at` is the when, not the who.
*/
assigned_at?: string;
notes: string;
/**
* Pricing audit-trail fields added by the 2026-07-06
* order-items-pricing-fields migration. They round-trip from the
* matched quote_item when the operator hits "use this cost" on the
* process page, or stay blank when the operator types the price
* manually. `sku` and `pricing_source` print on the PO so the
* supplier knows which part number to fulfil against.
*
* `pricing_source` is one of "quote" | "catalogue" | "manual" by
* convention (free text — PB doesn't enforce the union at write
* time). `matched_price_list_id` is the catalogue row id when
* sourced from `price_list`.
*/
sku?: string;
pricing_source?: string;
matched_price_list_id?: string;
pricing_notes?: string;
/**
* Stock split fields added by the 2026-07-14-create-stock migration.
* When the operator collects a line and the supplier shipped MORE
* than the customer's quantity (typical: MOQ bump at PO time),
* the surplus portion is booked into stock. `surplus_qty` is how
* much went to stock; `stock_booked_from` is which stock_item row
* it was booked into. The actual audit trail is the matching
* `stock_ledger` row(s) — query by `reference_type="order_item"
* && reference_id=<this.id>`.
*/
surplus_qty?: number;
stock_booked_from?: string;
/**
* When true this line never reaches a customer-facing document. It
* still gets a PO, a driver, a collection and a `delivered` status —
* it is simply dropped from the QuickBooks `items` array and the
* delivery-note PDF lines. Used for supplier-side component splits:
* the customer approved "bolt, nut and washer" as one priced line,
* but we must raise three PO lines to actually buy it.
*
* Named for the exception, not the norm: the PB default of `false`
* must mean "bill this normally". See the
* non-invoiceable-order-extras design (docs/superpowers/specs/2026-08-30-...)
* for the polarity rationale.
*/
exclude_from_invoice?: boolean;
/**
* Driver-recorded collected qty. The driver app PATCHes this when
* the line is delivered to the customer. Defaults to `null` (the
* field is filled in on the driver's first collection) and is
* later adjusted at /collection-confirm by the operator if the
* driver only managed a partial delivery.
*/
qty_collected?: number | null;
/**
* Problem flag + structured problem type + free-text notes. The
* driver app PATCHes these via `/api/v1/driver/stops/[id]/status`
* when they encounter an issue at the customer (no_show, wrong
* item, damaged). The office surfaces problem rows in the
* /collection-confirm UI.
*/
problem_flag?: boolean | null;
problem_type?: string | null;
problem_notes?: string | null;
/**
* Delivery note number this item belongs to. Stamped on the
* item when the office runs "Mark as delivery note" in
* Invoicing (Path A) or when the item is created as part of
* an ad-hoc delivery note (Path B). The same string is the
* human-readable identifier on the `delivery_notes` header
* (`DN26071401`-style), and the join key for "which items are
* in this delivery note". Optional because most items never
* have a DN (they go through the normal order → invoice
* pipeline).
*/
delivery_note_number?: string;
/**
* Delivery tracking fields added by the 2026-07-20-delivery-tracking
* migration. Powers the /deliveries page under Operations — the
* post-invoice physical-delivery loop.
*
* `loaded_at` is stamped when the operator hits "Load for driver"
* in the /deliveries "To Load" tab. `delivery_reference` is what
* the driver wrote on the signed paper the customer gave back —
* the operator types this in the "Check in" modal. `checked_in_at`
* + `checked_in_by` stamp the audit trail.
*
* The reference is free-text on purpose: customers use invoice
* numbers, DN numbers, or their own internal PO refs; we don't
* normalise. Max 64 chars matches `legacy_order_item_id` above.
*/
loaded_at?: string | null;
delivery_reference?: string | null;
checked_in_at?: string | null;
checked_in_by?: string | null;
/**
* Snapshot of the AI's "Item History" analysis for this line:
* last quoted/sold price + dates, stock status, supplier history.
* Free-form JSON, written by the n8n agent on order creation. See
* `components/ai-insights-renderer.tsx`.
*/
ai_item_insights?: unknown;
expand?: {
supplier?: Supplier;
checked_in_by?: User;
order?: Order;
};
}
export interface CollectionPool extends BaseRecord {
order: string;
order_item: string;
supplier?: string;
drv_id?: string;
pool_status: "queued" | "assigned" | "in_progress" | "completed" | "failed";
priority?: "low" | "normal" | "high" | "urgent";
pool_notes?: string;
assigned_at?: string;
completed_at?: string;
expand?: {
order?: Order;
order_item?: OrderItem;
supplier?: Supplier;
};
}
export interface PurchaseOrderEmailPayload {
/**
* Operator-supplied `To` override. Empty string means "fall back to
* supplier.email at send time" (resolution happens in n8n, not in
* the route). When non-empty AND different from the supplier's
* stored email, the service helper persists it to supplier.email
* so the next send to that supplier prefills correctly.
*/
to?: string;
/** Subject line of the outbound email. */
subject: string;
/** Body of the outbound email in HTML. n8n emailSend sends this
* as the rich Content-Type: text/html part. */
body: string;
/** Plain-text rendering of `body`. Used as the multipart/alternative
* fallback (RFC 2046 mandated) and as the text spam-filters parse
* first. Both forms are produced from the same source by
* `buildDefaultPoEmailBody` so they can never drift. Optional
* for backwards compatibility with rows written before the HTML
* upgrade. */
bodyText?: string;
/** Comma-separated CC addresses. */
cc?: string;
/** Comma-separated BCC addresses. */
bcc?: string;
/** RFC 5322 Message-ID we sent. Mirrored on `email_message_id` for searchability. */
sentMessageId?: string;
/** ISO 8601 timestamp of successful SMTP send. */
sentAt?: string;
/** RFC 5322 Message-ID of the supplier's most recent reply (if any). */
supplierReplyMessageId?: string;
/** First ~280 chars of the supplier's reply body, for at-a-glance scanning. */
supplierReplyPreview?: string;
}
export interface PurchaseOrderDocumentEntry {
/** Monotonic version number (1, 2, 3, ...). Newer is greater. */
version: number;
/** PB-generated filename for the file in `purchase_orders.documents`. */
file: string;
/**
* PB-side file token (the slugged-on-disk name) for the file in
* `purchase_orders.documents`. Distinct from `file` (the
* human-readable name we asked PB to store). New entries always
* populate this; legacy rows may be empty — the GET handler
* falls back to `documents[idx]` for those.
*/
token: string;
/** ISO 8601 timestamp the PDF was generated. */
generatedAt: string;
/** User id of the operator who triggered the regen (empty string if automated). */
generatedBy: string;
/** Free-text reason the operator gave for the regen (the modal's textarea). */
reason: string;
/** PB status — `current` for the latest, `superseded` for older ones. */
status: "current" | "superseded";
}
export interface PurchaseOrder extends BaseRecord {
po_number: string;
order: string;
supplier: string;
status: string;
total_amount: number;
/**
* Back-link from PO to the order_items that this PO was created
* from. Mirrors `order_items.purchase_order` — written on PO
* create, kept in sync when items are added/removed/reassigned.
* Renders in the PB admin UI on the PO row and is used by the
* "open the source order" affordance.
*/
order_item?: string[];
/**
* Outbound email composition + supplier reply metadata. Round-trips
* from the operator's Send PO modal → API route → n8n → PB → display.
* Mirrors the `quote_items.specifications` JSON-field convention.
*/
email_payload?: PurchaseOrderEmailPayload;
/** RFC 5322 Message-ID of the last successful outbound send. */
email_message_id?: string;
/** ISO 8601 timestamp of the most recent supplier reply (when reply tracking is built). */
last_reply_at?: string;
/** Last SMTP failure reason — surfaced in the mail_failed badge tooltip. */
email_error?: string;
/**
* Embedded PO documents (PDFs, signed delivery notes, etc).
* Newest is last. The Send PO flow attaches the last entry.
* Replaces the legacy `purchase_order_pdfs` collection.
*/
documents?: string[];
/**
* Files attached to the PO for the supplier (supplier quote, drawing,
* NDA, etc.). Sent alongside the versioned PO PDF on email. Distinct
* from `documents`, which is the versioned-PDF history; mixing the two
* breaks the `documents ↔ documents_history` invariant the PDF download
* path depends on.
*
* PB file field, multi, no PB-side cap. The upload route enforces
* 25 MB per file to match the rest of the app.
*/
additional_attachments?: string[];
/**
* Audit log of every document that has ever lived on this PO.
* Mirrors the structure of the legacy `purchase_order_pdfs` row
* (version, file, generatedAt, generatedBy, reason, status) but as
* a JSON array on the PO itself — no separate collection needed.
*/
documents_history?: PurchaseOrderDocumentEntry[];
expand?: {
order?: Order;
supplier?: Supplier;
order_item?: OrderItem[];
};
}
export type DeliveryNoteStatus =
| "draft"
| "sent"
| "loaded"
| "po_received"
| "invoiced"
| "completed"
| "cancelled";
export interface DeliveryNote extends BaseRecord {
/**
* Human-readable identifier. Format `DN{YY}{MM}{DD}{NN}` — e.g.
* `DN26071401`. Server-generated daily sequence (2-digit reset at
* midnight). Indexed unique. The same string is stamped on every
* `order_items.delivery_note_number` row that belongs to this
* delivery note — that's how we group items into a delivery note
* without a second collection.
*/
delivery_note_number: string;
/**
* Relation to the customer the items are for. Optional only because
* a brand-new counter-pickup delivery note can be created before a
* customer is picked; in practice the operator picks the customer
* in the same form. UI enforces required for status > `draft`.
*/
customer?: string;
company?: string;
/**
* Relation to the order that the items originated from (Path A —
* supplier-ordered items). Optional: null when items span multiple
* orders, OR when the delivery note was created ad-hoc (Path B —
* counter pickup, no order exists).
*/
order?: string;
/**
* Lifecycle status.
* draft — created, items assembled, not yet sent
* sent — sent to customer with the quote
* po_received — customer PO stamped on `customer_po`
* invoiced — invoice created from this delivery note
* completed — done
* cancelled — abandoned (kept for audit; never re-uses a number)
*/
status: DeliveryNoteStatus;
/** Date the items were (or will be) physically delivered. */
delivery_date?: string;
/**
* The customer's purchase order number. Filled in when the customer
* sends back their PO after seeing the quote + signed delivery
* note. Drives the `sent → po_received` transition.
*/
customer_po?: string;
/** Free text — operator notes, special instructions, etc. */
notes?: string;
/**
* Relation to the user who created the delivery note. Captures
* either the operator who hit "mark as delivery note" in Invoicing
* (Path A) or the operator who created an ad-hoc delivery note
* (Path B).
*/
created_by?: string;
/**
* Operator-uploaded signed copy of the delivery note. The
* customer signs the printed DN (or signs an electronic PDF
* in-place) and the operator scans / photographs it and
* uploads the file here. When the "Send to customer" flow
* runs, the signed copy is attached to the outbound email
* instead of the re-rendered unsigned PDF — the customer
* gets back the document they signed.
*
* Optional: most DNs go through the flow with the customer
* never seeing the unsigned PDF (the office sends the
* signed copy, customer countersigns and sends back). The
* first time this is empty is the common case.
*/
/**
* PocketBase file field, `maxSelect: 1`.
*
* Read as `string | string[]`, NOT `string[]`. We WRITE an array
* (`signed_copy: [file]`) but PB returns a bare STRING when exactly
* one file is attached and an array only when there are several.
* `signed_copy[0]` on the string form yields the first CHARACTER of
* the filename, and `Array.isArray(x) ? x[0] : null` yields null
* for a note that has a signed copy — both fail silently.
*
* Always read it through `firstFileName()` / `fileNames()` from
* `src/lib/file-fields.ts`.
*/
signed_copy?: string | string[];
expand?: {
order?: Order;
company?: Company;
customer?: Customer;
created_by?: User;
};
}
export type PriceListRow = {
sku: string;
/**
* The same SKU with non-alphanumeric characters stripped and the
* remainder uppercased. `JEITD-06` → `JEITD06`. Index on this
* column lets the Check-pricing matcher do substring + exact
* matches regardless of how the buyer typed it.
*/
sku_normalised: string;
description: string;
category: string;
subcategory: string;
brand: string;
/**
* Free-text supplier name as stored on the price_list row. The
* live schema has this as a text field rather than a relation
* to `suppliers`, so it's the human-readable name (or PB id, for
* pre-migration rows). The Catalogue tab does a client-side
* lookup against `suppliers` to render the canonical name.
*/
supplier: string;
cost_price: number;
selling_price: number;
/** Markup percentage as stored (e.g. 25 = 25%). 0 if unset. */
markup: number;
unit: string;
stock_qty: number;
/** Rich JSON: per-category attributes + synonyms for fuzzy match. */
specifications: {
diameter_mm?: number;
shank_mm?: number;
length_mm?: number;
material?: string;
synonyms?: string[];
[key: string]: unknown;
};
/** `raw_price_list.id` this row was extracted from. Empty for manually-created rows. */
source_file: string;
/**
* Denormalised search blob. PB can't substring-match a JSON
* field, so the n8n workflow and the backfill script flatten
* every searchable bit of a row into this column: sku,
* description, brand, supplier, category, subcategory, and
* the `specifications.synonyms` array. The Catalogue tab's
* search box hits this column directly so fuzzy matches via
* synonyms work ("slugger cutter" finds the HSS cutters whose
* synonyms list contains it).
*/
search_text: string;
};
/**
* One row in the `price_list` collection. The structured,
* post-AI-extraction catalogue that the quote-filler agent searches
* when filling prices.
*
* Schema (live): sku, description, category, subcategory, brand,
* supplier (text), cost_price, selling_price, markup, unit,
* stock_qty, specifications (json), source_file (text → raw_price_list.id).
* Indexed on sku, category, subcategory, brand, supplier.
*/
export interface PriceList extends BaseRecord, PriceListRow {}
export type RawPriceListStatus =
"Pending" | "Processing" | "Completed" | "Failed";
/**
* One row in the Pricelist page table. Holds a raw supplier price-list
* file (PDF / CSV / text / image) before any parsing/AI runs over it.
*
* Backed by the `raw_price_list` PocketBase collection. See
* `docs/pocketbase/raw_price_list.collection.json` and
* `scripts/migrations/2026-06-30-add-raw-price-list.ts` for the schema
* source of truth.
*
* The live PB collection's fields (discovered via the admin API):
* - supplier_file: the uploaded file (PDF/CSV/text/image)
* - supplier_name: relation → suppliers (free text → relation
* migration was the v1 design decision, kept for back-compat)
* - log_summary: free-text parser output / operator notes
* - status: select (pending / processing / completed / failed)
*
* If the collection is missing in a fresh deploy, the migration script
* creates it with this same shape.
*/
export interface RawPriceList extends BaseRecord {
/** PB file tokens. Single file per row — first element is the URL token. */
supplier_file: string[];
/**
* Original browser-side filename (e.g. "Acme-Bolts-2026-Q2.pdf").
* PB slugifies the upload into a token stored inside `supplier_file`,
* so without this field the operator sees the slugged version. The
* WFDASH upload form auto-populates it from `file.name`.
*/
file_name: string;
/** PB relation → suppliers. Empty string when unset. */
supplier_name: string;
status: RawPriceListStatus;
/** Free-text log / summary — parser output, manual notes, etc. */
log_summary: string;
/**
* Free-text operator notes for the AI extraction step. Read by the
* n8n workflow's LLM user message ("Brand is JEI. Apply 30% markup.
* All prices in ZAR excl. VAT. POA for missing values.") and by
* the post-extraction Code node (which regexes out a `markup: NN%`
* override for the selling_price calculation).
*/
notes: string;
expand?: {
supplier_name?: Supplier;
};
}
export interface Repair extends BaseRecord {
repair_number: string;
customer?: string;
company?: string;
order?: string;
/**
* Service centre / repair shop the machine is sent to for analysis.
* Relation to the `suppliers` collection. Set when the operator
* clicks "Send to Supplier" — the supplier is the destination of the
* delivery note that the driver carries with the machine.
*
* Not present on the current schema snapshot — operator added the
* relation in the PB admin. If a call is made before the field is
* added, the value will be undefined and the "Send to Supplier"
* action will surface a clear "set a supplier first" error.
*/
supplier?: string;
status:
| "new"
| "assessing"
| "quoted"
| "approved"
| "in_progress"
| "completed"
| "cancelled";
item_description: string;
fault_description: string;
notes?: string;
serial_no?: string;
/**
* Photos of the machine as received from the customer. Multi-file
* field (`maxSelect: 10`) so the operator can attach multiple angles
* in one go. Shown on the repair detail page; download-only (no
* rendering pipeline — the photos are for the office's records).
*/
photos?: string[];
/**
* Supplier's repair quote (PDF / image). Multi-file field. Set when
* the supplier sends back their quote after analysing the machine.
* Drives the `new/assessing → quoted` status transition.
*/
quote?: string[];
/**
* The PO that the office issues to the supplier to authorise the
* repair work. Multi-file field. Set when the customer approves the
* supplier's quote and the office cuts the supplier a PO.
*/
purchase_order?: string[];
/**
* Repair Request PDF(s) attached to the repair. Multi-file field.
* Generated when the operator hits "Generate Repair Request" on the
* detail page — the PDF is the "please assess and quote" document
* the office prints and sends to the supplier with the machine.
*
* NOT a delivery note. Repairs do not flow through the customer-DN
* pipeline at all (the final "deliver to customer" step, if any,
* is handled separately by the existing /delivery-notes flow).
*/
repair_request?: string[];
/**
* Customer-facing quote generated from this repair (the office's
* "we'll repair your machine for X" quote, sent to the customer
* for approval before the supplier PO is cut). Created via
* `/api/v1/repairs/[id]/create-customer-quote` from the repair
* detail page. The link is one-to-one — a repair has at most one
* customer quote (operator re-quotes by creating a new one).
*
* Added 2026-09-21. Optional in the type so pre-existing rows
* don't break the renderer.
*/
customer_quote?: string;
expand?: {
customer?: Customer;
company?: Company;
order?: Order;
supplier?: Supplier;
customer_quote?: Quote;
};
}
export interface DashboardStats {
totalCustomers: number;
activeQuotes: number;
ordersThisMonth: number;
totalRevenue: number;
}
// ─────────────────────────────────────────────────────────────────
// Stock management — added 2026-07-14
// ─────────────────────────────────────────────────────────────────
/**
* Allowed values for `stock_ledger.reason`. The schema field is plain
* `text` for now (operator hasn't added the enum in PB admin yet —
* see Option 2 of the 2026-07-14 plan). The API layer validates
* against this union regardless, so when the enum is later added to
* PB, the API contract doesn't change.
*
* - `purchase_surplus` — operator split a collected line, the
* excess over customer qty went to stock.
* Reference: the order_item that was split.
* - `stock_order` — qty booked in from a pure stock purchase
* (v2 stock-orders flow). Reference: the
* stock_purchase id (when that collection
* lands).
* - `customer_fulfillment` — qty booked out when a customer order is
* fulfilled from existing stock (v3 stock-
* out flow). Reference: the order_item
* that was fulfilled.
* - `adjustment` — operator manual correction (count, write-
* off, etc). Reference: optional manual id.
* - `return` — qty returned from a customer or driver.
* Reference: the source order_item.
*/
export type StockReason =
| "purchase_surplus"
| "stock_order"
| "customer_fulfillment"
| "adjustment"
| "return";
/** All allowed reasons, exported for validation + tests. */
export const STOCK_REASON_VALUES: readonly StockReason[] = [
"purchase_surplus",
"stock_order",
"customer_fulfillment",
"adjustment",
"return",
] as const;
export interface StockCostHistoryEntry {
cost_price: number;
quantity: number;
purchased_at: string;
purchase_id: string;
supplier_id?: string;
}
export interface StockItem extends BaseRecord {
sku: string;
name: string;
description: string;
/** Display unit (e.g. "ea", "box", "L", "m"). Free text. */
unit: string;
/** Bin / shelf / location label. */
location: string;
/**
* Denormalized running total. Authoritative source is the
* `stock_ledger` rows; the API layer maintains `current_qty` in
* the same transaction as the ledger insert so the two stay in
* sync. UI reads `current_qty` for fast display; the ledger
* drill-down is for audit / drift repair.
*/
current_qty: number;
/** Reorder threshold — UI flags this row when `current_qty <= min_qty`. */
min_qty: number;
/**
* The "I want this much on hand" target. The bulk-PO-from-low-stock
* flow uses this to compute the order qty: `order_qty = max(0, target_qty - current_qty)`.
* Items with `target_qty` unset or 0 are opted out of the bulk flow
* (the operator hasn't decided to auto-reorder them yet).
*/
target_qty?: number;
/** Current/latest purchase cost. Historical entries are kept in cost_history. */
cost_price?: number;
/** Current POS selling price. */
selling_price?: number;
/** POS markup percentage, e.g. 30 = 30%. */
markup?: number;
/** Relation to the shared categories collection. */
category?: string;
/** Append-only purchase cost snapshots for this SKU. */
cost_history?: StockCostHistoryEntry[];
/** Default supplier for re-orders. */
supplier: string;
/** Soft-hide flag — operator can deactivate without deleting history. */
is_active: boolean;
notes: string;
expand?: {
supplier?: Supplier;
};
}
export interface StockLedger extends BaseRecord {
stock_item: string;
/** Signed: positive = in, negative = out. */
change_qty: number;
/** Unit cost of this movement, when the movement has a known cost. */
unit_cost?: number;
reason: StockReason;
/**
* Free-text tag for what the row references, e.g. "order_item",
* "stock_order", "manual". Pairs with `reference_id` to find the
* originating record.
*/
reference_type: string;
/** PB id of the originating record. */
reference_id: string;
notes: string;
created_by: string;
expand?: {
stock_item?: StockItem;
/**
* Best-effort expand of the `created_by` user relation. May be
* missing (system writes, pre-migration rows). The detail page
* falls back to "System" when this is absent so the render
* never crashes on a missing expand.
*/
created_by?: { id: string; name?: string; email?: string };
};
}
// ─────────────────────────────────────────────────────────────────
// WFSALES POS (standalone counter-sale app) — added 2026-08-30
// ─────────────────────────────────────────────────────────────────
/** Shape returned by GET /api/v1/pos/catalog — flattened stock_items row. */
export interface PosCatalogItem {
id: string;
sku: string;
name: string;
description: string;
unit: string;
/** Expanded category name, or "" if uncategorised. */
category: string;
/** FA class string for the category icon (e.g. "fa-solid fa-bolt"), or "" if unset. */
category_icon: string;
current_qty: number;
selling_price: number;
cost_price: number;
/** PB `created` timestamp — the till sorts newest-first off this. */
created: string;
}
/**
* The four tender types the POS offers. Only `cash` puts money in the
* drawer — `card` and `eft` settle outside the till, and `account` is
* pay-later (the customer takes the goods on a delivery note). Cashup
* relies on that distinction, so all four are recorded distinctly
* rather than collapsed to cash/card.
*/
export type PosPaymentMethod = "cash" | "card" | "eft" | "account";
export const POS_PAYMENT_METHOD_VALUES: readonly PosPaymentMethod[] = [
"cash",
"card",
"eft",
"account",
] as const;
export interface PosSale extends BaseRecord {
sale_number: string;
payment_method: PosPaymentMethod;
/** Only meaningful for `cash`; 0 for every other tender. */
cash_given: number;
change_given: number;
/**
* The "gross" totals BEFORE any discount, on the EX-VAT basis
* (see `vat_percent`). `gross_subtotal` is the sum of the line
* items' (unit_price × qty) — catalogue prices are stored
* ex-VAT, so this is a net figure. `gross_vat` is the VAT on
* that net, and `gross_total` is what the customer would have
* paid pre-discount.
*/
gross_subtotal: number;
gross_vat: number;
gross_total: number;
/**
* Discount applied to the sale. Percentage-only and fixed-choice:
* the till offers exactly DISCOUNT_PERCENT_OPTIONS (20 / 40),
* never a free-typed amount, so a fat-finger can't produce an
* arbitrary discount. `discount_amount` is the *resolved* rand
* value taken off the EX-VAT gross subtotal, capped at that
* subtotal so the total can never go negative.
*
* Because the discount lands on the ex-VAT base, VAT is charged
* on the discounted net — a "20% off" is a true 20% off both
* net and VAT.
*
* The displayed `subtotal` / `vat` / `total` on the till and
* receipt are the *post-discount* values; `gross_*` is kept
* for the accountant / audit trail.
*/
discount_amount: number;
discount_kind: PosDiscountKind;
discount_input: number;
/** Ex-VAT net after discount. The receipt's "Subtotal" line. */
subtotal: number;
/** VAT charged on `subtotal` at `vat_percent`. */
vat: number;
/** What the customer pays: `subtotal + vat`. */
total: number;
/**
* The VAT rate this sale was rung up at, resolved server-side
* from the till's letterhead. Stored per sale so a later letterhead
* rate change never rewrites the history of what was charged.
*/
vat_percent: number;
/** customers relation id, or "" for a walk-in with no customer picked. */
customer: string;
/**
* Has this sale been reversed? Refunds are WHOLE-SALE only, so a boolean
* is the complete answer — there is no per-line refund table. Unset on
* every sale predating the feature, which is falsy, i.e. "not refunded".
*
* Stamped BEFORE the refund's stock rows are written, so a second
* concurrent call sees it and bails rather than crediting twice.
*/
refunded?: boolean;
/** When the refund happened, PB datetime format. */
refunded_at?: string;
/**
* Rand credited back. Stamped from the sale's post-discount `total`,
* which is what the customer actually paid — a discounted sale refunds
* the discounted amount, not the shelf price.
*/
refund_amount?: number;
/**
* Client-generated idempotency key (the POS device's local sale id).
* A sale that was committed server-side but whose response never
* reached the till gets re-posted by the offline queue; matching on
* this returns the original sale instead of decrementing stock twice.
*/
client_ref: string;
/**
* The system `delivery_notes` record raised for this sale, set only
* on `payment_method = "account"` sales that had a customer picked.
*
* This is the idempotency anchor for the delivery note. The till's
* offline queue re-posts `/checkout` with the same `client_ref` when
* the network drops, and `checkoutSale` replays the original sale
* untouched — reading this field first, so one physical sale can
* never produce two delivery notes. Blank means "no DN yet", which
* is also the retry signal if the first creation attempt failed.
*
* Cash / card / EFT sales leave it blank forever — they get the
* 80mm thermal slip, not a delivery note.
*/
delivery_note?: string;
/**
* Present on rows from `GET /api/v1/pos/sales` (history.html and
* cashup.html), which expands the customer and the linked delivery
* note so the till can rebuild the DN card on a reprint. Absent
* elsewhere.
*/
expand?: {
customer?: {
id?: string;
customer?: string;
company?: string;
expand?: { company?: { id?: string; company?: string } };
};
delivery_note?: { id?: string; delivery_note_number?: string };
};
}
/** How the operator entered the discount on the till. */
export type PosDiscountKind = "amount" | "percent" | "none";
export const POS_DISCOUNT_KIND_VALUES: readonly PosDiscountKind[] = [
"amount",
"percent",
"none",
] as const;
/**
* The only discounts the till offers: 20% off or 40% off.
*
* Percentage-only and fixed-choice on purpose — the previous till
* let the cashier type any percentage or any rand amount, which
* made an unbounded discount one fat-finger away and left the
* "is this deliberate?" question unanswerable after the fact.
* Restricting the choice to two known-good tiers means every
* discount on every slip is self-evidently a real decision.
*
* The server treats this list as the allowlist, not a hint: an
* input outside it is rejected rather than silently coerced, so a
* stale till or a hand-rolled request can't invent a discount.
*/
export const DISCOUNT_PERCENT_OPTIONS: readonly number[] = [20, 40] as const;
/** True when `pct` is one of the discounts the till is allowed to apply. */
export function isAllowedDiscountPercent(pct: number): boolean {
return DISCOUNT_PERCENT_OPTIONS.includes(pct);
}
export interface PosSaleItem extends BaseRecord {
sale: string;
/**
* Empty string for a custom (ad-hoc) line — see `is_custom`.
* Not required at the PB level: a line can exist with no
* catalogue row behind it.
*/
stock_item: string;
/**
* True when the cashier typed the line by hand (e.g. "Bolt
* cutting" at R20) rather than picking it from the catalogue.
* Custom lines carry a description and price only — they have
* no stock movement, no ledger row, and no catalogue entry.
*
* Kept as an explicit flag rather than inferring from a blank
* `stock_item` so "how much did we make on bolt cutting this
* month" is a plain query, and so a future stock-less line type
* can't be confused with a corrupt relation.
*/
is_custom: boolean;
sku: string;
description: string;
qty: number;
unit_cost: number;
/** EX-VAT unit price — the basis the catalogue stores. */
unit_price: number;
}
// ─────────────────────────────────────────────────────────────────
// Stock purchases (v2 pure stock-purchase flow) — added 2026-07-14
// ─────────────────────────────────────────────────────────────────
/**
* Lifecycle: draft → sending → sent (or mail_failed → retry → sent).
* └→ received (terminal) once all lines are booked into
* stock via the collection-confirm "Book to stock"
* flow (or the explicit "Mark received" button on
* /stock-orders/[id]).
* └→ cancelled (terminal, no goods expected).
*
* `sending` and `mail_failed` were added 2026-07-16 to mirror the
* customer-PO status enum so the same n8n workflow can drive both
* flows. Operator adds the new values to the PB select enum via the
* migration `2026-07-16-add-stock-purchase-send-statuses`.
*/
export type StockPurchaseStatus =
"draft" | "sending" | "sent" | "mail_failed" | "received" | "cancelled";
export const STOCK_PURCHASE_STATUS_VALUES: readonly StockPurchaseStatus[] = [
"draft",
"sending",
"sent",
"mail_failed",
"received",
"cancelled",
] as const;
/**
* Per-line state for stock-purchase lines. The state machine runs
* `collection_pool` → `assigned_to_driver` → `collected` → `booked`,
* mirroring the operator's workflow:
* - `collection_pool`: line is awaiting a driver assignment.
* - `assigned_to_driver`: line is on a driver's run; `assigned_at`
* holds the timestamp.
* - `collected`: driver has picked up the goods from the supplier.
* - `booked`: qty has been booked into `stock_items` via the
* collection-confirm "Book to stock" flow.
* `null`/`undefined` = line hasn't been sent yet (still in draft).
*/
export type StockPurchaseLineStatus =
"collection_pool" | "assigned_to_driver" | "collected" | "booked";
export const STOCK_PURCHASE_LINE_STATUS_VALUES: readonly StockPurchaseLineStatus[] =
["collection_pool", "assigned_to_driver", "collected", "booked"] as const;
export interface StockPurchase extends BaseRecord {
/**
* `PO{yy}{6d-seq}STK` — e.g. `PO26000001STK`. Per-year counter
* so the sequence resets on Jan 1. See
* `generateStockPurchaseNumber` in
* `@/lib/server/stock-purchases-service`.
*/
purchase_number: string;
supplier: string;
status: StockPurchaseStatus;
notes: string;
/** Outbound email composition (subject/body/cc/bcc/sentMessageId), if any. */
email_payload?: Record<string, unknown>;
/** SMTP error string (empty when the last send succeeded). */
email_error: string;
/** RFC 5322 Message-ID from the last send attempt. */
email_message_id: string;
/**
* Generated PO PDF + any other attachments the operator wants to
* send to the supplier. v1 send flow uploads the PDF here on the
* "Send to supplier" action; the stock-order list table renders
* a per-row icon to view/print.
*/
documents?: string[];
/**
* Per-regeneration audit trail for `documents` — mirrors the
* `PurchaseOrderDocumentEntry` shape on customer POs. Latest
* regen = `status: "current"`; older entries are `"superseded"`.
* The PDF route reads this to resolve `?version=N` downloads and
* to find the file proxy token for the latest PDF. Field added
* 2026-07-16 (was previously absent on stock purchases).
*/
documents_history?: PurchaseOrderDocumentEntry[];
/**
* Free-form attachments the operator has dropped on the PO
* (supplier invoice, packing slip, photo of damaged stock).
* Distinct from `documents`, which is the versioned-PDF ledger
* (see `documents_history`). Multi-file, no PB-side cap; the
* upload route enforces 25 MB per file.
*
* Mirrors `purchase_orders.additional_attachments` (see
* `src/app/api/v1/purchase-orders/[id]/attachments/upload/`).
*
* PB schema: added 2026-08-06.
*/
additional_attachments?: string[];
expand?: {
supplier?: Supplier;
};
}
export interface StockPurchaseItem extends BaseRecord {
purchase: string;
stock_item: string;
description: string;
quantity: number;
/**
* How much has actually been received against this line. Starts at
* 0; the "Book to stock" flow on collection-confirm writes here
* (capped at `quantity`). Repeated partial-bookings accumulate.
*/
quantity_received: number;
cost_price: number;
notes: string;
/**
* Per-line state — see `StockPurchaseLineStatus`. `null`/
* `undefined` means the line is in the parent PO's draft state
* (not yet sent to the supplier).
*/
status?: StockPurchaseLineStatus;
/**
* Timestamp the line was bound to a driver (the
* `assigned_to_driver` transition). Mirrors `order_items.assigned_at`.
*/
assigned_at?: string | null;
/**
* Driver the line is bound to (relation → users). Mirrors
* `order_items.drv_id`. Empty / undefined for unassigned lines
* (status = `collection_pool` or earlier).
*/
drv_id?: string;
/**
* Problem-tracking fields, set by the driver when something
* went wrong at the supplier (short-ship, wrong item,
* damaged, etc.). Mirror the same fields on `order_items`
* so the office-side render can use one shape for both
* customer and stock problem flags.
*
* Added 2026-07-17 (was previously absent on stock purchases).
*/
problem_flag?: boolean | null;
problem_type?: string | null;
problem_notes?: string | null;
/**
* Catalogue SKU that this PO line corresponds to. May differ from
* `supplier_sku` when the supplier relabels goods. WFDASH catalogue
* stays canonical; `supplier_sku` records what was actually printed
* on the supplier's invoice.
*
* Added 2026-08-07 by migration
* `2026-08-07-stock-purchase-items-catalogue-fields.ts`.
*/
sku?: string;
/**
* Supplier-side unit string (e.g. "L", "m", "kg"). Hermes may
* record this when the supplier invoice disagrees with the PO's
* unit. Empty string when unspecified.
*
* Added 2026-08-07 by migration
* `2026-08-07-stock-purchase-items-catalogue-fields.ts`.
*/
unit?: string;
/**
* SKU printed on the supplier's catalogue or invoice. Lets Hermes
* reconcile an invoice against the PO line without a manual lookup.
* Empty string when unspecified.
*
* Added 2026-08-07 by migration
* `2026-08-07-stock-purchase-items-catalogue-fields.ts`.
*/
supplier_sku?: string;
expand?: {
purchase?: StockPurchase;
stock_item?: StockItem;
};
}
// ─── Item history popover ────────────────────────────────────────────
/** Per-supplier purchase history for a single item. */
export interface ItemHistorySupplier {
supplier_id: string;
supplier_name: string;
last_cost: number;
last_bought_at: string;
times_bought: number;
last_order_id: string;
}
/** Other quotes to the same customer / other companies for the same SKU. */
export interface ItemHistoryOtherQuote {
quote_id: string;
quote_number: string;
company: string;
customer_name: string;
status: string;
selling_price: number;
quoted_at: string;
days_ago: number;
}
/** Last time this SKU was on a fulfilled order. */
export interface ItemHistoryLastSold {
selling_price: number;
sold_at: string;
order_id: string;
order_number: string;
company: string;
days_ago: number;
}
/** Stock snapshot at the matched supplier. */
export interface ItemHistoryStock {
in_stock: number;
status: "in_stock" | "low" | "out";
}
/** Derived cheapest supplier + cost. */
export interface ItemHistoryCheapest {
supplier_id: string;
supplier_name: string;
last_cost: number;
}
/** How the service matched "same item". */
export interface ItemHistoryMatch {
sku: string;
match_kind: "sku_exact" | "sku_normalised" | "description_fuzzy" | "none";
match_confidence: number; // 0..1
}
/** Aggregate returned by `getItemHistory`. */
export interface ItemHistory {
generated_at: string;
suppliers: ItemHistorySupplier[];
other_quotes: ItemHistoryOtherQuote[];
last_sold: ItemHistoryLastSold | null;
stock: ItemHistoryStock | null;
cheapest: ItemHistoryCheapest | null;
match: ItemHistoryMatch;
}
/** Response shape for `/api/v1/quote_items/[id]/item-history`. */
export interface ItemHistoryResponse {
deterministic: ItemHistory | null;
ai: Record<string, unknown> | null;
ai_updated_at: string | null;
generated_at: string;
error?: string;
}
// ─────────────────────────────────────────────────────────────────────────────
// Item Intel — the operator-facing popover payload produced by
// `populateItemIntel` in `src/lib/server/item-intel-service.ts`.
//
// Replaces the older `ai_item_insights` n8n-written blob AND the
// `getItemHistory` SKU-first aggregate. Description-fuzzy across all
// five sections, no SKU gate, so new items with no SKU still get
// the full intel.
//
// Stored at `quote_items.ai_item_insights` and
// `order_items.ai_item_insights`. Read by the new popover (Option A
// — replaces `ItemHistoryPopover` content). The five sections each
// cap at a fixed number of rows so the popover stays scannable;
// empty sections are empty arrays, never padded.
// ─────────────────────────────────────────────────────────────────────────────
/** The target row that the intel is about (header in the popover). */
export interface ItemIntelTarget {
id: string;
collection: "quote_items" | "order_items";
description: string;
sku: string;
quantity: number;
/** PB id of the supplier bound to the parent item. NULL on
* pre-PO order_items and on supplier-agnostic quote_items. */
supplier?: string;
/** Resolved supplier display name. Empty when no supplier is set
* (or the supplier id no longer resolves). */
supplier_name?: string;
}
/** One row in the Stock List — a fuzzy-matched stock_items row. */
export interface ItemIntelStockRow {
stock_item_id: string;
score: number;
name: string;
sku: string;
description: string;
qty: number;
min_qty: number | null;
cost_price: number;
selling_price: number;
supplier_id: string;
supplier_name: string;
status: "in_stock" | "low" | "out";
}
/** One row in the Supplier History — last purchase per unique supplier.
* Includes the stock_item the supplier was last bought for, so the
* operator can tell whether the supplier is for the target item or
* for a fuzzy-matched variant (e.g. "M30 vs M20 set screw"). */
export interface ItemIntelSupplierRow {
supplier_id: string;
supplier_name: string;
last_cost: number;
last_bought_at: string;
days_ago: number;
times_bought: number;
last_purchase_number: string;
/** The stock_item the latest purchase was for. Empty string if
* the purchase row didn't carry a stock_item reference. */
stock_item_id: string;
stock_item_sku: string;
stock_item_name: string;
}
/** One row in the Price List — fuzzy-matched price_list row. */
export interface ItemIntelPriceListRow {
score: number;
sku: string;
description: string;
brand: string;
category: string;
cost_price: number;
selling_price: number;
supplier: string;
source_file: string;
updated: string;
}
/** One row in the Recent Quote — fuzzy-matched past quote_items row. */
export interface ItemIntelQuoteRow {
score: number;
quote_item_id: string;
quote_id: string;
quote_number: string;
quote_status: string;
customer_name: string;
company: string;
description: string;
cost_price: number;
selling_price: number;
quantity: number;
quoted_at: string;
days_ago: number;
/** PB id of the supplier bound to the source quote_item. NULL when
* the quote was sourced supplier-agnostic (e.g. before a supplier
* was chosen). */
supplier_id?: string;
/** Resolved supplier display name. Empty when the supplier id no
* longer resolves. */
supplier_name?: string;
}
/** One row in the Recent Order — fuzzy-matched past order_items row. */
export interface ItemIntelOrderRow {
score: number;
order_item_id: string;
order_id: string;
order_number: string;
description: string;
cost_price: number;
selling_price: number;
quantity: number;
item_status: string;
supplier_id: string;
supplier_name: string;
updated: string;
days_ago: number;
}
/** Per-section caps — keep the popover scannable. */
export const ITEM_INTEL_LIMITS = {
stock: 3,
supplierHistory: Number.POSITIVE_INFINITY, // one row per unique supplier
priceList: 5,
recentQuote: 2,
recentOrder: 2,
} as const;
/**
* Verification metadata — populated by the operator's n8n AI
* verifier, NOT by `populateItemIntel`. Contract for the n8n
* workflow:
*
* 1. Read `ai_item_insights` (this whole shape) for an item.
* 2. For each row in `stock_list`, `supplier_history`, `price_list`,
* `recent_quote`, `recent_order` — decide if the row is a
* legit match for `item.description` (semantic match, not
* just token overlap).
* 3. REWRITE the field in the same shape, with:
* - The five arrays trimmed to only the legit rows.
* - `verified.at` = current ISO timestamp.
* - `verified.by` = a stable identifier (e.g. "n8n:ai-verifier"
* or your workflow name) so we can trace provenance.
* - `verified.removed_count` = total rows dropped across all
* five sections (e.g. 2 if you dropped 1 supplier and 1 quote).
* - `verified.notes` (optional) = short human-readable summary
* of the reasoning, shown in the popover.
* 4. Leave `verified` UNDEFINED when the item hasn't been
* verified yet — that's how the popover knows to show
* "Pending AI verification" instead of "Verified".
*
* The arrays being trimmed (rather than just flagged) means the
* popover naturally shows only the kept rows — no client-side
* filtering logic needed.
*/
export interface ItemIntelVerified {
at: string;
by: string;
removed_count: number;
notes?: string;
}
/** The full intel payload — written to `ai_item_insights` and read by the popover.
*
* ## Shape contract
*
* The `item` block is the TARGET row that the intel is about — the
* popover header renders `item.description`, `item.quantity`, and
* `item.sku`. Writers (the n8n AI verifier, the backfill script) MUST
* populate `item` with at least `{ id, collection, description, sku,
* quantity }` so the popover has a header. Without it, the popover
* shows a defensive empty state instead of crashing.
*
* The five section arrays are what populateItemIntel produces
* (description-fuzzy across stock, suppliers, price list, quotes,
* orders). Writers may trim them but should keep the field names.
*
* `verified` is set by the n8n AI verifier after it reviews the
* candidate lists and trims the non-matches. See `ItemIntelVerified`
* for the contract. Absent = not yet verified (raw fuzzy matches
* shown).
*/
export interface ItemIntel {
/** The target row's identifier block. Optional for backwards compat
* with n8n outputs that don't include it — the popover degrades
* gracefully. */
item?: ItemIntelTarget;
summary: string;
generated_at: string;
/** Top-3 stock_items ranked by description-fuzzy score. Empty if none. */
stock_list: ItemIntelStockRow[];
/** Last order at every unique supplier we ever bought the item from. */
supplier_history: ItemIntelSupplierRow[];
/** Top-5 price_list matches by overlapScore on the search_text blob. */
price_list: ItemIntelPriceListRow[];
/** Top-2 most recent past quote_items matches. */
recent_quote: ItemIntelQuoteRow[];
/** Top-2 most recent past order_items matches. */
recent_order: ItemIntelOrderRow[];
/** Set by the n8n AI verifier after it reviews the candidate lists
* and trims the non-matches. See `ItemIntelVerified` for the
* contract. Absent = not yet verified (raw fuzzy matches shown). */
verified?: ItemIntelVerified;
}
// ─────────────────────────────────────────────────────────────────────────────
// Quote-item insights blob — written by `populateQuoteItemInsights` in
// `src/lib/server/quote-items-insights-service.ts`. Replaces the
// snapshot-from-n8n pattern with a deterministic compute so Hermes
// (the agent) can populate it across the board without an LLM call.
//
// The renderer in `src/components/ai-insights-renderer.tsx` is shape
// agnostic, so the nested objects here render as nested key/value
// lists. The structured shape lets the operator scan a popover and
// answer "did we quote/order/invoice this SKU recently" at a glance.
// ─────────────────────────────────────────────────────────────────────────────
export type QuoteItemInsightsMatchKind =
| "sku_exact"
| "sku_normalised"
| "description_fuzzy"
| "none";
export interface QuoteItemInsightsMatch {
/** The SKU we ended up matching against (target's own SKU, or the SKU we picked via description fallback). */
sku: string;
/** How the match was made. `none` means no SKU and no description, or no fuzzy match above threshold. */
match_kind: QuoteItemInsightsMatchKind;
/** 1.0 for SKU matches, Jaccard score for description fuzzy, 0 for `none`. */
match_confidence: number;
}
export interface QuoteItemInsightsLastQuoted {
quote_item_id: string;
quote_id: string;
quote_number: string;
quote_date: string;
selling_price: number;
cost_price: number;
customer_id: string;
customer_name: string;
days_ago: number;
}
export interface QuoteItemInsightsPriceListEntry {
price_list_id: string;
/** The SKU on the price_list row itself — useful when matched via normalisation (e.g. target `DRL-006MM` matched against row `DRL-6MM`). */
sku: string;
description: string;
supplier_id: string;
supplier_name: string;
cost_price: number;
selling_price: number;
brand: string;
category: string;
subcategory: string;
updated: string;
}
export interface QuoteItemInsightsLastOrder {
order_item_id: string;
order_id: string;
order_number: string;
order_date: string;
delivered_at: string | null;
cost_price: number;
selling_price: number;
item_status: string;
supplier_id: string;
supplier_name: string;
days_ago: number;
}
export interface QuoteItemInsightsLastInvoiced {
order_item_id: string;
order_id: string;
order_number: string;
supplier_invoice_number: string;
customer_invoice_number: string;
invoiced_date: string;
cost_price: number;
quantity: number;
qty_invoiced: number;
days_ago: number;
}
export interface QuoteItemInsightsStock {
stock_item_id: string;
name: string;
sku: string;
current_qty: number;
min_qty: number | null;
selling_price: number | null;
markup_percent: number | null;
status: "in_stock" | "low" | "out";
}
export interface QuoteItemInsightsSupplier {
supplier_id: string;
supplier_name: string;
last_cost: number;
last_bought_at: string;
times_bought: number;
last_order_id: string;
}
/**
* The structured blob written to `quote_items.ai_item_insights` by
* `populateQuoteItemInsights`. Always includes `id` + `summary` +
* `match`; other keys are present only when data was found.
*/
export interface QuoteItemInsights {
id: string;
/** Free-text 1-2 sentence verdict. */
summary: string;
match: QuoteItemInsightsMatch;
last_quoted?: QuoteItemInsightsLastQuoted;
on_price_list?: boolean;
price_list_entries?: QuoteItemInsightsPriceListEntry[];
last_order?: QuoteItemInsightsLastOrder;
last_invoiced?: QuoteItemInsightsLastInvoiced;
stock?: QuoteItemInsightsStock;
suppliers?: QuoteItemInsightsSupplier[];
/** ISO timestamp when this blob was computed. */
generated_at: string;
}
export interface QuoteItemInsightsResult {
/** The `quote_items.id` we computed for. */
item_id: string;
/** The structured blob — same shape as `QuoteItemInsights` minus `id`. */
insights: QuoteItemInsights;
/** What match strategy produced data: `sku_exact`, `sku_normalised`, `description_fuzzy`, or `none`. */
match_kind: QuoteItemInsightsMatchKind;
/** True when at least one of price_list / quote / order / invoiced had data. */
found_data: boolean;
}
export interface QuoteItemInsightsBatchResult {
quote_id: string;
total: number;
populated: number;
no_match: number;
results: QuoteItemInsightsResult[];
}