refactor(sops): streamline SOP documents for roles, consolidating content into quickstart guides for easier reference

This commit is contained in:
Dave 2026-09-03 17:57:49 +02:00
parent c4bf4a24ab
commit 064145e5b7
6 changed files with 1269 additions and 1843 deletions

View file

@ -1,80 +1,32 @@
# WFDASH Standard Operating Procedures (SOPs)
# WFDASH SOPs
These documents define the **what, how, and how-measured** for every operator
role in the Westcott Fasteners business. Each role has its own SOP file
covering:
Quick cheat sheets for each role in the business. Read your role's SOP
on your first day. Keep it handy.
1. **Daily / Weekly Checklist** — concrete actions with cadence
2. **Step-by-Step Procedures** — how to do each recurring task
3. **Decision Tree** — what to do when X happens
4. **KPIs** — the scorecard, with how each metric is measured
## The four roles
The source of truth is the markdown in this folder. The WFDASH app
renders them at `/sops` for in-app reference.
| File | What they do |
|---|---|
| [quote-operator.md](./quote-operator.md) | Answer the phone, price items, send quotes, follow up. |
| [order-operator.md](./order-operator.md) | Turn accepted quotes into orders. Buy from suppliers. Get goods to the customer. |
| [collection-driver.md](./collection-driver.md) | Drive to suppliers, collect the goods, deliver to customers. (Mobile app.) |
| [approver-supervisor.md](./approver-supervisor.md) | Sign off quotes, run the weekly review, handle escalations. |
---
## How to read your SOP
## Roles
Each one has four short sections:
| Role | File | What they own |
|---|---|---|
| **Quote Operator** | [`quote-operator.md`](./quote-operator.md) | Sprint queue, line-item pricing, sending quotes, follow-ups |
| **Order Operator** | [`order-operator.md`](./order-operator.md) | Order lifecycle, purchase orders, delivery, invoicing handoff |
| **Collection Driver** | [`collection-driver.md`](./collection-driver.md) | Mobile app, supplier pickups, deliveries, surplus/shorts |
| **Approver / Supervisor** | [`approver-supervisor.md`](./approver-supervisor.md) | Approvals queue, pricing policy, escalations, dashboards |
1. **What you do** — your job, in plain English.
2. **Your day** — the actions, in order, with times.
3. **How to do the common stuff** — step-by-step for the 3-4 tasks
you'll do most often.
4. **Don'ts & when to ask** — the 3-4 things you must never do, and
the 3-4 situations where you stop and ask the supervisor.
---
That's it. There are no bonus points for reading more.
## How to read these
## When something changes
- Each SOP is **role-scoped**. Don't read another role's SOP unless you're
cross-training. They overlap on shared handoffs — those overlaps are
intentional and called out in the relevant procedures.
- The **Daily Checklist** is the minimum bar. If you only have 10 minutes,
do the checklist.
- The **Decision Tree** is a fallback. When you encounter the situation
and you don't know what to do, find the branch. The tree is not
exhaustive — when in doubt, escalate (see each role's escalation path).
- **KPIs are not aspirational.** They are how your performance is measured
in the weekly review. If a KPI is unclear, raise it — don't ignore it.
---
## How to suggest changes
1. Open a PR against the relevant file.
2. PR title: `sop(<role>): <change>` (e.g. `sop(quote-operator): add
partial-approval procedure`).
3. Tag the approver / supervisor in the review.
4. After merge, the `/sops` page in the app updates on the next deploy.
If you can't write the change yourself, drop the suggestion in
`#sop-feedback` and someone will turn it into a PR.
---
## Conventions used in these documents
- **Routes** are written as `/path/to/page` (e.g. `/sprint`,
`/orders/123`).
- **Buttons / actions** are in **bold** (e.g. **Claim**).
- **Status values** are in `code` (e.g. `approval_pending`,
`collection_pool`).
- **Collection / field names** are in `code` (e.g.
`order_items.item_status`).
- **SLA times** are in working days (Mon–Fri, SAST). Weekends don't
count. The `isOlderThanWorkingDays()` helper in the dashboard uses
the same convention.
- **Currency** is ZAR (excl. VAT) unless otherwise noted.
---
## When a section is missing or wrong
These SOPs are **derived from `APP_CAPABILITIES.md`, the
`item_status` lifecycle, the quote/order state machines, and the
order-workflow-rules**. If the system has changed and the SOP hasn't:
- It's a doc bug. **File a PR** or ping the supervisor.
- The SOP is **not** authoritative over the system. The system is
authoritative. If they disagree, follow the system and fix the SOP.
If your job changes and the SOP doesn't match, tell the supervisor.
They'll fix the SOP. Don't keep doing the old thing because the SOP
said so.

View file

@ -1,439 +1,122 @@
# Approver / Supervisor — SOP
# Approver / Supervisor — Quickstart
**Owns:** the approvals queue, pricing/markup policy, escalations
from Quote and Order Operators, weekly KPI reviews, and the
health of the system overall.
**One-line job:** keep the team unblocked, the system healthy, and
the customers happy.
**Tools:** `/quotes/approvals`, all operator routes (read),
`/dashboard`, `/customers`, `/suppliers`, `/settings`,
`/api/v1/...` admin endpoints, the daily summary call.
You don't do the pricing or the driving. You make sure the people
who do have what they need, the system gives them the right signals,
and the customers don't fall through the cracks.
**Handoffs:**
- **From Quote Operator** → quote in `approval_pending`.
- **To Quote Operator** → approved / rejected / change-requested.
- **From Order Operator** → escalations (cancellation, refund,
discount > 15%, supplier issue).
- **To Order Operator** → decision + instruction.
- **From Collection Driver** → escalations (refusal, damage
dispute, customer relationship issue).
- **To everyone** → policy updates, training, weekly review.
## What you do
---
- Sign off quotes that landed in `/quotes/approvals`.
- Handle escalations the operators bring to you (discount, cancellation,
customer complaint, supplier dispute).
- Pull the weekly KPI one-pager, run the Monday standup.
- Write the Friday summary.
- Set pricing policy when costs / markets shift.
## 1. Daily / Weekly Checklist
## Your day
### Every working day (start of shift, 08:00 SAST)
| Time | What to do |
|---|---|
| **08:00** | Open `/quotes/approvals`. Clear the queue. Anything > 4 hours old = bug. |
| **08:30** | Glance at `/dashboard`. Approval rate, pipeline depth, follow-ups due. Anything red? |
| **13:00** | Re-check `/quotes/approvals`. Anything still pending from this morning is now a bug. |
| **17:00** | Triage escalations. Every escalation gets a decision or a clear next step. |
- [ ] **Open `/quotes/approvals`.** Clear the queue. Anything
older than 4 working hours needs a decision today.
- [ ] **Open `/dashboard`.** Quick scan:
- **Approval Rate** gauge — should be > 70% on a healthy
pipeline.
- **Quote Pipeline** donut — too much in `new` for >2 days
is a Quote Operator capacity problem.
- **Follow-ups Due** — count should be trending down.
- **Concentric rings** (quote pipeline depth) — if the
"Approved" ring is growing while the "Sent" ring is
shrinking, the email send flow has a problem.
- [ ] **Check `#quote-ops` and `#order-ops`** channels. Triage
any escalations posted overnight.
- [ ] **Skim `/orders` — Cancelled tab.** A spike in
cancellations = supplier problem or pricing problem.
Investigate.
**Monday** (90 min block): pull the weekly KPI one-pager, run the
standup (15 min), update pricing policy if needed.
### Every working day (mid-day, 13:00 SAST)
**Friday** (afternoon): write the 1-page weekly summary, forecast
next month's cash collection.
- [ ] **Re-check `/quotes/approvals`.** Anything still in
`approval_pending` from this morning is now a process bug.
- [ ] **Triage any pending escalations.** Quote Operators and
Order Operators should be unblocked by lunch.
- [ ] **Review one random operator action** (audit). Pick an
order or quote at random, walk through the action log.
Calibrates your understanding of the team's actual work.
## How to do the common stuff
### Every working day (end of shift, 17:00 SAST)
### 1. Approve a quote
- [ ] **Clear the day's escalations.** Every escalation should
have either a decision or a clear next step.
- [ ] **Review today's KPI snapshot.** Any operator trending
red? Send them a 1-line note (not a formal message — keep
it human).
### Every Monday morning (90 min block)
- [ ] **Pull the weekly KPI one-pager** for each Quote and
Order Operator (and the Collection Driver's app-export).
- [ ] **Hold the weekly standup** (15 min):
- Each operator: 1 number (their worst KPI) + 1 win.
- You: 1 policy reminder + 1 system change coming this week.
- [ ] **Update pricing policy** if any of these moved:
- Supplier price list changes (cost-side).
- Competitor pricing (market-side).
- Currency moves (ZAR volatility).
- A new strategic account needs a standing discount.
### Every Friday afternoon
- [ ] **Write the 1-page weekly summary.** Distribution: owner
(you), the 4 operators, the invoicing clerk (if separate),
and the owner / MD if you have one. Sections:
- **Pipeline health** (counts by status, vs last week).
- **Top 3 wins** (one per operator, named).
- **Top 3 issues** (with an owner + deadline).
- **1 thing we changed** (a process, a policy, a system tweak).
- **Next week's risks.**
- [ ] **Forecast cash collection** for the next 30 days. Pull
`delivered` orders not yet `invoiced`, sum their
`selling_price`. This is the cash-in number the owner wants.
### Every month (1st, 60 min)
- [ ] **Monthly 1-on-1 with each operator.** 30 min each.
Agenda (use the 1-page review you wrote):
- Their KPIs vs target.
- Their 30-day improvement action.
- What they need from you (training, tools, headcount).
- What they want to keep doing.
- [ ] **Catalogue coverage review.** What % of quote items
this month were matched in the catalogue? If < 50%, the
Pricelist Curator needs to upload more supplier lists.
- [ ] **Supplier performance review.** Late deliveries,
cost-mismatch rate, damage rate. Note any supplier that
should be replaced or have terms renegotiated.
### Every quarter
- [ ] **Strategic review.** 2-hour session with the owner.
Topics: revenue trend, margin trend, customer concentration
risk, supplier concentration risk, system gaps.
- [ ] **Operator tier reviews.** Who's eligible for markup-
authority / trusted-buyer / senior-driver tier?
- [ ] **System roadmap input.** What 1-2 features would
unlock the most operator capacity?
---
## 2. Step-by-Step Procedures
### 2.1 Approve a quote
**When:** a quote lands in `approval_pending`.
1. Open `/quotes/approvals`. The queue is sorted by age,
oldest first.
2. Click a quote. Review:
1. Open `/quotes/approvals`. Oldest first.
2. For each quote, check:
- **Customer + rep** — known account? Standing discount?
- **All lines priced** (every line has `selling_price > 0`).
- **Markup** — does the markup meet the policy for this
category / customer?
- **Validity** — is the validity date realistic?
- **Supplier** — do we have a healthy relationship with
this supplier? Any open POs with them?
3. **Decide:**
- **Approve** → click **Approve**. Status flips to
`approved`. The Quote Operator can now send.
- **Request changes** → add a comment in the change-
request field. Status stays in `approval_pending` (or
reverts to `processed` if it was a pricing issue). The
Quote Operator re-submits.
- **Reject** → add a reason. Status flips to `rejected`.
The Quote Operator may revise or close the quote.
4. **Log the decision in `quote.notes`** (auto-stamped by
the system, but add context if it's non-obvious — e.g.
"Approved at 18% markup, customer's standing discount
from June 2026 contract").
- **Every line priced** — any zeros? Refuse and send back.
- **Markup** — does it match the policy for this category / customer?
- **Validity date** — realistic?
3. **Approve** (status → `approved`, operator can send) or
**Request changes** (operator re-prices) or **Reject** (with
a reason — not a silent "no").
4. **Log the decision in the quote's notes** if it's non-obvious
(e.g. "approved at 18% markup per Aug 2026 customer contract").
**Don't:**
- Don't approve without reading the full quote. A 30-second
review misses the wrong SKU, the wrong qty, the wrong
customer.
- Don't reject without a reason. "No" is demoralising;
"No, because the JEI prices are below cost" is a teachable
moment.
### 2. Handle a "should I give this discount?" escalation
### 2.2 Set / change a pricing policy
- **< 5%** — the operator can do it. Tell them to apply and log.
- **5-15%** — if strategic account OR one-off (large order, end-of-line),
approve. Otherwise tell the operator to push back.
- **> 15%** — you (or the owner) call the customer. Don't delegate.
**When:** cost or market conditions change.
### 3. Handle a "customer wants to cancel" escalation
1. **Identify the affected scope.** Is it a category (e.g.
"all FASTENERS"), a customer (e.g. "Account XYZ"), or a
supplier (e.g. "all JEI lines")?
2. **Pull the data.** What is the current avg markup for
that scope? What are competitors charging? What's the
supplier cost trend?
3. **Propose the new policy.** Document:
- Scope (which customers / categories / SKUs).
- New markup range.
- Effective date.
- Reason (market move, supplier cost, strategic account).
4. **Communicate.** Post in `#quote-ops`, mention in the
Monday standup, and pin in `/settings` (if there's a
pricing-policy field there).
5. **Audit after 2 weeks.** Pull the avg markup for the
scope — is the team applying the new policy? If not,
retrain.
- **No PO sent yet** — easy, cancel the line.
- **PO sent, supplier hasn't confirmed** — phone the supplier NOW
(most will cancel without charge if pre-confirm).
- **PO confirmed, goods in transit / collected** — three options:
accept and book to stock, refuse delivery (restocking fee?), or
return-to-vendor. You decide based on the customer + the cost.
### 2.3 Handle an escalation from a Quote Operator
### 4. Set / change pricing policy
**When:** a Quote Operator pings you with a question
(markup override, customer relationship, stuck item).
1. Identify the scope (category? customer? supplier?).
2. Pull the data: current avg markup for that scope, competitor
pricing, supplier cost trend.
3. Document the change: scope, new range, effective date, why.
4. Communicate: post in the team chat, mention Monday standup, pin
in `/settings` if there's a policy field.
5. Audit after 2 weeks — is the team actually applying it?
1. **Read the full context** before responding. Don't
respond to a 1-line ping with a 1-line answer.
2. **Categorise:**
- **Pricing** → see §2.2. Likely a one-off override.
- **Customer relationship** → call the customer yourself
if strategic; delegate back if not.
- **System / data** → the system has a gap. File a
feature request in `src/app/api/mcp/FEATURE-REQUESTS.md`
(or whatever the current backlog file is).
- **Process** → the SOP is unclear. Update the relevant
SOP (and tell the team).
3. **Respond with a decision + a why.** The operator needs
to learn from the decision, not just execute it.
4. **Follow up next day.** Verify the decision was applied.
## Don'ts — the 3 things you must never do
### 2.4 Handle an escalation from an Order Operator
1. **Never approve a quote without reading it.** 30-second reviews
miss the wrong SKU, wrong qty, wrong customer. If you don't have
time, do it later. Don't rubber-stamp.
2. **Never reject an operator's escalation without a reason.** "No"
is demoralising. "No, because the markup is below the policy
for this category" is a teachable moment.
3. **Never push a system-level decision to the operators.** If
the data is wrong, the SOP is unclear, or a workflow is
broken — that's yours to fix, not theirs to work around.
**When:** an Order Operator pings you with cancellation,
refund, discount > 15%, supplier dispute, customer
complaint.
## When to escalate to the owner (the 4 most common situations)
1. **Same triage as §2.3**, but the stakes are usually
higher (real money, real relationships).
2. **For customer complaints** — get the customer's side of
the story first. Don't take the operator's word at face
value; don't take the customer's at face value either.
3. **For supplier disputes** — pull the PO + delivery
evidence (photos, signed notes). Make the call based on
the contract, not the relationship.
4. **Document the decision** in the order's `notes` and
your private escalation log.
- **Strategic account (top 10% by spend) wants bespoke terms.** Call
the owner. Strategic accounts can have bespoke anything; it's
a relationship call, not an SOP.
- **A supplier wants to renegotiate terms that change > 20% of our
spend.** Call the owner. This is a business call, not an
operator call.
- **A customer escalates a quality issue and asks for the owner /
MD by name.** Call the owner. Don't try to be the buffer.
- **Anything involving a refund > R50,000, a credit note > R100,000,
or a write-off.** Call the owner. Documented, in writing.
### 2.5 Run a weekly KPI review
## How you're measured (the 3 numbers that matter)
**When:** every Monday.
1. **Approval turnaround** — target: **< 4 working hours median.**
(Quotes don't sit in your queue.)
2. **Escalation response time** — target: **< 2 working hours.**
(Operators aren't blocked waiting on you.)
3. **Team KPI attainment** — target: **> 80% of operator KPIs at
target** each week. (Your team is doing their job.)
1. **Pull the one-pager.** Each operator has 4-6 KPIs. The
report highlights who's red, who's yellow, who's green.
2. **For each red operator:**
- Look at the underlying data. Is it a process issue,
a capacity issue, or a skill issue?
- **Process** → fix the SOP, retrain.
- **Capacity** → redistribute the load or accept the
miss.
- **Skill** → 1-on-1 coaching, shadow a senior operator.
3. **For each green operator:**
- Find the win. Mention it in the standup.
- If they're consistently green, consider the tier
bump.
4. **Write the 1-page weekly summary.** (See §1 Friday.)
Bonus number: **catalogue coverage** — target > 60% of quote items
matched. If low, the Pricelist Curator needs to upload more supplier
lists. That's your nudge, not theirs.
### 2.6 Audit a random operator action
## Who to ask
**When:** mid-day, when you have 10 minutes.
1. Pick a random order or quote from the last 24 hours.
2. Walk the action log: who touched it, when, what
changed.
3. **Look for:**
- Status transitions that don't match the SOP (e.g.
`pending` → `delivered` skipping the intermediate
states).
- Pricing changes without a `pricing_notes` breadcrumb.
- Items stuck in a state longer than the SLA.
4. **If you find a real issue:** message the operator
privately, not in public. Coaching, not blame.
5. **If you find a SOP gap:** update the SOP.
---
## 3. Decision Tree
### 3.1 Should I approve this discount?
```
What's the customer's standing discount (per the customer
record / contract)?
├── Matches → approve at face value
└── Doesn't match
├── Is the difference small (< 3%)? → approve, log the
│ reason (e.g.
│ "volume uplift
│ from Q3 contract").
├── Is the difference medium (3-10%)? → approve if
│ the customer
│ is strategic
│ OR the deal
│ is a one-off
│ (large order,
│ end-of-line
│ stock, etc).
│ Otherwise,
│ ask the
│ operator to
│ split the
│ margin with
│ the customer
│ themselves.
└── Is the difference large (> 10%)? → talk to the
customer
yourself.
Strategic
pricing is a
relationship
call, not a
quote.
```
### 3.2 Supplier wants to renegotiate terms
```
Is the supplier a strategic dependency (> 20% of our spend)?
├── Yes → call the supplier yourself. Don't delegate.
│ Renegotiation is a relationship call.
└── No
├── Will the new terms save us money or improve
│ service? → accept, log in the supplier record.
└── Will they cost more or worsen service? → decline,
keep the
relationship
professional
(don't burn
the bridge).
```
### 3.3 Customer escalates a quality issue
```
Is the issue real (defect, wrong item, damaged in transit)?
├── Yes
├── Was the issue caused by us (wrong pick, late
│ delivery, damaged in our care)?
│ ├── Yes → own it. Replace or refund, no argument.
│ │ Don't ask the customer to prove the
│ │ damage.
│ └── No (supplier defect, customer mishandling)
│ ├── Defect is our supplier's → call them, get
│ │ a credit or
│ │ replacement, pass
│ │ it on.
│ └── Customer mishandling → politely decline.
│ Offer a goodwill
│ discount at your
│ discretion.
└── What's the cost of fixing vs losing the customer?
├── Customer is strategic → fix, no matter the
│ cost. Relationships
│ > margins.
└── Customer is transactional → fix only if it's
cheaper than
losing them. Most
one-off customers
aren't worth a
free replacement.
```
### 3.4 An operator is consistently missing a KPI
```
How long have they been missing it?
├── < 2 weeks → noise. Mention in the 1-on-1, move on.
├── 2-4 weeks → pattern. Diagnose:
│ ├── Process gap (unclear SOP) → update the SOP,
│ │ retrain.
│ ├── Capacity gap (too much load) → redistribute.
│ └── Skill gap (doesn't know how) → pair with a
│ senior operator for
│ a week.
└── > 4 weeks → performance issue. Formal conversation.
Set a 30-day improvement target with consequences
if missed.
```
### 3.5 A quote is in `approval_pending` for > 1 working day
```
Who submitted it?
├── Quote Operator on shift → ping them directly. "Why is
│ this still in my queue?"
└── Quote Operator off shift / on leave → reassign to
another operator
OR approve it
yourself if
straightforward.
```
### 3.6 The system is misbehaving (data loss, wrong status, etc.)
```
Is the issue isolated to one record?
├── Yes → fix the record directly via the admin tools or
│ `pb-helpers.ts` / service layer. Document the
│ fix.
└── No (multiple records, or a pattern)
├── Stop the workflow that's causing the damage.
│ (e.g. pause the n8n email intake if it's creating
│ bad quotes.)
├── Page the dev. Don't try to fix the code yourself
│ unless you ARE the dev.
├── Communicate to the team: "stop doing X until
│ further notice."
└── After the fix: write up the post-mortem. What
happened, what we did, what we'll do to prevent
it. Share with the team.
```
### 3.7 Strategic account wants terms we don't usually offer
```
What's the customer's annual spend with us?
├── Top 10% → call the owner / MD. Strategic accounts
│ can have bespoke terms; it's a relationship
│ call.
├── 10-25% → decide yourself. Document the terms in the
│ customer record, in a contract if material.
└── Bottom 75% → decline. Standardise. Bespoke terms
for non-strategic accounts erode
margin and create admin overhead.
```
---
## 4. KPIs
The supervisor's KPIs are different from the operators' —
they're about the team's health, not the supervisor's own
output.
| KPI | Target | How it's measured |
|---|---|---|
| **Approval turnaround** | < 4 working hours median | `approved_at - submitted_at` on `quotes/approvals` |
| **Escalation response time** | < 2 working hours | `decision_at - escalation_at` on the escalation log |
| **Team KPI attainment** | > 80% of operator KPIs at target | weekly one-pager summary |
| **Quote → order conversion** | > 35% trailing 90 days | orders / emailed quotes |
| **On-time delivery rate** | > 90% trailing 30 days | orders delivered by `delivery_date` / total delivered |
| **Catalogue coverage** | > 60% of quote items matched | sprint items with `matched_price_list_id` / total sprint items |
| **Supplier cost-mismatch rate** | < 3% trailing 30 days | `cost_mismatch` verdicts / total receives |
| **Customer concentration risk** | top customer < 30% of revenue | revenue by customer over trailing 90 days |
| **System uptime** | > 99% trailing 30 days | uptime of WFDASH + PB + n8n (monitor externally) |
| **Team tenure** | trending up | months since each operator's start date |
### How the supervisor's KPIs feed into YOUR review
- **Weekly**: the owner / MD sees the weekly summary. Your
team KPIs are the headline.
- **Monthly**: 1-on-1 with the owner / MD. Focus on
strategic accounts, concentration risk, and system gaps.
- **Quarterly**: strategic review. This is where you push
for the headcount / tools / process changes the team
needs.
### What the system does NOT measure (yet)
- Operator morale (no internal NPS yet)
- Customer satisfaction (no external NPS yet)
- Operator skill growth (no per-operator skill matrix yet)
If a KPI is unfair, raise it. The owner can adjust
targets, but the metric stays.
- **The owner / MD** — for any of the 4 escalation situations above,
anything strategic, anything you can't decide alone.
- **Other supervisors / industry peers** — for pricing policy sanity
checks ("does this markup make sense for JEI?").
- **When in doubt, write it down.** If you made a decision, log it
in the relevant record. Future you (and the auditors) will
thank you.

View file

@ -1,383 +1,118 @@
# Collection Driver — SOP
# Collection Driver — Quickstart
**Owns:** picking up goods from suppliers, delivering to customers,
capturing proof of collection and delivery. Runs on the
**WFDASHDRIVER-APP** (mobile) and `/collection-confirm` (browser
fallback).
**One-line job:** pick up goods from suppliers, drop them at customers.
**Tools (mobile):** WFDASHDRIVER-APP, `/collection-confirm`.
**Tools (office view):** `/collection-pool`, `/collection-map`.
You do this on your **phone** (WFDASHDRIVER-APP) or the office
`/collection-confirm` page. The app is the source of truth — what's
on the app is what counts.
**Handoffs:**
- **From Order Operator** → PO is `po_generated`, item is in
`collection_pool` or `assigned` to you.
- **To Order Operator** → you mark `collected` on the way back
to the office, or on delivery.
- **To Stock Controller** → surplus or damaged goods need to be
booked.
## What you do
**Note:** `drv_status` and `drv_id` are the driver-side fields on
`order_items`. The status used here is the item-level
`item_status` (see Decision Tree for the exact mapping).
Every day, the app has a list of pickups and deliveries. You:
---
- Drive to the supplier, collect the goods.
- Take a photo + sign at the supplier.
- Drop the goods at the customer.
- Take a photo + get the customer's signature.
- Flag anything weird (short, wrong, damaged) before leaving.
## 1. Daily / Weekly Checklist
## Your day
### Every working day (start of shift, 07:00 SAST)
| Time | What to do |
|---|---|
| **07:00** | Open the app. Look at today's run — suppliers, in order. |
| **07:15** | Phone the first supplier if you haven't worked with them in a while. Confirm they're open. |
| **08:00 - 17:00** | Do the pickups + deliveries. Use the app, not memory. |
| **12:00** | Lunch + call the office if anything is blocking. |
| **17:00** | Sync the app. Mark all today's items with their final status. Nothing in flight overnight. |
- [ ] **Open WFDASHDRIVER-APP — Today's Run.** Review the
supplier list, the order of pickups, and any urgent adds from
the Order Operator.
- [ ] **Check the truck:** fuel, waybills, blank GRN books,
packing tape, camera (phone), and a power bank.
- [ ] **Confirm any special instructions** on items (oversize,
fragile, supplier with restricted hours).
- [ ] **Phone the first supplier** if you haven't worked with
them in a while. Confirm they're open and the order is ready.
**Before you leave the truck**: every item from today is either
`collected`, `delivered`, or marked with a problem and a note. If
you don't, the office won't know what happened.
### Every working day (mid-day, 12:00 SAST — or at lunch)
## How to do the common stuff
- [ ] **Triage anything blocking you.** Phone the Order Operator
if a supplier can't fulfil today, if a customer can't receive,
or if you have a route problem.
- [ ] **Update the app** with any deviations (supplier short,
customer closed, etc).
### 1. At the supplier — happy path
### Every working day (end of shift, 17:00 SAST)
1. Tap the supplier row in the app. Tap **Navigate** (opens Maps).
2. At the supplier, tap **Confirm Collection**.
3. Scan or tap each line. Enter the qty received.
4. **Photograph the supplier's delivery note / invoice.** The app
attaches it automatically.
5. Tap **Submit**. Items flip to `collected`.
- [ ] **Mark all today's items with their final status.** No
item should be left "in flight" overnight. If a customer can't
receive, mark `assigned` (not `delivered`) and add a note.
- [ ] **Park any surplus / damage** with the Stock Controller.
Don't leave it on the truck.
- [ ] **Sync the app** — verify all photos uploaded, all
confirmations saved. Mobile data can be patchy; the sync
indicator in the top-right is the truth.
### 2. At the supplier — short / wrong / damaged
### Every Monday morning
- [ ] **Review your KPIs** (see §4). Look at last week's
collection rate, on-time rate, and error rate.
- [ ] **Walk the truck.** Inventory the packing materials,
reusable crates, etc. Report any missing.
### Every Friday afternoon
- [ ] **Triage any open collections** — items still
`collection_pool` over the weekend are at risk of supplier
closure or customer cancellation. Flag to the Order Operator.
---
## 2. Step-by-Step Procedures
### 2.1 View your assigned run
**When:** you arrive for your shift.
1. Open WFDASHDRIVER-APP.
2. Tap **Today's Run**. You see a list grouped by supplier, in
the order the Order Operator suggested (usually optimised by
proximity — see `/collection-map`).
3. For each supplier, you see:
- Supplier name, address, contact.
- Items to collect, with qty + description.
- The PO number (so the supplier can match).
- Any special instructions.
**If you need to re-order the run:** tap the supplier row,
**Re-order**. The Order Operator gets a notification.
### 2.2 Navigate to a supplier
**When:** you're at the first pickup.
1. Tap the supplier row in the app.
2. Tap **Navigate**. The app opens your default maps app with
the supplier's address.
3. **If the address is wrong:** tap **Report Wrong Address** on
the supplier row. Don't try to find the supplier yourself —
the Order Operator needs to fix the master data.
### 2.3 Confirm collection (the happy path)
**When:** you arrive at the supplier and the order is ready.
1. Tap **Confirm Collection** on the supplier row.
2. The app shows each line item. For each:
- **Scan the barcode** if the supplier labels have them
(faster, more accurate). The app matches against the
expected SKU.
- **Or tap to mark received** if no barcode.
3. If the supplier gave you a delivery note / invoice (paper
or PDF), tap **Attach**. Photograph it. The app stores it
with the standard `supplier_invoice_` filename prefix.
4. **Review the totals** at the bottom. The qty received should
match the qty ordered.
5. Tap **Submit**. The order items flip to `collected` and
the supplier invoice is attached to the PO.
**Don't:**
- Don't submit without attaching the supplier's invoice. The
Order Operator needs it to do the diff/receive.
- Don't submit with the wrong qty. If the supplier shorted
you, see §3.1.
### 2.4 Confirm a partial collection
**When:** the supplier delivered less than ordered.
1. Follow §2.3 up to step 3.
2. For each line, enter the **actual qty received** (not the
full qty). The app shows the difference.
3. In the **Note** field, write the reason ("supplier short by
2, ETA TBD" or "supplier out of stock on this SKU").
4. Continue. Tap **Submit**.
5. The items flip to `collected` with `qty_collected` set to
the partial amount. The Order Operator creates a
continuation order for the missing qty (see Order Operator
§2.6).
### 2.5 Report a wrong / damaged item
**When:** the supplier gave you the wrong item, or it's damaged.
1. **Don't accept the goods** (refuse at the supplier).
1. **Don't accept** the bad goods. Talk to the supplier's rep.
2. In the app, on the line, tap **Report Problem**.
3. Choose: `wrong_item`, `damaged`, `expired`, or `other`.
4. **Take photos** — at least 2 (whole item + close-up of the
problem). The app attaches them with a `damage_` or
`wrong_item_` filename prefix.
5. Add a note explaining the situation.
6. **Get the supplier's rep to sign** the delivery note with
the discrepancy. Photograph the signed note.
7. Tap **Submit**. The item stays in `collection_pool` (not
flipped to `collected`). The Order Operator handles the
dispute.
3. Pick: `short` / `wrong_item` / `damaged` / `other`.
4. **Take 2+ photos** (whole item + close-up of the issue).
5. Get the supplier to **sign the delivery note** with the
discrepancy. Photograph the signed note.
6. **Add a note** explaining what happened.
7. Tap **Submit**. The item stays in `collection_pool` — the office
handles the dispute.
### 2.6 Deliver to a customer
### 3. At the customer — happy path
**When:** you're at the customer's site with their order.
1. Tap the order row. Tap **Confirm Delivery**.
2. Each line: tap **Delivered** (qty matches).
3. **Photograph the goods at the customer's site.**
4. **Get the customer's signature** on the app (or on the waybill
+ photograph it).
5. Tap **Submit**. Items flip to `delivered`.
1. Tap the order row in the app.
2. Tap **Confirm Delivery**.
3. **For each line:**
- **Mark delivered** (qty matches).
- Or **partial delivery** (customer accepts less — see §3.4).
- Or **refused** (customer won't accept — see §3.5).
4. **Take a photo of the delivered goods** at the customer's
site (best practice for disputes).
5. **Get the customer's signature.** The app captures the
signature on screen. Alternatively, photograph the signed
waybill.
6. Tap **Submit**. Items flip to `delivered`.
### 4. At the customer — partial / refused / not home
**Don't:**
- Don't deliver without a signature / photo. A "delivered"
item without proof is a dispute magnet.
- **Customer wants less than you brought:** mark the line as
**Partial**, enter the qty they took, return the rest to the
office. Note why.
- **Customer refuses (damaged, wrong, "changed my mind"):**
photograph everything, get them to sign the refusal note, bring
it back. The office decides next.
- **Customer not on site:** wait 15 min, try to call, leave a card.
Mark `assigned` with a note "not on site" + the time you tried.
Bring the goods back.
### 2.7 Report a surplus
## Don'ts — the 3 things you must never do
**When:** the supplier gave you MORE than ordered (e.g. 12
instead of 10).
1. **Never deliver without a photo + signature.** A "delivered"
item with no proof is a dispute magnet. The app needs both.
2. **Never submit a wrong / damaged pickup silently.** The
supplier will say "we never gave you damaged goods" and you'll
be on the hook. Photo + signed note at the supplier, every time.
3. **Never leave the app showing items "in flight" overnight.**
Mark everything as final before you clock out. If something's
stuck, the office can't help you at 7am Monday if the app says
you're still on the road.
1. Follow §2.3, but for the over-delivered qty, tap **Surplus**.
2. Enter the surplus qty (e.g. 2).
3. The system splits the line: customer qty (10) stays on
the order, surplus qty (2) is booked to stock. A
`stock_ledger` entry is written with
`reason: "purchase_surplus"`.
4. Continue with §2.3 for the rest.
## When to ask the office (the 4 most common situations)
### 2.8 Hand off a damaged delivery to stock
- **Supplier closed / order not ready.** Don't wait. Mark
`assigned`, note the time, re-order your run. Call the office
so they can reschedule the customer.
- **Wrong address.** Don't drive around. Tap **Report Wrong
Address** on the row. The office fixes the master data and
re-routes you.
- **Customer wants a discount to accept.** Don't promise anything.
Call the office. They'll decide.
- **Truck breakdown / accident.** Safety first. Then call the
office. The goods are secondary.
**When:** a customer refuses damaged goods on arrival.
## How you're measured (the 2 numbers that matter)
1. Take photos at the customer's site (see §2.5).
2. Get the customer's signature on the refusal note.
3. Bring the goods back to the office. **Don't** book them to
stock yourself — hand to the Stock Controller. They'll
inspect, decide return-to-vendor vs write-off, and book the
ledger entry.
1. **On-time delivery rate** — target: **> 95%** of items delivered
by the customer-promised time. (You hit the date.)
2. **Photo + signature proof rate** — target: **100%** of collections
and deliveries. (You have the evidence if it goes wrong.)
---
Bonus number: **error rate** — target < 2% of line items have
damage / wrong-item reports. (You get it right first time.)
## 3. Decision Tree
## Who to ask
### 3.1 Supplier short / partial
```
Did the supplier know they were short (pre-called you)?
├── Yes → log partial qty, attach their note, submit. Order
│ Operator creates the continuation.
└── No
├── Did they offer a back-order ETA?
│ ├── Yes (within 1 week) → take the partial, log the
│ │ ETA, Order Operator schedules the back-order.
│ └── No / vague → take what they have. Order Operator
│ phones the supplier for an ETA, may
│ source from a backup.
└── Is the partial critical (customer waiting, no
substitute)?
├── Yes → phone the Order Operator NOW. They may
│ source from a backup supplier while you're
│ still at the supplier.
└── No → take the partial, log it, continue the run.
```
### 3.2 Supplier closed / not ready
```
Can you reach the supplier by phone?
├── Yes
├── "Order not ready, will be in 2 hours" → wait if your
│ schedule allows. If not, re-order your run.
├── "Order not ready, will be tomorrow" → re-order your
│ run, do the other suppliers first. Phone the Order
│ Operator if it means the customer won't get it today.
└── "Closed for the day" → re-order, mark `assigned` not
`collected`, phone the Order
Operator.
└── No → re-order your run. Phone the Order Operator. Don't
wait — every minute parked is a minute not delivering.
```
### 3.3 Wrong address (supplier or customer)
```
Is the address in the app matching what you see?
├── No (typo in the app, building really exists)
│ └── Tap **Report Wrong Address**. Don't try to find it
│ yourself — the master data needs fixing. Wait for
│ the Order Operator.
└── The address is right but you can't find the place
├── Phone the contact on the row.
├── If no answer, ask locals / security.
└── If still can't find → mark `assigned`, note "couldn't
locate", return to the office. Order Operator
resolves.
```
### 3.4 Customer wants partial delivery (not what we brought)
```
Is the customer a strategic account?
├── Yes → call the Order Operator before doing anything. Don't
│ make a delivery decision on the customer's porch.
└── No
├── Will they accept what they need and leave the rest?
│ ├── Yes → deliver the partial, note the refused qty
│ │ and reason, return the rest to the office.
│ └── No (must be all or nothing) → mark `assigned`,
│ return the whole
│ delivery to the
│ office. Phone the
│ Order Operator.
└── Is the partial a "while you're here" ask (can they
take an extra qty)?
└── Phone the Order Operator. Don't unilaterally
add to the delivery.
```
### 3.5 Customer refuses (damaged, wrong, "changed my mind")
```
Why are they refusing?
├── Damaged → §2.5 / §2.8. Take photos, get signature, return
│ to office.
├── Wrong item → §2.5. Same process.
├── Customer changed mind (no defect)
│ ├── Customer pays restocking fee per policy?
│ │ ├── Yes → take the refusal, return to office, note
│ │ │ the fee on the order.
│ │ └── No / unclear → phone the Order Operator. Don't
│ │ bring the goods back without
│ │ confirming the restocking fee.
│ └── Customer wants to keep it but at a discount?
│ └── Phone the Order Operator. Don't unilaterally
discount.
└── Other
└── Phone the Order Operator. Get instruction.
```
### 3.6 Customer not on site when you arrive
```
Have you called ahead?
├── Yes (no answer) → wait 15 min. Try again. If still
│ nothing, leave a card / note, mark
│ `assigned` with "not on site", return
│ to office. Order Operator reschedules.
└── No → call now. If no answer, same as above.
```
### 3.7 Truck breakdown / accident
```
Is anyone hurt?
├── Yes → emergency services first. Then phone the Order
│ Operator. The goods are secondary.
└── No
├── Is the truck driveable?
│ ├── Yes → drive to a safe spot. Phone the Order
│ │ Operator. They'll re-assign the rest of
│ │ your run to another driver.
│ └── No → road service. Phone the Order Operator.
│ They'll re-assign.
└── Can the goods be salvaged? → assess, take photos,
continue if possible.
```
### 3.8 Bad weather
```
Is the road to the customer / supplier safe?
├── No → don't go. Phone the Order Operator. They'll
│ re-schedule. Customer/supplier gets a weather
│ delay note on the order.
└── Yes → continue. Take extra care.
```
---
## 4. KPIs
All KPIs measured per working day unless noted.
| KPI | Target | How it's measured |
|---|---|---|
| **On-time collection rate** | > 90% of `assigned` items `collected` same day | `count(items where item_status=collected AND collected_at date = assigned_at date) / count(assigned items)` |
| **On-time delivery rate** | > 95% of `ready_delivery` items `delivered` within the customer-promised window | per-order `delivery_date` vs `delivered_at` |
| **Error rate (wrong item / damaged)** | < 2% of line items | `count(items with damage/wrong_item photo attached) / total items handled` |
| **Photo proof rate** | 100% of collections + deliveries have at least one photo | `count(photos) / count(items handled)` — should be 1:1 minimum |
| **Signature capture rate** | 100% of deliveries have a signature | same as above, for the signature field |
| **Surplus booking rate** | 100% of surpluses booked same day | `stock_ledger` entries with `reason="purchase_surplus"` per day vs supplier delivery notes flagged with surplus |
| **Refusal rate** | < 3% of attempted deliveries | `count(customer refusals) / count(delivery attempts)` |
| **Re-run rate** | < 5% of items needing a second visit | `count(items with 2+ collection attempts)` |
| **App sync success** | 100% (all confirmations uploaded by EOD) | `count(items confirmed offline more than 4h) / total items confirmed` — should be zero |
| **Cost per collection** | tracked, target stable | total driver cost (incl truck, fuel) / number of collections per month |
### How KPIs feed into your review
- **Daily** (end of shift): the app's home screen shows your
day's KPI snapshot. If any are red, the supervisor gets an
alert.
- **Weekly** (Monday standup): one-pager with the week's KPIs.
On-time rate and error rate are the headline numbers.
- **Monthly** (1st of month): 1-page review with the
supervisor. **Photo proof rate and signature capture rate
must be 100%** — these are the dispute-defence evidence.
Anything below is a process failure.
- **Quarterly**: top performers on on-time + zero-error are
eligible for the senior-driver tier (they can take the
high-value / strategic-account runs).
### What the system does NOT measure (yet)
- Customer satisfaction (no NPS yet)
- Fuel efficiency per route
- Truck wear-and-tear
If a KPI is unfair, raise it. The supervisor can adjust
targets, but the metric stays.
- **The office phone number** — for anything that needs a decision
(refusal, discount, wrong address, breakdown).
- **Your supervisor** — for any pattern (same supplier always short,
same customer always refusing).
- **When in doubt, call.** Don't guess from the cab.

View file

@ -1,479 +1,119 @@
# Order Operator — SOP
# Order Operator — Quickstart
**Owns:** the order lifecycle, from quote → order → delivered →
invoiced. Creates purchase orders, drives the `order_items` state
machine, hands off to invoicing.
**One-line job:** turn accepted quotes into delivered goods.
## What you do
The customer said yes. You:
- Create the order from the quote.
- Order the goods from the supplier (a Purchase Order).
- Track them through collection, packing, and delivery.
- Hand off to invoicing once delivered.
You're the bridge between "they said yes" and "they have it".
**Tools:** `/orders`, `/orders/[id]`, `/orders/new`, `/collection-pool`,
`/collection-map`, `/stock`, `/stock-orders`, dashboard widgets.
**Handoffs:**
- **From Quote Operator** → customer accepted a quote.
- **To Collection Driver** → PO sent, item moves to
`collection_pool`.
- **To Invoicing** → item delivered, ready to invoice.
- **To Approver** → escalated pricing / margin / customer issues.
**Note:** the order lifecycle is **item-level**, not order-level.
`orders.status` does not exist as a field. The "Complete" badge on
the order header is derived from `every item is delivered`. This is
in `src/lib/server/order-completion.ts`. Get familiar with it.
---
## 1. Daily / Weekly Checklist
### Every working day (start of shift, 08:00 SAST)
- [ ] **Open `/orders` — New tab.** Every `pending` item should have
a clear next action: either waiting for pricing review, waiting
for a PO, or waiting for a supplier reply.
- [ ] **Open `/orders` — Processing tab.** Verify all `processed`
items have a PO generated. Items stuck in `processed` for >1
working day = process bug. Fix or escalate.
- [ ] **Open `/orders` — Collection Pool tab.** If you see
`ready_for_collection: true` items with no driver assigned, the
collection driver hasn't picked them up — phone them.
- [ ] **Open dashboard → "Follow-ups Due".** Action anything from
you (POs waiting on supplier reply, etc).
- [ ] **Check `/api/v1/purchase-orders/stale`** (or the dashboard
widget). POs with no supplier reply for >3 working days must
be phoned.
### Every working day (mid-day, 13:00 SAST)
- [ ] **Open `/orders` — Collected tab.** Verify `stock_verified`
and `picking` items are progressing. Anything stuck in
`stock_verified` for >2 hours = stock discrepancy. Resolve or
escalate to the Stock Controller.
- [ ] **Check `/stock?filter=low`** — if any of your orders used a
stock item that's now critical, the customer needs a heads-up.
### Every working day (end of shift, 17:00 SAST)
- [ ] **Review every order that changed today.** Make sure all
status transitions are correct. A `delivered` item that
shouldn't be `delivered` is a customer-facing disaster.
- [ ] **Triage `/orders` — Delivered tab.** Any `delivered` items
older than 1 working day that aren't yet `ready_invoice`? Push
them through.
### Every Monday morning
- [ ] **Review your KPIs** (see §4). Pay attention to the items
that drifted last week.
- [ ] **Triage stuck orders** — anything in `pending` or
`processed` for >5 working days is a zombie. Either ship,
cancel, or escalate.
### Every Friday afternoon
- [ ] **Close the week.** Make sure no orders have items in
limbo states over the weekend. `assigned` to a driver who
doesn't work weekends = uncollected Monday morning.
- [ ] **Invoicing handoff**: confirm the invoice clerk has
received everything they need for the week's `ready_invoice`
items. They'll print Monday morning.
---
## 2. Step-by-Step Procedures
### 2.1 Create an order from a quote
**When:** a customer accepts a quote (you got the call, or the
customer replied accepting).
1. Open the quote detail.
2. Click **Create Order**. The system creates:
- One new `orders` record with number `ORD-YYYY-MM-NNN`.
- One `order_items` row per `quote_items` row, linked back
via `order_items.quote_item`.
3. The quote status does NOT change automatically. The quote
stays in `approved` (or `emailed` + your acceptance note).
4. The order starts in **New** (`pending`) tab.
**Don't:**
- Don't try to create an order from the same quote twice. The
system returns the existing order, not a duplicate.
- Don't edit the quote after creating the order — the items
are now in `order_items`, edits to `quote_items` don't
propagate.
### 2.2 Create a walk-in order (no quote)
**When:** a customer walks in / phones with an order that wasn't
quoted.
1. Open `/orders/new`.
2. Pick or create the customer (and company).
3. Add items manually. Each item needs at minimum `description`
+ `quantity` + `unit_price`. Cost is optional at creation
(defaults to 0 — fix later from the PO).
4. **Save.** Order lands in `pending`.
5. Pricing: see §2.3.
### 2.3 Price and process a `pending` item
**When:** an order item lands in `pending` and you have a quote
to source from.
1. Open the order detail.
2. Click the line item. Set `cost_price` (from the quote or
catalogue) and `selling_price`.
3. Click **Mark Processed** (status flips to `processed`).
4. **Decide next path:**
- **In stock?** → click **Book from Stock**. The item flips
to `stock_verified` and the stock qty decrements via the
ledger. (See §2.7 for stock flow.)
- **Not in stock, need to buy?** → generate a PO. See §2.4.
### 2.4 Generate a purchase order (PO)
**When:** an item needs to be ordered from a supplier.
1. On the order detail, click **Generate PO**. The system groups
items by supplier and creates one PO per supplier.
2. **Open the PO.** Verify:
- Items + quantities match the order.
- Cost prices are correct (from the quote or a fresh
supplier call).
- Delivery date is realistic.
3. Click **Send PO**. The email modal opens. Same shape as
sending a quote (§2.4 in the Quote Operator SOP).
4. n8n sends the email. The supplier reply monitor updates
`supplier_replied_at` when the supplier replies.
5. The order item flips to `po_generated`.
**Don't:**
- Don't generate a PO without verifying the cost. Once sent, the
PO is the contract — wrong cost = wrong margin.
### 2.5 Receive a supplier invoice and book the goods
**When:** a supplier invoice / delivery note arrives (PDF in
email, or paper).
1. Identify the PO. Look for the "Your Ref" / "Order No" on
the document. It'll be `PO-YYYY-MM-NNN` (customer PO) or
`PO{yy}{6d-seq}STK` (stock PO).
2. **Diff first** — call the system to compare the invoice
against the PO. Read the verdict:
- `ready_to_receive` → continue to step 3.
- Anything else (`cost_mismatch`, `qty_mismatch`,
`supplier_mismatch`, `wrong_status`, `po_not_found`,
`unmatched_*`) → **stop and report** to the supervisor.
Don't force it through.
3. **Commit the receive** — for stock POs this books into
`stock_items.current_qty` and writes a `stock_ledger` row.
For customer POs, the order items flip to `collected` and
the supplier invoice number is stamped.
4. The system attaches the invoice PDF to the PO.
**Mode:**
- `mode: "invoice"` — full invoice received (price + qty).
- `mode: "grn"` — only a delivery note (qty only, no price
check). Use this when the supplier sends the packing slip
before the invoice.
**Surplus / Short:**
- If the supplier delivered MORE than ordered (surplus), the
PO diff flags it. Confirm with the Stock Controller; they
may book the surplus to stock (§2.8).
- If LESS than ordered (short), the system records the
received qty. Create a continuation order for the missing
qty (§2.6).
### 2.6 Split an order into a continuation
**When:** some lines are stuck (waiting on a back-ordered part),
the rest can ship. Or: a walk-in order has lines added later
and the original is past `pending`.
1. Open the order detail.
2. Verify which lines are `pending`. **Only `pending` lines can
be moved** (others refuse silently to avoid orphaning a PO
or driver assignment).
3. Click **Split Order**. Pick the `pending` lines to move.
4. A new `ORD-YYYY-MM-NNN` order is created with the moved
lines. The parent keeps its number; the moved lines get a
breadcrumb in `notes`.
5. Process the new continuation order normally.
### 2.7 Book from stock
**When:** a `processed` line item has a `stock_item_id` set and
qty available.
1. On the order detail, click **Book from Stock**.
2. The system calls `/api/v1/stock/fulfill` — decrements
`stock_items.current_qty` and writes a `stock_ledger` row
with `reason: "customer_fulfillment"`.
3. The order item flips to `stock_verified` → `picking`
(or directly to `collected` if the picking step is
bypassed for direct-from-stock orders).
**If the stock qty is short** (someone else booked it first),
the system refuses. Either:
- Cancel the line (status → `cancelled`).
- Wait for restock and re-try later.
- Source from the supplier instead (revert to `processed` →
generate PO).
### 2.8 Book a surplus
**When:** the supplier delivered more than ordered (e.g. 12
instead of 10), and the customer only needs 10.
1. On the order item, click **Book Surplus**.
2. Enter the surplus qty (e.g. 2).
3. The system:
- Splits the order line: customer qty (10) stays on the
order, surplus qty (2) goes to stock.
- Writes a `stock_ledger` row with `reason: "purchase_surplus"`.
- Sets `order_items.surplus_qty` and `stock_booked_from` on
the line.
4. The stock item's `current_qty` increments.
### 2.9 Confirm collection (driver flow)
**When:** the driver has picked up the goods and the collection
is being confirmed back at the office (or via the mobile app).
1. On the order detail, find the item. It should be in
`collection_pool` or `assigned`.
2. Click **Confirm Collected**. Enter the qty received (the
`quantity_collected` field is the amount to ADD, not the
new total).
3. For partial deliveries: clamp to `received_qty - qty_collected`
(≥0). Add a `note` explaining the partial.
4. The item flips to `collected` (full) or stays in
`collection_pool` (partial) with the `qty_collected`
updated.
**Actor field is required.** The system stamps the actor on
every audit entry. If you don't pass it, the audit trail
loses you.
### 2.10 Pick, pack, deliver
**When:** items are `collected` and ready to be packed for
delivery.
1. `collected` → `stock_verified` (verify qty against the
shelf count).
2. `stock_verified` → `picking` (operator starts pulling).
3. `picking` → `ready_delivery` (packed, on the truck).
4. `ready_delivery` → `delivered` (signed for at the customer).
**Each step needs an operator action.** Don't bulk-flip —
the audit trail per step is the customer's dispute evidence.
### 2.11 Mark an order delivered (header-level)
**When:** every item on the order is `delivered` (or
short-circuits via the eligible set — see tool doc).
1. Open the order detail.
2. Click **Mark Delivered**. The system flips every eligible
item to `delivered` and stamps `delivered_at` +
`delivered_date`.
3. **Does NOT** flip `order.invoiced`. The Invoicing Clerk does
that.
If the tool reports `data.itemsFailed` (some items refused),
inspect them. The most common reason is an item in
`pending`/`processed`/`cancelled` that wasn't eligible.
### 2.12 Hand off to invoicing
**When:** items are `delivered` and the customer has been
notified.
1. On the order detail, click **Mark Ready to Invoice** (the
button shows when items are eligible). The items flip to
`ready_invoice`.
2. **Don't** call `mark_invoiced` here — that's the
Invoicing Clerk's job, after they generate the QB invoice
or delivery note.
---
## 3. Decision Tree
### 3.1 The customer wants to cancel after the order is placed
```
Has the supplier been PO'd yet (item is in `processed` or later)?
├── No → cancel the line(s), no cost incurred. Easy.
└── Yes
├── Has the supplier confirmed the order (replied to PO)?
│ ├── No → phone the supplier NOW. Most will cancel
│ │ without charge if you catch it pre-confirm.
│ └── Yes
│ ├── Is the goods in transit / collected?
│ │ ├── No → call the supplier, negotiate a cancel.
│ │ │ Some will charge a handling fee.
│ │ └── Yes → goods will arrive. Three options:
│ │ a) Accept the goods, book to stock,
│ │ re-sell later.
│ │ b) Refuse delivery (record the refusal
│ │ on the order notes, supervisor
│ │ decides whether to charge the customer
│ │ a restocking fee).
│ │ c) Ask the supplier to return-to-vendor.
│ │ Their policy, not yours.
│ └── Surplus / different qty → see §3.3
└── Customer wants to change items, not cancel → see §3.2
```
### 3.2 Customer wants to change items after the order is placed
```
Is the order still all-`pending`?
├── Yes → use the order editor to swap the items.
└── No (some lines already `processed` or later)
├── Only the `pending` lines need to change → split off the
│ pending lines, edit on the parent, leave the rest.
└── Some `processed` lines need to change
├── Has the supplier PO'd been sent?
│ ├── No → revert the line to `pending`, edit.
│ └── Yes → phone the supplier first, then decide:
│ either cancel the PO + re-issue, or ship
│ the original and create a separate order
│ for the replacement.
└── The change affects pricing → update the order's
selling price, document the customer agreement in
`order.notes`.
```
### 3.3 Supplier delivered wrong / damaged / short
```
What's the discrepancy?
├── Wrong qty (short)
│ ├── Receive what arrived (use the `quantity_collected`
│ │ to-add semantics).
│ ├── Mark the missing qty as back-ordered. Create a
│ │ continuation order for it (§2.6) when you can re-supply.
│ └── Phone the supplier, log the short in `order.notes`.
├── Wrong qty (over — surplus)
│ └── See §2.8. Book the surplus to stock, keep the order qty
│ at the customer's need.
├── Wrong item
│ ├── Refuse delivery. Phone the supplier for a return-to-
│ │ vendor or a credit note.
│ └── If customer will accept the wrong item at a discount →
│ call the supervisor, don't unilaterally discount.
├── Damaged
│ ├── Refuse delivery. Take photos, attach to the PO via the
│ │ supplier_invoice_ prefix on `order_item_attachements`.
│ └── If customer accepts damaged at a discount → supervisor
│ call, photos on file, credit note from supplier.
└── All correct
└── Confirm the receive (§2.5).
```
### 3.4 Stock item is short when I try to book
```
Is the short a rounding / measurement error?
├── Yes → recheck the stock count. The Stock Controller may
│ have missed an entry.
└── No
├── Is the item critical (low stock) → flagged in
│ `/stock?filter=critical`?
│ ├── Yes → the order is more important than the stock
│ │ refill. Book it. File a stock-order for
│ │ restock.
│ └── No → cancel the stock booking, source from the
│ supplier via PO.
└── Has the stock item been double-booked? → check
`stock_ledger` for recent bookings. The Stock
Controller can release the duplicate.
```
### 3.5 Supplier hasn't replied to a PO in 3+ days
```
Did the PO send successfully (check `email_message_id`)?
├── No (mail_failed) → retry the send, verify recipient address
└── Yes
├── Phone the supplier. Most POs are lost in spam.
├── If no answer, email the sales rep directly (check
│ `/suppliers` for the contact).
└── After 7 working days with no reply → escalate to
supervisor. We may need to find a backup supplier.
```
### 3.6 Customer wants a delivery date that's not realistic
```
Can the supplier meet it (have they confirmed)?
├── Yes → set the order's `delivery_date` accordingly.
└── No
├── Is the customer willing to wait?
│ ├── Yes → set a realistic date, log the agreement in
│ │ `order.notes`. Don't over-promise.
│ └── No → escalate to supervisor. We may need to split
│ sources, expedite (at a cost), or partial-
│ ship.
└── Is there stock for at least part of the order? → split
the order (§2.6). Ship what's in stock, back-order
the rest.
```
### 3.7 An order has items in mixed states (some delivered, some pending)
```
Is the customer OK with partial delivery?
├── Yes → ship what's ready. The delivered items go to
│ invoicing. The pending items stay on the order
│ until they're ready.
└── No (customer needs everything together) → leave the order
open. Don't flip anything to delivered until everything
is on the truck. The customer is OK with the wait.
```
---
## 4. KPIs
All KPIs measured in working days.
| KPI | Target | How it's measured |
|---|---|---|
| **Order creation turnaround (quote accepted → order exists)** | < 30 min median | `orders.created - quotes.approved_at` |
| **`pending` → `processed` turnaround** | < 4 working hours | median `processed_at - created` on `order_items` |
| **PO send turnaround (processed → po_generated)** | < 1 working day | median `po_generated_at - processed_at` |
| **Supplier reply time** | < 2 working days median | `supplier_replied_at - email_message_id_sent` |
| **PO stale rate** | < 5% of open POs | POs without reply > 3 working days / open POs |
| **Collection turnaround (po_generated → collected)** | < 3 working days | median `collected_at - po_generated_at` |
| **`collected` → `delivered` turnaround** | < 1 working day | median `delivered_at - collected_at` |
| **Order completion rate (delivered within 5 working days of `pending`)** | > 80% | orders with all items `delivered` within 5 wd / orders created |
| **PO cost-mismatch rate** | < 3% of receives | count of `cost_mismatch` verdicts on diff / total receives |
| **Surplus booking rate** | 100% of surpluses booked same day | `stock_ledger` entries with `reason="purchase_surplus"` per day vs supplier delivery notes |
| **Split order rate** | < 10% of orders | `orders` with `notes` containing a split breadcrumb / total orders |
| **Cancellation rate** | < 5% of `pending` lines | `order_items.item_status="cancelled"` / total `order_items` created |
### How KPIs feed into your review
- **Weekly** (Monday standup): one-pager with the operator's
KPIs. Stale POs, cost mismatches, and stuck pending items are
the supervisor's first questions.
- **Monthly** (1st of month): 1-page review with the operator.
Surplus booking rate and PO cost-mismatch rate are weighted
double — getting them wrong is a money leak.
- **Quarterly**: top performers on completion rate + on-time
delivery are eligible for the "trusted buyer" tier — they can
raise POs up to a higher value without supervisor sign-off.
### What the system does NOT measure (yet)
- Customer satisfaction post-delivery (no feedback loop yet)
- Damage rate per supplier (only the PO-level diff verdict is
captured, not the cause)
- Driver utilisation (that's the collection driver's KPI)
If a KPI is unfair, raise it. The supervisor can adjust
targets, but the metric stays.
## Your day
| Time | What to do |
|---|---|
| **08:00** | Open `/orders?tab=New`. Anything new from yesterday? Move it forward. |
| **08:30** | Open `/orders?tab=Processing`. Every line should have a PO. Flag any stuck > 1 day. |
| **12:00** | Glance at `/orders?tab=Collected`. Anything stuck in `stock_verified`? Investigate. |
| **17:00** | Anything you changed today — sanity check. Don't leave surprises for Monday. |
**Three times a day**, scan the dashboard. The order-trend chart
should show lines moving right (New → Processing → Collection →
Collected → Delivered). If anything's piling up in one tab, that's
where to focus.
## How to do the common stuff
### 1. Create an order from a quote
1. Open the quote (status `approved` or `emailed`).
2. Click **Create Order**. One new order, one row per quote line.
3. Order lands in **New** tab with everything `pending`.
4. The quote status does **not** change. Leave it alone.
### 2. Process a `pending` line
1. Open the order detail, click the line.
2. Set `cost_price` (from the quote) and `selling_price` (auto-derived
from cost × markup).
3. Click **Mark Processed**. Status → `processed`.
4. Decide next: in stock? → **Book from Stock**. Not in stock? →
**Generate PO** (next step).
### 3. Generate a Purchase Order (PO)
1. Click **Generate PO**. One PO per supplier.
2. Open the PO. Verify items, quantities, costs, delivery date.
3. Click **Send PO**. Same email modal as quotes — check recipient,
attach any terms docs, hit send.
4. Status → `po_generated`. The order item waits for the supplier to
reply / ship.
### 4. Receive a supplier invoice (when the email or paper arrives)
1. Find the PO number on the invoice (top right usually — "Your Ref").
2. The system will diff the invoice against the PO and tell you if
it matches. **If it says "ready to receive"** → click **Confirm
Receive**. Done.
3. **If it says anything else** (cost mismatch, qty mismatch, etc.) →
**stop and ask the supervisor.** Don't force it.
### 5. Mark delivered (once the goods are with the customer)
1. Confirm the order has all its items in `delivered`. The header
will show a green "Complete" badge.
2. Click **Mark Delivered** on the header. (Idempotent — safe to
click twice.)
3. Then click **Mark Ready to Invoice**. Items flip to `ready_invoice`.
The Invoicing Clerk takes it from here.
## Don'ts — the 3 things you must never do
1. **Never generate a PO without verifying the cost.** Once sent, it's
the contract. Wrong cost = wrong margin.
2. **Never "force through" a receive diff that doesn't say
`ready_to_receive`.** If the diff says cost mismatch, qty mismatch,
supplier mismatch — call the supervisor. Don't bypass.
3. **Never mark an order delivered before every line is `delivered`.**
Use the per-line `Mark Delivered` if needed. Don't bulk-flip the
header to "make it look done".
## When to ask the supervisor (the 4 most common situations)
- **Supplier hasn't replied to a PO in 3+ working days.** Phone them
first. If no answer after 2 attempts, escalate.
- **Customer wants to cancel after the PO is sent.** Depends on
whether the supplier has confirmed / shipped. Call the supervisor —
there are 3 options and they pick.
- **Supplier delivered the wrong / damaged goods.** Refuse at the
supplier (if still there) or take photos at receipt. **Don't
accept damaged goods silently.** The supervisor decides credit /
return / write-off.
- **Stock item is short when you try to book.** Either the Stock
Controller can re-check the count, or we source from the supplier
instead. Phone, don't poke.
## How you're measured (the 2 numbers that matter)
1. **Order completion time** — target: **> 80% of orders delivered
within 5 working days** of the order being created. (From "yes" to
"they have it".)
2. **PO stale rate** — target: **< 5% of open POs** without a supplier
reply for > 3 working days. (Catch the silent ones early.)
Bonus number the supervisor watches: **PO cost-mismatch rate** —
target < 3% of receives. If yours is higher, your cost entries are
probably sloppy.
## Who to ask
- **Anything about a specific order, a PO, a customer, a supplier**
→ supervisor.
- **Anything broken in the system** → supervisor.
- **Anything you're not sure about** → supervisor. Better to ask
than guess.

View file

@ -1,527 +1,112 @@
# 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.
# Quote Operator — Quickstart
**One-line job:** turn customer requests into priced quotes, fast.
## What you do
A customer wants something. We need to tell them "yes, we can supply
it, and here's the price". You:
- Pick up new quotes that come in by email or phone.
- Find the right price (from the catalogue, an AI suggestion, or a
supplier list).
- Get the quote approved.
- Send it to the customer.
- Follow up if they don't reply.
## Your day
| Time | What to do |
|---|---|
| **08:30** | Open `/quotes?tab=new`. Anything new from overnight? Pick it up. |
| **09:00** | Open `/sprint`. Claim 2-3 items you can price fast. (30-min timer per item.) |
| **10:00, 13:00, 16:00** | Same as 09:00. Keep the sprint queue small. |
| **12:00** | Lunch. |
| **17:00** | Set `follow_up_due` on every quote you sent today (5 working days from now). |
**Three times a day**, glance at `/dashboard` — is the "Quote Pipeline"
doughnut getting stuck in `new` or `claim`? If yes, the queue is
backing up. Pick up more.
## How to do the common stuff
### 1. Price a line item (the most common task — you'll do this 50× a day)
1. Open the line.
2. Is there a **matched catalogue row** (look for the catalogue panel
on the line)? Use its price. Set `pricing_source: "catalogue"`.
3. Not matched? Search `/pricelist` by description or SKU. If you
find it, copy the price across. Set `pricing_source: "catalogue"`.
4. Still nothing? Look at recent supplier lists in `/pricelist`
(the "Raw Uploads" tab). If the supplier is one we work with,
use their price. Set `pricing_source: "manual"`.
5. **Cannot find a price anywhere** → flag the line for the
supervisor. Don't guess.
### 2. Send a quote (once everything is priced)
1. Click **Submit for Approval**. Status → `approval_pending`.
2. Wait for the supervisor. (You'll see it move off the queue.)
3. Approved? Click **Send Email**.
4. Check the recipient. Add the customer rep to CC if you know them.
5. The PDF is already attached. Hit **Send**.
6. **Set `follow_up_due`** to today + 5 working days. Don't forget.
### 3. Follow up on a sent quote
1. Open the quote. Is there a reply logged? (Green chip on the row.)
2. No reply? **Phone the customer first** — faster than email.
3. Log the call in the quote's notes.
4. Set a new `follow_up_due` date. Try again in 5 working days.
### 4. Inbound email quote (the common one)
1. n8n already created the quote in `/quotes?tab=new`. Open it.
2. Check: right **customer**? right **company/letterhead**? items
look right?
3. Items with `pricing_source: "ai"` are pre-priced — verify, save.
4. Items with no price landed in `/sprint` — go price them.
## Don'ts — the 3 things you must never do
1. **Never send a quote with a line that has `selling_price = 0`.**
The system will refuse the send. If you can't price a line, ask
the supervisor.
2. **Never override an AI price without checking the catalogue.**
The AI is right ~85% of the time. If you change it, write a
`pricing_notes` line saying why (e.g. "catalogue price used,
AI was 12% low").
3. **Never leave a sprint claim to expire with no work.** Save
what you have (even partial), or release the claim by walking
away. Don't ghost your own claims.
## When to ask the supervisor (the 4 most common situations)
- **AI price is way off (more than 10% from catalogue).** Use the
catalogue price and log the why. Don't agonise.
- **Customer wants a discount.** < 5% — apply it, log the reason.
5-15% — apply it, flag the approver in the quote notes.
> 15% — don't apply; tell the customer it's above your authority.
- **Email send failed.** Click **Retry** once. If it fails again,
check the address (typo? bounced?). If still failing, escalate.
- **Customer is unknown / wrong.** The quote landed on "Unknown
Sender". Don't price it. Hand to the supervisor to create the
customer first.
## How you're measured (the 2 numbers that matter)
1. **Time to first touch on a new quote** — target: **< 2 hours**
during business hours. (How fast did you pick it up?)
2. **Win rate** — target: **> 30%** of quotes you send become
orders. (Out of every 10 quotes you sent, how many got accepted?)
The supervisor pulls these for you every Monday. If either is red
for 2+ weeks, you'll get a 1-on-1.
## Who to ask
- **Anything about a specific quote, a customer, a price** → supervisor.
- **Anything broken in the system (button missing, error message, weird
data)** → supervisor, who'll loop in the dev if needed.
- **Anything you're not sure about** → supervisor. Better to ask
than guess.

View file

@ -0,0 +1,831 @@
# Network (Ethernet) Thermal Printer Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add support for printing sale receipts and delivery notes to a network (Ethernet) Epson TM-T20II printer from the `public/pos` PWA, alongside the existing Web Bluetooth path, working on both Android and iOS.
**Architecture:** The printer exposes Epson's ePOS-Print XML API over **HTTPS** (confirmed live against the shop's TM-T20II at `192.168.1.33` — see verification notes below), with an open CORS header (`Access-Control-Allow-Origin: *`). Because both the PWA and the printer are HTTPS, calling the printer directly from the browser hits neither mixed-content blocking nor Safari's lack of Web Bluetooth support — no native wrapper is needed. A new `public/pos/epos-print.js` module builds the ePOS-Print XML (mirroring the existing `buildEscPos()` byte-builder in `receipt.html`) and POSTs it with `fetch()`. `receipt.html` gains a printer-type setting (Bluetooth vs. Network) persisted in `localStorage`, and `printThermal()`/`printDelivery()` branch on it.
**Tech Stack:** Vanilla JS (no build step — this is a static PWA), `fetch`, `<canvas>` for logo rasterization, Epson ePOS-Print XML.
**Verified findings this plan relies on (from live testing against the shop printer):**
- `http://192.168.1.33/` 301-redirects to `https://192.168.1.33/` (self-signed cert).
- `POST https://192.168.1.33/cgi-bin/epos/service.cgi?devid=local_printer&timeout=10000` with an ePOS-Print SOAP XML body returns `HTTP 200` and `<response success="true" .../>`, and a real slip printed with text, item list, subtotal/VAT/total, and the WFDASH logo image.
- Raw ESC/POS bytes also print successfully over plain TCP to port 9100 (confirms the byte-building logic already in `buildEscPos()` is sound, though this plan uses the HTTPS path since browsers can't open raw sockets).
- The response's `success="true"` was observed once without a physical printout (cause not fully isolated — possibly a transient state on the printer). Task 10 below re-verifies each XML feature individually against the real printer before calling this done.
- Each till device (iOS or Android) must visit `https://192.168.1.33` once in its browser and accept the self-signed certificate warning before `fetch()` calls from the PWA will succeed silently.
---
### Task 1: Fix the pre-existing `const d` redeclaration bug
**Files:**
- Modify: `public/pos/receipt.html:535-591` (the `buildEscPos` function)
This is a blocking bug unrelated to network printing, but it sits inside the exact function this plan extends, and it currently breaks the entire inline `<script>` block (lines 319-657) — meaning `renderReceipt`, `printThermal`, and everything else on this page silently fails to run today.
- [ ] **Step 1: Confirm the bug**
Run: `grep -n "const d" public/pos/receipt.html`
Expected output includes both:
```
552: const d = new Date(s.createdAt);
565: const d = s.discount || { kind: 'none', input: 0, amount: 0 };
```
Both are in the same function scope (`buildEscPos`, no intervening block) — this is a `SyntaxError: Identifier 'd' has already been declared`.
- [ ] **Step 2: Rename the second declaration**
In `public/pos/receipt.html`, inside `buildEscPos(s){...}`, change:
```js
const sub = s.sub != null ? s.sub : (s.total - (s.vat||0));
const vat = s.vat != null ? s.vat : (s.total * 15/115);
const d = s.discount || { kind: 'none', input: 0, amount: 0 };
const hasDiscount = d.kind !== 'none' && Number(d.amount) > 0;
if (hasDiscount) {
const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price)||0) * (Number(it.qty)||0), 0);
const grossSub = gross / 1.15;
const grossVat = gross - grossSub;
cmds.push(`Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}\n`);
cmds.push(`VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}\n`);
cmds.push(`Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}\n`);
cmds.push(`Discount${d.kind==='percent' ? ` (${d.input}%)` : ''}${' '.repeat(15)}- R ${d.amount.toFixed(2)}\n`);
}
```
to:
```js
const sub = s.sub != null ? s.sub : (s.total - (s.vat||0));
const vat = s.vat != null ? s.vat : (s.total * 15/115);
const discount = s.discount || { kind: 'none', input: 0, amount: 0 };
const hasDiscount = discount.kind !== 'none' && Number(discount.amount) > 0;
if (hasDiscount) {
const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price)||0) * (Number(it.qty)||0), 0);
const grossSub = gross / 1.15;
const grossVat = gross - grossSub;
cmds.push(`Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}\n`);
cmds.push(`VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}\n`);
cmds.push(`Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}\n`);
cmds.push(`Discount${discount.kind==='percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)}\n`);
}
```
- [ ] **Step 3: Verify the script now parses**
Run: `node --check <(sed -n '319,657p' public/pos/receipt.html | sed '1s/.*<script>//; $s/<\/script>.*//')`
(This extracts just the inline script body and checks it as a standalone JS file.) Expected: no output, exit code 0. If Node reports a different syntax error, fix it before continuing — do not proceed to Task 2 with a script that doesn't parse.
- [ ] **Step 4: Commit**
```bash
git add public/pos/receipt.html
git commit -m "fix(pos): resolve duplicate const d declaration breaking receipt.html script"
```
---
### Task 2: Create `epos-print.js` with printer settings persistence
**Files:**
- Create: `public/pos/epos-print.js`
- [ ] **Step 1: Create the file with settings get/set**
```js
// Network (ePOS-Print) thermal printing for Epson TM-series Ethernet
// printers. Runs alongside the existing Web Bluetooth path in
// receipt.html — the till picks a transport in Printer settings.
//
// The printer's ePOS-Print endpoint is served over HTTPS with a
// self-signed cert and open CORS (`Access-Control-Allow-Origin: *`),
// confirmed by direct testing against the shop's TM-T20II. Each till
// device must visit `https://<printer-ip>` once in its browser and
// accept the certificate warning before fetch() calls here succeed.
window.EposPrint = (function () {
'use strict';
const SETTINGS_KEY = 'pos.printerSettings';
function getSettings() {
try {
return JSON.parse(localStorage.getItem(SETTINGS_KEY)) || { type: 'bluetooth', ip: '' };
} catch (e) {
return { type: 'bluetooth', ip: '' };
}
}
function setSettings(settings) {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}
return { getSettings, setSettings };
})();
```
- [ ] **Step 2: Verify it loads without error**
Run: `node --check public/pos/epos-print.js`
Expected: no output, exit code 0.
- [ ] **Step 3: Commit**
```bash
git add public/pos/epos-print.js
git commit -m "feat(pos): add printer settings persistence for network printing"
```
---
### Task 3: Add logo rasterization to `epos-print.js`
**Files:**
- Modify: `public/pos/epos-print.js`
The ePOS-Print `<image>` tag needs a packed 1-bit-per-pixel bitmap, MSB-first, each row padded to a whole byte, where bit `1` means "print a dot". This matches the format manually verified against the real printer during testing (Python/Pillow prototype), reimplemented here with `<canvas>` since the PWA has no build step to run Python.
- [ ] **Step 1: Add `imageToRaster` inside the IIFE, before the `return` statement**
```js
// Rasterize a same-origin image into the packed 1bpp bitmap the
// ePOS-Print <image> tag expects. targetWidth must be a multiple of
// 8; 384 is the width verified against the shop's TM-T20II.
function imageToRaster(url, targetWidth) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const targetHeight = Math.round(img.height * targetWidth / img.width);
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, targetWidth, targetHeight);
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
let imageData;
try {
imageData = ctx.getImageData(0, 0, targetWidth, targetHeight);
} catch (e) {
reject(new Error('Could not read logo pixels (cross-origin image?): ' + e.message));
return;
}
const data = imageData.data;
const rowBytes = Math.ceil(targetWidth / 8);
const packed = new Uint8Array(rowBytes * targetHeight);
for (let y = 0; y < targetHeight; y++) {
for (let x = 0; x < targetWidth; x++) {
const i = (y * targetWidth + x) * 4;
const luminance = data[i] * 0.3 + data[i + 1] * 0.59 + data[i + 2] * 0.11;
const alpha = data[i + 3];
if (alpha > 128 && luminance < 128) {
packed[y * rowBytes + (x >> 3)] |= 0x80 >> (x % 8);
}
}
}
let binary = '';
for (let i = 0; i < packed.length; i++) binary += String.fromCharCode(packed[i]);
resolve({ width: targetWidth, height: targetHeight, base64: btoa(binary) });
};
img.onerror = () => reject(new Error('Could not load logo image: ' + url));
img.src = url;
});
}
```
- [ ] **Step 2: Export it from the module**
Change the `return` statement at the end of the IIFE from:
```js
return { getSettings, setSettings };
```
to:
```js
return { getSettings, setSettings, imageToRaster };
```
- [ ] **Step 3: Verify it loads without error**
Run: `node --check public/pos/epos-print.js`
Expected: no output, exit code 0.
- [ ] **Step 4: Manual browser check**
Serve `public/` locally (e.g. `npx serve public` or via the app's own dev server) and open `public/pos/index.html`'s console, then run:
```js
EposPrint.imageToRaster('/wfdash-logo.png', 384).then(r => console.log(r.width, r.height, r.base64.length))
```
Expected: logs `384 <some-height> <some-length>` with no thrown error.
- [ ] **Step 5: Commit**
```bash
git add public/pos/epos-print.js
git commit -m "feat(pos): add canvas-based logo rasterization for ePOS-Print"
```
---
### Task 4: Add ePOS-Print XML builder for sale receipts
**Files:**
- Modify: `public/pos/epos-print.js`
This mirrors `buildEscPos()` in `receipt.html:535-591` line-for-line in content (same fields, same discount handling, same layout), just emitting ePOS-Print XML tags instead of raw ESC/POS bytes.
- [ ] **Step 1: Add `xmlEscape` and `buildEposXml` inside the IIFE, before the `return` statement**
```js
function xmlEscape(s) {
return String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
}
// Mirrors buildEscPos() in receipt.html — same fields, same discount
// math — but emits ePOS-Print XML tags instead of raw ESC/POS bytes.
function buildEposXml(s, logo) {
const parts = [];
if (logo) parts.push(`<image width="${logo.width}" height="${logo.height}" color="color_1" mode="mono" align="center">${logo.base64}</image>`);
parts.push('<feed line="1"/>');
parts.push('<text align="center" width="2" height="2">WFSALES POS&#10;</text>');
if (s.letterhead && s.letterhead.name) {
parts.push(`<text align="center">${xmlEscape(s.letterhead.name)}&#10;</text>`);
}
const addrLines = [];
if (s.letterhead && s.letterhead.address) addrLines.push(xmlEscape(s.letterhead.address.split('\n')[0]));
if (s.letterhead && s.letterhead.phone) addrLines.push('Tel: ' + xmlEscape(s.letterhead.phone));
if (s.letterhead && s.letterhead.vat_number) addrLines.push('VAT: ' + xmlEscape(s.letterhead.vat_number));
if (addrLines.length) parts.push(`<text align="center">${addrLines.join('&#10;')}&#10;</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
const d = new Date(s.createdAt);
parts.push(`<text align="left">Date: ${xmlEscape(d.toLocaleString('en-ZA'))}&#10;Slip: ${xmlEscape((s.localId || '').slice(-8).toUpperCase())}&#10;</text>`);
if (s.customer) parts.push(`<text align="left">Customer: ${xmlEscape(s.customer.name)}&#10;</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
const itemLines = s.items.map(it => {
const priceLine = ` ${it.qty} x ${(it.selling_price || 0).toFixed(2)} ${(it.selling_price * it.qty).toFixed(2)}`;
return `${xmlEscape(it.description || it.sku)}&#10;${xmlEscape(priceLine)}&#10;`;
}).join('');
parts.push(`<text align="left">${itemLines}</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
const sub = s.sub != null ? s.sub : (s.total - (s.vat || 0));
const vat = s.vat != null ? s.vat : (s.total * 15 / 115);
const discount = s.discount || { kind: 'none', input: 0, amount: 0 };
const hasDiscount = discount.kind !== 'none' && Number(discount.amount) > 0;
if (hasDiscount) {
const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price) || 0) * (Number(it.qty) || 0), 0);
const grossSub = gross / 1.15;
const grossVat = gross - grossSub;
parts.push(`<text align="left">Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}&#10;VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}&#10;Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}&#10;Discount${discount.kind === 'percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)}&#10;</text>`);
}
parts.push(`<text align="left">Subtotal${' '.repeat(22)}R ${sub.toFixed(2)}&#10;VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)}&#10;</text>`);
parts.push(`<text align="left" em="true">TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)}&#10;</text>`);
const paidLabel = s.payment === 'cash' ? `CASH R ${(s.cashGiven || 0).toFixed(2)}` : s.payment.toUpperCase();
parts.push(`<text align="left">Paid: ${xmlEscape(paidLabel)}&#10;</text>`);
if (s.payment === 'cash') parts.push(`<text align="left">Change${' '.repeat(24)}R ${(s.change || 0).toFixed(2)}&#10;</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
if (s.letterhead && s.letterhead.footer_text) parts.push(`<text align="center">${xmlEscape(s.letterhead.footer_text)}&#10;</text>`);
parts.push('<feed line="3"/>');
parts.push('<cut type="feed"/>');
return `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">${parts.join('')}</epos-print></s:Body></s:Envelope>`;
}
```
- [ ] **Step 2: Export it from the module**
Change the `return` statement to:
```js
return { getSettings, setSettings, imageToRaster, buildEposXml };
```
- [ ] **Step 3: Verify it loads without error**
Run: `node --check public/pos/epos-print.js`
Expected: no output, exit code 0.
- [ ] **Step 4: Manual browser check with a fake sale**
In the browser console (same page as Task 3's check):
```js
const fakeSale = {
createdAt: new Date().toISOString(),
localId: 'test0001',
payment: 'cash', cashGiven: 300, change: 5.51,
items: [{ description: 'Widget A', qty: 2, selling_price: 45 }],
sub: 78.26, vat: 11.74, total: 90,
letterhead: { name: 'WFDASH POS', address: '123 Shop Street', phone: '021 555 0123' }
};
console.log(EposPrint.buildEposXml(fakeSale, null));
```
Expected: a full XML string starting with `<?xml version="1.0"...` and containing `<epos-print`, item text, and `<cut type="feed"/>`, no thrown error.
- [ ] **Step 5: Commit**
```bash
git add public/pos/epos-print.js
git commit -m "feat(pos): add ePOS-Print XML builder for sale receipts"
```
---
### Task 5: Add the network transport (`printViaNetwork`)
**Files:**
- Modify: `public/pos/epos-print.js`
- [ ] **Step 1: Add `printViaNetwork` inside the IIFE, before the `return` statement**
```js
async function printViaNetwork(ip, xml) {
const res = await fetch(`https://${ip}/cgi-bin/epos/service.cgi?devid=local_printer&timeout=10000`, {
method: 'POST',
headers: { 'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': '""' },
body: xml
});
if (!res.ok) throw new Error('Printer HTTP error: ' + res.status);
const text = await res.text();
const doc = new DOMParser().parseFromString(text, 'text/xml');
const response = doc.getElementsByTagName('response')[0];
const success = response && response.getAttribute('success') === 'true';
if (!success) {
const code = response ? response.getAttribute('code') : null;
throw new Error('Printer rejected job' + (code ? ' (code: ' + code + ')' : ''));
}
}
```
- [ ] **Step 2: Export it from the module**
Change the `return` statement to:
```js
return { getSettings, setSettings, imageToRaster, buildEposXml, printViaNetwork };
```
- [ ] **Step 3: Verify it loads without error**
Run: `node --check public/pos/epos-print.js`
Expected: no output, exit code 0.
- [ ] **Step 4: Manual test against the real printer**
With a device on the shop wifi that has already visited `https://192.168.1.33` and trusted the cert, open the browser console on any page served from the app (must be same-origin `fetch`, no special config needed since the printer sends `Access-Control-Allow-Origin: *`) and run:
```js
EposPrint.printViaNetwork('192.168.1.33', EposPrint.buildEposXml(fakeSale, null)).then(() => console.log('OK')).catch(e => console.error(e));
```
(reuse `fakeSale` from Task 4's check). Expected: logs `OK`, and a real slip prints on the TM-T20II with the fake sale's items and totals.
- [ ] **Step 5: Commit**
```bash
git add public/pos/epos-print.js
git commit -m "feat(pos): send ePOS-Print jobs over HTTPS to network printers"
```
---
### Task 6: Add printer settings UI to `receipt.html`
**Files:**
- Modify: `public/pos/receipt.html`
- [ ] **Step 1: Add CSS for the settings block**
In `public/pos/receipt.html`, find the `.status` rule block (around line 134):
```css
.status{padding:12px 14px;border-radius:12px;background:var(--surface);border:1px solid var(--border);font-size:13px;display:flex;align-items:center;gap:10px;color:var(--fg);line-height:1.4}
```
Add immediately after it:
```css
.printer-settings{padding:12px 14px;border-radius:12px;border:1px solid var(--border);background:var(--surface-card);display:flex;flex-direction:column;gap:8px}
.printer-settings label{font-size:12px;font-weight:600;color:var(--ink-soft);display:flex;flex-direction:column;gap:4px}
.printer-settings select,.printer-settings input{padding:9px 10px;border-radius:8px;border:1px solid var(--muted);background:var(--bg);color:var(--fg);font:inherit}
```
- [ ] **Step 2: Add the settings toggle row and form to the markup**
In `public/pos/receipt.html`, find (around line 295-298):
```html
<div class="status" id="status">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.5"/></svg></span>
<span class="t" id="statusText">Ready to print.</span>
</div>
```
Add immediately after it:
```html
<div class="status" id="printerSettingsToggle" style="cursor:pointer" onclick="togglePrinterSettings()">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 7h18v6H3zM7 13v6h10v-6M7 17h10"/></svg></span>
<span class="t" id="printerSettingsLabel">Printer: Bluetooth</span>
</div>
<div class="printer-settings" id="printerSettingsForm" style="display:none">
<label>Printer type
<select id="printerTypeSelect">
<option value="bluetooth">Bluetooth</option>
<option value="network">Network (Ethernet)</option>
</select>
</label>
<label>Printer IP address
<input id="printerIpInput" placeholder="192.168.1.33">
</label>
<button class="btn" style="min-height:40px" onclick="savePrinterSettings()">Save printer settings</button>
</div>
```
- [ ] **Step 3: Add `epos-print.js` script tag**
Find (around line 317):
```html
<script src="pos-config.js"></script>
<script src="theme.js"></script>
```
Change to:
```html
<script src="pos-config.js"></script>
<script src="theme.js"></script>
<script src="epos-print.js"></script>
```
- [ ] **Step 4: Add the settings toggle/save functions and init call**
Inside the main inline `<script>` IIFE in `receipt.html`, immediately after the `paintNet();` call near the end of the file (currently the last line before `})();`), add — but call it earlier, right after the existing `paintNet();` line at the bottom (around line 655):
Find:
```js
paintNet();
})();
```
Change to:
```js
function togglePrinterSettings(){
const form = document.getElementById('printerSettingsForm');
form.style.display = form.style.display === 'none' ? 'flex' : 'none';
}
function savePrinterSettings(){
const type = document.getElementById('printerTypeSelect').value;
const ip = document.getElementById('printerIpInput').value.trim();
window.EposPrint.setSettings({ type, ip });
paintPrinterSettings();
document.getElementById('printerSettingsForm').style.display = 'none';
}
function paintPrinterSettings(){
const settings = window.EposPrint.getSettings();
document.getElementById('printerTypeSelect').value = settings.type;
document.getElementById('printerIpInput').value = settings.ip || '';
document.getElementById('printerSettingsLabel').textContent =
'Printer: ' + (settings.type === 'network' ? ('Network' + (settings.ip ? ' (' + settings.ip + ')' : '')) : 'Bluetooth');
}
paintPrinterSettings();
paintNet();
})();
```
- [ ] **Step 5: Verify the script still parses**
Run: `node --check <(sed -n '319,700p' public/pos/receipt.html | sed '1s/.*<script>//; $s/<\/script>.*//')`
Expected: no output, exit code 0. (Adjust the line range in the `sed -n` command to match wherever the inline `<script>...</script>` block now starts and ends after your edits.)
- [ ] **Step 6: Manual browser check**
Open `receipt.html` in a browser (with a `pos.lastSale` value in `sessionStorage`, or just check that the page doesn't error in the console). Click the new "Printer: Bluetooth" row — the settings form should expand. Switch the select to "Network (Ethernet)", type `192.168.1.33`, click "Save printer settings" — the row should update to read "Printer: Network (192.168.1.33)" and the value should persist across a page reload (check via `localStorage.getItem('pos.printerSettings')` in the console).
- [ ] **Step 7: Commit**
```bash
git add public/pos/receipt.html
git commit -m "feat(pos): add printer type/IP settings UI to receipt page"
```
---
### Task 7: Wire `printThermal()` to the network path
**Files:**
- Modify: `public/pos/receipt.html`
- [ ] **Step 1: Rename the existing Bluetooth flow**
Find (around line 487):
```js
async function printThermal(){
const btn = document.getElementById('printBtn');
```
Change the function name only:
```js
async function printThermalBluetooth(){
const btn = document.getElementById('printBtn');
```
(Leave the rest of that function's body untouched.)
- [ ] **Step 2: Add the network variant and the dispatcher**
Immediately before the (now renamed) `printThermalBluetooth` function, add:
```js
async function printThermal(){
const settings = window.EposPrint.getSettings();
if(settings.type === 'network') return printThermalNetwork(settings);
return printThermalBluetooth();
}
async function printThermalNetwork(settings){
const btn = document.getElementById('printBtn');
const status = document.getElementById('statusText');
const errBox = document.getElementById('status');
errBox.classList.remove('err');
btn.disabled = true;
if(!settings.ip){
status.textContent = 'Set the printer IP address in Printer settings first.';
errBox.classList.add('err');
btn.disabled = false;
return;
}
status.textContent = 'Sending to ' + settings.ip + '…';
try{
const logo = await window.EposPrint.imageToRaster('/wfdash-logo.png', 384).catch(()=>null);
const xml = window.EposPrint.buildEposXml(sale, logo);
await window.EposPrint.printViaNetwork(settings.ip, xml);
status.textContent = 'Printed · ' + settings.ip;
}catch(e){
status.textContent = 'Print failed: ' + e.message;
errBox.classList.add('err');
}finally{
btn.disabled = false;
}
}
```
- [ ] **Step 3: Verify the script still parses**
Run: `node --check <(sed -n '319,760p' public/pos/receipt.html | sed '1s/.*<script>//; $s/<\/script>.*//')`
Expected: no output, exit code 0. (Adjust line range as needed.)
- [ ] **Step 4: Manual end-to-end test against the real printer**
On a device on the shop wifi that has trusted the printer's certificate, open a real sale's receipt page (so `sessionStorage['pos.lastSale']` is populated), set Printer type to Network with IP `192.168.1.33` via the UI from Task 6, tap "Print slip". Expected: status text shows "Printed · 192.168.1.33" and a real receipt (logo, items, subtotal, VAT, total, footer) prints on the TM-T20II.
- [ ] **Step 5: Commit**
```bash
git add public/pos/receipt.html
git commit -m "feat(pos): print sale receipts over the network transport"
```
---
### Task 8: Add network transport for delivery notes
**Files:**
- Modify: `public/pos/epos-print.js`
- Modify: `public/pos/receipt.html`
- [ ] **Step 1: Add `buildEposDeliveryXml` to `epos-print.js`, before the `return` statement**
Mirrors the delivery-note ESC/POS builder in `receipt.html`'s `printDelivery()` (currently inline, lines ~607-637).
```js
function buildEposDeliveryXml(s, showPrices) {
const parts = [];
parts.push('<text align="center" width="1" height="2">WFSALES POS&#10;</text>');
parts.push('<text align="center">Delivery Note&#10;</text>');
if (s.customer && s.customer.company) parts.push(`<text align="left">Company: ${xmlEscape(s.customer.company)}&#10;</text>`);
parts.push(`<text align="left">Customer: ${xmlEscape((s.customer && s.customer.name) || '—')}&#10;</text>`);
const d = new Date(s.createdAt);
parts.push(`<text align="left">Date: ${xmlEscape(d.toLocaleDateString('en-ZA'))}&#10;</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
const itemLines = s.items.map(it => {
const qtyLine = ` ${it.qty} ${it.unit || 'ea'}`;
const priceSuffix = showPrices ? ` R ${((it.selling_price || 0) * it.qty).toFixed(2)}` : '';
return `${xmlEscape(it.description || it.sku)}&#10;${xmlEscape(qtyLine + priceSuffix)}&#10;`;
}).join('');
parts.push(`<text align="left">${itemLines}</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
if (showPrices) parts.push(`<text align="left">TOTAL: R ${s.total.toFixed(2)}&#10;</text>`);
parts.push('<text align="left">Received by: __________ Driver: __________&#10;</text>');
parts.push('<feed line="3"/>');
parts.push('<cut type="feed"/>');
return `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">${parts.join('')}</epos-print></s:Body></s:Envelope>`;
}
```
- [ ] **Step 2: Export it**
Change the `return` statement to:
```js
return { getSettings, setSettings, imageToRaster, buildEposXml, printViaNetwork, buildEposDeliveryXml };
```
- [ ] **Step 3: Verify it loads without error**
Run: `node --check public/pos/epos-print.js`
Expected: no output, exit code 0.
- [ ] **Step 4: Wire `printDelivery()` in `receipt.html`**
Find (around line 593):
```js
async function printDelivery(){
if(!sale) return;
const btn = document.getElementById('printDnBtn');
const status = document.getElementById('statusText');
const errBox = document.getElementById('status');
errBox.classList.remove('err');
btn.disabled = true;
status.textContent = 'Printing delivery note…';
if(!btChar){
status.textContent = 'Pair a Bluetooth printer first to print.';
errBox.classList.add('err');
btn.disabled = false;
return;
}
```
Change to:
```js
async function printDelivery(){
if(!sale) return;
const btn = document.getElementById('printDnBtn');
const status = document.getElementById('statusText');
const errBox = document.getElementById('status');
errBox.classList.remove('err');
btn.disabled = true;
status.textContent = 'Printing delivery note…';
const printerSettings = window.EposPrint.getSettings();
if(printerSettings.type === 'network'){
if(!printerSettings.ip){
status.textContent = 'Set the printer IP address in Printer settings first.';
errBox.classList.add('err');
btn.disabled = false;
return;
}
const dnToggleNet = document.getElementById('dnShowPrices');
const showPricesNet = dnToggleNet ? !!dnToggleNet.checked : true;
try{
const xml = window.EposPrint.buildEposDeliveryXml(sale, showPricesNet);
await window.EposPrint.printViaNetwork(printerSettings.ip, xml);
status.textContent = 'Delivery note printed · ' + printerSettings.ip;
}catch(e){
errBox.classList.add('err');
status.textContent = 'DN print failed: ' + e.message;
}finally{
btn.disabled = false;
}
return;
}
if(!btChar){
status.textContent = 'Pair a Bluetooth printer first to print.';
errBox.classList.add('err');
btn.disabled = false;
return;
}
```
(Leave the rest of the existing Bluetooth body below this point unchanged.)
- [ ] **Step 5: Verify the script still parses**
Run: `node --check <(sed -n '319,800p' public/pos/receipt.html | sed '1s/.*<script>//; $s/<\/script>.*//')`
Expected: no output, exit code 0. (Adjust line range as needed.)
- [ ] **Step 6: Manual test against the real printer**
With Printer type set to Network in the UI, open an account-payment sale's receipt (so the delivery note section is visible), tap "Print delivery note". Expected: a delivery-note slip prints with company/customer, item list, and the received-by/driver line.
- [ ] **Step 7: Commit**
```bash
git add public/pos/epos-print.js public/pos/receipt.html
git commit -m "feat(pos): print delivery notes over the network transport"
```
---
### Task 9: Register `epos-print.js` with the service worker
**Files:**
- Modify: `public/pos/sw.js`
Without this, the till would work online but `epos-print.js` wouldn't be available offline (the printer itself is only reachable on the shop LAN anyway, but the PWA shell — including this script — should still be cached like every other JS file here).
- [ ] **Step 1: Add the file to the precache list and bump the cache version**
Find (lines 1-17):
```js
// Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static.
const CACHE = "pos-v15";
const APP_SHELL = [
'./',
'./index.html',
'./pos.html',
'./receipt.html',
'./history.html',
'./cashup.html',
'./pos-config.js',
'./pos-app.js',
'./theme.js',
'./manifest.json',
// Font Awesome 6 Free (solid) — bundled so the till still renders icons offline.
'./fa/all.min.css',
'./fa/webfonts/fa-solid-900.woff2',
];
```
Change to:
```js
// Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static.
const CACHE = "pos-v16";
const APP_SHELL = [
'./',
'./index.html',
'./pos.html',
'./receipt.html',
'./history.html',
'./cashup.html',
'./pos-config.js',
'./pos-app.js',
'./epos-print.js',
'./theme.js',
'./manifest.json',
// Font Awesome 6 Free (solid) — bundled so the till still renders icons offline.
'./fa/all.min.css',
'./fa/webfonts/fa-solid-900.woff2',
];
```
- [ ] **Step 2: Verify the file parses**
Run: `node --check public/pos/sw.js`
Expected: no output, exit code 0.
- [ ] **Step 3: Manual check**
Open `receipt.html` in a browser with DevTools open to the Application/Service Worker panel, force-reload, and confirm the new service worker (`pos-v16`) activates and `epos-print.js` appears in the Cache Storage listing for `pos-v16`.
- [ ] **Step 4: Commit**
```bash
git add public/pos/sw.js
git commit -m "chore(pos): cache epos-print.js in the PWA app shell"
```
---
### Task 10: Full end-to-end verification against the physical printer
**Files:** none (verification only)
- [ ] **Step 1: Confirm the certificate-trust step on a fresh device**
On an iPhone or iPad that has never visited the printer before, open Safari, navigate to `https://192.168.1.33`, and confirm you get a certificate warning; tap through "visit this website" (or equivalent). Confirm subsequent `fetch()` calls from the PWA's origin succeed without a further prompt.
- [ ] **Step 2: Ring up a real (or test) sale with items and a discount applied**
Use the till to create a sale with at least 2 line items and a discount, so the "gross" block in `buildEposXml` is exercised, not just the plain-total path.
- [ ] **Step 3: Set Printer type to Network with IP `192.168.1.33` and print the slip**
Confirm the physical printout shows: WFDASH logo, "WFSALES POS" header, date/slip number, item lines, gross/discount/net totals, VAT, TOTAL in bold, payment method, and footer text — matching what the on-screen receipt shows.
- [ ] **Step 4: If it's an account-payment sale, print the delivery note over network too**
Confirm the physical printout matches the on-screen delivery note (company/customer, item list respecting the "Include prices" toggle, received-by/driver line).
- [ ] **Step 5: Repeat Steps 2-4 on an Android device**
Confirms parity between iOS Safari and Android Chrome for this transport (Bluetooth already only works on Android, per Task 6-8 branching).
- [ ] **Step 6: Note any printer status codes seen during testing**
If any print job returns `success="true"` but nothing physically prints (as was observed once during initial protocol testing), capture the full XML response (including the `status` attribute) and check it against the printer's technical reference guide before shipping this to the till — do not treat `success="true"` alone as proof of a physical printout without confirming paper actually came out.