Majd Louka
All work

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
    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 dashboard

    Owns catalogue, stock, promotions, staff, targets; plans the field team's days; watches the live map; asks the AI assistant business questions

  • Sales representative

    Mobile app

    Gets an optimized route each morning, checks in on site, books orders, tracks their own target

  • Distributor

    Mobile app

    Generates 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

  1. 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.

  2. One transaction, one record. Booking an order moves stock, the rep's target and the pharmacy's balance in the same step.

  3. Record what actually happened. Arrival, departure, time on site and the outcome of every stop, together with its cause.

  4. Improve with use. Feed real travel and service times back to the optimizer.

  5. 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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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

Every module runs the same layered pipeline: Route, then Middleware (permissions from JWT, locale), then FormRequest (validation), then Controller (thin), then DTO, then Service interface, then [cache decorator], then Service, then Use case (one business operation per class). The use case then goes either to Repository interface, Eloquent, MySQL, or to Integration port, Gateway adapter, other module / external AI service. Both return through API Resource to unified JSON envelope. Interfaces are drawn dashed; classes are solid.

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

  • 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 InitiatePlan unchanged; the nightly job and a console command share one use case
  • Caching as a decorator

    Where
    CachedOrderService, CachedMedicineService etc., 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

The app sends POST /plans/initiate to the API and gets 202 { request_id, channel } back straight away. The API queues GenerateRepPlanJob. A queue worker makes the optimizer call to the AI optimizer, then does persist plan, then sends broadcast "plan.ready" through Reverb. Reverb delivers it to the app on private-w1.user.{id}.plans. As a fallback, the app can call GET /plans/generation-requests/{uuid}.

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.

    InitiatePlan deletes its own plan row if optimization throws, so a second attempt starts clean. Failure is reported only from failed(), 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.

Stops 1–2 are completed and stop 3 is skipped, so stops 4–6 are re-ordered from the current position.

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_uid per leg makes resends harmless. Plans are stamped outcomes_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

  1. Symptom

    At scale, the measurements did not add up: a 40-stop request returned 19 stops, and so did a 79-stop request.

  2. Investigation

    I called the optimizer directly, changing one factor at a time, and found the cause.

  3. 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.

  4. 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.

A 79-stop day as 79 squares. Before the fix, only the first 19 came back and the rest were silently dropped. After it, all 79 come back, the waves merged in order into one sequence from 1 to N.
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

  1. Validate that every medicine exists, then price each line from the live catalogue (a price snapshot per line).

  2. Apply "buy N, get M free" gift rules, then threshold offers (percentage discount or free product).

  3. Persist the order and its lines.

  4. Decrement stock first-expiry-first-out (FEFO): the earliest-expiring, not-yet-expired batch is used first. Rows are locked with lockForUpdate() 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.

  5. Write a movement ledger per order line, so a cancellation returns stock to the exact batch it came from.

  6. Advance the rep's target and accrue the pharmacy's yearly debt.

COMMIT

after commit

  1. 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:

ExperimentWhat it measuredRuns
E13 regions × 8 profiles × 2 travel modes: coverage, consistency, route quality48
E2Repeatability: the same request six times18
E3Scale: 5 to 79 stops9
E4The driver's delivery-planning endpoint16
E5Validation and boundary cases20
E6Per-leg geometry audit (road polylines against reported distances)6
E7Direct optimizer calls, one factor at a time, to locate the wave truncationE7 has no run count.
Total runs117
  • 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:

Driving tour length in km, list order against optimized. Al-Midan: 19.8 km against 9.0 km, 54.3% shorter. Jaramana: 12.7 km against 7.1 km, 43.9% shorter. Shaalan: 16.3 km against 9.5 km, 42.1% shorter.
RegionStopsList orderOptimizedShorter byDrive time saved
Al-Midan1119.8 km9.0 km54.3%11.6 min
Jaramana1012.7 km7.1 km43.9%7.4 min
Shaalan916.3 km9.5 km42.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

MeasureResult
Stop coverage on regular tours48/48 runs served every stop, with no duplicates and contiguous sequences
Internal consistencyPlan totals match the sum of their legs within 0.004% (distance) and 0.03% (duration)
Repeatability18 repeated runs, identical every time (0% variation)
Large days, after the wave-merge fix79-stop coverage: 24% → 100%; 40-stop coverage: 47.5% → 100% (driving)
ResponsivenessRequest 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
Plan-ready time on a 0 to 120 s axis: 4.5 s for 5–10 stops, 10–11 s for 20–40 stops, 23 s for 79 stops. Before the change, requests were 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.