# 8. Developer handover

Written for the team taking the system to production. It assumes strong PHP/Laravel experience and maps Laravel concepts to how this codebase does the same thing.

## 8.1 Laravel to this stack

| Laravel | Here | Where |
|---------|------|-------|
| `routes/api.php` + controllers | NestJS controllers with decorators (`@Controller`, `@Get`, `@Post`) | `apps/api/src/modules/*/*.controller.ts` |
| Form requests / validation rules | zod schemas shared with the front end (`CreateAsnSchema` ...) applied by `zodBody()` / `zodQuery()` pipes | `packages/shared/src/dto/*.ts`, `apps/api/src/common/validation` |
| Eloquent models + migrations | Drizzle schema (TypeScript tables) + SQL migrations generated by `drizzle-kit` | `apps/api/src/db/schema/*.ts`, `apps/api/drizzle/*.sql` |
| Service classes / actions | Injectable services, one per aggregate (`AsnService`, `LedgerService` ...) | `apps/api/src/modules/*/*.service.ts` |
| Middleware + policies / gates | `RequestContextMiddleware` + `AuthGuard` with `@RequirePermissions`, `@RequireFeature`, `@PlatformOnly` | `apps/api/src/common/auth` |
| `DB::transaction()` | `this.db.run(async (tx) => { ... })` which also sets the tenant for RLS; services accept an outer `tx` to compose | `apps/api/src/common/db/db.service.ts` |
| Events + listeners + queued jobs | Transactional outbox (`EventBus.publish` inside the transaction) + in-process handlers run by the dispatcher job; webhooks fan-out | `apps/api/src/common/events`, `apps/api/src/modules/jobs` |
| Scheduler (`schedule:run`) | `@nestjs/schedule` crons + interval jobs in the worker (`JOBS_ENABLED=true`) | `apps/api/src/modules/jobs/scheduled.job.ts` |
| `php artisan migrate` / seeders | `pnpm db:migrate`, `pnpm db:seed -- --demo` | `apps/api/src/db/migrate.ts`, `seed.ts` |
| `.env` + `config/*.php` | `.env` validated by zod at boot (`loadEnv()`), the app refuses to start with missing secrets | `apps/api/src/config/env.ts` |
| Exceptions handler | `HttpExceptionFilter` maps domain errors and Postgres errors to a stable JSON shape | `apps/api/src/common/errors` |
| PHPUnit feature tests | Jest e2e suites booting the real app with supertest against a fresh test database | `apps/api/test/e2e/*` |
| Blade / Inertia | React (Vite) SPA talking to the API | `apps/web` |

Things that have no Laravel equivalent and matter here:

- **Decimals**: all quantities/money are `decimal.js` objects in services and strings over the wire. Never use JS numbers for stock or money. Helpers: `D()`, `sum()`, `ZERO` in `apps/api/src/common/util`.
- **Request context** (AsyncLocalStorage): `requireRequestContext()` gives tenant, actor, permissions anywhere in a request or job without passing it around.
- **Row Level Security**: queries are automatically tenant-filtered by Postgres. A query that "returns nothing" in a job usually means the job did not set the tenant (`runWithContext(systemContext(tenantId), ...)`).
- **Correlated sub-queries**: inside a `sql\`...\`` template that references an outer table column, use `qcol(table.column)`; Drizzle drops the qualifier on single-table queries (see `common/util/index.ts` and the regression tests).

## 8.2 Repository layout

```
apps/api            NestJS API (src/common cross-cutting, src/modules domain modules, src/db schema+migrations+seed, test/unit, test/e2e)
apps/web            React app (src/app shell+router, src/features/<area> pages, src/rf handheld, src/components ui kit, src/lib api/auth/hooks)
packages/shared     zod DTOs, enums, events, permissions (build with pnpm --filter @wms/shared build after editing; both apps import the dist)
infra               docker, k8s
docs                this documentation
legacy              earlier Laravel prototype (reference only)
```

## 8.3 Daily developer workflow

```bash
pnpm install
pnpm --filter @wms/shared build          # or `pnpm --filter @wms/shared dev` to watch
pnpm --filter @wms/api dev               # nest --watch, http://localhost:3000/api/docs
pnpm --filter @wms/web dev               # http://localhost:5173
pnpm -r typecheck                        # zero errors is the bar (the web app also enforces noUnusedLocals)
pnpm --filter @wms/api test              # unit
pnpm --filter @wms/api test:e2e          # e2e (drops/creates wms_test; needs DATABASE_MIGRATION_URL superuser)
```

Database changes: edit `src/db/schema/*.ts`, run `pnpm db:generate` (writes `drizzle/00xx_*.sql` + snapshot), review the SQL, then `pnpm db:migrate`. For hand-written SQL (functions, triggers, policies) add a numbered file and an entry in `drizzle/meta/_journal.json` like `0001`/`0002`. New tables with `tenant_id` are covered by RLS and the reference guard automatically only after `SELECT app_apply_rls(); SELECT app_apply_tenant_ref_guard();` (add both calls to the migration that creates the table).

## 8.4 Adding a feature end to end (worked example: "QC inspection on receipt")

1. **Contract**: add `QcDecisionSchema` to `packages/shared/src/dto/inbound.ts`, extend enums if needed (`packages/shared/src/enums.ts`), add event type to `events.ts`. Build shared.
2. **Schema**: add columns/tables in `apps/api/src/db/schema/inbound.ts`; generate and review the migration.
3. **Service**: implement in `InboundModule` (`qc.service.ts`): run inside `this.db.run`, post movements through `LedgerService.post` (never update balances directly), write `audit.log`, `events.publish` in the same transaction, throw `DomainError` with a code from `ErrorCodes`.
4. **Controller**: route with `@RequirePermissions('inbound.qc')` (add the permission to the catalogue and to system roles), validate with `zodBody`.
5. **Task handler** (if RF work): register with `TasksService.registerHandler('QC', handler)` to define what `confirm` expects.
6. **Billing** (if billable): subscribe `ChargeCaptureService` to the new event with a new `CHARGE_TRIGGER`.
7. **Web**: page under `src/features/inbound`, route in `src/app/router.tsx`, nav in `src/app/nav.ts`, RF screen under `src/rf` if needed.
8. **Tests**: scenario in `test/e2e/inbound.e2e-spec.ts` (create ASN -> receive -> QC decision -> balances/status/events). Unit tests for pure rules.
9. **Docs**: update `docs/04-operations-flows.md` and the permission list.

## 8.5 Coding conventions

- Services own transactions; controllers never touch the database. Every public service method validates access (`assertWarehouseAccess`, `resolveStorerScope`) before `db.run`.
- Never update `inventory_balances` directly; everything goes through `LedgerService.post()`.
- Reads for lists return `paginate(items, total, q)`; detail reads use `getInTx(tx, id)` so callers inside transactions do not open nested ones.
- Business numbers come from `NumberingService.next(docType, tx, warehouseId)`.
- Errors: `DomainError(code, message, status, details)`; add codes to `ErrorCodes`; the web shows `message` and uses `code` for behaviour.
- Serialization: return `serialize(row)` (decimals to strings) from services.
- Keep modules acyclic: inbound/outbound depend on inventory and master-data, never the reverse; `ai` depends on everything and nothing depends on `ai`.
- Front end: pages are self-contained; shared UI lives in `components/ui`; all API access through `lib/api.ts`; permission checks with `useCan()`; follow `apps/web/CONVENTIONS.md`.

## 8.6 Jobs and background work

| Job | Trigger | Notes |
|-----|---------|-------|
| Outbox dispatcher | every 2 s | claims PENDING events (`SKIP LOCKED`), runs handlers (billing capture, metering), enqueues webhooks, retries up to MAX_ATTEMPTS then DEAD |
| Webhook sender / email sender | every 5 s / every 10 s | HMAC signed POSTs with backoff (URLs re-validated against private networks); SMTP via nodemailer or log driver |
| Storage snapshots | hourly check at minute 7, runs once per warehouse after its day-end hour | base for storage billing |
| Replenishment | every 15 min | min/max pick faces |
| Nightly | 01:30 | expiry sweep, zero-balance purge, AI proposal expiry, trial expiry (each step isolated) |

All jobs iterate active tenants with `runWithContext(systemContext(tenantId))`.

## 8.7 Go-live checklist (engineering side)

- [ ] Build images in CI; tag; deploy to a staging environment with production-like data volume.
- [ ] Set real secrets (`JWT_ACCESS_SECRET`, `ENCRYPTION_KEY`, DB passwords, SMTP, S3); `COOKIE_SECURE=true`; `CORS_ORIGINS` exact.
- [ ] Run migrations, seed plans and platform admin, change the platform admin password.
- [ ] Provision the production tenant, load master data via Excel templates, print location labels.
- [ ] Configure storer settings (tolerances, RCR emails), contracts and tariffs, users and roles, API keys/webhooks for ERP.
- [ ] Test RF devices on the warehouse Wi-Fi against staging (scan input, label printing).
- [ ] Backups and restore drill; monitoring/alerts on DEAD events and failed deliveries.
- [ ] Run the e2e suite against staging (point `DATABASE_*` at a scratch database) before each release.
- [ ] Decide AI provider (`disabled` is a valid production setting) and the token budget per plan.
- [ ] Read `docs/10-roadmap-and-gaps.md` and agree what is in scope for go-live.

## 8.8 Support and troubleshooting

| Symptom | Likely cause | Where to look |
|---------|--------------|---------------|
| `DbService.run called without tenant context` | a job or script forgot `runWithContext(systemContext(...))` | stack trace |
| Lists empty although rows exist | RLS: wrong tenant in context, or `app.bypass_rls` not set for a platform read | `DbService`, policy `tenant_isolation` |
| `CROSS_TENANT_REFERENCE` | a client sent an id from another tenant (or test data mixed tenants) | request payload |
| `INSUFFICIENT_STOCK` on allocation although stock shows | stock not AVAILABLE, on hold, expired, below min shelf life, or allocated by another order | allocations table, holds |
| Charges not captured | no ACTIVE contract at the charge date, tariff trigger/conditions mismatch, outbox event DEAD | `outbox_events.last_error`, AI billing anomaly |
| Webhook not delivered | endpoint not reachable/2xx; see `webhook_deliveries` attempts and `last_error`; redeliver from UI | Integrations > Webhooks |
| AI returns `AI_DISABLED` | `AI_PROVIDER=disabled` or missing API key | env, `/api/ai/status` |
