Case study · 2026
IntelliPharma: Planning, Selling and Delivering Medicine from One Platform
A case study on replacing four disconnected systems with one backend that plans the field team's day, records what actually happened, and learns from it every night.
- median shorter routes vs. list order
- 43.9%
- median gap to the exact optimum
- 8.1%
- stop coverage on 79-stop days after the fix
- 24% → 100%
Quick facts
- Product
- B2B platform for pharmaceutical wholesalers and distributors
- My role
- Backend engineer. I owned route planning and the AI-optimizer integration end to end, co-built the ERP core (orders, stock, offers, targets, deliveries, debts and payments), and designed and ran the evaluation
- Team
- Two backend engineers, web (React) and mobile (Flutter) client developers, and an external AI route-optimization service
- Timeline
- January – August 2026 · graduation project
- Surfaces
- Admin web dashboard + two mobile apps (sales rep, delivery driver), Arabic and English
- Stack
- Laravel 12
- PHP 8.2
- MySQL
- Redis
- Laravel Reverb (WebSockets)
- queues and scheduler
- JWT
- Firebase Cloud Messaging
- Python AI route optimizer (OSRM / GraphHopper road networks)
- Gemini
TL;DR
- Problem
- pharma distributors run their warehouse, sales reps and delivery drivers on separate tools, spreadsheets and memory, so routes are guessed, visits are self-reported, and stock, cash and targets get reconciled at the end of the month, a problem validated with two pharmaceutical warehouses and field pharmacists in Syria.
- Solution
- one modular Laravel backend that holds the catalogue and stock, books and prices orders, plans every rep's and driver's day with an AI optimizer, replans mid-day, and sends what really happened back to the optimizer every night, with driver identities pseudonymized.
- Gains
- in a 117-run evaluation against the production API, optimized driving tours were a median 43.9% shorter than visiting stops in list order and came within 8.1% of the mathematical optimum. The evaluation also surfaced a bug, and after the fix, coverage on large days went from 24% to 100%.
The business case
How the business works
A pharmaceutical wholesaler buys medicine from laboratories and importers, stores it in warehouses, and sells it to pharmacies through a field force:
Sales representatives visit pharmacies to take orders.
Distributors (drivers) take the booked orders out to the pharmacies and collect payment at the door.
The back office manages the catalogue, stock, promotions, targets and staff.
The pharmacy is the customer. It receives the service but never logs in.
The problem
| Pain | What it costs the business |
|---|---|
| Reps plan their day from habit and memory | Fuel and hours lost to backtracking; high-value pharmacies get visited as often as low-value ones |
| Know-how and history live with the experts | When an expert leaves, the history leaves with them |
| Visits are self-reported | Coaching and performance reviews rely on what reps say happened, with nothing to check it against |
| Expiry dates live in a spreadsheet nobody opens | Slow-moving batches are written off instead of pushed while there is still time |
| Cash is collected at the door and reconciled from a notebook | Partial payments and proof of receipt are the first things to go missing |
| Targets are measured only at period close | Neither rep nor manager can course-correct while it still matters |
| Every analytics question is a ticket to whoever has database access | "Which region ordered the most antibiotics last winter?" gets answered days later, if at all |
- Pain
- Reps plan their day from habit and memory
- What it costs the business
- Fuel and hours lost to backtracking; high-value pharmacies get visited as often as low-value ones
- Pain
- Know-how and history live with the experts
- What it costs the business
- When an expert leaves, the history leaves with them
- Pain
- Visits are self-reported
- What it costs the business
- Coaching and performance reviews rely on what reps say happened, with nothing to check it against
- Pain
- Expiry dates live in a spreadsheet nobody opens
- What it costs the business
- Slow-moving batches are written off instead of pushed while there is still time
- Pain
- Cash is collected at the door and reconciled from a notebook
- What it costs the business
- Partial payments and proof of receipt are the first things to go missing
- Pain
- Targets are measured only at period close
- What it costs the business
- Neither rep nor manager can course-correct while it still matters
- Pain
- Every analytics question is a ticket to whoever has database access
- What it costs the business
- "Which region ordered the most antibiotics last winter?" gets answered days later, if at all
Most of these trace back to one root cause.
The ERP, the CRM, the route planner and the fleet tracker are usually four separate purchases, and their data never meets until somebody reconciles it by hand.
Validated in the field
Validated with real stakeholders
Before building, we validated the problem with people who live it: two pharmaceutical warehouses in Syria, and pharmacists working in the field for distribution companies and warehouses. The pains in the table above come from those conversations, not from assumptions.
- Pharmaceutical warehouses
- 2 warehouses in Syria
- Field pharmacists
- Working for distribution companies and warehouses
Who uses the system
Admin
Web dashboardOwns catalogue, stock, promotions, staff, targets; plans the field team's days; watches the live map; asks the AI assistant business questions
Sales representative
Mobile appGets an optimized route each morning, checks in on site, books orders, tracks their own target
Distributor
Mobile appGenerates a delivery route from assigned tasks, records receiver and amount collected, marks stops completed / skipped / failed / blocked
Pharmacy
None (beneficiary)Has a location, opening hours, visit history, notes, and a 0–100 value score
What success had to look like
Plan the day instead of guessing it. Every rep and driver gets an ordered route with predicted travel and service time per stop, with high-value pharmacies prioritized.
One transaction, one record. Booking an order moves stock, the rep's target and the pharmacy's balance in the same step.
Record what actually happened. Arrival, departure, time on site and the outcome of every stop, together with its cause.
Improve with use. Feed real travel and service times back to the optimizer.
Let managers question the data directly, in Arabic or English, without filing a request.
Constraints
- A small team deploying to a single server.
- Arabic and English stored natively at the data level, not as a translation layer.
- Damascus geography, including dense old-city districts where walking beats driving.
- The route optimizer is an external service whose responses can take up to 120 seconds.
- Field apps are online-first.
A day in the system
Every feature belongs to one moment in this daily loop:
Morning
the day gets planned.
A plan is requested for a region. The backend collects the pharmacies, their value scores and the rep's position, sends them to the AI optimizer, and stores an ordered route with real road geometry and a predicted time for every leg and every stop.
In the field
arrival is recorded.
The rep starts a visit at the pharmacy and the backend measures the time spent there. Their position streams to the live map.
At the counter
the order prices itself.
Prices come from the live catalogue, "buy N, get M free" gifts and threshold discounts apply automatically, stock is reserved against the correct batch, and the value is credited to the rep's target.
Warehouse
stock moves with the order.
No overnight sync and no double entry. If the order is cancelled, quantities return to the batch they came from.
Dispatch
the order becomes a delivery.
The driver generates their own optimized route from today's tasks and records receiver, amount collected and full or partial delivery at the door.
23:00
the system learns.
For every leg driven, predicted and actual values are compared and posted to the optimizer, with identities pseudonymized.
Then back to Morning: real travel times feed tomorrow's plan.
Any time
the business answers its own questions.
Dashboard, quarterly analytics engines, and a natural-language assistant over the metrics.
Architecture
A modular monolith: one deployable Laravel application, eight bounded contexts.
One deployable Laravel application, split into eight bounded contexts with nwidart/laravel-modules. Each module owns its routes, models, migrations, providers and tests.
ERP
Catalogue, warehouses and batch-level stock, pharmacies, regions, employees, orders, offers, gifts, targets, payments, debts
Planner
Route plans, visits, delivery tasks, AI-optimizer integration, nightly feedback
CRM
Quarterly analytics: pharmacy value score, seasonal demand, regional performance, substitution acceptance, visit effectiveness
LLM
Natural-language analytics assistant over CRM metrics
Auth
JWT auth, refresh-token rotation, roles and permissions, device tokens, notification inbox
Tracking
Live location broadcast over WebSockets, position history
Dashboard
Aggregated read models for the overview screen
Shared
Cross-cutting only: response envelope, query filters, permission constants, ownership policies
Every module runs the same layered pipeline
Why not microservices?
Almost every write crosses contexts. One order touches stock, targets and debt, and those changes have to commit together. Splitting into services would have meant distributed transactions and a heavy operational load for a small team on one server. The modular monolith keeps the boundaries that microservices would give while keeping ACID transactions and a single deployment. Because modules talk only through gateway interfaces, one can still be extracted later.
Patterns that paid off
| Pattern | Where | What it bought |
|---|---|---|
| Ports and adapters between modules | Planner depends on its own ErpGatewayInterface; ERP implements it and returns a Planner-owned DTO, not an Eloquent model | CRM value scores flow into route optimization without Planner knowing CRM exists |
| One use case per class | CreateOrder, InitiatePlan, OptimizeNextLeg, PostDriverOutcomes | Delivery planning reuses InitiatePlan unchanged; the nightly job and a console command share one use case |
| Caching as a decorator | CachedOrderService, CachedMedicineService etc., Redis tags keyed per permission scope | Cheaper list endpoints with zero changes to business logic; invalidation lives in one place |
| Optimizer behind an interface | RouteOptimizerInterface | Swappable and mockable; optimizer contract changes are absorbed in one gateway class |
| Stateless permission checks plus ownership policies | perm: middleware reads permissions from JWT claims; policies compare record owner on *.view.own | Row-level "own vs. all" rules with no extra database queries on the hot path |
| Unified envelope and central exception mapping | ApiResponse, bootstrap/app.php | Web and both mobile apps parse exactly one response shape, and business errors arrive as readable messages |
Ports and adapters between modules
- Where
- Planner depends on its own
ErpGatewayInterface; ERP implements it and returns a Planner-owned DTO, not an Eloquent model - What it bought
- CRM value scores flow into route optimization without Planner knowing CRM exists
One use case per class
- Where
CreateOrder,InitiatePlan,OptimizeNextLeg,PostDriverOutcomes- What it bought
- Delivery planning reuses
InitiatePlanunchanged; the nightly job and a console command share one use case
Caching as a decorator
- Where
CachedOrderService,CachedMedicineServiceetc., Redis tags keyed per permission scope- What it bought
- Cheaper list endpoints with zero changes to business logic; invalidation lives in one place
Optimizer behind an interface
- Where
RouteOptimizerInterface- What it bought
- Swappable and mockable; optimizer contract changes are absorbed in one gateway class
Stateless permission checks plus ownership policies
- Where
perm:middleware reads permissions from JWT claims; policies compare record owner on*.view.own- What it bought
- Row-level "own vs. all" rules with no extra database queries on the hot path
Unified envelope and central exception mapping
- Where
ApiResponse,bootstrap/app.php- What it bought
- Web and both mobile apps parse exactly one response shape, and business errors arrive as readable messages
The area I owned
AI route planning
Building the request
- Pharmacies are resolved through the ERP gateway with coordinates and their CRM composite score (0–100). The score is normalized into a 0–1 priority, and pharmacies scoring 80 or more are flagged VIP.
- The rep's current GPS position is the start; the rep is modelled as one agent with an 8-hour working limit.
- Two travel modes: driving, and walking for dense districts. Walking uses a GraphHopper pedestrian matrix.Travel modes
- driving
- walking
- Eight optimization profiles:
- balanced
- fastest
- cheapest
- priority_first
- vip_first
- time_window_first
- pedestrian_light
- all_factors
Making generation asynchronous
The optimizer can take up to 120 s, and at first that held every HTTP request open. I moved generation onto the queue and delivered the result over WebSockets:
Before
HTTP request held open for up to 120 s
After
Request acknowledged immediately
Design decisions that made it production-safe
Idempotent by design.
A second request while one is in flight returns the same
request_id. A unique-job lock on{user}:{date}adds a second guard, and in-flight rows older than 10 minutes are ignored, so a dead worker cannot block a rep from replanning.Retries are safe.
InitiatePlandeletes its own plan row if optimization throws, so a second attempt starts clean. Failure is reported only fromfailed(), so the client never sees an error from an attempt that is about to be retried.Never fail after persisting.
Once the plan is saved, broadcast and push-notification errors are logged rather than retried, because a retry would build a duplicate plan.
Locale restored in the worker.
Pharmacy and region names are translatable, and workers never pass through the locale middleware.
Notify the right people.
When an admin plans a rep's day, the result is broadcast to both, and the rep also gets a push notification.
Replanning mid-day
When a stop is marked skipped, failed or blocked, the backend removes that stop's inbound leg and replans only the remaining pending stops from the rep's current position. It then places the already-completed stops back at the front, so the app always shows the whole day. A lighter optimize-next-leg endpoint recomputes just the next leg and updates the plan totals incrementally. Every replan is recorded with its reason and details, so disruptions also become data.
Closing the loop: nightly driver-outcome feedback
A scheduled job runs at 23:00 (Asia/Damascus) and posts the previous day's legs to the optimizer's ingest API. One record per driven leg carries predicted and actual travel time, predicted and actual service time, the stop outcome, and the driver-reported cause.
- Privacy
- user IDs are replaced with salted SHA-256 pseudonyms before anything leaves the system.
- Data quality
- legs outside the Damascus bounding box, without timestamps, or without a predicted value are filtered out.
- Idempotency
- a stable
outcome_uidper leg makes resends harmless. Plans are stampedoutcomes_posted_at, and plans with no eligible legs yet are left for a later run. - Observability
- each batch is logged with accepted, rejected and duplicate counts, there is a warning above a 20% reject rate, and every run writes a JSON snapshot.
The bug the evaluation caught
Symptom
At scale, the measurements did not add up: a 40-stop request returned 19 stops, and so did a 79-stop request.
Investigation
I called the optimizer directly, changing one factor at a time, and found the cause.
Root cause
The optimizer splits large days into "waves" of at most 19 stops, and the integration only read the first wave.
Every stop after the nineteenth was silently dropped.
Fix
The fix merges all of the agent's waves in chronological order. It renumbers visit sequences and leg indices so the rest of the pipeline still sees one contiguous route from 1 to N, and sums the totals.
Unit tests against a recorded multi-wave response and a persistence feature test keep it from regressing. A post-deploy script (
verify_deploy.py) re-measures coverage after any deployment that touches the planner.
- 79-stop coverage
- 24% → 100%
- 40-stop coverage (driving)
- 47.5% → 100%
Orders in one transaction
CreateOrder runs everything a sales rep's order sets in motion as a single database transaction:
BEGIN TRANSACTION
Validate that every medicine exists, then price each line from the live catalogue (a price snapshot per line).
Apply "buy N, get M free" gift rules, then threshold offers (percentage discount or free product).
Persist the order and its lines.
Decrement stock first-expiry-first-out (
FEFO): the earliest-expiring, not-yet-expired batch is used first. Rows are locked withlockForUpdate()in a deterministic order (expiry date, then id) that matches the cancellation path, so concurrent orders and cancellations cannot deadlock.Deterministic lock order (expiry date, then id) shared with cancellation, so concurrent orders and cancellations cannot deadlock.Write a movement ledger per order line, so a cancellation returns stock to the exact batch it came from.
Advance the rep's target and accrue the pharmacy's yearly debt.
COMMIT
after commit
Queue an out-of-stock notification only after commit, so nobody is alerted about a rolled-back order.
Analytics and the AI assistant (built with a teammate)
Pharmacy value score: a weighted composite of order completion rate, order volume, basket size and recency, with the recency component decaying over time. It is recomputed quarterly alongside the seasonal, regional, substitution and visit-effectiveness engines. The same score drives route priority, so analytics and routing use one definition of value.
Natural-language assistant (Gemini): the model turns the question into SQL over the metrics tables. The backend executes it only if it is a single SELECT or WITH statement containing no write or DDL keywords. The rows go back to the model, which answers in Arabic or English, and conversation history keeps follow-up questions in context.
Guardexecutes only a single SELECT or WITH, no write or DDL keywords
Security and access
- JWTs carry permission claims, which middleware checks with no database queries, supporting AND/OR permission groups.
- Ownership policies give "a rep sees only their own plans and orders; an admin sees everything" in one reusable place.
- Refresh tokens are hashed at rest (SHA-256), rotated on every use, and expire after 14 days.
- Private WebSocket channels are authorized with the same bearer token.
How I validated it
Unit tests showed that the code ran. They could not show that the plans were good, so I built a black-box evaluation harness in Python and ran it against the production API on 18 August 2026:
| Experiment | What it measured | Runs |
|---|---|---|
| E1 | 3 regions × 8 profiles × 2 travel modes: coverage, consistency, route quality | 48 |
| E2 | Repeatability: the same request six times | 18 |
| E3 | Scale: 5 to 79 stops | 9 |
| E4 | The driver's delivery-planning endpoint | 16 |
| E5 | Validation and boundary cases | 20 |
| E6 | Per-leg geometry audit (road polylines against reported distances) | 6 |
| E7 | Direct optimizer calls, one factor at a time, to locate the wave truncation | E7 has no run count. |
| Total runs | 117 | |
Ground truth
every driving route was re-scored on the real OSRM road matrix, and the exact optimum was computed with the Held–Karp algorithm.
Baselines
visiting in list order (what a rep unfamiliar with the area would do), the mean of 5,000 random orders, and greedy nearest-neighbour (a careful local planning by eye).
Statistics
Wilcoxon signed-rank and sign tests for the mode comparisons.
Automated suite: 40 tests covering multi-wave merging, polyline handling, debts and payments, order PDF, roles, notifications and tracking.
Results and limitations
Shorter routes, measured on the real road network
Driving tours, with the system's route compared against the same stops visited in list order:
| Region | Stops | List order | Optimized | Shorter by | Drive time saved |
|---|---|---|---|---|---|
| Al-Midan | 11 | 19.8 km | 9.0 km | 54.3% | 11.6 min |
| Jaramana | 10 | 12.7 km | 7.1 km | 43.9% | 7.4 min |
| Shaalan | 9 | 16.3 km | 9.5 km | 42.1% | 7.2 min |
- Median 43.9% shorter than list order, and 48.2% shorter than a random order.
- Median 8.1% above the exact optimum, and exactly optimal in Al-Midan.
- Against a careful planner working by eye (nearest-neighbour), the system is 37% ahead in Al-Midan, tied in Jaramana, and 7.9% behind in Shaalan. On small tours it matches a careful local planner on median. The real gain is that every rep gets that quality automatically, including new reps with no local knowledge, while value score, VIP status and working hours are weighed at the same time.
- For scale: the median saving is about 6.9 km and 7.4 minutes of driving per tour. Over 22 working days that is roughly 150 km and 2.7 hours per rep per month. This is an extrapolation from the median, assuming one tour a day.
Reliable and fast enough for the field
| Measure | Result |
|---|---|
| Stop coverage on regular tours | 48/48 runs served every stop, with no duplicates and contiguous sequences |
| Internal consistency | Plan totals match the sum of their legs within 0.004% (distance) and 0.03% (duration) |
| Repeatability | 18 repeated runs, identical every time (0% variation) |
| Large days, after the wave-merge fix | 79-stop coverage: 24% → 100%; 40-stop coverage: 47.5% → 100% (driving) |
| Responsiveness | Request acknowledged immediately; plan ready in about 4.5 s for 5–10 stops, 10–11 s for 20–40 stops, and 23 s for 79 stops, where requests used to be held open for up to 120 s |
Business gains
- Four systems become one. A booked order updates stock, the rep's target and the pharmacy's balance in the same database transaction, so there is nothing to reconcile at month-end.
- Field activity becomes a record. Arrival, departure and time on site are timestamped automatically, and every stop ends with an outcome and a cause.
- Cash is tied to the delivery that produced it. Receiver, amount collected and full or partial delivery are captured at the door, and payments flow straight into debt and target tracking.
- The product improves with use. Every leg driven becomes pseudonymized training data for the optimizer, every night.
- Managers ask the data directly. Questions that used to wait days for a report are answered in one sentence, in Arabic or English.
Engineering gains
- Parallel work without collisions: nearly 40 feature branches across eight modules in seven months, with merge conflicts kept rare because features stayed inside their own module.
- Reuse instead of rewrites: driver delivery routing shipped by reusing the rep-planning use case, with no duplicated routing logic.
- Changes contained in one place: caching was added without editing business logic, and optimizer contract changes stayed inside one gateway class.
- A repeatable measurement: the evaluation harness turned "the routes look good" into reproducible numbers, and that is how the silent truncation on large days was found and fixed before any customer would have hit it.
Known limitations
Honest scope
- Evaluated, not yet piloted. The figures come from controlled runs on real pharmacy coordinates, not from a customer deployment.
- Scope of the measurements. Route quality was costed on tours of 9–11 stops in three Damascus regions. Walking optimality is not claimed, because no exact ground truth exists for the pedestrian network used.
- Testing-time relaxations. Pharmacy opening hours are currently sent as "open all day", and the 10 m check-in geofence is relaxed. Both are implemented and need to be switched back on before a pilot.
- Online-first apps. The field apps need connectivity.