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

527 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.