Pharmacy Software in Pakistan: Engineering Batch and Expiry Tracking
Pharmacy software in Pakistan has to do more than a generic inventory system, which treats a medicine like any other stock item: a name, a price and a quantity. A pharmacy does not work that way. The same medicine sits on the shelf in several batches, each with its own expiry date and often its own printed price. Some of it may only be sold against a prescription. This post covers how we engineered pharmacy inventory management in FlowForg ERP: for each feature, why it matters at the counter, and then how it is built.
Batch and expiry tracking as a sub-ledger, not a parallel system
Why it matters. Batch number and expiry date are what make a recall possible, what stop expired medicine reaching a patient, and what make an expiry claim to a distributor possible at all.
How we built it. Registering a product as a medicine turns batch tracking on, and from then on a single service is the only thing allowed to move that medicine's stock. Every operation writes three things in one database transaction: the batch balance, a batch movement explaining the change, and the ordinary stock movement plus on-hand figure that the rest of the ERP already reports on.
So a batch is a sub-ledger of existing stock: the sum of a medicine's batches always equals its on-hand quantity.
The service refuses rather than silently correcting: no receipt without a batch number, no adjustment that drives a batch negative. Stock corrections and changes to a printed expiry date need a written reason, because those are the edits an inspection asks about. A quarantined or written-off batch is not revived by a new delivery.
Batch allocation by expiry: first-expiry-first-out
Why it matters. What arrived first is not always what expires first. Dispensing in order of receipt is how a shop ends up writing off the batch it should have sold.
How we built it. The "dispensable" query only returns batches that are active, have stock and are in date, ordered by expiry date with undated batches last. A batch counts as expired from the day after its expiry date.
Allocation and dispensing are separate steps on purpose:
// Plan only — nothing is written.
$plan = $batches->allocate($item, $quantity);
// Each line: which batch, how much, and at that batch's printed price.
foreach ($plan as $line) {
$line->batch->batch_no; $line->quantity; $line->value();
}
// Commit. Re-checked against locked rows, not trusted.
$batches->dispense($plan, 'sales_invoice', $invoiceId);
The counter can show the cashier which batches a sale will draw on, and at which printed price, before anything is committed. On commit, each batch row is locked and re-checked, because two tills can be selling from the same batch. When a pharmacist has to hand over a specific batch (a patient mid-course, or a partial recall), that batch is moved to the front, and any remainder still comes off the earliest-expiring packs.
GS1 barcode parsing at receiving
Why it matters. Batch and expiry used to be typed by hand off a carton at receiving time, quickly, by someone with more cartons to open. Modern pharma packs carry a GS1 DataMatrix that already encodes them.
How we built it. The parser reads the application identifiers that matter: (01) GTIN, (17) expiry as YYMMDD, (10) batch/lot, (21) serial, and (7003) expiry to the minute when a manufacturer prints it.
The hard part is that fixed-length elements run straight into whatever follows them, while variable-length ones such as batch and serial end at an ASCII 29 group separator or at the end of the string. A parser that simply split on brackets would get the batch wrong on exactly the packs that matter. Ours:
- strips a leading symbology identifier (such as
]d2); - accepts both the raw stream and the human-readable bracketed form, turning both into one shape so there is a single parsing path;
- treats a day of
00as "end of that month", which is what a pack marked only with month and year carries; - returns
nullfor anything that is not GS1, because an ordinary EAN-13 goes through the same field and must keep working.
DRAP catalogue import
Why it matters. A new shop should not have to type in every medicine it sells. But catalogue data must not overwrite a shop's own records, or invent regulated facts.
How we built it. FlowForg keeps a shared catalogue of medicines. A shop adopts entries into its own register, and that is one-way. Once adopted, the shop owns the row; no later catalogue update writes over it. Two things are deliberately not copied across: the MRP, which is a fact about the batch on the shelf and is entered at receiving, and the DRAP registration number, which the shop reads off the pack.
To populate the catalogue from an official source, a console command imports a DRAP price-list CSV that the owner supplies. It writes only what is in the file, tagged with its own source so hand-curated rows are never touched. It matches existing rows on the DRAP registration number when the file has one, and on brand, strength and dosage form when it does not. Because a national list is large, rows are committed in batches of 500 instead of in one long transaction. The command is idempotent, so a failed run can simply be run again. An optional --deactivate-missing flag retires rows the file no longer mentions. It is a console command, not a screen, because no single tenant should be able to rewrite a catalogue every workspace shares.
Salt-based substitution
Why it matters. "Do you have something cheaper with the same formula?" is one of the most common questions at a Pakistani counter.
How we built it. A substitute is the same salt at the same strength. Strength is not optional: paracetamol 500 mg tablets and a 120 mg/5 ml syrup share a generic name and are not the same medicine. The service matches on the normalised generic name and strength, excludes the item the cashier is holding, counts only in-date stock, and sorts cheapest first, because price is why the question was asked. When the shelf is empty, the counter shows a one-line suggestion naming at most two alternatives. A Salt Finder screen lists every product stocked for a given salt, with in-date quantity and price.
A controlled-drug register that shows the gaps
Why it matters. An inspector asks for a register of prescription-only dispensing, and the first question is whether the shelf matches the book.
How we built it. Each medicine carries a schedule class modelled on the Drugs Act 1976 classes a Pakistani chemist deals with: OTC, prescription only, Schedule G, and narcotic/controlled. Anything not OTC needs a prescription, whatever the per-record checkbox says. Each workspace chooses how strictly this is enforced: record only, warn, or require a prescription before sale. Whatever happens is recorded, including a sale that went ahead after a warning.
The register deliberately includes sales with no prescription recorded, and can filter down to only those, because a register listing only the sales someone did the paperwork for hides the shop's exposure. Batch and expiry travel with every row, for recalls. A running-balance view shows opening stock, receipts, issues and closing stock for each medicine. Closing stock is computed from movements, not read from the batch, so any mismatch with what is on the shelf is visible rather than hidden.
A short book that understands expiry
Why it matters. Every medical store keeps a short book: a list of what to reorder, taken to the distributor. A plain low-stock report misses the key case. Thirty packs that all expire next month are not really stock.
How we built it. Stock expiring within 60 days is subtracted before the reorder check. The list is grouped by the supplier each medicine was last bought from, which is the honest answer to "who do we buy this from?", with the largest order first. One click turns a supplier's page into a purchase order at ordered status, never received. Batches are only created when the delivery arrives with real batch numbers and expiry dates.
Expiry claims posted to the ledger
Why it matters. Distributors take near-expiry stock back against a credit note, but only for what is actually sent and claimed in time. Chemists lose money on stock nobody claimed for and on claims nobody chased.
How we built it. An expiry claim is a receivable with a life cycle, scoped to one supplier. A draft moves nothing. Sending the claim takes the stock off the shelf, recorded as a return rather than a write-off, and posts a journal entry:
Sent DR Expiry Claims Receivable CR Inventory
Credited DR Accounts Payable CR Expiry Claims Receivable
Shortfall DR Expired Stock Written Off CR Expiry Claims Receivable
Returning goods is not a loss. It is stock turning into a claim on the supplier. The loss is only the part that never comes back, so it is a separate entry, not a plug. A received credit also raises a supplier credit note, so the amount you owe that distributor goes down.
The pricing guard: MRP is a ceiling
Why it matters. In Pakistan the maximum retail price is set by DRAP and printed on the pack. Selling above it is not a pricing decision a shop gets to make.
How we built it. The ceiling comes from the dispensable batches, not the medicine record, because MRP is revised by notification and two batches on the same shelf often carry different printed prices. The highest dispensable MRP is used, since selling the dearer-marked pack at its own printed price is legitimate. A one-paisa tolerance absorbs rounding from tax-inclusive totals. The warning names the pack unit, so a mis-entered per-tablet MRP is diagnosable. Enforcement is set per workspace, and the guard reports rather than refuses, so the counter can offer a manager override when the price on file is wrong.
What this means for pharmacy software in Pakistan
If you run a pharmacy or a medical store and want batch and expiry tracking, DRAP-aware pricing and a register built for inspection, see FlowForg ERP.