# Pharmacy/Warehouse-Specific Layer vs. Core Generic ERP Functionality

> The most valuable and least-obvious part of the codebase. See [`../CLAUDE.md`](../CLAUDE.md) for
> the project overview, [`../DATABASE_SCHEMA.md`](../DATABASE_SCHEMA.md) for exact columns, and
> [`../docs/zota_erp_system_diagram.md`](zota_erp_system_diagram.md) for a visual system map.
> Compiled 2026-07-30.

This is GITCS's Laravel 9 ERP, extended into a **multi-warehouse pharmacy chain ERP**. Two
distinct layers of customization on top of the app's core generic ERP functionality:

1. **Pharmacy master-data layer** — drug-specific product classification, doctors/prescriptions,
   salts/compositions for regulatory & clinical reference.
2. **A full custom Warehouse Management System (WMS) + supply-chain layer** ("Tiers A–D" per code
   comments) on top of the core purchase/business/contact model, turning a single-tenant POS
   into a hub-and-spoke chain: retail stores + central supplier warehouses + third-party
   manufacturers (TPMs).

## 1. Pharmacy Master Data: Doctor, Composition, Salt, Division, Manufacturer

**Doctor** (`app/Doctor.php`, `DoctorController.php`, `doctors` table, migrations
`2026_07_27_000001..000003`)
- `doctor_id` (auto ref, prefix `DOC-`), `name`, `registration_number` (medical council reg. no.),
  `gender`, `dob`, `contact_number`, `email`, `address`, `department`; soft-deletes; `business_id`-scoped.
- Dropdown endpoint `GET /doctors/dropdown` formatted `"Dr. Name (Reg No.)"`.
- `transactions.doctor_id` links every sale to a doctor — the POS sale form
  (`resources/views/sale_pos/partials/pos_form.blade.php`) has a doctor-select2 with inline
  "add new doctor" — standard regulatory practice for Schedule H/H1/X drug sales in India.
- Permissions: `doctor.view/create/update/delete`.

**Salt** (`app/Salt.php`, table `salts`: `business_id` + `name`) — a single active pharmaceutical
ingredient/API (e.g. "Paracetamol"). `belongsToMany` Composition via `composition_salt`.

**Composition** (`app/Composition.php`, `CompositionController.php`, table `compositions`) — a
drug formula = a set of salts (e.g. "Paracetamol + Ibuprofen").
- `Composition::buildNameFromSaltNames()` deterministically builds the name from de-duplicated,
  trimmed, `" + "`-joined salt names — prevents duplicate compositions differing only in
  casing/order.
- `CompositionController::store/update` accepts `salt_ids[]` (existing) or free-text `salts[]`
  (unknown names auto-`Salt::firstOrCreate`d) — "type a name, create it inline" UX.
- `destroy()` blocks deletion while any `Product.composition_id` references it.
- `products.composition_id` links every drug SKU to its formula — used for
  **generic-substitution/drug-interaction lookups** ("show all brands with this same
  composition"), a core pharmacy-retail feature.

**Division** and **Manufacturer** (migration `2026_07_21_300000_add_manufacturer_division_and_pharmacy_flags.php`)
— simple `business_id`+`name`+`description` lookup tables, soft-deletable. `products.manufacturer_id`/
`division_id` classify a drug by maker and by therapeutic category. Same migration adds
`can_be_purchased`/`can_be_stored`/`can_be_sold`/`product_tags` to `products`, and `ptr`/`pts`
(**Price to Retailer / Price to Stockist** — Indian pharma trade-price terms) to `variations`.

**Pharma product fields** (migration `2026_07_21_200000_add_pharmacy_fields_to_products_and_variations.php`)
on `products`: `hsn_code` (GST HSN/SAC), `drug_schedule` (Drugs & Cosmetics Act schedule H/H1/X/OTC
— governs prescription & narcotic-register rules), `prescription_required`, `dosage_form`,
`storage_condition` (matched against warehouse bin `temp_class`). On `variations`:
`mrp_inc_tax` — India's regulated **Maximum Retail Price**, a legal ceiling distinct from
`sell_price_inc_tax`.

**PharmacyController** (`app/Http/Controllers/PharmacyController.php`) — single action
`expiryAlerts()`: a 3-KPI dashboard (batches within N days, already-expired count, units at risk)
for store-side pharmacists, gated on `pharmacy_expiry.view`. Reuses
`App\Utils\WarehouseExpiryUtil::nearExpiryBatches()` — the same engine the warehouse side uses, so
a store pharmacist sees the same batch-level expiry risk as the central warehouse operator.

**Not pharmacy-specific (common misreads)**:
- `app/Warranty.php` / `warranties` table — a **core generic** product-warranty
  feature (name, duration, duration_type), not layered with any pharmacy logic.
- `app/ProductRack.php` / `product_racks` table (2018 migration, pre-dates the 2026 pharmacy work)
  — the original core single free-text rack label per product
  (`business.enable_racks`). Much simpler than, and separate from, the true custom warehouse bin
  system in §3.

## 2. Supplier Portal — Custom Multi-Warehouse Supply-Chain Hub

`app/Http/Controllers/SupplierPortalController.php` (~1600 ln) is the single largest piece of
custom code in the app — an entire chain-wide WMS/procurement system on top of the core
`Transaction`/`Contact`/`Business` primitives. **Not part of the core generic ERP at all — built
specifically for this chain.**

**Core architecture**:
- Each retail store is its own `Business` (reusing the app's existing multi-business model).
- A "supplier" is a **master Contact** (`contacts.common_supplier_id`) cloned into every store's
  `contacts` table as a per-store supplier record; a supplier login (`users.user_type =
  'user_supplier'`) sees data across **all stores** by scoping on `common_supplier_id` rather than
  a single `business_id` — a deliberate exception to the app's normal single-business
  isolation.
- A **warehouse** is itself a `Business` flagged `is_supplier_warehouse = 1` — it has its own
  stock ledger, POs, and location, reusing the same PO/GRN/stock machinery stores use.
- Access gated by `CheckSupplierLogin` middleware: only `user_type='user_supplier'` logins (or a
  superadmin who "opened" a warehouse via Warehouse Oversight, `session('sa_warehouse_id')`) can
  reach `/supplier/*`. `BlockSupplierPortalUser` confines a plain supplier login to `/supplier/*`
  only, except a "warehouse operator" (a supplier login that also owns an
  `is_supplier_warehouse` business), treated as a full ERP operator of its own business.

**Workflow modules** (`routes/web.php` ~lines 582–627, prefix `/supplier`):

| Feature | Purpose |
|---|---|
| Dashboard | KPI/chart overview: stock health, expiry risk buckets, dispatch pipeline, 30-day demand trend |
| Purchase Orders (received) | POs raised by stores to this warehouse; supplier updates `shipping_status` independent of the store's GRN `transactions.status` |
| Raised POs | Auto-generated POs the warehouse sends to its TPMs by email (`TpmPoNotificationUtil`) |
| Incoming Orders | Store POs mirrored into warehouse `sales_order`s, deep-linked to "Bill & Ship in POS" |
| Expiry Alerts | Near-expiry/expired batch list (shared engine with store-side `PharmacyController`) |
| Cold Chain | Temperature log CRUD (see §3) |
| Cycle Counts | Physical stock audit (see §3) |
| Consignments | Groups finalized warehouse→store sells into a dispatch trip (driver, vehicle, transport mode, distance) with proof-of-delivery; reconciles per-bin ledger on dispatch; builds a bin-grouped pick list |
| Goods QC | Batches held `qc_status='quarantine'`; pass releases to sellable stock, reject raises a debit-note purchase-return (`WarehouseReturnUtil::rejectBatchAtQc`) and reopens the qty for reorder |
| Manufacturer Returns | Reverse logistics for expired/recalled/rejected/damaged batches |
| Inter-warehouse Rebalance | Suggests moving surplus from an over-max warehouse to an under-min peer (`WarehouseRebalanceUtil`) |
| Bin Locations | Rack/bin location management, put-away (see §3) |
| Demand & Procurement Intelligence | Forecasting (Holt + weekday seasonality), ABC×XYZ classification, supplier scorecards, reorder-point application, "AI Insights" combining at-risk-batch value + rebalance recommendations + reorder-now lists |

Permissions are granular per submodule (`warehouse.dashboard`, `warehouse.stock`, `warehouse.view_po`,
`warehouse.demand`, `warehouse.goods_qc`, `warehouse.bin_locations`, `warehouse.consignments`,
`warehouse.cycle_count`, `warehouse.returns`, `warehouse.rebalance`, `warehouse.expiry`,
`warehouse.cold_chain`), plus a legacy catch-all (`supplier_portal.view_dashboard`,
`supplier_portal.view_po`, `supplier_portal.update_po_status`) — so a "Supplier#<business_id>"
role can be curated (e.g. a QC-only clerk).

## 3. Custom Rack/Bin Warehouse Inventory System (distinct from `product_racks`)

Evolved in two steps, all 2026-dated:

**Step 1 — flat bins** (`2026_07_29_100000_create_warehouse_bins_table.php`): `warehouse_bins`
(code, zone enum: receiving/pick/bulk/quarantine, capacity, is_active) + a single
`variation_location_details.bin_id` pointer. Gated by `business.enable_bin_locations`
(auto-on for warehouses).

**Step 2 — hierarchical pharma location master** (`2026_07_30_120000_pharma_bin_locations.php`,
model `app/WarehouseBin.php`): rebuilt into a zone → aisle → rack → shelf → bin tree via
`parent_id` + `location_type`. Only `location_type='bin'` rows physically hold stock. Constants:
- `TEMP_CLASSES`: `cold_2_8`, `cool_8_15`, `crt_15_25`, `ambient` — matched against
  `products.storage_condition` on put-away, enforcing cold-chain compliance at the shelf level.
- `ZONES`: `receiving, pick, bulk, quarantine, rejected, expired, returns, narcotic, dispatch` —
  `quarantine/rejected/expired/returns/narcotic` are the pharma segregation areas required by
  GDP (Good Distribution Practice) / Schedule-H (narcotic-drug segregation for Schedule X).
- `STATUSES`: `available, occupied, blocked, quarantine, damaged`.

`warehouse_bin_stock` (new table) is a **per-bin × per-batch (`purchase_line_id`) quantity
sub-ledger** — "WHERE the units physically are," while `variation_location_details.qty_available`
stays the single authoritative total (no reconciliation risk if the bin ledger drifts). This
enables **FEFO (First-Expiry-First-Out) picking**: `binLocations()` lists un-binned batches
ordered by `exp_date ASC`; `consignmentShow()` builds a pick list ordered by bin code.

**Cycle Counting** (`app/StockCount.php`, `StockCountLine.php`): a count sheet freezes expected
qty per SKU (`startCount()`), the operator enters physical counts (`saveCount()`), and posting
(`postCount()` → `WarehouseCountUtil::post()`) applies the variance — shortages through the
stock-adjustment engine (COGS/purchase-line mapping stays consistent), overages via increment.

**MovementTagConfig** (`app/MovementTagConfig.php`, table `movement_tag_configs`) — **not**
FIFO/aging tagging, a **velocity classification system** (ABC-style) bucketing products into
`SFM`/`FM`/`NFM`/`SM` (Super/Fast/Non-Fast/Slow Moving) based on monthly-sales thresholds, each
carrying `avg_days_for_min_stock`/`max_stock_buffer_percent` used to auto-calculate min/max
reorder levels. Resolves in a 3-tier fallback (per-location override → business-global →
chain-template-business global); warehouse configs scale up by served-store count
(`seedWarehouseDefaults($scale)`). Feeds the nightly auto-reorder/requisition engine.

**TemperatureLog** (`app/TemperatureLog.php`, table `temperature_logs`) — cold-chain log: `zone`,
`log_type` (storage/inbound/outbound), `temperature`, `min_allowed`/`max_allowed` (default 2–8°C),
auto-derived `is_breach` (`syncBreach()`), optional link to a GRN/dispatch `transaction_id`.

**Consignment** (`app/Consignment.php`, table `consignments`) — a dispatch trip/manifest:
`driver_name`, `vehicle_no`, `transport_mode`, `distance_km`, status
(`draft/dispatched/delivered/cancelled`), `pod_receiver`/`pod_note`. Groups many finalized
warehouse→store `transactions` (via `transactions.consignment_id`) into one physical delivery run,
distinct from each transaction's own `shipping_status`.

## 4. End-to-End Pharmacy Retail Workflow

1. **Procurement**: Warehouse auto-raises POs to TPMs (or stores raise POs to the warehouse) →
   tracked with `shipping_status`.
2. **Receiving (GRN)**: Purchase at the warehouse creates `purchase_lines` batches (lot number,
   expiry date), held `qc_status='quarantine'` pending **Goods QC**; pass/reject releases stock or
   files a manufacturer return.
3. **Put-away**: Un-binned batches placed into the hierarchical bin tree, matching
   `storage_condition`/`temp_class` (cold-chain drugs → `cold_2_8` bins), tracked per-batch in
   `warehouse_bin_stock`.
4. **Cold-chain monitoring**: Periodic temperature readings logged per zone; breaches flagged.
5. **Demand & Replenishment**: `MovementTagConfig` velocity tags + `DemandForecastUtil` forecasting
   drive min/max reorder points per SKU per store, aggregated into the warehouse's own procurement
   plan (`ProcurementIntelligenceUtil`, `PredictiveInsightsUtil`).
6. **Fulfilment**: Store POs mirror into warehouse sales orders → billed in POS → grouped into a
   consignment dispatch trip (bin-ordered pick list) → delivered with POD.
7. **Store-side sale**: Pharmacist sells a drug, optionally selecting the **prescribing doctor**
   (Schedule H/H1/X compliance) and viewing the product's **composition/salts** for
   generic-substitution guidance; MRP enforced as price ceiling; HSN code drives GST.
8. **Expiry/compliance**: Store (`PharmacyController::expiryAlerts`) and warehouse
   (`SupplierPortalController::expiryAlerts`) share the same `WarehouseExpiryUtil` engine;
   expired/rejected/recalled batches route to manufacturer returns or write-off.
9. **Audit**: Periodic **cycle counts** reconcile physical vs. system stock at bin/warehouse
   level; **inter-warehouse rebalancing** redistributes surplus instead of over-ordering.

## 5. Batch identity & manufacturer traceability (2026-08)

The chain that makes a customer invoice able to name the maker of the exact strip dispensed:

```
warehouse GRN from manufacturer   purchase_lines.lot_number / exp_date / mfg_date / manufacturer_id
        │  FEFO dispatch (mapPurchaseSell picks the batch)
        ▼
warehouse sell line               transaction_sell_lines + manufacturer snapshot
        │  StoreGrnHandoffUtil pre-fills the store's draft GRN, one line per despatched batch
        ▼
store GRN                         purchase_lines.source_purchase_line_id -> the warehouse batch
        │  FEFO again at the till
        ▼
customer invoice                  batch no. + expiry + manufacturer, gated on invoice_layouts.show_manufacturer
```

A product may have **several** manufacturers (`product_manufacturers` pivot, `is_primary` mirrored
onto the legacy `products.manufacturer_id`). Write only through `Product::syncManufacturers()` —
it is the single writer that keeps pivot and scalar in step. Batch-level attribution
(`purchase_lines.manufacturer_id`) beats the product primary wherever both exist.

## 6. Procurement follow-up & urgent replenishment (2026-08)

**PO Follow-up** (`PoFollowupUtil`, `/supplier/po-followups`). Every open warehouse→manufacturer PO
gets an ETA of *PO date + product lead time* (`products.mfg_lead_time_days`, else the warehouse's
observed PO→GRN average, else a `common_settings` default; **null means no ETA and the PO is
excluded rather than given a fabricated date**). The chase ladder:

| Rung | Fires |
|---|---|
| `T-15`, `T-7` | once each, when first crossed |
| `T-6` … `T-0` | every day as the ETA closes |
| `OVD-n` | every day past the ETA — the stage carries the days late so it keeps changing, and so keeps flagging, until the goods arrive |

Past the ETA with nobody having acted, it escalates to a `delivery_delay` support ticket (a new
ticket type; `purchase_line_id`/`raised_by` became nullable for it). Manufacturers are emailed only
at T-7, T-0 and every third overdue day — the screen nags daily, the supplier relationship should
not. The per-PO **trail** merges manual chases, reminder emails, escalations and goods receipts into
one timeline, and the list shows partial fulfilment (received / ordered, balance due) because
chasing a half-delivered order is a different conversation.

**Urgent off-cycle PO** (`UrgentPurchaseOrderUtil`). A store product falling to
`Business::effectiveUrgentPoMinPercent()` (default 50%) of its location min does not wait for the
auto-PO cadence day: its requisition line is flagged (`purchase_lines.critical_at`) and that product
alone is ordered. Two details that matter:

- the PO is built **after commit** (`DB::afterCommit`), never inside the sale — the PO builder opens
  its own transaction and locks requisition rows, and a rolled-back sale must leave no PO behind;
- the cadence timer (`common_settings.last_auto_po_at`) is deliberately **not** stamped, or one
  product's emergency would push every other product's PO out by a full cycle.

## 7. Compliance Management (2026-08)

`/superadmin/compliance` — a register over the licence columns that already existed on `business`
(Form 20/21 retail, 20B/21B wholesale, trade, shop & establishment, FSSAI), bucketed **expired /
not on record / due in 30 / 60 / 90 / valid**, worst first. `Not on record` sorts near the top on
purpose: a pharmacy with no drug licence on file is the most serious case, not the least.

Recording a status writes to `business_license_logs` (append-only); a *renewed* status also writes
the new number and expiry **back to `business`**, because that is what the rest of the app reads —
dispatch already blocks on an expired warehouse licence.

Tier relevance decides what to *chase*, never what to *hide*: a licence actually recorded against a
business is always shown even if it nominally belongs to the other tier. Real data broke the tidy
split (a warehouse here holds a 20/21 retail licence), and hiding a recorded licence days from
expiry would defeat the register.

## 8. Expiry & Disposal Management (2026-08)

Two halves. **Part 1** is the chain-wide analytics board; **part 2** is the physical chain of
custody for expired medicine.

### Chain of custody

```
STORE                                   WAREHOUSE
purchase_return  ───────────────────▶   purchase_transfer, qc_status='expired'
stock −n                                stock +n  [UNSELLABLE, quarantine bin]
                                              │
                                        stock_adjustment (abnormal), lot_no_line_id per batch
                                        stock −n  +  Certificate of Destruction
```

Stores never destroy their own stock — destruction is centralised at the warehouse so it can be
witnessed, certified and audited. `expired_goods_returns` runs `draft → sent → received / rejected`;
nothing may be disposed of before `received`, which is what stops medicine being written off while
still in transit.

**Why `qc_status='expired'` is the whole trick**: `TransactionUtil::mapPurchaseSell` already refuses
any batch whose `qc_status` is not NULL/`passed`, so received expired stock is physically counted
and auditable yet structurally impossible to sell — no change to the selling engine.

**Why disposal does not use `mapPurchaseSell`**: that same filter (plus the stop-selling-expired
one) makes expired stock impossible to *write off* through the mapper. `DisposalUtil` therefore
corrects `purchase_lines.quantity_adjusted` on the exact batch and decrements the location directly,
exactly as `WarehouseReturnUtil` already does for the other reverse-logistics path.

A Schedule H/H1/X batch cannot be completed without a witness name — enforced server-side, mirrored
in the form.

### Analytics

Valued at **purchase cost** (the loss actually incurred; MRP would overstate it and is unpopulated
across most of the catalogue). Filters: store / state / warehouse / tier / whole chain.

The diagnostic figure is **expiry ratio** (wasted ÷ received), ranked by ratio not value — absolute
loss just lists the biggest sellers, whereas a high ratio on a small product is still a buying
error. Waste counts both currently-expired and already-destroyed units, otherwise clearing the
shelves would make the ratio look like it improved; consequently the batch drilldown shows
destroyed batches alongside on-hand ones, or a product whose waste is historical would drill down
to an empty table.

Trend and seasonality report how much history they actually had (`months_of_data`,
`years_of_data`) and say so when it is too thin, rather than drawing a confident line through two
points. Seasonality deliberately includes future-dated stock — that is the forward-looking half —
but only *elapsed* years count toward whether a repeating pattern can be claimed.

## 9. Bounce rate

`product_bounces` + `BounceRateUtil` capture demand the chain failed to serve: a POS product search
that ended in nothing sellable, classified `out_of_stock` (listed, no stock) or `not_listed` (not in
the catalogue). Capture is wrapped so it can never break a sale. The chain-wide board lives at
`/superadmin/bounce-rate`.
## Key file index

- Controllers: `app/Http/Controllers/PharmacyController.php`, `DoctorController.php`,
  `CompositionController.php`, `SupplierPortalController.php`
- Models: `app/Doctor.php`, `Composition.php`, `Salt.php`, `Division.php`, `Consignment.php`,
  `TemperatureLog.php`, `WarehouseBin.php`, `WarehouseBinStock.php`, `StockCount.php`,
  `StockCountLine.php`, `MovementTagConfig.php`
- Middleware: `app/Http/Middleware/CheckSupplierLogin.php`, `BlockSupplierPortalUser.php`
- Migrations: `database/migrations/2026_07_*` (pharmacy/warehouse batch)
- Views: `resources/views/pharmacy/`, `doctor/`, `composition/`, `supplier_portal/`
- Routes: `routes/web.php` ~L123 (pharmacy), ~L150-151 (doctors), ~L185 (compositions),
  ~L582-627 (supplier portal)

### Added 2026-08
- Utils: `app/Utils/{PoFollowupUtil,UrgentPurchaseOrderUtil,ComplianceUtil,ExpiryUtil,
  ExpiredGoodsReturnUtil,DisposalUtil,ExpiryIntelligenceUtil,BounceRateUtil}.php`
- Models: `app/{BusinessLicenseLog,ExpiredGoodsReturn,ExpiredGoodsReturnLine,DisposalRecord,
  DisposalRecordLine,ProductBounce}.php`
- Controllers: `app/Http/Controllers/{ExpiryDisposalController,ImportManufacturersController}.php`,
  `Modules/Superadmin/Http/Controllers/{ComplianceController,ExpiryAnalyticsController,BounceRateController}.php`
- Commands: `app/Console/Commands/{FlagPoFollowups,AutoRaiseUrgentPurchaseOrders,SyncMasterProducts}.php`
- Views: `resources/views/expiry_disposal/`, `Modules/Superadmin/Resources/views/{compliance,expiry_analytics,bounce_rate}/`
- Migrations: `database/migrations/2026_08_*`, `Modules/SupportTicket/Database/Migrations/2026_08_17_*`
- Permissions (all on the Roles screen): `expiry.view`, `expiry.send_to_warehouse`,
  `expiry.receive_return`, `expiry.dispose`, `expiry.analytics`, `compliance.view`,
  `compliance.update`, `warehouse.po_followup`
