Status: BUILT, DEPLOYED AND VERIFIED LIVE 2026-09-27 — see §10. Built by OpenCode; verified by me on the app host and over the client surfaces (64/64). Spec written and authorised the same day: Matthew approved the approach (2026-09-27, "go along with
your recommendations") and left the two open questions to me; both rulings and their reasoning are in §4
(D-C, D-D) so either can be overruled without unpicking the rest. Nothing is built yet — this page is
the brief OpenCode builds from.
The one thing that makes this a build rather than a view: the bridge does not exist. A contractor's purchase
price rests on aStockItem; the NHS reimbursement rests on a dm+d pack. Nothing connects them today, and
every figure in this phase depends on that connection. So most of this stage is a coding job with a measured
ceiling — 3,779 of the 4,439 packs that already carry a derived figure (85.1%) are reachable from a GTIN the
store holds, 660 are not (§3) — and the remainder is a visible queue, which is the discipline this project
already uses for the pack link and the Part II item list.
Parents: Central Reference Data (phase 8),
The Derived Pricing — Phase 7
(the figure being compared), Supply Orders Part 3
(the purchase price being compared against), NHS Pricing View
(the three levels, and the panel the figure already lands on).
A contractor sees what they paid for a pack beside what the NHS will reimburse for it, on the screens where they
already see their own price — advisory only, never blocking an order.
That is the whole of it. It is not a new feed, not a new calculation and not a new price: the figure is phase 7's
(the derived reimbursement, 4,439 packs, reproducible from the store), and the purchase price is stages 1–4's
(the client's own imported wholesaler files, with best price, spread and freshness already computed in one place).
The only thing missing is the identity of the pack each stock item is.
Two settled rules land here rather than being decided here:
| Piece | Where it is | State |
|---|---|---|
| The derived reimbursement | DerivedPackPrice in the central store |
live: 4,439 figures, one per VMPP, each with basis, deduction kind, rate, net, SAF, rule text and the releases behind it |
| The published prices behind it | DmdDrugTariff, DmdAmppPrice, the concession notices |
live — dm+d release 3170926, tariff 2026-09, notices 2026-09 |
| The client's own purchase prices | SupplierPriceFile / SupplierPrice (client DB) |
live — stages 1–4 built, deployed and verified |
| The one place a price answer lives | pharmacy_stock/price_view.py |
live — best price, spread, price count, freshness in working days; a NOT_STATED basis is never presented as comparable |
| The screens that show a price | low-stock report, expiry report, order build | live — all read price_view, so the column set is a one-place change |
| The pack the figure belongs to | /medicines/ pack detail panel |
live — shows the pack, the rule that produced the figure and its releases (phase 7) |
| The link between them | nowhere | the gap this phase closes |
contractor's stock item → AMPP → VMPP
(PIP / barcode, free text) (the brand they hold) (the pack the NHS reimburses)
A contractor buys an AMPP and the NHS reimburses the VMPP. Their stock catalogue is their own free text:
StockItem carries item_name, pack_size, form, strength (all free text), pip_code and barcode — and
no dm+d reference of any kind.
I checked the whole central store: there is no PIP attribute anywhere in it (the dm+d models carry gtin and
no PIP field; the reference-data grep for PIP returns nothing). PIP is the commercial code — the wholesaler's
catalogue number — which is why it matches a price file's row to a StockItem, and why it can never reach dm+d.
PIP matches the price to the item; only a barcode or a name matches the item to the pack. The brief's shorthand
("the StockItem PIP → pack bridge") names the wrong key; the build must not be written as if PIP reaches dm+d.
Measured on the live dm+d release, live (non-end-dated) GTINs only:
| GTIN rows in the live release | 84,420 — 74,016 at 13 digits and 10,404 at 14 digits |
| Distinct GTINs in the store (all releases) | 98,352 over 187,156 AMPPs; 37,397 VMPPs |
| Packs with a derived figure reachable from a live GTIN | 3,779 of 4,439 (85.1%) |
| Packs with a derived figure and no live GTIN | 660 (14.9%) |
By deduction kind — which matters, because the interesting states are the well-covered ones:
| Deduction kind | Priced packs | With a live GTIN | Without |
|---|---|---|---|
category (20% / 5% / 9.85%) |
3,248 | 2,618 | 630 |
none_part_two (a Part II item) |
598 | 596 | 2 |
none_group (a Part II group) |
377 | 349 | 28 |
none_concession (on concession) |
216 | 216 | 0 |
| Total | 4,439 | 3,779 | 660 |
Both widths are needed. A 13-digit GTIN (EAN-13) and its 14-digit form (GTIN-14, a leading zero) are the same
product, and the store holds both shapes for different packs — so a match must try the code as held, zero-padded to
14, and stripped to 13. A one-shape match would silently lose packs, which is the exact failure this project has
already recorded once (the CATDT checksum error on the controlled-drug field, phase 7 §9.3).
Where there is no usable barcode, the item's name is enough to reach a VMP: on the test catalogue, 6 of the 9
item names resolve to a VMP exactly once the demo suffix is dropped (Amoxicillin 500mg capsules, Ibuprofen
400mg tablets, Paracetamol 500mg tablets). But a VMP is not a pack, and the figure is per pack:
So the name route is name + pack size resolved to a VMPP, using the same deterministic normalisation the pack
link uses (case, whitespace, the space between a number and its unit, the spacing around /, trailing punctuation)
— and no similarity score. Where more than one VMPP matches, that is an ambiguity: queue it, never pick the
first candidate. The store's own pack text is the VMPP's tail ("Amoxicillin 500mg capsules 21 capsule"), so the
comparison is between the item's free text and a real pack description, not between two approximations.
| Stock items | 9 (9 active) |
| With a PIP code | 4 — all 7 digits, all synthetic (0123456 …) |
| With a barcode | 7 — all demo strings (DEMO-BARCODE-0001 …), none numeric |
| With neither | 2 |
| With an imported supplier price | 5 items, 17 rows from 11 price files |
| Items that bridge to a dm+d pack | 0 |
| Items that could show a comparison | 0 |
Nothing bridges today, and not because the link is hard: the test catalogue's barcodes are placeholder strings
and its names carry a (DEMO) suffix. That is a seed problem, and it is fixed in §9, because a walkthrough that
cannot show the comparison show nothing of the purpose.
D-A — how a stock item is tied to the pack that is reimbursed: RULED — GTIN first, name+pack second, everything
else queued with an override that holds.
The link stores which route made it and what the evidence was, so a figure on a screen can be defended: a
contractor asking "why does the app think this is that pack?" gets an answer from the row, not from a re-run.
D-B — where the comparison appears: RULED — on the screens that already show a supplier price, never as a new
destination. The low-stock report, the expiry report and the order build already read price_view, so the
reimbursement joins them there as one more column, computed in that same module; the stock item's own view shows
the linked pack and the rule behind the figure. A separate "comparison page" would be a place nobody visits and a
second copy of the wording to keep in step.
D-C — may the client-facing figure name a concession-derived reimbursement? RULED — yes, and this is not a new
policy, it is the rule already built. The concession figure the client sees comes from the NHSBSA Drug Tariff
News notices — source drug_tariff_news, Open Government Licence v3, consent_required = false, release
2026-09 published and live — which is exactly the source the /medicines/ view already reads, on the built
rule that "the CPE-derived rows are never read here". The CPE stream stays where it is: source
price_concessions, consent required and not granted, its 2026-09 release imported and not live. Verified
on the live store: all 4,439 derived figures cite the notices release (dm+d 3170926, tariff 2026-09, notices
2026-09 — all published), so the CPE release contributes to nothing this phase displays. The build must still
read the gate from the source row (the existing may_publish rule) rather than hard-coding "concessions are
fine", so that a figure depending on a source that is not publishable is withheld and named instead of shown.
Nothing here opens the CPE gate or pre-empts Matthew's licensing task — if he later opens it, the display
follows the row.
D-D — should the demo seed carry real dm+d GTINs? RULED — yes. The walkthrough has to show the purpose working,
and today it cannot (§3.5). The seed's seven real items get a real, public dm+d GTIN matched to a pack that
already carries a figure, and their name and pack text aligned to that pack so both routes resolve; two items
stay deliberately unbridgeable so the queue and the "no published figure" states are visible rather than described.
The GTINs are public product identifiers from the dm+d release, not invented values and not personal data;
PIP codes stay synthetic, because we hold no PIP catalogue and inventing commercial codes would put a wrong
number in a field a wholesaler matches on. The seed values are the measured set in §9.
D-E — the states where there is nothing to compare: RULED — name them, never fill them. "No supplier price on
file" and "no published figure for this pack" are different facts from "£0.00" and from a blank cell, and every
screen must say which one it is — the rule price_view and the pricing panel already follow.
W1 — the link, as data (client tier, per client). A new StockItemDmdLink on the client database, one row per
stock item, carrying: the item; the route (gtin / name_pack / manual); the codes (gtin, ampp_id,
vmpp_id) and the dm+d release the link was made against; the state (linked / queued, plus the override
below); the evidence (what matched, and the matched text where a name matched); who made or last changed it and
when. Codes only — no foreign key into the store, which is a different database and is read, never written.
Migration tracked.
W2 — the resolver, its own re-runnable command. link_stock_items_to_dmd: pass 1 GTIN (both widths, §3.3,
end-dated GTINs excluded); pass 2 name + pack size (§3.4); everything else queued with its reason (no code to
match on, no name match, ambiguous pack). It records what it did (counts by route and state) and a second run
is provably a no-op, not assumed to be. It never writes to the store and never resolves an ambiguity by picking a
candidate.
W3 — the comparison, in the one place prices are read. price_view returns the reimbursement beside the price
answer, so every screen showing a price shows the same figure in the same states. The comparison is stated only
when the live best price's basis is PER_PACK; a PER_UNIT or NOT_STATED price shows the reimbursement beside
a named reason it is not compared — never a silent conversion and never a guess at the pack count in the file.
W4 — the screens. The low-stock report and the expiry report gain the reimbursement column beside the existing
price columns; the order build shows, per line, the best price and the reimbursement; the stock item's view shows
the linked pack, the rule that produced the figure and the releases behind it. The wording is rendered from the
same source as the /medicines/ panel, not written a second time.
W5 — the queue, visible. Where an item is not linked, the screen says "Not linked to a dm+d pack yet" with
its reason — never a blank and never a figure — and the stock dashboard carries the counts (linked / queued). The
queue is per client, because the catalogue is.
W6 — the manual link, and the override that holds. The stock-management roles (RP, Dispensing Technician,
Business Manager — the roles that already maintain stock) may link an item to a pack by hand where no rule could,
or correct a wrong link; the operator and the date are recorded on the row and the link holds until superseded
— the same discipline as the pack link's overrides and the Part II item list: a person decides, no score does.
W7 — the label. Every displayed figure carries the pack it came from (VMPP identity), the rule text that
produced it, and the releases behind it (dm+d, tariff, notices) — generated from the stored rows, so next month's
releases update it with no code change.
W8 — tests and the seed (§9). Suites for the link, the resolver's idempotence, the ambiguity queue, each screen
state, and the no comparison without a stated per-pack basis rule.
| State | What it reads |
|---|---|
| Linked, figure, a per-pack price | Best supplier price £3.10 · NHS reimburses £3.61 — on concession this month, so no discount is deducted |
| Linked, figure, no price imported | The reimbursement, with no supplier price on file |
| Linked, no figure | NHS reimbursement: no published figure for this pack — with the missing input named, never £0.00 |
| Linked, price whose basis is not stated | The reimbursement, and why the purchase price is not comparable |
| Not linked | Not linked to a dm+d pack yet — (no code to match on / no name match / more than one pack matches) |
| Not linked and no price | Both statements, separately |
£0.00 anywhere.DerivedPackPricepharmacy_stock,pharmacy_medicines, the combined run).price_view is the one place it changes).Seven real items, each a pack that carries a derived figure and a live GTIN, so both routes resolve; name and
pack text are the dm+d pack's own words. Figures below are the live September 2026 store values.
| Seed item | Item name (= VMP name) | Pack size (= the VMPP's tail) | GTIN (barcode) | VMPP | Category | Rule on the month | Reimburses |
|---|---|---|---|---|---|---|---|
DEMO-AMOX-500 |
Amoxicillin 500mg capsules | 21 capsule | 05012617008855 | 1037711000001109 | Cat M | 20% deducted | £1.04 |
DEMO-ACIC-400 |
Aciclovir 400mg tablets | 56 tablet | 05012617004260 | 1109511000001108 | Cat M | on concession — no deduction | £3.61 |
DEMO-IBU-400 |
Ibuprofen 400mg tablets | 84 tablet | 05060057400289 | 1274111000001106 | Cat M | 20% deducted | £2.07 |
DEMO-SALB-100 |
Salbutamol 100micrograms/dose inhaler CFC free | 200 dose | 5000124439750 | 1056811000001104 | Cat C | 5% deducted (brand) | £1.43 |
DEMO-MPH-10 |
Methylphenidate 10mg modified-release capsules | 30 capsule | 04260020521800 | 9098411000001101 | Cat C | Part II group — controlled drugs, no deduction | £25.00 |
DEMO-BRIV-25 |
Brivaracetam 25mg tablets | 56 tablet | 05012617003744 | 31500311000001103 | Cat C | Part II — the named drug | £129.64 |
DEMO-TYPH-VAC |
Typhoid vaccine (live, oral, strain ty21a) gastro-resistant capsules | 3 capsule | 5715036000959 | 2998411000001102 | Cat C | Part II group — vaccines and antisera | £14.77 |
Two items stay deliberately unbridgeable, so the queue is demonstrated and not just described:
DEMO-OTC-IBU-200 — a barcode that is not a GTIN (DEMO-OTC-BARCODE-0002) → "no code that matches a pack";DEMO-ONEDAY-001 (and any item with neither code) → "no code to match on".The supplier prices the demo needs (dummy values, per pack, on the existing import path): at least one demo item
priced below its reimbursement (the profitable script) and at least one above it — the buy-above-reimbursement
case is the whole reason the guardrail exists, so the walkthrough must show it rather than a comfortable example.
Everything else about the seed stays as it is today.
Status: BUILT, DEPLOYED AND VERIFIED LIVE, 2026-09-27. Built by OpenCode (branch
feature/phase8-client-comparison@49bd358, off the deployed78c9b2c; migration
pharmacy_stock/0008— the link table, the twoStockSettingsfields; bundle beside the deployment on the app
host, the standing deliverable note honoured). Deployed and verified by me, on the app host and over HTTPS on the
client surfaces. Two verifiers, both committed toveripath-ops: the client-surface one
(scripts/verify-client-comparison-live.py, 64/64,08fec29) and the host-side one
(scripts/verify-client-comparison-host.py,3564277) that reproduces the table below in one command inside the app
container.
What I measured myself, rather than taking on report
| Check | Result |
|---|---|
| The bridge, read straight from the client DB | 13 items · 7 linked (all by GTIN) · 6 queued — 2 no_code, 4 no_code_match |
| The figures behind the links | 7 of 7 recomputed equal to the stored DerivedPackPrice row on net, basis and rule text |
| The resolver, re-run live | idempotent: linked 7 (gtin 7), queued 6, changed 0, and a before/after hash over every link row is identical |
| The evidence on each link | route, the code shape used, the matched text, the candidate pack(s) and the dm+d release (3170926) — a figure on a screen is defendable from the row |
| The central store | untouched: DmdGtin 101,309 · DmdAmpp 187,156 · DmdVmpp 37,397 · DmdVmp 24,662 · DmdDrugTariff 8,686 · DerivedPackPrice 4,439 — every count identical to the pre-build baseline |
| The client surfaces (GET only, real OIDC) | 64/64 — every measured pack shows the pack, the figure, the rule, the releases and the contractor's own price, with no blank and no £0.00; the queue names its reason |
| The advisory rule | structural, not cosmetic: order_routing, order_value and order_profiles contain no reference to the link or the reimbursement, and the above-reimbursement item renders both figures with nothing warning or blocking |
| The licence gate | every linked figure rests on dm+d / tariff / notices — 0 non-publishable dependencies; no CPE wording on any stock surface |
| The manual link (once, live, then reverted) | route=manual, evidence='linked by hand', operator tc.tech.one and the date on the row, an audit event written, and the resolver left it alone (manual 1, changed 0, the queue 6 → 5) |
| Access | anonymous /stock/items/<pk>/ → 302 to OIDC; the manual-link route is stock-manage only |
| Suites | pharmacy_stock 159 and the full pharmacy run 496 — both with only the two failures below |
The verifier needs the catalogue's pks, since not every row appears on those three screens. Measured read-only on the
app host and passed as --items-file:
ssh app-engine 'cd /root/work/pharmacy_booking_app && docker compose exec -T app python manage.py shell -c "
import json
from tenancy.models import Tenant
from tenancy.utils import set_current_tenant, get_tenant_db_alias
t = Tenant.objects.get(subdomain=\"test-client-pharmacy\", is_active=True)
set_current_tenant(t)
from pharmacy_stock.models import StockItem
print(json.dumps({i.item_name: i.pk for i in StockItem.objects.using(get_tenant_db_alias(t)).all()}))
"' > /tmp/phase8-items.json
The buy-above case is live, which is the whole point of the phase. Amoxicillin 500mg capsules — buy £2.10 from
Synthetic Wholesaler Two against a reimbursement of £1.04; Salbutamol — £3.60 against £1.43; Ibuprofen 400 —
£0.79 against £2.07, the comfortable direction. Three of the seven packs already carry an imported price, so the
comparison is demonstrable on the test client before any new data is uploaded.
The two test failures are date-dependent and pre-existing — verified, not accepted on report. The failing pair is
test_seed_covers_the_three_freshness_states and test_seed_reaches_all_three_freshness_states, both asserting
One working day old for a file dated the seed's "previous working day". Run against the live helpers on Sunday
2026-09-27: the previous working day is Friday 2026-09-25, which is 0 working days back, so the state is
Current — and the same call on a Monday or Tuesday returns One day, exactly as the tests expect. The build does
not touch the freshness helper or the seed's date arithmetic (its diff adds items, batches and CSV rows); this is a
weekend-only test artefact, not a defect in this phase.
The §9 seed naming — ruled and recorded here (D-D, continued). The seven real packs carry the dm+d names verbatim
with no (DEMO) marker; the deliberately-unbridgeable rows keep theirs. Keep it as built: the exact dm+d wording is
what keeps the deterministic name + pack route available on a later month, and a contractor's catalogue is supposed
to read like a catalogue. The demo identity is still carried by the item codes (DEMO-…, unchanged), the tenant
and the two rows left unbridgeable on purpose. Nothing here is patient data — these are public product descriptors and
published identifiers.
One thing still owed, and it is not this build. The stage-4 partial advisory — "£X (partial — N line(s)
without a price)" — is still unreachable from a fresh seed, because it needs two rows inside one supplier group with
one of them unpriced. The second supplier minimum landed with this build, so the short half of the 2026-09-22 note is
closed; the two-line group is briefed back to OpenCode.
VeriPath internal. Dummy/synthetic data only — no patient-identifiable information appears in this work, and no
credential or internal address is recorded here. Every figure on this page was measured read-only on the live store
and the live client database on 2026-09-27.