WFDASH-WEBAPP/docs/sops/quote-operator.md

528 lines
22 KiB
Markdown
Raw Normal View 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.