# WeStore WMS – Architecture

Version 1.0 · Laravel 12 · PHP 8.3 · PostgreSQL 16

This document is the contract between the design and the code. Every module in `app/Domain` follows the rules here. When code and this document disagree, fix the code or update the document in the same PR.

---

## 1. Design principles (non-negotiable)

| # | Principle | Why it matters operationally |
|---|-----------|------------------------------|
| P1 | **The ledger is the truth.** Every quantity change is an append-only row in `inventory_transactions`. `inventory_balances` is a derived projection, never edited by hand. | Any balance can be rebuilt and proven. Audits, client disputes, and shrinkage investigations are answerable from one table. |
| P2 | **Every mutation goes through a domain service inside a DB transaction with row locks.** No controller or job touches inventory tables directly. | Two RF users picking the same location at the same time cannot oversell stock. |
| P3 | **Explicit state machines.** Documents (ASN, order, wave, count, RMA…) have a defined set of states and allowed transitions enforced in one place. | No "order shipped but never picked" data. Reports are trustworthy because states mean one thing. |
| P4 | **Idempotent writes.** All mutating API calls accept an `Idempotency-Key`; RF retries and integration replays never double-post. | Scanner on flaky Wi-Fi retrying a confirm does not double-receive a pallet. |
| P5 | **Multi-client, multi-warehouse by construction.** Every client-owned row carries `client_id`; every physical row carries `warehouse_id`. Access is enforced by policies + global scopes, not by remembering to filter. | A client portal user can never see another client's stock. |
| P6 | **Immutable audit + transactional outbox.** State changes write an `audit_events` row and an `outbox_events` row in the same transaction. Integrations and AI consume the outbox. | Nothing is lost between the WMS and SAP/freight/clients; every change has an actor and a reason. |
| P7 | **AI advises; the system decides; humans govern.** AI never writes inventory. It produces `ai_recommendations` that are applied by the same domain services as a human action, under a per-client policy (off / advise / auto above a confidence threshold). | AI failures degrade to "no suggestion", never to wrong stock. Every AI decision is auditable with prompt, cost and outcome. |
| P8 | **Base unit of measure everywhere.** Quantities are stored in the product's base UoM (`decimal(18,3)`). Entered UoM and quantity are kept for audit only. | No conversion drift between cases, pallets and eaches. |

---

## 2. Bounded contexts

```
app/Domain/
  Tenancy/         clients, client users, API keys
  Facility/        warehouses, zones, locations, docks, yard slots
  Catalog/         products, UoMs/packaging, barcodes, handling attributes, kits (BOM)
  Inventory/       ledger, balances, lots, serials, handling units (LPN), holds, adjustments, transfers
  Inbound/         ASN, dock appointments, receipts, QC, putaway
  Outbound/        orders, allocation, waves, pick tasks, packing, shipments, carriers
  InventoryControl/ cycle counts, replenishment, slotting
  Returns/         RMA, dispositions
  WorkOrders/      kitting, VAS
  Labor/           activity capture, standards, productivity
  Billing/         rate cards, billable events, billing periods
  Yard/            trailers, yard moves
  Platform/        audit, outbox, webhooks, idempotency, numbering, exceptions
  AI/              gateway, providers, prompts, jobs, recommendations, policies, usage
```

Each context contains `Models/`, `Services/`, `Enums/`, `Events/`, `Exceptions/`, `DTOs/`, `Rules/` as needed. Contexts talk to each other **only through services or events**, never by reaching into another context's tables.

Dependency direction (arrows = "may call"):

```
Inbound ──┐
Outbound ─┼──► Inventory ──► Facility, Catalog
Control ──┤        │
Returns ──┤        └──► Platform (audit, outbox, numbering)
WorkOrders┘
Billing  ◄── (listens to events from all of the above)
Labor    ◄── (listens to task events)
AI       ◄── (listens to events) ──► produces recommendations consumed by the above via services
```

---

## 3. Inventory model (the trust layer)

### 3.1 Balance key

A balance is the quantity of one product in one place in one condition:

```
(client_id, warehouse_id, location_id, product_id, lot_id?, handling_unit_id?, inventory_status, owner_ref?)
```

- `lot_id` null = not lot-tracked.
- `handling_unit_id` null = loose stock (not on an LPN).
- `inventory_status` ∈ `available | quarantine | damaged | hold | expired | in_transit`.
- Uniqueness is enforced with `UNIQUE NULLS NOT DISTINCT` (PostgreSQL 15+), so two nulls collide as intended.

Columns: `quantity_on_hand`, `quantity_allocated`. **Available to promise** = `on_hand − allocated` only when `inventory_status = available`.

Serial-tracked products additionally have one `serial_numbers` row per unit, pointing at the balance that holds it. Serial quantity is always 1; the balance quantity equals the count of serials attached.

### 3.2 Ledger row

`inventory_transactions` is append-only (no UPDATE/DELETE grants for the app role in production; see `docs/DEPLOYMENT.md`). Each row is a single movement:

```
type            receipt | putaway | move | pick | pack | ship | adjust_in | adjust_out |
                count_adjust | status_change | hold | release | return_receipt |
                kit_consume | kit_produce | transfer_out | transfer_in | unpick | unpack
quantity        > 0 always; direction is given by from/to
from_balance_id / to_balance_id   (either may be null for in/out of the system)
from_location_id, to_location_id, from_hu_id, to_hu_id, from_status, to_status
reference_type / reference_id     the document that caused it (receipt line, pick task line…)
reason_code                       required for adjustments, holds, status changes
performed_by, performed_at
idempotency_key                   unique per (client, key)
on_hand_after_from, on_hand_after_to   snapshots, for fast audit reads
```

### 3.3 Locking protocol

`InventoryService::post(Movement $m)`:

1. Open transaction.
2. Resolve or create both balance rows with `INSERT … ON CONFLICT DO NOTHING`.
3. `SELECT … FOR UPDATE` both balances **ordered by id ascending** (prevents deadlocks between concurrent movers).
4. Validate: source `on_hand − allocated ≥ qty` (unless movement is of allocated stock, in which case allocation is carried), status rules, location rules (mixing, capacity, temperature, hazmat).
5. Insert ledger row; update both balances; write snapshots.
6. Delete balance rows that hit zero on_hand and zero allocated (keeps the table small; history is in the ledger).
7. Write `audit_events` and `outbox_events`.
8. Commit. Domain events are dispatched after commit.

Multi-line operations (a whole receipt, a whole pick task) lock all affected balances in one ordered pass before any write.

### 3.4 Reservations

`allocations` rows (order line → balance → quantity) increment `quantity_allocated` on the balance under the same lock. Picking converts an allocation into a `pick` movement to a staging/pack location, carrying the allocation with it. Cancelling an order releases allocations. Allocation and movement never bypass each other: a balance with `quantity_allocated > 0` cannot be moved by a non-pick movement unless `allowAllocated` is explicitly set (e.g., a supervisor relocating a whole pallet, which re-points the allocations).

### 3.5 Directed putaway and planned occupancy

`PutawayStrategy` filters locations by hard rules (storage type, status, temperature, hazmat, mixing, LPN capacity) and ranks them: fixed pick slot → consolidate same SKU/lot → nearest empty → shared location with room. Open putaway tasks already directed to a location count as *planned occupancy*, so a burst of receipts is spread across slots instead of all being pointed at the first empty one. Suggestions are refreshed when a task is picked up on RF; the AI advisor may only re-rank the rules' candidates.

### 3.6 Reconciliation

`php artisan wms:inventory:reconcile` rebuilds balances from the ledger into a temp table and diffs. Any drift is an incident. Runs nightly and is exposed in the ops dashboard.

---

## 4. State machines

Transitions are defined in `app/Domain/*/Enums/*Status.php` with `canTransitionTo()` and enforced by `StateMachine::transition()` which also writes the audit row.

| Document | States |
|----------|--------|
| ASN | draft → confirmed → arrived → receiving → received \| cancelled |
| Receipt | open → in_progress → completed \| cancelled |
| QC inspection | pending → in_progress → passed \| failed \| partial |
| Putaway task | pending → assigned → in_progress → completed \| cancelled |
| Order | draft → confirmed → allocated(partial/full) → waved → picking → picked → packing → packed → shipped \| cancelled; `on_hold` is a flag, not a state |
| Wave | planned → released → in_progress → completed \| cancelled |
| Pick task | pending → assigned → in_progress → completed \| short \| cancelled |
| Shipment | open → packed → manifested → dispatched \| cancelled |
| Cycle count | planned → in_progress → counted → recount → review → posted \| cancelled |
| Replenishment task | pending → assigned → in_progress → completed \| cancelled |
| RMA | requested → authorized → received → inspected → dispositioned → closed \| rejected |
| Work order (kit/VAS) | planned → released → in_progress → completed \| cancelled |
| Dock appointment | scheduled → checked_in → at_dock → departed \| no_show \| cancelled |

---

## 5. Multi-tenancy and access

- **Client** = the cargo owner (WeStore's customer). **Warehouse** = WeStore site (Shuwaikh, Mina Abdullah, …).
- Users have a `user_type`: `operator` (WeStore staff; may be restricted to warehouses) or `client` (restricted to one client).
- Roles (spatie/laravel-permission): `super_admin`, `ops_manager`, `supervisor`, `operator`, `inventory_controller`, `finance`, `client_admin`, `client_viewer`, `integration`.
- `ClientScoped` trait adds a global scope from the authenticated context; `WarehouseScoped` likewise. Policies re-check on every write.
- Client API keys are Sanctum tokens with abilities (`orders:write`, `inventory:read`, …).

---

## 6. Integration surface

- REST API `/api/v1/*` (see `docs/API.md` and `docs/openapi.yaml`).
- RF/scanner API `/api/v1/rf/*`: scan-driven, small payloads, every step idempotent, returns the next instruction.
- Outbox → webhooks: clients subscribe to events (`order.shipped`, `receipt.completed`, `inventory.adjusted`…). Signed with HMAC, retried with backoff, dead-lettered after N attempts.
- Outbox → internal consumers: Billing (billable events), Labor, AI triggers, SAP export (billing periods), WeStore Freight (shipment handoff).

---

## 7. AI layer

See `docs/AI.md` for the full design. Summary:

- `AiGateway` is the single entry point. Providers implement `AiProvider` (Claude via HTTP; `FakeProvider` in tests). Structured output is enforced by JSON schema validation; invalid output = failed job, never a partial apply.
- Every call is recorded in `ai_invocations` (use case, prompt version, input hash, tokens, cost, latency, outcome).
- Use cases produce `ai_recommendations` with `confidence`, `rationale`, `proposed_action` (a typed DTO the matching domain service can execute).
- `ai_policies` (per client × use case): `mode = off | advise | auto`, `auto_threshold`, `daily_cost_cap`.
- Use cases shipped: ASN document extraction, putaway advisor, slotting optimiser, exception triage, replenishment forecasting, natural-language operations query (read-only views, restricted DB role, SQL allow-list validation), labor demand forecast, count anomaly detection.

---

## 8. Numbering

Native PostgreSQL sequences, one per document type per warehouse (`docseq_{type}_{warehouse_id}`), created lazily by `NumberGenerator`. Sequences are non-transactional, so concurrent creators never block each other or collide; rolled-back transactions leave gaps, which is accepted. Format `{PREFIX}-{WH}-{000000}`: `ASN-KW1-000123`, `RCP-…`, `PUT-…`, `SO-…`, `WAV-…`, `PCK-…`, `PKG-…`, `SHP-…`, `CNT-…`, `ADJ-…`, `RPL-…`, `RMA-…`, `WO-…`. LPNs (`LPN-KW1-000123-7`) carry a mod-10 check digit so a mis-scan is rejected rather than mis-located.

---

## 9. Reporting

Read models are PostgreSQL views in `database/views/*.sql` (applied by migration): `v_inventory_summary`, `v_stock_by_lot`, `v_expiring_stock`, `v_location_utilization`, `v_order_cycle_times`, `v_receiving_accuracy`, `v_pick_productivity`, `v_billable_summary`. The NL query use case can only read these views through the `wms_readonly` role.

---

## 10. Non-functional

- **Performance**: hot paths (post movement, allocate, pick confirm) target < 50 ms DB time; all list endpoints paginated and indexed by their filters; no N+1 (tested with `preventLazyLoading` in non-production).
- **Reliability**: queue jobs are idempotent and retry with backoff; outbox dispatcher is at-least-once; consumers dedupe on `event_id`.
- **Security**: Sanctum tokens, ability checks, per-row policies, rate limiting on RF endpoints, HMAC-signed webhooks, secrets only in env, PII minimal (consignee data on shipments only).
- **Observability**: structured JSON logs with `request_id`, `client_id`, `warehouse_id`; `/up` health; metrics endpoint for queue depth, outbox lag, reconciliation drift.
- **Data retention**: ledger is forever; audit is forever; AI invocation payloads purge after 90 days (configurable).
