WFDASH-WEBAPP/docs/sops/quote-operator.md
Dave aed3f5c861 feat: implement discount functionality in POS system
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.
2026-09-03 12:19:21 +02:00

22 KiB
Raw Blame History

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.