Status: AUTHORISED by Matthew 2026-09-14; all seven decisions SETTLED and the vocabulary (gate G5)
RATIFIED the same day ("all as recommended", then "ratify the vocabulary"). G1, G2 and S1–S12 are
built, pushed, verified AND ACCEPTED (2026-09-14) — the one blocker (S5) is closed and re-verified
live, along with a hidden batch-position bug it had been masking; see the follow-up under "As delivered".
Phases 4–6 are all authorised (2026-09-14): Phase 4's gates are closed and its counter is pending, and Phases 5–6 are specified and gated on the phases before them. This page follows the shape of the
authorised
Phase 2 spec: scope in and out, what exists
to build on, pre-work gates, work items with owners, decisions held for Matthew with a recommendation
each, an acceptance list and the risks. Build order is gated: the module map sets Phase 2 as Phase
3's gate, so Phase 3 build starts only once the Phase 2 work is deployed and accepted — not while it is
an unpushed branch.
Phase 3 of the agreed plan in module-map — its
module 4 row (Stock & inventory: Django orchestration → ERPNext). This is the first pharmacy module
whose data is commercial rather than clinical: it is the client's own stock ledger, not patient
data.
In:
Out (deliberately):
tab* tables — never, in this phase or any other.Stock data is the client's commercial data, not patient data — but the rule is unchanged: it lives on
the client tier and never centrally. Concretely for this phase:
| Data | Where it lives |
|---|---|
| Items, batches, quantities, goods-in receipts, movements | The client's own ERPNext (on the client's box — SIAAS), reached over frappe REST |
| The Django-side operating state (mapping table, FEFO selection cache, alert thresholds) | The client tenant's own database, via a PHARMACY_APPS app — the same partition Phase 2 uses |
| The audit trail of stock actions | The tenant's database, actor + action + target + timestamp, no clinical content |
| Anything at all in the central database | Nothing. Not a stock row, not a batch number |
A dispensing token (Phase 2) carries patient identifiers; the stock movement it causes carries a
quantity and a batch. The two must not be joined into one client-tier record that leaves the client's
box, and the movement must not name the patient in ERPNext — the ERPNext Sales Invoice for a patient
charge is the existing pattern for that, not a stock document.
Checked in the pharmacy repository rather than assumed:
expiry matches are the unrelated temporary-access expiry in onboarding.PHARMACY_APPS is a data-driven list, proven by Phase 2's W2,migrate_client --app-label pharmacy. A new stock app inherits that mechanism.integrations/erpnext_client.py here implements ping,get_or_create_item, list_items, get_or_create_customer, create_sales_invoice,get_sales_invoice_status, create_payment_entry, get_or_create_insurer_customer andget_invoice_pdf. It has no warehouses, batches, stock entries, item prices, item groups,dental_booking_app/integrations/erpnext_client.py) has the fuller surface (companies, accounts,provision_erpnext_config command in this repo (dental has one). Whatever provisionsPharmacyDispensaryRequiredMixin pluscore/permissions.py, pharmacy_dispensing/views.py).provision_erpnext_config from| # | Work item | Owner | Indicative size |
|---|---|---|---|
| S0 | Gates G1–G5 closed and recorded | OpenCode (G1), Hermes (G5 vocabulary), Matthew (G4) | S |
| S1 | The stock app on the client tier — a new app registered in PHARMACY_APPS, holding only what Django must own: the SNOMED↔item mapping, alert thresholds, and a local read-cache of stock positions. No second ledger |
OpenCode | M |
| S2 | ERPNext stock integration — warehouses, batches, item prices and stock entries over frappe REST, with idempotency keys so a retried receipt does not double-post, and every call's failure surfaced with the frappe message (the dental client's error handling is the pattern) | OpenCode | M–L |
| S3 | Goods-in — receipt screen and command: supplier, lines (item, pack, quantity, batch, expiry), validation (expiry required, not in the past, pack size sane), then a submitted Stock Entry in ERPNext. Actor recorded | OpenCode | M |
| S4 | FEFO selection — the selection function that returns the batches to consume for a quantity, earliest expiry first, excluding expired stock; recorded against the movement so the batch is answerable afterwards | OpenCode | M |
| S5 | Consumption from dispensing — Phase 2's dispense transition (and Phase 4's sale, later) calls the stock service; the movement records quantity and batch and no patient identifier |
OpenCode | M |
| S6 | Adjustments and wastage — a recorded, reason-bearing correction path; never a silent edit of a posted document | OpenCode | S–M |
| S7 | Stock views — on hand by item and batch, expiry report (with the configured warning bands), low/zero stock, goods-in history; every queryset scoped to the tenant's premises | OpenCode | M |
| S8 | Role gating — who may receive, adjust and view: a view-layer mixin per surface, refusals tested for the roles that must not reach them (recommend: RP and Technician own the dispensary stock; the Counter Assistant reaches counter stock and no adjustment; the Business Manager sees all and owns receipts — to be confirmed with the Phase 2 role decisions) | OpenCode | S–M |
| S9 | Audit and DSPT evidence — every stock action with actor, action, target, timestamp, and no clinical or patient content | OpenCode | S |
| S10 | Synthetic seed — obviously fake supplier, items, batches and dates (including one expired and one short-dated batch, so the alerts and the FEFO exclusion have something real to show), idempotent and re-runnable | OpenCode | S–M |
| S11 | Tests and the documented runner — FEFO ordering and its expired-batch exclusion, the goods-in path posting once under retry, consumption recording the batch, alert bands, RBAC refusals, and that no stock row or movement reaches the central database | OpenCode | M |
| S12 | Report back (file pointers plus every place this spec met reality differently), then verification against the artifacts, the walkthrough, and the user guides one page at a time | OpenCode then Hermes | S + S |
Matthew answered all seven on 2026-09-14, every one as recommended. They are recorded as settled with
the reasoning kept — that reasoning is what a later reader needs when a trade-off is revisited, exactly
as on the Phase 2 page.
| # | Decision | Settled |
|---|---|---|
| D3.1 | Which side owns the stock ledger — ERPNext as the ledger of record with Django holding only operating state, or Django's own stock tables mirrored into ERPNext | SETTLED: ERPNext owns the ledger. It is already PostgreSQL-backed, native for batch/expiry, and the module map says exactly this. Two ledgers would be two truths; Django's stock tables are a read-cache and nothing more |
| D3.2 | Barcode handling in v1 — capture the barcode as data, or integrate a physical scanner at the counter and goods-in | SETTLED: capture only. Most scanners present as a keyboard, so a text field accepts them today; a device integration is a hardware project with no DSPT payback |
| D3.3 | One warehouse or two — a single stock pool, or a Dispensary pool and a Counter pool | SETTLED: two. It mirrors the ratified dispensing/counter vocabulary, keeps zero-rated NHS stock distinguishable from standard-rated OTC stock (which Phase 4's VAT split needs), and costs nothing to set up now |
| D3.4 | Zero or negative stock at dispense — refuse, or warn and record | SETTLED: warn and record. A pharmacist must not be blocked from dispensing a patient's prescription because the ledger is behind; the movement posts and the shortfall surfaces in the zero-stock report |
| D3.5 | Expiry bands — the warning thresholds, and whether short-dated stock may still be picked | SETTLED: configurable bands defaulting to 3 months and 1 month; expired stock is never selected by FEFO, short-dated is flagged but pickable |
| D3.6 | Goods-in second actor — one actor records the receipt, or a second confirms it | SETTLED: one actor with a name snapshot — consistent with Phase 2's D1/D6 pattern and with small-pharmacy reality; revisit with the signing question |
| D3.7 | Where the SNOMED↔item mapping comes from — a licensed dm+d feed, or a per-client mapping table maintained in the app | SETTLED: a per-client mapping table with a management-command import, seeded with obviously synthetic entries. dm+d licensing and updates are their own project; v1 must not depend on them |
With all seven settled, every work item in this phase is buildable; the only remaining gate is the one
below, which is a naming question rather than a design one.
Phase 2's lesson was that words become model fields and screen labels, so they were pinned before the app
was written. Matthew ratified this on 2026-09-14. One word per concept; nothing here renames Phase 2's
ratified vocabulary, and these names are now fixed for the models, the screens and the guides.
| Concept | Use | Do not use |
|---|---|---|
| Stock arriving from a supplier | receipt (the act: receive) | "goods-in" in the model (it stays fine as a screen label), "delivery", "GRN" |
| Stock leaving because a patient's script was dispensed | consumption, with the reason "dispensing" | "sale" (that is Phase 4's counter event), "issue" |
| Stock leaving for any other reason | adjustment, with a reason | "correction" (a correction is an adjustment with a specific reason), "write-off" (that is one reason value) |
| The unit a pharmacy buys and dispenses | pack (with pack size) | "box", "unit" for the pack itself |
| The thing stock is recorded against | item | "product", "line" |
| A batch/lot with an expiry | batch (expiry date on the batch) | "lot" (use it nowhere; one word) |
| The two stock pools (D3.3) | Dispensary and Counter | "shop", "retail", "front of shop" |
Recorded on the module map's pinned terminology list alongside Phase 2's terms. Unlike Phase 2's
vocabulary, this one is not pinned by an applied migration — the stock app is new, so a rename is still a
cheap change rather than a new migration.
OpenCode built the whole phase across five sessions. Everything is pushed: origin/dev = 1634804
(8d5e4c9 G1/G2 the ERPNext stock client surface and pharmacy provisioning, 0f48194 S1,
7c0703a S2–S11, then the seed/SNOMED fixes c4d2833…8cd24e4, and 1634804 honouring the stated
batch on a stock entry). The Phase 2 close-out landed first as 09b1c6f. Verified against the artifacts:
| Checked | Evidence |
|---|---|
| Phase 2's five defects — all fixed | The mock store now rebuilds when the file's mtime changes and only when USE_MOCK_EPS is true, so a seed no longer needs a restart (and no synthetic file is consulted at the live switch); transaction.atomic(using=db_alias) in both places that lacked it; the three EPS error paths now log with exc_info=True and show a generic message (the remaining str(exc) are guard messages, which are safe and useful); ReleaseTokenView checks permitted_actions before touching EPS and re-claims if the local transition is then refused; PharmacyAuditEvent gained the same append-only delete() barrier as the transition table |
| G1 — the ERPNext client is now complete for stock | integrations/erpnext_client.py gained warehouses, item groups, stock items, item prices, batches, stock entries (create/get/submit/cancel/list), stock balance, batch positions and stock settings — plus the accounts/journal surface. The pharmacy repo is no longer behind dental |
| G2 — a stock-capable site exists | test-client.accounts.pharmacy.veripath.co.uk is live with SSO, a Dispensary and a Counter warehouse and batch/expiry tracking; the probe item reports has_batch_no=1 has_expiry_date=1 |
| Tests | Independent re-run here: 134 tests, OK under scripts/run_pharmacy_tests.sh (was 90) |
| Migrations applied on both client-tier instances | Read directly: apex sector_pharmacy and the client box's test_client_pharmacy both carry pharmacy_stock.0001/0002, pharmacy_dispensing.0003 and pharmacy.0004, with the nine pharmacy_stock_* tables |
| The boundary holds | The central database contains zero pharmacy% tables. Local stock movements name a batch, an expiry, a warehouse and an opaque dispensing:<pk>/receipt reference — no patient identifier (checked all four movement rows) |
| Live ERPNext postings — read from ERPNext itself, not from the build note | Through the app's own client: MAT-STE-2026-00017 Material Issue docstatus 1, batch DEMO-AMOX-B2 (the earliest-expiry unexpired batch — FEFO as intended); MAT-STE-2026-00019 Material Issue docstatus 1; MAT-STE-2026-00018 Material Receipt docstatus 1; MAT-STE-2026-00016 and MAT-STE-2026-00007 docstatus 2 (cancelled) — so submit and cancel both genuinely work |
| Seed | Four receipts recorded locally with batch, expiry and DEMO-GIN-0001, matching MAT-STE-2026-00008 |
machine._replace_items()assemble) re-creates the token's items from a fixed field list that omits snomed_code,task_to_items() is thrown away at assembly; consume_for_dispensing()DEMO-SNOMED-001 was claimed, assembled, checked and dispensed — batch DEMO-AMOX-B2 read 19.0StockMovement row was written. The seeded demo tokens work onlysnomed_code directly onto its items, which is exactly why the livesnomed_code in _replace_items, add a test that the code survives claim → assemble → dispense,consume_for_dispensing() is best-effort by design (D3.4 — a dispense is never blocked) and logs theVERIFY-S5-1 andVERIFY-S5-2, both DISPENSED — as the evidence for finding 1. They cannot be removed through the appuse_serial_batch_fields is consistentbench console not loading gunicorn_app means console-basedVerified in commit 1716142 (origin/dev), and re-verified live by me rather than from the report:
machine._replace_items now carriessnomed_code, and views.py builds the assembly payload from the model's own fields_ITEM_FIELDS, derived from DispensingItem._meta) instead of a hand-written list — which is theStockMovement is written. 141 tests, OK on my independent run (was 134).DEMO-SNOMED-001 was claimed → assembled → checkedsnomed_code survived assembly (DEMO-SNOMED-001 on the item);DEMO-AMOX-B2 (expiry 2026-10-04) with thedispensing:18;MAT-STE-2026-00022 Material Issue, docstatus 1;purge_dispensing_tokens command and cancelled the Material Issuedocstatus 2), restoring DEMO-AMOX-B2 to 15.0 — the demo tenant is tidy and holds only the six democonsume_for_dispensing now returns consumed/skipped with reasons, records anSTOCK_CONSUMPTION_SKIPPED audit event and shows a warning badge on the token — sopharmacy.0005 is applied onVERIFY-S5-* tokens are purged, and I used the same command on my ownhealthcare_accounting.api.stock.batch_positions summed case when is_outward = 1 then -qty else qty end, but a Serial and Batch Entry qty is already signed, so every batch was inflated by twiceBatch.batch_qty only while nothing had been consumed —sum(be.qty) (erpnext-deploy de4eb4b), and my run above confirms the read nowPhase 3 is ACCEPTED. The walkthrough is unblocked and is mine to write.
Phase 3 is done when, against the client's own ERPNext and with synthetic data: