A medicine isn't a SKU, so the catalog is generic, strength and company before it's a product
A pharmacy counter system whose catalog is modeled on how medicines actually differ (generic, strength and manufacturer as separate axes), so when a brand is out of stock the counter can find an equivalent instead of turning the customer away.
What the engagement involved
- Industry
- Healthcare & Dental Practices
- Duration
- 16 weeks
- Cooperation model
- Dedicated team
- Services
- Platform engineeringCatalogue modellingBack office
- Integrations
- Barcode label printingReceipt printingSMS notificationsAccounting exportScheduled backups
- Technologies
- LaravelMySQLBladejQueryBootstrap
- Team
- 1 Engagement lead2 Backend engineers1 Frontend engineer1 QA engineer
Client name withheld under NDA. Engagement details are shown to the extent our agreement permits.
The hard problem
Their previous system treated every medicine as a product with a name. Paracetamol 500mg from one manufacturer and paracetamol 500mg from another were unrelated rows, so a search for one returned nothing when the other was on the shelf. Staff kept the equivalences in their heads, and locum staff didn't have them.
Paracetamol 500mg from one manufacturer and the same from another were unrelated rows, so a search for one returned nothing while the other sat in the drawer. Regular staff bridged that from memory, which meant the business didn't actually possess the knowledge. Locum cover exposed it immediately, and out of stock became a dead end instead of a substitution.
Model the axes separately. A product is a generic at a strength from a company, so "what else is this" becomes a query instead of a memory. Out of stock stops being a dead end: the counter sees the alternatives on the shelf, with their prices.
The system we were asked to build
The pharmacy chain runs community pharmacies where the counter question is rarely "do you have this box" and usually "do you have something equivalent." We built the system around that: a catalog split into generic, strength and manufacturer, with purchasing, dispensing and payments on top.
A community pharmacy chain where the counter question is rarely "do you have this box" and almost always "do you have something equivalent." Their system treated every medicine as a product with a name, so the equivalences lived in the heads of regular staff. The engagement was commissioned after a run of locum cover during which sales were lost on stock that was physically on the shelf.
Full-Stack Engineering
How the pieces fit
- 01
Split generic, strength and manufacturer into their own dimensions, each queryable on its own
Generic, strength and manufacturer are three separate dimensions, not components of a product name, which is what makes equivalence an indexed query.
- 02
Made substitution a query over those dimensions instead of a maintained equivalence list
Substitution derives from those dimensions, with no maintained list, so it can't go stale as the catalog changes.
- 03
Kept purchase and dispensing against the same product rows, so stock is a derived figure
Making stock derived removed the adjustment screen entirely. It was the change staff objected to most, and they stopped mentioning it within two weeks.
- 04
Recorded payment type on the order, because part-cash part-card is normal at a counter
Payments are typed records with several allowed per order, because part cash and part card is routine at a counter.
- 05
Gave locum staff the same answers as regulars by putting the equivalences in the data
The equivalences live in the data instead of in staff memory, so a locum gets the same answers a regular would have given.
Generic, strength, company
Three separate axes under every product name, which is what makes an equivalence a query.
A product is modeled as three separate things: a generic, a strength and a manufacturer. None of them is buried inside a name. Once the axes are separate, "what else is this medicine" becomes an indexed query instead of something the person at the counter has to know. Packs and units hang off that, so the same medicine in two pack sizes is one equivalence, not two products.
- Generic, strength and manufacturer as three separate axes
- Equivalence becomes a query, not counter knowledge
- Two pack sizes of one medicine remain one equivalence
Browsing the catalog on its axes: choose a generic, then a strength and form, and every interchangeable pack falls out in the third column, two pack sizes included, with no equivalence list behind it.
Substitution at the counter
An out-of-stock brand resolving to equivalents with the same generic, strength and form, each with its price and shelf count; other strengths and forms shown but not offered, and the pharmacist's decision recorded on the patient.
An out-of-stock line shows what else on the shelf is the same medicine, with price and quantity.
An out-of-stock line stops being a dead end: the counter immediately sees the same generic at the same strength from other manufacturers, with the price and the quantity actually on the shelf. The substitution is offered, never applied (the pharmacist decides), and the choice is recorded so a customer who prefers one brand isn't asked again next month.
- Alternatives shown with real shelf price and quantity
- Offered, never applied: the pharmacist decides
- The choice recorded, so a preference isn't asked twice
Stock is derived
Purchases and dispensing write to the same product rows, so the figure on screen is a sum nobody can adjust.
Purchases and dispensing write to the same product rows, so the stock figure on screen is a sum of movements that nobody can type over. That removes the whole category of drift where an adjustment was made to make a screen agree with a shelf and the reason was never recorded. A discrepancy here is an event you can point at.
- Stock derived from movements, never directly editable
- Purchases and dispensing write the same rows
- Every discrepancy traces to a specific event
A wholesaler delivery booked in line by line: two lines short and one never sent, the credit raised from the same screen, and each received line written as a movement so the shelf figure is a sum.
Part card, part cash: one order carrying two tender records, each with its own type, the change on the cash record, and the order closing when the records sum to the total.
Split tenders
Payments are records with a type, so part cash and part card is the normal path.
Payments are records with a type, so an invoice can carry cash and card together as two entries, with no single tender and no remainder handled as a workaround. Split tenders are ordinary here, which matters at a counter where part-cash is routine and the previous system was being defeated with a second invoice.
- Payments are typed records, several per invoice
- Part cash and part card is the normal path
- No second invoice needed to represent a split
Reports by generic
Buying decisions come from what was dispensed, whichever manufacturer happened to be stocked.
Reporting aggregates by generic instead of by manufacturer, so a buying decision reflects what was actually dispensed, whichever brand happened to be stocked when demand arrived. That inverts the previous cycle, where stocking a brand made it look popular and popularity justified stocking it again.
- Aggregated by generic, not by whichever brand was stocked
- Buying driven by dispensed demand
- Breaks the stock-it-so-it-sells feedback loop
Four weeks grouped by generic instead of by box, with how often an equivalent was reached for, beside the same omeprazole row split by company to show what a by-box report would have said.
Phase by phase
Phase 1: The Catalogue
Three Axes, Not One Name
Modeled generic, strength, company, category and unit as separate entities. A product is the intersection, which is what makes substitution answerable without maintaining a second list.
- Generic & Strength
- Company
- Product Model
Phase 2: Purchasing
Suppliers And Intake
Built purchase orders and intake against the same product rows the counter sells from, so stock is derived from movements, never kept as a number somebody adjusts.
- Suppliers
- Purchase Orders
- Intake
Phase 3: The Counter
Substitution In The Flow
Built dispensing so that an out-of-stock line offers the equivalents immediately (same generic, same strength, different company) with each one's price and quantity on the shelf.
- Dispense Flow
- Substitution Panel
- Stock Display
Phase 4: Payment
Split Tenders
Built order payments as their own records with a payment type, because a counter regularly takes part cash and part card, and a single payment field turns that into a workaround.
- Payment Types
- Split Payment
- Receipts
Phase 5: Reporting
By Generic, Not By Box
Built reporting that groups by generic, so buying decisions follow what was actually dispensed instead of which manufacturer happened to be in stock.
- Sales By Generic
- Stock Reports
- Supplier Reports
One pack's stock as its movements: intakes, dispensings and a returned damaged pack summing to the shelf figure, its interchangeable siblings beside it, and a stock-check discrepancy traced to the event that caused it.
What it carries now
3
Catalogue axes
0
Equivalence lists maintained
None
Out-of-stock dead ends
Any
Tenders per order
Catalog axes is a count. Equivalence lists maintained is zero: equivalence is derived from the dimensions, never curated. Out-of-stock dead ends is none, since alternatives are shown wherever they exist. Tenders per order is "any," which describes the payment model and isn't a measured figure.
Client name withheld under NDA. Figures are approximate, drawn from the engagement’s own reporting.
What the architecture settled
- 01
If the domain has axes, model the axes. Folding them into a product name is what makes obvious questions unanswerable.
When a domain genuinely has axes, folding them into a name makes the obvious questions unanswerable, and the questions people ask at a counter are the obvious ones.
- 02
An equivalence you maintain as a list goes stale; one you derive from dimensions can't.
A maintained equivalence list goes stale the first month nobody updates it; one derived from the dimensions is correct by construction.
- 03
Knowledge held in regular staff's heads is knowledge the business doesn't have. Locum cover exposes that immediately.
The chain measured it during a locum week: sales lost on stock physically present, which is the clearest possible statement of who actually held the catalogue.
- 04
A single payment field is a bet that nobody pays two ways. At a counter, that bet loses daily.
One payment field bets that nobody pays two ways, and at a pharmacy counter that bet loses daily and is defeated by staff raising a second invoice.
How the work was run
- 01
The superintendent pharmacist reviewed the catalog model before a line of it was built, and rejected the first version: it had strength as a string on the product instead of its own entity, which would have made "anything at this strength" unanswerable. That correction shaped everything after it.
A dedicated team for sixteen weeks, with the three-axis catalog modeled and migrated before any counter work, because substitution is a property of the model and not a feature on top of it. The superintendent pharmacist reviewed the substitution rules directly: an offered equivalence is a clinical suggestion, not a merchandising one.
One out-of-stock brand, followed to the register
The box is gone. The medicine is still on the shelf.
Choose an out-of-stock brand and step through what the counter does with it: resolve it to a generic, a strength and a form, see which packs share all three and why the others are excluded, then take the sale in as many tenders as the customer wants. Switch tabs, or use the arrow keys once one is focused.
The request names a brand from one company, and that pack is out of stock.
Why nobody keeps a list: equivalence is derived from the axes every pack already has, so a new company’s pack is interchangeable the moment it is entered. Brands, companies, prices and shelf counts here are illustrative.
From a line at the counter to an equivalent on the shelf
The three-axis catalog was modeled and migrated before any counter work, because substitution is a property of the model and not a feature on top of it. Everything downstream reads the same product rows.
- 01 · TriggerA counter line or a delivery lineDispensing and wholesaler intake are both recorded against a product row, never against a name typed at the register.
- 02 · IngestionLaravel purchase & dispense flowsPurchases and dispensing write to the same product rows, so stock is derived from movements and never kept as a number.
- 03 · EngineSubstitution as a queryEquivalents are an indexed query over the catalog's dimensions, not a maintained list, so they can't go stale as the catalog changes.
- 04 · StateMySQL: products, movements, paymentsGeneric, strength, company, category and unit are separate entities; payments are typed records, several allowed per order.
- 05 · DeliveryBlade counter panel & reportsAlternatives are offered with price and shelf quantity, never applied; reports aggregate by generic, not by manufacturer.
What the counter can't get wrong
Strength, storage & shortages
A different strength or form is never offered
An equivalent must share the generic, the strength and the form. Other strengths and forms of the same generic are shown as not offered, and any substitution is only offered: the pharmacist decides, and the choice is recorded.
Fridge and CD items are flagged on the worklist
The dispenser's worklist marks which prescriptions need the fridge and which need the controlled drugs cabinet, beside who is waiting and who is owed a line, so neither is found by reading the label.
A shortage is a line, not a silent gap
Wholesaler deliveries are booked in line by line, so a short or never-sent line is visible where it was found and the credit is raised from that screen. Stock is derived from movements, so every discrepancy traces to an event.
Is your catalogue a list of names when the counter needs to know what else is the same? Scope your build in 3 minutes.
Scope your buildNearby engagements
Web PlatformsThe ransomware attack that became a four-hour non-event
A ransomware attack encrypted the primary patient records server. Drilled offsite backups and a written runbook turned what could have been a scramble into a full restore inside the recovery time objective.
Healthcare & Dental Practices · Ongoing retainer
E-commerceA cart that won't check out until the prescription is real
An online pharmacy where a prescription is a first-class record with its own lifecycle, and prescription-only items simply can't leave the cart without one.
Healthcare & Dental Practices · 20 weeks
Web PlatformsThe right blood type isn't enough: it has to be someone who can get there
A donor register that matches each request on blood-group compatibility and on whether the donor can actually reach the hospital. One platform serves a web admin and a mobile client from the same records.
Healthcare & Dental Practices · 16 weeks
Let's talk
Running a large platform, shaping a first MVP, or getting a product ready for a funding round? Tell us where you are. We'll shape the process around it, and stay with you after launch.














