Status: SPEC READY (2026-09-22) — HELD, because stage 1 is not built yet. Per the plan, no stage starts before
the previous one is verified live. This page exists so the next stage is ready the moment stage 1 lands.PARKED (Matthew, 2026-09-22): the column set. "It's too early to comment on this without seeing this in
operation" — whether the reports show one fixed set of columns or every wholesaler side by side is left open, so
the build must make it a one-place change (W1's column set is defined in the service, not the templates) and the
decision is taken with the real screens in front of us, after first use.
Parent plan: supply-orders-part3-pricing ·
previous stage: supply-orders-pricing-phase1-import ·
the external review that settled the open questions: supply-orders-pricing-stage2-consultation
(temporary — delete once this page is signed off).
Reading the prices that stage 1 imported. Read-only: no price is exported, ordered from, or used to choose
anything, and nothing new is written. It adds no new tables — everything shown comes from stage 1's
SupplierPriceFile and SupplierPrice — with one flagged exception (W2, a bank-holiday setting on StockSettings).
| Decision | Value | Source |
|---|---|---|
| Where prices appear | The low-stock report and the expiry report; a new supplier detail page carries the price-file history | review C1 |
| Where they must not appear | The order-building screen (that is stage 3), and receipts | review C1, Matthew |
| Item price-comparison page | Not built — the reports carry the columns instead | review Q1 |
| Freshness | Working days, never hours: Current (uploaded today) / One working day old / Stale (older than 2 working days) | review B2 |
| A stale price | Never hidden. Shown muted/struck-through with its age and the file's date, and marked not usable for ordering | review B1, B3 |
| Absence vs bad data | "No supplier data" and "Invalid price row" are distinct states — never a blank cell and never £0.00 |
review Q5 |
| Unconfirmed basis | The import is not refused for it (settled 2026-09-22, below) — a NOT_STATED price is shown with its basis beside it and never as comparable |
review C2, amended |
| dm+d | Its list price is never displayed or used as a comparison — it is a dictionary, not a reimbursement rate | review A2, A3 |
| The reimbursement guardrail | Not built in this stage. It waits until the NHS Drug Tariff and the monthly price concessions are licensed and imported as their own dataset | review A4 |
| Who sees prices | RP, Dispensing Technician, Business Manager. Counter Assistant: 403 on every price surface | Matthew, 2026-09-22 |
SETTLED 2026-09-22 (Hermes, after Matthew questioned it): the import is NOT refused for an unconfirmed basis.
The review's C2 answer said to block such an import; stage 1 as built recordsprice_basis = NOT_STATEDinstead.
Blocking is the wrong trade: a Business Manager who cannot say whether a file is per pack or per unit would then be
unable to import prices at all, while recording the uncertainty and making it visible costs nothing. So:
stage 1 stays as built (no amendment), and this stage must never show aNOT_STATEDprice as comparable —
it carries its basis on screen beside the price, exactly as a stale price carries its age. Reversible in one line
if the pharmacy ever wants the strict behaviour; nothing about it blocks the build.
pharmacy_stock/templates/pharmacy_stock/low_stock_report.html is three columns — item, on-hand (cached), alertLowStockReportView from services.reorder_suggestions()-style threshold reads; the expiryExpiryReportView. Both are gated PharmacyStockStaffRequiredMixin (all four roles), which is why theSupplierListView + SupplierCreateView/SupplierUpdateView + /toggle/pharmacy_stock/urls.py); there is no supplier detail page today./stock/items/ route) — so this stage adds none.record_audit / PharmacyAuditEvent pattern are the data source and the audit trail.StockSettings.get_solo() already carries the expiry bands — the precedent for W2's holiday list.W1 — one price-lookup service, and every screen reads it. pharmacy_stock/price_view.py (or a function in
services.py), so the "what does this item cost" answer exists in exactly one place:
prices_for_items(items, suppliers) → for each item: every supplier that has a live price row, its price, the basisW2 — the working-day age, and the one schema touch. A small helper turning a file's price_date/imported_at
into the three states. Weekends are handled arithmetically; bank holidays cannot be invented, so this stage adds
one field to StockSettings (bank_holidays, a blank list of dates, migration pharmacy_stock.0005) — empty
means weekends only, and the field is edited by the Business Manager (or seeded). The age is always displayed
("uploaded 4 working days ago, 18 Sep"), so a wrong calendar is visible rather than silent.
W3 — the report columns. Low-stock report and expiry report gain the W1 columns and the three states, with the
existing visual conventions. No sorting or filtering changes in this stage; the reports stay the same screens.
W4 — the supplier detail page. /stock/suppliers/<pk>/ (new): the supplier's own details, its profile
(code, account reference, export profile, minimum order value) and its price-file history — every import newest
first with the price date, status (PARSED / FAILED / SUPERSEDED), the four counts, the basis recorded and the
actor, linking through to the stage 1 file detail where the unmatched and rejected lines are listed. The supplier
list links to it. No delete anywhere (house rule), and no edit of imported prices.
W5 — roles. A read gate the three stock roles hold and the Counter Assistant does not — the same shape as
PharmacyStockManageRequiredMixin, used by the reports' price columns' screens and the supplier detail page. A
Counter Assistant hitting the supplier detail page gets 403, and no price is rendered into a page they can
reach (the reports themselves stay readable to them, so the columns must be omitted server-side, not merely styled).
W6 — tests. Best-price and spread selection across suppliers; the fixed column set with 1, 2 and 5 suppliers;
"no supplier data" versus "invalid price row" (no £0.00 anywhere, asserted on the rendered body); the three
freshness states across a weekend and across a bank holiday once the list is set; a stale price still being rendered
and marked unusable; the working-day arithmetic with an empty holiday list; the Counter Assistant refused the supplier
detail page and no price value present in any page they can load; the RP/Technician/BM allowed; and the standing
assertions — nothing written by any read (no audit rows, no ERPNext client, no cache write) and no pricing
tables in the central database.
W7 — report back in from-opencode with the screens and the states shown, for
Hermes to verify live.
No prices on the order-building screen (stage 3); no item price-comparison page; no dm+d list price anywhere; no
reimbursement guardrail; no purge or retention (stage 5); no price editing or correction (a wrong file is replaced
by re-importing for the same supplier and date, per stage 1); no export of anything; no Counter Assistant surface; no
new tables beyond W2's single setting.
PRICE field must not be shown as a proxy (review A2/A4)./srv/datasets/dmd/2026-09-22.Deployed by Hermes: feature/pharmacy-price-visible pushed, the app engine fast-forwarded dev to 7ddddf1,
pharmacy_stock.0005 applied on the apex and the client tenant, seed_pharmacy_prices re-run (four files, three
synthetic suppliers). Verifier: /root/work/veripath-ops/scripts/verify-pharmacy-price-visible.py — live OIDC, no
mocks — plus a read-boundary probe. Synthetic example files only.
Passing (22 of 24): the parked column set renders on the low-stock and the expiry reports; the best price
with its supplier (£0.79, Synthetic Wholesaler Two), the spread (£0.26), the count (3) and the Current state; the
"not usable for ordering" note; no £0.00 anywhere; the supplier list links to the detail pages; a detail page shows
the price-file history with the counts, the basis and a Superseded entry; the Counter Assistant is refused the
supplier detail page (403), still reads both reports, and no price column or price value reaches them (omitted
server-side, not styled away); the Business Manager sees the columns; no price on the order-building screen;
reading wrote nothing (audit 176, files 4, rows 9, cache 6 — identical before and after); and stage 1's own
verifier still passes 35/35 as a regression.
CLOSED 2026-09-22 (evening): the demo data now covers every state and this verifier passes 24/24. OpenCode's
stage 3 commit (abd61de) seededDEMO-STALE-001(only a stale price) andDEMO-ONEDAY-001(only a one-working-day
price) plus a stale-only supplier, and the low-stock report now renders Stale (struck through, marked not usable,
with its age) and One working day old alongside Current. The verifier's own two assertions were also wrong
in form and were fixed rather than the app: the cell classes are lowercase (cell-stale) and a row carries two
stale cells — the price (whose title says it is not usable) and the freshness cell (whose text carries the age).
Reading it as a defect was the right call; the rule was never broken.
The finding (as first raised) — the two failing checks were about the stale state being demonstrable.
Because the parked column set shows the best price only, and the freshness column describes that best price, a
supplier's stale (or one-working-day-old) price never appears while another supplier is cheaper and current. In the
seeded data all three suppliers cover the same three items and two of them are current, so only Current is
reachable on the reports — which contradicts the "all three states are demonstrable live" claim in the build note. I
took the honest live route to it (importing wholesaler Two's file backdated, through the BM screen — "the latest live
file per supplier wins", so a newer date still wins and the price does not go stale) and reverted it. The rendering
rule is implemented and covered by tests; what is missing is a case in the demo data where the best price is old
(an item no other supplier covers, or an item whose only file is stale). Raised with OpenCode in
from-hermes. Also relevant to the parked column decision: with a best-price-only
column set, freshness can only ever describe the price that won.
As for stage 1: the thresholds, the holiday list and the column set are configuration, so a change is a setting
or a small edit, not a rewrite; and any behaviour change after first use is recorded as a dated update here and on
the plan page, never as a silent edit.