Status: READY TO BUILD (2026-09-22), authorised by Matthew — "okay let's make a start", with the expectation of
"further modifications post-completion when we start using it". Phase 1 is stage one of five on
supply-orders-part3-pricing; it changes no
existing behaviour: to date the app holds no purchase prices at all. Read the parent page for the full plan,
the risks and the evidence behind every claim here.
A Business Manager can upload a wholesaler's own price file, confirm which of the columns the file actually has
is the product code and which is the price, and see honestly what the app did with every line: matched, unmatched
(no such item), or rejected (unusable price). The import is an audited record. Nothing else in the app changes
— no price appears on the item, the supplier, the low-stock report or the order build in this stage.
| Decision | Value |
|---|---|
| Who imports | Business Manager only |
| Who sees prices on the stock screens | RP, Dispensing Technician, Business Manager (PharmacyStockManageRequiredMixin) — never the Counter Assistant |
| Where prices live | The app, on the client DB. No ERPNext buying price list, no Item Price write, nothing central |
| Built against | Example data — seeded, obviously-invented price files; example minimum order values already exist on the seeded supplier |
| Per pack or per unit | Captured, not guessed — the mapping records the basis (PER_PACK / PER_UNIT / NOT_STATED); the first real file settles the value |
| Retention | Not in this stage; the model is shaped so any of the three Phase 5 options can be added later |
38e2271)pharmacy_stock.0004, pharmacy.0018.core/permissions.py): PharmacyStockManageRequiredMixin (RP / Technician / BM) for read;PharmacyStaffUserManageRequiredMixin is the exact Business-Manager-only precedent to mirror for the importrecord_audit(action, actor=None, target=None, detail='', using=None) in pharmacy/models.py;PharmacyAuditEvent.Action already holds the ORDER_* events and is append-only (delete raises).StockItem: pip_code (CharField, blank, leading zeros are real — the live export proved0123456 survives), barcode (free text), item_code (never used as a wholesaler code), is_active,pack_size.pharmacy_stock/order_profiles.py — "the only place a column name or a filenameGENERIC entry, a real wholesaler added as one dict entry when a layout has actually been seen.seed_pharmacy_stock creates Synthetic Wholesaler (DEMO) withminimum_order_value = 150.00, and items with PIP codes 0123456, 2345678 plus a PIP-less item carryingDEMO-BARCODE-0003.pharmacy_stock today (the only request.FILES uses in the codebasecore/views.py and integrations/api/views.py).W1 — the models (pharmacy_stock, migration 0004).
SupplierPriceFile — one row per upload, the audited record:
| Field | Shape |
|---|---|
supplier |
FK Supplier, PROTECT, related_name='price_files' |
filename / file_sha256 / file_size |
CharField(255) / CharField(64) / PositiveIntegerField — the original upload is parsed and discarded, never stored |
price_date |
DateField, default today, editable at upload |
status |
TextChoices PARSED / FAILED / SUPERSEDED |
mapping |
the four column names as recorded (code_column, price_column, description_column, pack_column, all CharField blank) |
price_basis |
TextChoices PER_PACK / PER_UNIT / NOT_STATED, default NOT_STATED |
rows_read, rows_matched, rows_unmatched, rows_rejected |
PositiveIntegerField, default 0 |
notes |
TextField blank — why a file failed, what was rejected |
uploaded_by / uploaded_by_name / uploaded_at |
the house user-ref pattern (db_constraint=False, PROTECT) + a name copy + default=timezone.now |
created_at / updated_at |
auto_now_add / auto_now |
SupplierPrice — one row per line of the file, with nothing dropped:
| Field | Shape |
|---|---|
file |
FK SupplierPriceFile, CASCADE, related_name='rows' |
supplier |
FK Supplier, PROTECT, related_name='prices' (denormalised so comparison needs no join) |
stock_item |
FK StockItem, PROTECT, null when the line matched nothing, related_name='supplier_prices' |
outcome |
TextChoices MATCHED / UNMATCHED / REJECTED |
reason |
CharField(255) blank — why it is unmatched or rejected |
raw_code, raw_description |
exactly what the file said, never rewritten |
price |
DecimalField(max_digits=12, decimal_places=4), null when rejected; never rounded by us, never zero-filled |
imported_at |
auto_now_add=True on every row — this is what makes a Phase 5 purge a date filter |
Indexes: (supplier, stock_item) and (stock_item). No DB unique constraint on (file, raw_code) — a
duplicate inside one file must not abort the import; it is detected in W3 and the later row is REJECTED with
reason='duplicate code in file' so the first occurrence wins deterministically and the file is still counted
honestly. (This supersedes the "unique (file, raw_code)" wording on the plan page, deliberately.)
W2 — the mapping registry (pharmacy_stock/price_profiles.py). Modelled on order_profiles.py: a module-level
dict, no model imports, label + candidate header names per column, price_basis, and a verified field that says
where the layout came from. The entries are suggestions only — the parser always reads the file's real header
row, and the screen always shows the file's own headers in its dropdowns. v1 ships one entry, GENERIC_HINTS,
whose candidates are header names seen commonly in price lists (PIP, PIP Code, Product Code, Item Code for
the code; Price, Net Price, Cost, Trade Price, Contract Price for the price) and whose verified says
plainly that these are hints, not an evidenced layout. Nothing per-wholesaler is invented here — a real
wholesaler entry is added when a file has been seen, exactly as the export side does it.
W3 — the import service (pharmacy_stock/price_import.py). Pure functions + one committed operation, so the
whole thing is testable without a browser:
read_file(upload) → header list + raw rows. File rules: .csv / .txt / .tsv only; 5 MB cap and a, ; \t; a header row is required — a file without one is refusedsuggest_mapping(headers) → a pre-selection from price_profiles, plus the header list itself for the screen.normalise_code(value) → strip surrounding whitespace and any hyphen, keep leading zeros, casefold (barcodespip_code, then barcode. No fuzzy matching. A code that only matches anUNMATCHED, reason "item is not active".parse_price(value) → strip a currency symbol and thousands separators, then require a positive number:REJECTED with the reason, never stored as 0. No VAT arithmetic, noimport_file(supplier, price_date, mapping, price_basis, rows, actor) → writes the file record and all its rowsmatched + unmatched + rejected == rows_read;FAILED file record with the reason and no price rows.PARSED) file already exists for that supplier + price_date, the earlier fileSUPERSEDED and its price rows are deleted (the file record, its counts and its hash stay as audit).StockPositionCache row is written.W4 — the screens and routes.
| Route | Gate | What it does |
|---|---|---|
/stock/prices/ |
RP / Tech / BM | List: supplier, price date, uploaded by/at, the four counts, status |
/stock/prices/import/ |
Business Manager only | Step 1 upload → step 2 confirm mapping (the file's own headers in the dropdowns, five sample rows shown, basis radio, pre-selected hints, and a notice when this will supersede a live file) → commit → detail |
/stock/prices/<pk>/ |
RP / Tech / BM | Detail: counts, mapping and basis used, and the unmatched + rejected lines with their raw code/description and reason |
Add the entry point to the stock dashboard and the stock sub-navigation, in the existing style. No delete
anywhere (the pattern already set by suppliers and orders); a wrong file is corrected by importing the correct one
for the same date, which supersedes it. Repeated uploads of the same file are visible via the hash, not blocked.
W5 — audit and gates. Add PRICE_FILE_IMPORTED, PRICE_FILE_FAILED, PRICE_FILE_SUPERSEDED to
PharmacyAuditEvent.Action (migration pharmacy.0018). Every event's detail carries the supplier, the filename,
the price date and the four counts — enough to answer "who loaded what, when, and what did it contain" without
opening the rows. Add PharmacyPriceImportRequiredMixin (Business Manager only, mirroring the staff-user
precedent) in core/permissions.py; read surfaces keep PharmacyStockManageRequiredMixin. Nothing in this stage
is reachable by the Counter Assistant (HTTP 403, like the order surfaces).
W6 — admin. Register both models, read-mostly, in the existing admin.py style —
SupplierPriceFileAdmin (supplier, price date, status, counts, uploaded by/at; filter by supplier and status),
SupplierPriceAdmin (file, supplier, outcome, raw code, item, price; filter by outcome and supplier; search by raw
code). No bulk-edit surfaces that could rewrite prices silently.
W7 — example data (this is what the stages are learned from). A management command
seed_pharmacy_prices --tenant <slug>, mirroring seed_pharmacy_stock: it imports two checked-in example
CSVs through the real service (never by writing rows directly), so the procedure the pharmacy will follow is the
procedure the seed exercises. The example files live beside the command, are obviously synthetic in name and
content, and deliberately include:
0123456, 2345678) plus a leading-zero case so the matching rule is visible;UNMATCHED);£1,234.56 formatting (REJECTED and parsed);--reset, exactly like the stock seed.W8 — tests. Golden-file tests over the two example CSVs (read through the real parser, not hand-built dicts):
header detection and refusal of a header-less file; the delimiter sniff (; and tab files); BOM tolerance; a
£1,234.56 price parsing; blank/zero/negative/non-numeric prices rejected and never stored as 0; leading zeros
preserved through match; barcode fallback when there is no PIP code; an inactive item unmatched with its
reason; a duplicate code rejected with the first occurrence kept; the matched + unmatched + rejected == rows_read
invariant; supersede (earlier file SUPERSEDED, its rows gone, the record and hash kept); a failed parse writing a
FAILED record and no rows; the ≤5 MB and ≤50,000-row refusals; the audit events; RBAC (the RP, Technician and
Counter Assistant are all refused the import 403, the RP/Technician/BM can read the list and detail, the Counter
Assistant is refused those too); and the standing boundary assertions — no ERPNext client constructed, no
StockPositionCache write, and zero pricing tables in the central database.
W9 — report back. A commit and a note in
from-opencode, with the example files and the counts they produce, so Hermes can
verify the same numbers live.
No ordering change; no price shown on the item, supplier, low-stock, order or receipt screens (that is Phase 2);
no purge or retention (Phase 5); no unmatched-CSV download; no scheduled or automatic import; no ERPNext write of
any kind; no wholesaler-specific layout; no Counter Assistant surface; nothing central.
Deployed and verified by Hermes the same day. Deploy: feature/pharmacy-price-import pushed, the app engine
fast-forwarded dev to 1a01d8c, pharmacy.0018 and pharmacy_stock.0004 applied on the apex and on the
client tenant (migrate_client --slug test-client-pharmacy). Verifier:
/root/work/veripath-ops/scripts/verify-pharmacy-price-import.py — live OIDC login, no mocks — 35/35 checks,
plus database and boundary reads. Synthetic example files only, never a real price list.
tc.bm.one: the mapping step showed the file's own headers and samples; file 01 imported9999999 named as unmatched, the bad price rejected rather0123456 rejected with the first occurrence kept, and the mapping plus basistc.rp.one and tc.tech.one read the list and the detail (200) and are refused the importtc.ca.one is refused all three price surfaces (403); only the Business Manager is offered the importPRICE_FILE_IMPORTED and 2 × PRICE_FILE_SUPERSEDED, every one attributed to Bess Marginwelltc.bm.one) with the counts in the detail; nothing else wrote an audit row that day, and the supersededpharmacy_stock_supplierpricefile andpharmacy_stock_supplierprice are in the client tenant only.seed_pharmacy_prices --tenant test-client-pharmacy reproduced exactly those counts through theThe price basis: settled, not outstanding. An earlier version of this page called it an open item, from the
stage 2 review's answer that an import with an unconfirmed basis should be refused. It should not be refused — see
the settled note above: a Business Manager who cannot say whether a file is per pack or per unit would otherwise be
unable to import prices at all, so stage 1 stays as built and stage 2 carries the basis on screen instead.
Guides: deliberately not written yet — the Business-Manager price-import guide is written after this stage's
behaviour is agreed to be final, and stage 2's report columns will change C2 in any case.
Matthew expects modifications once the pharmacy actually uses this ("further modifications post-completion"). Two
rules keep that cheap: the mapping, the price basis and the file rules are configuration, so a change to them is
a mapping or a profile entry, not a rewrite; and any behaviour change after first use is recorded as a dated update
on this page and the plan page, never as a silent edit.