# Quote Operator — SOP **Owns:** the full quote pipeline — inbound RFQ intake, item-level pricing (sprint or manual), approval submission, sending, and follow-up. The sprint queue is one part of the pricing piece; the operator owns the whole pipeline from "new quote" to "customer replied". **Tools:** `/quotes`, `/quotes/[id]`, `/sprint`, `/quotes/approvals`, `/quotes/email-monitor`, dashboard widgets. **Entry points** (where a quote can come from): - **Inbound email** (n8n ingests RFQ → quote lands in `new`) - **Walk-in** (operator types "make me a quote for John" — no email) - **Template** (re-using a previous quote as a scaffold) - **Sprint** (item-level pricing queue — items from the above that need a price from you) **Handoffs:** - **From** Email Intake (n8n) → quote lands in `new` status. - **To Approver** → when status flips to `approval_pending`. - **To Order Operator** → when customer accepts (quote becomes an order). - **To Customer** → outbound email + inbound reply tracking. --- ## 1. Daily / Weekly Checklist ### Every working day (start of shift, 08:30 SAST) - [ ] **Open `/quotes?tab=new`** — every quote that came in via email overnight must have an owner. Either claim it (you'll work it), or pass to a colleague. Quotes older than 1 working day in `new` are a process bug. - [ ] **Open `/sprint`** — clear the "UNCLAIMED" filter. Look for items you can claim in your strongest categories first. (If the inbound-email intake has already pre-priced most items, the sprint queue may be small today — that's the goal.) - [ ] **Open `/quotes/email-monitor`** — scan for any failed sends from the last 24h. If any, **retry or escalate** (see Decision Tree §3.5). - [ ] **Open `/quotes?tab=claimed`** — pick up any of your items whose 30-min claim is about to expire. - [ ] **Open dashboard → "Follow-ups Due"** — every quote you own with a `follow_up_due` date of today or earlier must get a follow-up action today (call, email, or set a new `follow_up_due`). ### Every working day (end of shift, 17:00 SAST) - [ ] **Release any sprint items you can't finish** — the 30-min claim expires automatically, but it's polite to leave a `pricing_notes` breadcrumb on what's been tried. - [ ] **Confirm every quote you sent today** has moved out of `sending` — if any are stuck in `sending` for >5 min, see §3.5. - [ ] **Set `follow_up_due`** for every quote you sent without a reply expected inside 5 working days. Default: today + 5 working days. ### Every Monday morning - [ ] **Review your KPIs** (see §4). Look for any that are trending down vs last week. Note them in `#quote-ops` with a one-line reason. - [ ] **Clear the `/quotes/approvals` queue** if you have items there from Friday. Approvals older than 1 working day are a process bug — surface to the approver. ### Every Friday afternoon - [ ] **Close out the week** — every quote in `claim` by you with no activity for 3+ days must either be priced, sent, or `problem`-ed. Don't leave zombies for Monday. --- ## 2. Step-by-Step Procedures The procedures are ordered by the quote lifecycle: | # | Procedure | Entry point | |---|---|---| | 2.1 | Process an inbound email quote | RFQ email arrives via n8n | | 2.2 | Create a walk-in quote | Phone / face-to-face / WhatsApp | | 2.3 | Create a quote from template | Repeat customer, similar items | | 2.4 | Claim a sprint item | Unpriced item from §2.1, §2.2, or §2.3 | | 2.5 | Price a line item | Inside the line editor | | 2.6 | Submit a quote for approval | Every line priced | | 2.7 | Send the quote to the customer | Approver has approved | | 2.8 | Follow up on a sent quote | `follow_up_due` reached | | 2.9 | Partial approval / split a quote | Customer accepts some, rejects others | ### 2.1 Process an inbound email quote **When:** a customer sends an RFQ email. n8n ingests it, the AI extracts items, and a new quote appears in `/quotes?tab=new` with status `new`. You'll see a `📨` icon on the dashboard's "New Quotes" widget. 1. **Open `/quotes?tab=new`.** Find the new quote. 2. **Sanity-check the AI extraction** (open the quote detail): - **Customer** — did the AI match it to a known customer? If it landed on `Unknown Sender`, see Decision Tree §3.7. - **Company** — same check. Wrong company = wrong letterhead on the PDF. - **Items** — are the descriptions readable? Are the quantities right? If a line is missing or wrong, edit it. - **Attachments** — is the customer's original RFQ attached? The buyer needs to be able to compare our quote against their ask. 3. **Check `pricing_source` on each line:** - `ai` or `catalogue` → already priced. Verify the price against the AI's reasoning (if shown). If sane, move on. - `manual` or empty → unpriced. These lines will land in the sprint queue (see §2.4). 4. **Claim the quote** by clicking **Claim** on the header (or it'll auto-claim on first edit). Status flips to `claimed`. 5. **Triage unpriced lines:** if the sprint queue has unpriced lines from this quote, claim them in §2.4. 6. **If everything is priced**, jump to §2.6 (submit for approval). **Don't:** - Don't submit a quote with `Unknown Sender` — the customer needs to exist first (see §3.7). - Don't edit the AI's prices without a `pricing_notes` breadcrumb explaining why. ### 2.2 Create a walk-in quote **When:** a customer calls / walks in / WhatsApps with an RFQ that didn't come through email. No inbound message to extract from — you build the quote from scratch. 1. **Open `/quotes/new` (or click "+ New Quote" on the quotes list).** 2. **Pick or create the customer** in the customer picker. - If they exist, pick them. The letterhead, rep, and email template default from the customer record. - If they don't exist, type the name. The system creates a draft customer row with that name — the supervisor completes the rest of the fields later. 3. **Pick the company** (which letterhead). CNL vs ISHERWOOD etc — match what the customer is used to. 4. **Set the validity** (default 30 days). 5. **Add the description** — the free-text subject of the RFQ (e.g. "M16 hex bolts, grade 8.8, zinc, 2000 units"). Goes on the PDF header. 6. **Add items** in the line editor: - One row per SKU / spec combo the customer asked for. - Each row needs at minimum `description`. Add `quantity` and `unit`. - If you know the price (e.g. the customer named a SKU that is in the catalogue), set `cost_price` and `markup` — `selling_price` is auto-derived. - If you don't know the price, leave it blank and the lines will land in the sprint queue (§2.4). 7. **Save.** The quote lands in `new` status. 8. **If items are unpriced**, go to §2.4. If everything is priced, go to §2.6. **Don't:** - Don't pick a company / letterhead you don't recognise — ask the supervisor. - Don't save with `quantity = 0` — that's a placeholder, not an order. If the customer is uncertain about qty, write "TBD" in the description and price the line anyway, or mark the line as "Re-quote later" (split it out — §2.9). ### 2.3 Create a quote from a template **When:** a customer asks for something we've quoted before with the same line items / similar scope. Re-using a template saves the line-item re-entry. 1. **Open `/quotes?tab=templates`.** Pick the template that matches. 2. Click **Create from Template**. A new quote is spawned with: - Same customer, company, letterhead. - Same line items (descriptions + quantities). - Status: `new`. - Prices reset (templates don't carry live pricing — you re-price from the current catalogue). 3. **Update `description`** to reflect the new RFQ (date, customer reference, anything that's changed since the template was made). 4. **Update quantities** if the customer wants different amounts. 5. **Re-price** the lines from the current catalogue (or leave them for sprint if the SKU / supplier has changed). 6. **Continue from §2.6** (or §2.4 if items are unpriced). **Don't:** - Don't send a template quote without verifying the prices against today's catalogue. Supplier cost changes, and template-stored prices go stale fast. - Don't re-use a template for a new customer without confirming the markup policy applies. Strategic accounts may have different markups. ### 2.4 Claim a sprint item **When:** an item lands in the sprint queue and you have capacity. 1. Open `/sprint`. The "UNCLAIMED" tab shows available items. 2. **Prefer items you can resolve in <30 min** — known SKUs, items with attachments, or items in your strongest category. 3. Click **Claim**. The 30-min TTL starts. The item moves to your `Claimed` filter. 4. **Within 30 min:** price the item (see §2.5), or **release** by simply not saving (the claim auto-expires). **Don't:** - Don't claim more than 3 items at once. The 30-min TTL is per item; the bottleneck is your attention. - Don't claim items you'll need a supplier call for. Sprint is for pricing from known data, not for sourcing. ### 2.5 Price a line item **When:** you've claimed an item and you're at the line editor. 1. **Read the description.** If the AI provided a match (look for `matched_price_list_id` filled in), use it. 2. **Decide pricing source:** - **Catalogue** (`pricing_source: "catalogue"`) — the matched catalogue row has a valid `cost_price` and `markup`. Use it. - **AI** (`pricing_source: "ai"`) — the AI agent pre-filled a suggested price. **Verify against the catalogue or supplier list** before saving. Never save an AI price blind. - **Manual** (`pricing_source: "manual"`) — no match. You need to find a price yourself (see §3.1). 3. **Set `cost_price` and `selling_price`.** Selling price is auto-derived from `cost_price × (1 + markup/100)` if you set the markup — pass `selling_price` explicitly only when the catalogue has a stored selling price you want to preserve. 4. **Set `pricing_source`** to the source you actually used. 5. **Add a `pricing_notes` breadcrumb** (e.g. "matched JEI catalogue row, ZAR excl VAT, 30% markup per August 2026 policy"). 6. **Click Save.** The item drops off the sprint queue. The linked `quote_item` flips to `user_edited: true`. **Don't:** - Don't save with `cost_price = 0` and `selling_price = 0`. That's a sign you didn't find a price — see §3.1. - Don't override a catalogue price without a `pricing_notes` reason. ### 2.6 Send a quote for approval **When:** every line on a quote has `selling_price > 0`. 1. Open the quote detail (`/quotes/[id]`). 2. Click **Submit for Approval**. The status flips to `approval_pending`. Idempotent — if it's already in `approval_pending`, nothing changes. 3. If the submit returns `unpriced_items` with `offendingItemIds`: - Open each offending line. - Either price it (see §2.5) or move it to a "Re-quote" group (see §2.9). 4. **Wait for the approver.** The quote is now in their queue at `/quotes/approvals`. **Don't:** - Don't bypass submit by calling **Send Email** directly. The pre-send validator will reject with `invalid_status` for `new` quotes. ### 2.7 Send the quote to the customer **When:** the approver has flipped the quote to `approved`. 1. Open the quote detail. 2. Click **Send Email**. The email modal opens. 3. **Verify the recipient(s):** - `To` — the customer's primary email. - `CC` — usually the customer rep (`rep` on the customer row). - `BCC` — internal archive address (already pre-filled). 4. **Edit subject/body if needed.** Default subject is `Quote {number} — {description}`. Don't remove the quote number — the inbound reply tracker uses it. 5. **Attachment picker:** the quote PDF is auto-included. Add any supporting docs (datasheet, authorisation letter) the customer asked for. 6. Click **Send**. Status flips to `sending` → n8n takes over the SMTP send → callback records `emailed` or `mail_failed`. 7. **Set `follow_up_due`** to today + 5 working days. **Don't:** - Don't send without verifying the recipient. Wrong customer = lost quote. - Don't include internal-only files. The customer sees everything in the attachment picker. ### 2.8 Follow up on a sent quote **When:** a sent quote has a `follow_up_due` date of today or earlier. 1. Open the quote detail. Check the **Reply** chip — green if a reply is logged, red if not. 2. **If no reply:** - **Phone first** (faster than email). Log the call in `quote.notes`. - If no answer, send a follow-up email: keep it short, reference the original quote number, ask one direct question (e.g. "Are the JEI prices workable?"). - Update `follow_up_due` to today + 5 working days. 3. **If replied:** open the reply in `/quotes/email-monitor`, read it, then **either:** - **Customer accepted** → hand off to the Order Operator (create an order from quote). Mark the quote `approved` if not already. - **Customer wants changes** → see §3.3. - **Customer rejected** → flip to `rejected`. Move on. ### 2.9 Partial approval / split a quote **When:** a customer says "yes to lines 1, 2, 5; no to 3, 4". 1. Open the quote. 2. Click **Partial Approve**. For each line, mark `approved` or `rejected`. Add a `note` for each rejection. 3. Click **Split Quote** (in the action bar). A new quote is created with the rejected lines. 4. **Re-price the new quote** if needed (the prices inherit but sometimes a re-quote justifies a different markup). 5. **Send the new quote** following §2.7. --- ## 3. Decision Tree ### 3.1 I can't find a price for an item ``` Is the item in the catalogue (search /pricelist)? ├── Yes → use the catalogue row, set pricing_source="catalogue" └── No ├── Is there a recent price_list upload from the supplier? │ ├── Yes → open /pricelist, find the upload, check if the │ │ item is in the extracted list (sometimes AI misses │ │ lines) │ └── No → call the supplier └── Supplier not reachable / no price available → Set the line to "Re-quote" group (separate it from the rest of the quote). Don't hold up the rest of the quote. → Or, if the customer is waiting urgently, price the item at your best guess with a high margin (40%+) and add a pricing_notes disclaimer. ``` ### 3.2 The AI price looks wrong (too low / too high) ``` Is the catalogue price within 10% of the AI price? ├── Yes → accept the AI price, set pricing_source="ai" └── No ├── Low (< catalogue - 10%) → reject, use catalogue price, │ add pricing_notes "AI underpriced │ by X%, used catalogue per policy" └── High (> catalogue + 10%) → reject, use catalogue price, escalate to approver if the customer has a standing discount you don't know about ``` ### 3.3 Customer wants changes to a sent quote ``` Are the changes only pricing/markup? ├── Yes → re-price the affected lines, save, regenerate the PDF, │ send a "Revised quote" email referencing the original │ number. └── No (changes to scope, qty, items) ├── Customer wants fewer lines → Partial Approve (§2.9) + Split ├── Customer wants more lines → Add the lines, re-price, send │ as "Revised quote" └── Customer wants different items entirely → Clone the quote as a new RFQ, mark old as `rejected` ``` ### 3.4 The 30-min sprint claim is about to expire ``` Have you made progress (found a price, just not saved)? ├── Yes → save your work, even if partial. The claim can be re-claimed. └── No ├── Item is too hard → release (just don't save). It returns │ to the pool. └── Item needs supplier input → add to your "needs supplier" personal list, release the claim. ``` ### 3.5 Email send failed ``` What's the failure reason (check n8n log)? ├── SMTP / network error │ └── Click Retry on the quote. If it fails again, escalate to │ IT. The quote stays in `mail_failed` until retried. ├── "Recipient rejected" │ └── Check the email address. If typo, fix and resend. If │ genuine reject (mailbox full, domain dead), phone the │ customer for a new address. ├── "PDF render failed" │ └── Escalate to the Order Operator / dev. Don't retry — the │ PDF will fail again until the bug is fixed. └── Unknown └── Capture the full n8n log. Escalate with the log attached. ``` ### 3.6 Customer escalates (calls you directly to push for a discount) ``` Is the customer a strategic account (rep = senior staff)? ├── Yes → call the approver before quoting anything. Don't │ promise a discount over the phone. └── No ├── Discount < 5% → apply it. Log in pricing_notes with the │ reason. ├── Discount 5-15% → apply it, but flag the approver in the │ quote notes. └── Discount > 15% → don't apply. Tell the customer it's above your authority, escalate to the approver. Don't bluff. ``` ### 3.7 An inbound email looks like a new RFQ but lands in `Unknown Sender` ``` Is the sender a known customer (search /customers)? ├── Yes → reassign the quote to the right customer via the │ customer picker on the quote detail. └── No ├── Is the sender domain one we recognise (e.g. a known │ customer at a new branch)? │ └── Yes → ask the supervisor to create the customer, then │ reassign. └── No → it's a real new customer. Hand to the supervisor to create the customer record. Don't price the quote until the customer exists — the foreign key chain breaks otherwise. ``` ### 3.8 The AI extracted the quote wrong ``` What's wrong? ├── Wrong company / letterhead │ └── Re-pick the company on the quote header. The PDF will │ re-render with the right letterhead on the next send. ├── Wrong items (missing lines, extra lines, wrong description) │ ├── Missing line → add it manually, price it. │ ├── Extra line (AI hallucinated) → delete it. Log in │ │ quote.notes: "removed │ │ hallucinated line X". │ └── Wrong description → edit the description. The SKU │ and matched_price_list_id may │ need re-linking. ├── Wrong quantities │ └── Cross-check the original RFQ email attachment │ (quote.attachments). If the customer typed qty=200 │ and the AI read qty=20, fix it. If the AI hallucinated │ the qty, delete and re-add. └── Wrong unit (each / pack / metre) └── Edit per line. Note: until PB adds the `unit` column (see `quote-items` AGENTS.md), this is stored in the description, not a structured field. ``` ### 3.9 A walk-in customer has no account yet ``` Do you have a contact name and a way to follow up? ├── Yes (phone, email, name) → create the quote. The system │ creates a draft customer with the name you typed; the │ supervisor completes the address / VAT / contact fields │ later. Don't block the quote on missing customer data. └── No (anonymous, cash-on-delivery) → ask the supervisor before creating. We may need a different flow for cash customers (e.g. a "Cash" pseudo-customer). ``` --- ## 4. KPIs All KPIs are measured in **working days** (Mon–Fri, SAST). The dashboard's "Quote Pipeline" widget is the source of truth for most of these. | KPI | Target | How it's measured | |---|---|---| | **Sprint claim → priced turnaround** | < 30 min median | `now - claim_expires_at` for resolved items, where resolution = `pricing_source` set + `user_edited: true` | | **Quote first-touch turnaround** | < 2 working days | `now - created` for quotes whose status has left `new` | | **Quote send turnaround (priced → emailed)** | < 4 working hours after approval | `emailed_at - approved_at` | | **Follow-up rate** | 100% of overdue quotes actioned same day | count of quotes with `follow_up_due <= today AND last_action < today` (should be zero) | | **Approval rate** | > 70% of `approval_pending` quotes approved within 1 working day | `approved_at - submitted_at` | | **Sprint abandonment rate** | < 10% of claims released without save | `released_claims / total_claims` | | **Mail-failure rate** | < 2% of sent emails | `mail_failed / (mail_failed + emailed)` over rolling 30 days | | **AI pricing override rate** | < 15% of `pricing_source="ai"` lines changed before save | operator save of a different price on an AI-pre-priced line | | **Win rate** | > 35% of `emailed` quotes become orders | `orders.quote -> quotes.id` join, over rolling 90 days | | **Quote value captured** | tracked per rep, monthly | sum of `selling_price` on quotes in `emailed`/`approved`/`complete` status, attributed to `claimed_by` | ### How KPIs feed into your review - **Weekly** (Monday standup): the supervisor pulls a one-pager showing each quote operator's KPIs. Red flags = items below target for 2+ weeks. - **Monthly** (1st of month): the supervisor writes a 1-page review per operator covering the four KPIs they're weakest on and agreeing on a 30-day improvement action. - **Quarterly**: above-target on all KPIs for 2 consecutive months = eligible for the markup-authority bump (you can approve your own quotes up to 10% discount without supervisor sign-off). ### What the system does NOT measure (yet) - Customer satisfaction (no NPS in WFDASH yet) - Cross-sell effectiveness (no line-item upsell tracking) - Catalogue coverage of your claims (you might be claiming a lot of items that the catalogue doesn't cover — that's a catalogue problem, not a you problem, but it's worth flagging) If you think a KPI is unfair or misleading, raise it in `#quote-ops`. The supervisor can adjust targets, but the metric stays.