2553 lines
94 KiB
TypeScript
2553 lines
94 KiB
TypeScript
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[];
|
||
}
|