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: ` 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 | 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=`. */ 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; /** 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 | 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[]; }