Added support for discounts in the POS checkout process, allowing operators to apply either a fixed amount or a percentage off the gross total. Updated the state management to include discount details and modified the receipt and cart rendering to display pre- and post-discount values. Enhanced the checkout API to handle discount inputs and ensure proper calculations. Updated relevant tests to cover new discount logic.
22 KiB
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
newstatus. - 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 inneware 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_duedate of today or earlier must get a follow-up action today (call, email, or set a newfollow_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_notesbreadcrumb on what's been tried. - Confirm every quote you sent today has moved out of
sending— if any are stuck insendingfor >5 min, see §3.5. - Set
follow_up_duefor 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-opswith a one-line reason. - Clear the
/quotes/approvalsqueue 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
claimby you with no activity for 3+ days must either be priced, sent, orproblem-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.
- Open
/quotes?tab=new. Find the new quote. - 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.
- Customer — did the AI match it to a known customer? If
it landed on
- Check
pricing_sourceon each line:aiorcatalogue→ already priced. Verify the price against the AI's reasoning (if shown). If sane, move on.manualor empty → unpriced. These lines will land in the sprint queue (see §2.4).
- Claim the quote by clicking Claim on the header (or
it'll auto-claim on first edit). Status flips to
claimed. - Triage unpriced lines: if the sprint queue has unpriced lines from this quote, claim them in §2.4.
- 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_notesbreadcrumb 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.
- Open
/quotes/new(or click "+ New Quote" on the quotes list). - 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.
- Pick the company (which letterhead). CNL vs ISHERWOOD etc — match what the customer is used to.
- Set the validity (default 30 days).
- 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.
- Add items in the line editor:
- One row per SKU / spec combo the customer asked for.
- Each row needs at minimum
description. Addquantityandunit. - If you know the price (e.g. the customer named a SKU that
is in the catalogue), set
cost_priceandmarkup—selling_priceis auto-derived. - If you don't know the price, leave it blank and the lines will land in the sprint queue (§2.4).
- Save. The quote lands in
newstatus. - 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.
- Open
/quotes?tab=templates. Pick the template that matches. - 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).
- Update
descriptionto reflect the new RFQ (date, customer reference, anything that's changed since the template was made). - Update quantities if the customer wants different amounts.
- Re-price the lines from the current catalogue (or leave them for sprint if the SKU / supplier has changed).
- 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.
- Open
/sprint. The "UNCLAIMED" tab shows available items. - Prefer items you can resolve in <30 min — known SKUs, items with attachments, or items in your strongest category.
- Click Claim. The 30-min TTL starts. The item moves to your
Claimedfilter. - 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.
- Read the description. If the AI provided a match (look for
matched_price_list_idfilled in), use it. - Decide pricing source:
- Catalogue (
pricing_source: "catalogue") — the matched catalogue row has a validcost_priceandmarkup. 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).
- Catalogue (
- Set
cost_priceandselling_price. Selling price is auto-derived fromcost_price × (1 + markup/100)if you set the markup — passselling_priceexplicitly only when the catalogue has a stored selling price you want to preserve. - Set
pricing_sourceto the source you actually used. - Add a
pricing_notesbreadcrumb (e.g. "matched JEI catalogue row, ZAR excl VAT, 30% markup per August 2026 policy"). - Click Save. The item drops off the sprint queue. The linked
quote_itemflips touser_edited: true.
Don't:
- Don't save with
cost_price = 0andselling_price = 0. That's a sign you didn't find a price — see §3.1. - Don't override a catalogue price without a
pricing_notesreason.
2.6 Send a quote for approval
When: every line on a quote has selling_price > 0.
- Open the quote detail (
/quotes/[id]). - Click Submit for Approval. The status flips to
approval_pending. Idempotent — if it's already inapproval_pending, nothing changes. - If the submit returns
unpriced_itemswithoffendingItemIds:- Open each offending line.
- Either price it (see §2.5) or move it to a "Re-quote" group (see §2.9).
- 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_statusfornewquotes.
2.7 Send the quote to the customer
When: the approver has flipped the quote to approved.
- Open the quote detail.
- Click Send Email. The email modal opens.
- Verify the recipient(s):
To— the customer's primary email.CC— usually the customer rep (repon the customer row).BCC— internal archive address (already pre-filled).
- Edit subject/body if needed. Default subject is
Quote {number} — {description}. Don't remove the quote number — the inbound reply tracker uses it. - Attachment picker: the quote PDF is auto-included. Add any supporting docs (datasheet, authorisation letter) the customer asked for.
- Click Send. Status flips to
sending→ n8n takes over the SMTP send → callback recordsemailedormail_failed. - Set
follow_up_dueto 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.
- Open the quote detail. Check the Reply chip — green if a reply is logged, red if not.
- 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_dueto today + 5 working days.
- Phone first (faster than email). Log the call in
- 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
approvedif not already. - Customer wants changes → see §3.3.
- Customer rejected → flip to
rejected. Move on.
- Customer accepted → hand off to the Order Operator
(create an order from quote). Mark the quote
2.9 Partial approval / split a quote
When: a customer says "yes to lines 1, 2, 5; no to 3, 4".
- Open the quote.
- Click Partial Approve. For each line, mark
approvedorrejected. Add anotefor each rejection. - Click Split Quote (in the action bar). A new quote is created with the rejected lines.
- Re-price the new quote if needed (the prices inherit but sometimes a re-quote justifies a different markup).
- 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.