refactor(sops): streamline SOP documents for roles, consolidating content into quickstart guides for easier reference
This commit is contained in:
parent
c4bf4a24ab
commit
064145e5b7
6 changed files with 1269 additions and 1843 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
831
docs/superpowers/plans/2026-09-03-pos-network-printer.md
Normal file
831
docs/superpowers/plans/2026-09-03-pos-network-printer.md
Normal 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 => ({ '&': '&', '<': '<', '>': '>' }[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 </text>');
|
||||
if (s.letterhead && s.letterhead.name) {
|
||||
parts.push(`<text align="center">${xmlEscape(s.letterhead.name)} </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(' ')} </text>`);
|
||||
parts.push('<text align="left">-------------------------------- </text>');
|
||||
const d = new Date(s.createdAt);
|
||||
parts.push(`<text align="left">Date: ${xmlEscape(d.toLocaleString('en-ZA'))} Slip: ${xmlEscape((s.localId || '').slice(-8).toUpperCase())} </text>`);
|
||||
if (s.customer) parts.push(`<text align="left">Customer: ${xmlEscape(s.customer.name)} </text>`);
|
||||
parts.push('<text align="left">-------------------------------- </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)} ${xmlEscape(priceLine)} `;
|
||||
}).join('');
|
||||
parts.push(`<text align="left">${itemLines}</text>`);
|
||||
parts.push('<text align="left">-------------------------------- </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)} VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)} Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)} Discount${discount.kind === 'percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)} </text>`);
|
||||
}
|
||||
parts.push(`<text align="left">Subtotal${' '.repeat(22)}R ${sub.toFixed(2)} VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)} </text>`);
|
||||
parts.push(`<text align="left" em="true">TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)} </text>`);
|
||||
const paidLabel = s.payment === 'cash' ? `CASH R ${(s.cashGiven || 0).toFixed(2)}` : s.payment.toUpperCase();
|
||||
parts.push(`<text align="left">Paid: ${xmlEscape(paidLabel)} </text>`);
|
||||
if (s.payment === 'cash') parts.push(`<text align="left">Change${' '.repeat(24)}R ${(s.change || 0).toFixed(2)} </text>`);
|
||||
parts.push('<text align="left">-------------------------------- </text>');
|
||||
if (s.letterhead && s.letterhead.footer_text) parts.push(`<text align="center">${xmlEscape(s.letterhead.footer_text)} </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 </text>');
|
||||
parts.push('<text align="center">Delivery Note </text>');
|
||||
if (s.customer && s.customer.company) parts.push(`<text align="left">Company: ${xmlEscape(s.customer.company)} </text>`);
|
||||
parts.push(`<text align="left">Customer: ${xmlEscape((s.customer && s.customer.name) || '—')} </text>`);
|
||||
const d = new Date(s.createdAt);
|
||||
parts.push(`<text align="left">Date: ${xmlEscape(d.toLocaleDateString('en-ZA'))} </text>`);
|
||||
parts.push('<text align="left">-------------------------------- </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)} ${xmlEscape(qtyLine + priceSuffix)} `;
|
||||
}).join('');
|
||||
parts.push(`<text align="left">${itemLines}</text>`);
|
||||
parts.push('<text align="left">-------------------------------- </text>');
|
||||
if (showPrices) parts.push(`<text align="left">TOTAL: R ${s.total.toFixed(2)} </text>`);
|
||||
parts.push('<text align="left">Received by: __________ Driver: __________ </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.
|
||||
Loading…
Reference in a new issue