diff --git a/.claude/skills/edr-db/SKILL.md b/.claude/skills/edr-db/SKILL.md new file mode 100644 index 000000000..47333128d --- /dev/null +++ b/.claude/skills/edr-db/SKILL.md @@ -0,0 +1,42 @@ +--- +name: edr-db +description: Query, EXPLAIN-validate, and inspect the remote EDR freight dev database. Use whenever you need to check data, verify a raw SQL statement before shipping it, list a table's columns, check schema drift, or see which migrations are recorded. psql is NOT installed on this machine — this runner is the sanctioned path. Triggers - "check the db", "query edr_dev", "does column X exist", "validate this SQL", "is migration recorded", "seed check", diagnosing a 400/500 whose cause may be data or schema. +--- + +# EDR dev-DB runner + +One script, runs from anywhere in the repo (resolves `pg` from `apps/edr-freight-api`): + +```bash +node .claude/skills/edr-db/query.cjs "SELECT ... " # run SQL, console.table output +node .claude/skills/edr-db/query.cjs explain "SELECT ..." # EXPLAIN-validate only (no rows touched) +node .claude/skills/edr-db/query.cjs columns # freight.
column list +node .claude/skills/edr-db/query.cjs migrations [like] # public.migrations rows (newest first) +node .claude/skills/edr-db/query.cjs drift
# bare column names, for diffing vs the entity +``` + +Connection comes from `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME`, +defaulting to the shared dev database (`edr_dev`). + +## Rules that go with it + +- **HARD RULE: every raw SQL statement you write into a service must pass + `explain` here before you ship it.** A typo'd column is a runtime 500 the + type-checker cannot catch. +- Never assume a recorded migration applied — check `migrations ` **and** + `columns
` together. Recorded-but-absent = schema drift; fix with a + NEW repair migration (idempotent DDL, no-op `down()`), never by editing the + recorded one. +- Writes to dev data are fine for seeding/diagnosis but keep them idempotent + (`WHERE NOT EXISTS` guards) — watch-mode API instances race `migrationsRun`, + and non-idempotent statements have double-run here before. +- Timestamps for new migrations: must be unique across `src/migrations/` AND + higher than `SELECT max(timestamp) FROM public.migrations`. + +## Diagnosing a pasted 400/500 (the recurring loop) + +1. Find the route: grep the path segment in `apps/edr-freight-api/src/modules/*/**.controller.ts`. +2. Read the service method — list its guard `throw`s. Most "bugs" are a guard + working as designed (handover unsigned, fee unpaid, not PAID, wrong direction). +3. Check the actual DB state for that record with this runner. +4. Only then decide: guard doing its job (fix the UI affordance) vs real defect. diff --git a/.claude/skills/edr-db/query.cjs b/.claude/skills/edr-db/query.cjs new file mode 100644 index 000000000..b4797bd45 --- /dev/null +++ b/.claude/skills/edr-db/query.cjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * Dev-DB runner for the EDR freight database. psql is NOT installed on this + * machine; this is the sanctioned way to query, EXPLAIN-validate, and inspect + * the remote dev DB. Resolves `pg` from apps/edr-freight-api so it runs from + * anywhere in the repo. + * + * node .claude/skills/edr-db/query.cjs "SELECT ... " run SQL (console.table) + * node .claude/skills/edr-db/query.cjs explain "SELECT..." EXPLAIN-validate only + * node .claude/skills/edr-db/query.cjs columns
list freight.
columns + * node .claude/skills/edr-db/query.cjs migrations [like] public.migrations rows + * node .claude/skills/edr-db/query.cjs drift
columns vs entity check helper + * + * Connection: DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME env vars, falling + * back to the shared dev database. + */ +const path = require('path'); +const { createRequire } = require('module'); + +const repoRoot = path.resolve(__dirname, '..', '..', '..'); +const apiRequire = createRequire( + path.join(repoRoot, 'apps', 'edr-freight-api', 'package.json'), +); +const { Client } = apiRequire('pg'); + +const cfg = { + host: process.env.DB_HOST ?? '10.18.7.207', + port: parseInt(process.env.DB_PORT ?? '5432', 10), + user: process.env.DB_USER ?? 'postgres', + password: process.env.DB_PASSWORD ?? 'dcba@1234', + database: process.env.DB_NAME ?? 'edr_dev', +}; + +const [, , first, ...rest] = process.argv; + +async function main() { + if (!first) { + console.error('usage: query.cjs "" | explain "" | columns
| migrations [like] | drift
'); + process.exit(2); + } + const c = new Client(cfg); + await c.connect(); + try { + if (first === 'columns') { + const r = await c.query( + `SELECT column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema='freight' AND table_name=$1 + ORDER BY ordinal_position`, + [rest[0]], + ); + console.table(r.rows); + } else if (first === 'migrations') { + const like = rest[0] ? `%${rest[0]}%` : '%'; + const r = await c.query( + `SELECT id, timestamp, name FROM public.migrations + WHERE name ILIKE $1 ORDER BY id DESC LIMIT 40`, + [like], + ); + console.table(r.rows); + } else if (first === 'drift') { + // Quick drift signal: DB columns for the table. Compare by eye against + // the entity's @Column names; a recorded-but-absent column = drift. + const r = await c.query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema='freight' AND table_name=$1 ORDER BY column_name`, + [rest[0]], + ); + console.log(r.rows.map((x) => x.column_name).join('\n')); + } else if (first === 'explain') { + await c.query('EXPLAIN ' + rest.join(' ')); + console.log('OK — statement is valid against', cfg.database); + } else { + const sql = [first, ...rest].join(' '); + const started = Date.now(); + const r = await c.query(sql); + if (Array.isArray(r.rows) && r.rows.length) console.table(r.rows); + console.log(`${r.rowCount ?? 0} row(s), ${Date.now() - started}ms`); + } + } finally { + await c.end(); + } +} + +main().catch((e) => { + console.error('FAIL:', e.message); + process.exit(1); +}); diff --git a/.claude/skills/standup/SKILL.md b/.claude/skills/standup/SKILL.md new file mode 100644 index 000000000..272f280e7 --- /dev/null +++ b/.claude/skills/standup/SKILL.md @@ -0,0 +1,40 @@ +--- +name: standup +description: Produce the work report Hagernesh asks for - "what have I done today", "tasks of yesterday and today", daily/period summaries for tickets or timesheets. Builds the answer from git history plus uncommitted work, never from memory alone. +--- + +# Work report (standup / ticket summary) + +Ground every line in git. Do not reconstruct from conversation memory — commits +are the record. + +## Gather + +```bash +# Commits in the window (adjust dates; author matches "Hagernesh") +git log --since="YYYY-MM-DD 00:00" --until="YYYY-MM-DD 00:00" --author=Hagernesh \ + --pretty=format:"%h|%ad|%s" --date=short + +# What each commit actually contains (subjects lie sometimes) +git show --stat --pretty=format:"%s" | head -12 + +# In-flight work = part of "today" even if uncommitted +git status --short +git log origin/dev..HEAD --oneline # branch commits not yet in dev +``` + +## Known pitfalls in this repo + +- **Check subjects against contents.** Commit titles here sometimes mismatch the + diff (e.g. a commit titled "unload export" that actually contained ISO + container validation). Use `git show --stat` before reporting a title as fact. +- A day with no commits usually still has uncommitted/in-flight work — report it + as its own section with per-item status (done / uncommitted / blocked). +- Merge commits from other authors are noise; filter with `--author`. + +## Output format + +One table per day: `# | Task (plain language, not the commit subject verbatim) | +Commit / Status`. Follow with a short "carry-over / blocked" list naming what +blocks each item. Keep it ticket-ready: no jargon that needs the repo open to +decode. diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 000000000..a2c715db4 --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,59 @@ +--- +name: verify +description: Project definition-of-done runner for the EDR platform. Use before calling any code change finished, before committing, and whenever asked "is it done / does it work". Runs the targeted checks that actually catch this repo's failure modes - type-check with turbo filters, @edr/types dist rebuild, raw-SQL EXPLAIN validation, migration safety, and honest test reporting. +--- + +# Verify a change (EDR definition of done) + +Run these in order. Report which you ran and what each said — never call +unverified work done. + +## 1. Type-check exactly what you touched + +```bash +pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice --filter=@edr/freight-portal +``` + +Drop filters you didn't touch; whole-repo runs waste minutes. **If you edited +`packages/types`, rebuild it FIRST** — consumers read its `dist/`, not `src/`: + +```bash +pnpm turbo build --filter=@edr/types +``` + +## 2. Validate every raw SQL statement + +Each new/edited `dataSource.query` / `manager.query` string must pass: + +```bash +node .claude/skills/edr-db/query.cjs explain "" +``` + +## 3. Migration checklist (if you added one) + +- Timestamp unique in `src/migrations/` **and** greater than + `node .claude/skills/edr-db/query.cjs "SELECT max(timestamp) FROM public.migrations"`. +- DDL idempotent (`IF NOT EXISTS`, guarded backfills). +- Watch-mode reload does NOT run migrations — apply the SQL to the dev DB + yourself or fully restart the API, then confirm with + `query.cjs columns
`. + +## 4. Tests — honest bar + +`pnpm test` for `@edr/freight-api` is currently red on `dev`, so a green suite +is not the bar. The bar: run the specs nearest what you touched and introduce +**no new failure**. If you touched a service constructor, update its `.spec.ts` +mocks (constructor-arity breaks are this repo's most common test regression). + +## 5. Observe the behaviour + +Compiling is not working. Hit the endpoint, drive the UI flow, or query the +resulting rows. If you genuinely could not observe it, say so explicitly in the +summary — do not imply it was seen working. + +## 6. Before commit + +- Conventional message (`fix(warehouses): …`). Git hooks do NOT run in this + repo (husky shims exist but no user hooks) — nothing will catch it for you. +- Lint the files you touched if in doubt: `pnpm turbo lint --filter=`. +- Do not commit or push unless the user asked. diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 62530611c..68f92eea7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,7 +18,7 @@ jobs: matrix: ${{ steps.filter.outputs.matrix }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v4e with: fetch-depth: 2 diff --git a/.gitignore b/.gitignore index ffdc4b78b..ca2a5b7af 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ coverage/ *~ \#*\# .\#* +docker-compose.override.yml diff --git a/CLAUDE_NEW.md b/CLAUDE_NEW.md new file mode 100644 index 000000000..11075e9f0 --- /dev/null +++ b/CLAUDE_NEW.md @@ -0,0 +1,294 @@ +# EDR Platform — Developer Guide + +> This file is the contract. If something here contradicts the code, the code is the +> truth and this file is a bug — fix it in the same PR. + +## Overview + +Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight +Management and Passenger Management applications, a payment microservice, plus shared +types, NestJS utilities, and React component libraries. + +The freight domain is the largest and most active area. Its core flow is: +**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload +→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.** +Fees (storage, demurrage, double handling, truck detention) and allocation rules +(warehouse/yard/zone) hang off the warehouse stage. + +## Apps + +| App | Package name | Purpose | Default port | +| ------------------------------ | --------------------------- | -------------------------------------------------- | ------------ | +| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 | +| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 | +| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 | +| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 | +| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 | +| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 | +| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 | + +`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. +Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace +packages (see `pnpm-workspace.yaml`). + +`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace +package and is not built, linted, or type-checked. Leave it alone unless asked. + +## Packages + +| Package | Purpose | +| ---------------------- | ---------------------------------------------------------------------------------- | +| `@edr/types` | Shared TypeScript interfaces and enums | +| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | +| `@edr/ui-common` | Shared React components and theme | +| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) | +| `@edr/tsconfig` | Shared TypeScript configurations | +| `@edr/prettier-config` | Shared Prettier configuration | + +**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a +type in `packages/types/src` changes nothing for consumers until you rebuild: + +```bash +pnpm turbo build --filter=@edr/types +``` + +If a type-check fails on a field you just added to `@edr/types`, this is why. + +## Commands + +| Command | Description | +| --------------------------- | ---------------------------------------- | +| `pnpm install` | Install all workspace dependencies | +| `pnpm dev` | Run every app in dev mode | +| `pnpm dev:freight` | Freight API + portal + backoffice | +| `pnpm dev:freight:api` | Freight API only | +| `pnpm dev:freight:portal` | Freight portal only | +| `pnpm dev:freight:backoffice` | Freight backoffice only | +| `pnpm dev:passenger` | Passenger API + web | +| `pnpm dev:payment` | Payment API | +| `pnpm build` | Build every package and app | +| `pnpm test` | Run all tests (turbo) | +| `pnpm lint` | Lint everything | +| `pnpm type-check` | Type-check every package | +| `pnpm format` | Format all files with Prettier | + +Prefer targeted turbo filters over whole-repo runs — they are minutes faster: + +```bash +pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice +``` + +`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains, +gate-pass scenarios). Read the script before running one; several write real rows. + +## Environment & database + +- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and + no port `5433`/`5434` is published anywhere in the repo. +- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, + `DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a + remote database. +- The connection sits behind a **connection pooler**. Do **not** pass + `extra.options: '-c search_path=…'` — the pooler rejects it with + `08P01 unsupported startup parameter in options: search_path`. `search_path` is applied + per-connection in a pool `connect` handler instead. See + `apps/edr-freight-api/src/config/database.config.ts` before touching connection options. +- Each app owns its own database. **No cross-database joins**; cross-domain data flows + through API calls or message queues. +- `psql` is not installed on the dev machine. To query the database, write a short Node + script using the `pg` client and run it from `apps/edr-freight-api` (where `pg` resolves). + +## Hard rules + +These are non-negotiable. Everything else is a strong default. + +- **pnpm only.** Never run `npm install` or `yarn`. +- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not + reach for `any` to make an error go away. +- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false` + in every config and it has already corrupted this database twice (see *Migrations*). + All schema changes go through TypeORM migrations. +- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`). +- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`, + `deletedAt` (soft delete). +- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`); + TypeScript properties are `camelCase`. +- **Controllers contain no business logic.** They validate, delegate, and shape the response. +- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`. +- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands. +- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and + offer the safe version. + +## Architecture + +### NestJS module shape + +`module → controller → service → repository`, with `entities/` and `dto/` alongside. + +### Data access — the real model + +There are two sanctioned ways to read and write, and you must pick the right one: + +1. **Entity CRUD → the custom repository class.** Extends `BaseRepository` from + `@edr/api-common`. Services inject the repository class, never `Repository` directly. +2. **Read projections, queue endpoints, cross-table reports → raw SQL** via + `this.dataSource.query(...)` or `manager.query(...)` inside a transaction. + +Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it. +It carries one obligation: + +> **HARD RULE — validate every raw SQL statement against a real database before you ship it.** +> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through +> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*). + +Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository, +so they join the caller's transaction. + +**Never do slow I/O inside a database transaction.** Queue the work and fan it out after +commit. An SMS awaited inside a transaction once held capacity locks open for the whole +gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to +no timeout and will wait forever. + +### Migrations + +Migrations are the most dangerous surface in this repo. Two production-grade incidents have +already come from it. + +- `migrationsRun: true` — **migrations run automatically on API boot**, with + `migrationsTransactionMode: 'each'`. +- Consequences you must design for: + - Running several `nest start --watch` instances races `migrationsRun`. A non-idempotent + data migration can execute twice. Keep one instance. + - A watch-mode hot reload does **not** re-run migrations. If you add a column that new + code reads, apply it to the dev database yourself (idempotently) or fully restart. +- **Give every migration a unique timestamp.** 34 timestamps are currently shared by two or + more migrations. TypeORM orders by timestamp and breaks ties non-deterministically. Before + adding one, check the filename prefix is unused *and* higher than the newest recorded row. +- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and + backfills guarded by `WHERE col IS NULL`. +- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory` + was recorded in `migrations` while its column was absent — it had been dropped out of band. + TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*. +- **A repair migration's `down()` should be a no-op.** Reverting a repair must not + re-introduce the outage it fixed. + +### Auth + +Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own. + +- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub. +- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`. +- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS..)`. +- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`. + Add a permission there before referencing it. +- IAM has its own migrations, run ahead of freight migrations from the same data source, and + its own CLI scripts (`iam:migration:run`, `iam:seed:run`). + +Ownership checks are separate from permission checks. A staff user passes +`hasFreightPermission`; a customer must additionally pass an ownership assertion such as +`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed. + +## Frontend conventions + +- The web apps use **Mantine v9**. Its APIs differ from v6/v7 — check the installed version + before copying a snippet. +- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the + freight web apps. Prefer it over re-implementing a component. +- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'` + delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no + `.message` and degrades to `"Request failed with status code 400"`. Use + `await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches + keep the synchronous version — their bodies are already parsed JSON. +- Server-side guards must be reflected in the UI. If the API will reject the action, the + button should be disabled, hidden, or explain the blocker — not fire and surface a 400. +- Prefer disabling a control with a visible reason over silently hiding it. + +## Notifications + +In-app notifications resolve recipients from the company's **linked portal users**. If a +company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error. +SMS and email still send, because they address the company's phone and email directly. Check +this before debugging a "missing notification". + +## PDF generation + +Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled +generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than +assume a headless browser exists. + +## Adding a new module to a NestJS app + +1. Create `modules//` with `entities/`, `dto/`, and the four + `.{module,controller,service,repository}.ts` files. +2. The entity extends `BaseEntity` from `@edr/api-common`. +3. The repository extends `BaseRepository` from `@edr/api-common`. +4. The service injects the repository class (not `Repository` directly). +5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route. +6. Register the module in the app's `app.module.ts`. + +## Adding a new shared component to `@edr/ui-common` + +1. Create `src/components//.tsx` and `src/components//index.ts`. +2. Export from `src/index.ts`. +3. Component is a functional component with a `ComponentNameProps` interface + (named-exported alongside the default). + +## Definition of done + +A change is done when **all** of these hold. State explicitly which you ran. + +1. **It type-checks.** `pnpm turbo type-check --filter=` passes. + If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first. +2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the + dev database without error. +3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds + something the new code reads — applied to the dev database, since watch mode will not run it. +4. **No new test failures.** `pnpm test` for `@edr/freight-api` is **currently red on `dev`**, + so a fully green suite is not the bar. Run the specs covering what you touched and confirm + you introduced no new failure. +5. **Lint and format are clean** for the files you touched. Git hooks do **not** run these + automatically (see below), so run them yourself. +6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the + endpoint, or ran the query. If you could not, say so plainly. +7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in + the summary. Never describe unverified work as done. + +### Hooks do not run + +`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed +at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`, +`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever +fire.** Nothing validates your commit message or formats your staged files. Run the checks by +hand; do not assume the hook caught it. + +## Known traps + +| Trap | What happens | What to do | +| --- | --- | --- | +| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one | +| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp | +| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` | +| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout | +| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` | +| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging | +| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully | + +## Project skills + +Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps: + +| Skill | Use for | +| --- | --- | +| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. | +| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. | +| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. | + +## Working style + +- **Verify before asserting.** Read the code or query the database. Do not infer behaviour + from a filename. +- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the + trade-off before changing files. +- **Small, reviewable commits**, one logical change each, conventional message. +- **Branch from `dev`; PRs target `dev`.** +- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result. diff --git a/apps/edr-freight-api/docs/priority-batch-window-flow.md b/apps/edr-freight-api/docs/priority-batch-window-flow.md new file mode 100644 index 000000000..9c8a52878 --- /dev/null +++ b/apps/edr-freight-api/docs/priority-batch-window-flow.md @@ -0,0 +1,74 @@ +# Priority & Batch Window Flow (Import, Freight) + +Export = no batch, no priority. Pure first-come-first-served (`booking-batch.service.ts:462-467, 625-628`). Everything below is import only. + +## Step by step + +**1. Booking submitted → priority score computed** +`booking-transition.service.ts:110-111,196-197` → `booking-pricing.service.ts:403-407` `computeSubmitPriorityScore()` → `rule-engine.service.ts:118`. +- Government booking: `+50,000` (`government-priority.constants.ts:2`, applied `rule-engine.service.ts:201`) +- Plus cargo/weight modifiers +- Stored on `booking.priorityScore` + +**2. Window opens (PRE_WINDOW → OPEN)** +Cron tick every 10s: `booking-window.service.ts:63` → `advanceImport` → `booking-window.service.ts:232-251`. +Times computed by `computeImportWindowTimes` (`batch-window.util.ts:248-283`). + +**3. Customers book during OPEN** +Booking lands as: +- Commercial → `FULLY_EXECUTED` +- Government → `APPROVED/PAID` (skips contract flow) + +**4. Window closes (OPEN → DOC_REVIEW)** +`booking-window.service.ts:254-268`. Staff review docs for `docReviewMinutes`. + +**5. Doc review ends** +Staff `completeDocReview()` (`booking-window.service.ts:124-159`) or timeout → `booking-window.service.ts:270-295`. +Before batch runs: `expireUnacceptedForRouteDay` (`booking-batch.service.ts:1853-1883`) kills never-accepted bookings so they can't compete. + +**6. Batch fill runs** +`processRouteDay` → `fillRouteDay` (`booking-batch.service.ts:1138-1319`), or single-schedule `fillSchedule` (`:1018-1128`). + +- Pool pulled pre-sorted: `findBatchPool`/`findBatchPoolByCorridorDay` (`bookings.repository.ts:991-1008, 1055-1083`) + `ORDER BY is_government DESC, priority_score DESC, fully_executed_at ASC, created_at ASC` +- Consolidated pairs grouped as one atomic unit: `groupConsolidatedPool` (`:1962-1987`) — never split. +- Greedy placement, earliest-departing fitting train first: loop at `:1218-1306`. +- No fit + government booking → `preemptForGovernment` (`:1891-1910`): bumps lowest-`priorityScore` commercial victim first, only if legs overlap (`:1920`). +- No fit + commercial import (GENERAL/ONE_TIME) → maybe partial "split" offer: `maybeOfferPartial`/`isSplitEligible` (`:1326-1370`). +- Still no fit → stays pooled, `notifier.unplaced` (`:1278-1280`). + +**7. Placed bookings get reserved/allocated** +- Commercial: `reserve()` (`:1673-1703`) → `SELECTED_FOR_BATCH`, payment deadline set, DOC_REVIEW→PAYMENT (`booking-window.service.ts:275-294`). +- Government: `allocate()` directly (`:1706-1746`), no payment step. + +**8. Payment phase ends** +`booking-window.service.ts:297-309` → `settleDueReservations` → `settleReserved` (`:1437-1491`): +- paid → allocated +- unpaid → expired, capacity freed + +Then `concludeCycle` (`:315-373`): +- Train full → `DONE` + auto-finalize (`:320-329`) +- Not full → reopen same/next day (`nextCycleOpensAt` / office hours, `:331-372`, `batch-window.util.ts:217-224`) or `DONE` if no cycle fits before departure. + +**9. Backstop** +`settleOverdueReservations` (`booking-window.service.ts:388-406`) catches any reservation whose deadline passed outside the normal tick. + +## Phase enum + +`PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → (reopen PRE_WINDOW | DONE)` +(`booking-window.config.ts:27-34`) + +## What decides priority + +1. `is_government` — always first, both in SQL sort and `compareSchedulingPriority` util (`compare-scheduling-priority.util.ts:9-23`) +2. `priority_score` DESC (rule engine: government bonus + cargo/weight modifiers) +3. `fully_executed_at` ASC (earlier wins) +4. `created_at` ASC + +## Edge cases + +- Government preemption only bumps if legs overlap; picks lowest-priority victim first. +- Consolidated pairs are both-or-neither, never split (`:1326-1334, 1793`). +- Only GENERAL/ONE_TIME import bookings are eligible for partial "split" offers. +- Per-unit try/catch around reserve — one failure can't cause silent trickle/stagger allocation (comment at `:1283-1288`). +- Each train freezes its own rule snapshot at window-open time, not live config (`booking-window.service.ts:85-93`). diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 9561a7b73..afb214137 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -41,6 +41,7 @@ import { NotificationsModule } from "./modules/notifications/notifications.modul import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; +import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { OtpModule } from "./modules/otp/otp.module"; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; @@ -158,6 +159,7 @@ import { LoggerMiddleware } from "./logger.middleware"; NotificationInboxModule, FileUploadSettingsModule, DropdownSettingsModule, + ContractTemplatesModule, OtpModule, RuleEngineModule, BackofficeModule, diff --git a/apps/edr-freight-api/src/contracts/contract-article.util.ts b/apps/edr-freight-api/src/contracts/contract-article.util.ts new file mode 100644 index 000000000..5f0c86a6c --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-article.util.ts @@ -0,0 +1,63 @@ +import Handlebars from 'handlebars'; + +/** One numbered clause of a dynamic article, with optional nested bullets. */ +export interface RenderedClause { + text: string; + bullets: string[]; +} + +/** A dynamic article ready for the Handlebars template. */ +export interface RenderedArticle { + number: number; + title: string; + /** Set (instead of clauses) when the body is a single plain paragraph. */ + paragraph?: string; + clauses: RenderedClause[]; +} + +/** + * Parse a template article body into clauses. Format: one clause per line; + * lines prefixed with "- " become bullets nested under the preceding clause. + * A body that reduces to a single clause without bullets renders as a plain + * paragraph rather than a numbered list of one. + */ +export function parseArticleBody(body: string): Pick { + const lines = (body ?? '') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + const clauses: RenderedClause[] = []; + for (const line of lines) { + if (line.startsWith('- ')) { + const bullet = line.slice(2).trim(); + if (clauses.length === 0) { + clauses.push({ text: bullet, bullets: [] }); + } else { + clauses[clauses.length - 1].bullets.push(bullet); + } + } else { + clauses.push({ text: line, bullets: [] }); + } + } + + if (clauses.length === 1 && clauses[0].bullets.length === 0) { + return { paragraph: clauses[0].text, clauses: [] }; + } + return { clauses }; +} + +/** + * Interpolate Handlebars placeholders ({{client.companyName}}, {{contractDate}}, + * …) inside admin-authored template text against the contract view model. + * Malformed placeholders must never break document generation — fall back to + * the raw text. + */ +export function interpolateTemplateText(text: string, context: unknown): string { + if (!text || !text.includes('{{')) return text ?? ''; + try { + return Handlebars.compile(text)(context); + } catch { + return text; + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index 05061ed9a..559415fd8 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -8,6 +8,7 @@ import { ContractSignerRole, } from '../modules/contracts/entities/contract-signature.entity'; import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing.service'; +import { ContractTemplatesService } from '../modules/contract-templates/contract-templates.service'; import { ContractTemplateResolver } from './contract-template.resolver'; import { getTemplateMeta } from './contract-template.registry'; import { ContractViewModel } from './contract-view-model.builder'; @@ -74,6 +75,7 @@ export class ContractDocumentViewModelBuilder { constructor( private readonly contractsRepository: ContractsRepository, private readonly templateResolver: ContractTemplateResolver, + private readonly contractTemplates: ContractTemplatesService, ) {} async build( @@ -86,7 +88,32 @@ export class ContractDocumentViewModelBuilder { const templateKey = contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract)); - const template = getTemplateMeta(templateKey); + let template = getTemplateMeta(templateKey); + + // Prefer the admin-editable DB template matching the contract's + // direction/freight pair; fall back to the code-defined generic layout + // when none is active. + const dynamicSource = await this.contractTemplates.findActiveForContract( + contract.tradeDirection, + contract.freightType, + ); + const dynamicTemplate = dynamicSource + ? { + code: dynamicSource.code, + name: dynamicSource.name, + documentTitle: dynamicSource.documentTitle, + whereasClauses: dynamicSource.whereasClauses ?? [], + articles: dynamicSource.articles ?? [], + } + : undefined; + if (dynamicTemplate) { + template = { + ...template, + title: dynamicTemplate.name, + templateFile: 'edr-dynamic.hbs', + }; + } + const pricing = this.buildPricing(contract); const signatures = await this.loadSignatures(contractId); @@ -139,6 +166,7 @@ export class ContractDocumentViewModelBuilder { hasContractDocument: hasContractFile, hasCustomerSignature: hasCustomer, hasStaffSignature: hasStaff, + dynamicTemplate, }; return { contract, view }; diff --git a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts new file mode 100644 index 000000000..032030243 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts @@ -0,0 +1,151 @@ +import { parseArticleBody, interpolateTemplateText } from './contract-article.util'; +import { ContractRendererService } from './contract-renderer.service'; +import { getTemplateMeta } from './contract-template.registry'; +import type { ContractViewModel } from './contract-view-model.builder'; + +describe('parseArticleBody', () => { + it('numbers each non-empty line as a clause', () => { + const parsed = parseArticleBody('First clause.\nSecond clause.\n\nThird clause.'); + expect(parsed.paragraph).toBeUndefined(); + expect(parsed.clauses.map((c) => c.text)).toEqual([ + 'First clause.', + 'Second clause.', + 'Third clause.', + ]); + }); + + it('nests "- " lines as bullets under the previous clause', () => { + const parsed = parseArticleBody('Rates are:\n- USD 10 per ton\n- USD 20 per wagon\nPayment in advance.'); + expect(parsed.clauses).toHaveLength(2); + expect(parsed.clauses[0].bullets).toEqual(['USD 10 per ton', 'USD 20 per wagon']); + expect(parsed.clauses[1].text).toBe('Payment in advance.'); + }); + + it('renders a single bare line as a paragraph', () => { + const parsed = parseArticleBody('This Agreement becomes effective when signed.'); + expect(parsed.paragraph).toBe('This Agreement becomes effective when signed.'); + expect(parsed.clauses).toEqual([]); + }); +}); + +describe('interpolateTemplateText', () => { + it('fills placeholders from the view model', () => { + expect( + interpolateTemplateText('Valid until August 31, {{contractYear}}.', { + contractYear: 2026, + }), + ).toBe('Valid until August 31, 2026.'); + }); + + it('falls back to raw text on malformed placeholders', () => { + expect(interpolateTemplateText('Broken {{#if}} tag', {})).toBe('Broken {{#if}} tag'); + }); +}); + +describe('dynamic template rendering (edr-dynamic.hbs)', () => { + const renderer = new ContractRendererService(); + renderer.onModuleInit(); + + function dynamicView(): ContractViewModel { + const meta = getTemplateMeta('IMP_BULK_USD_FORWARDING'); + return { + bookingId: 'test-id', + reference: 'EDR/CT/2026/0042', + status: 'CONTRACT_READY', + templateKey: 'IMP_BULK_USD_FORWARDING', + template: { ...meta, title: 'Bulk Import Contract', templateFile: 'edr-dynamic.hbs' }, + contractDate: '1 January 2026', + contractYear: 2026, + client: { + companyName: 'Abyssinia Trading PLC', + companyAddress: 'Bole Sub-city, Addis Ababa', + companyLocation: 'Ethiopia', + phone: '+251900000000', + email: 'test@example.com', + tinNumber: '1234567890', + vatNumber: 'VAT-001', + fanNumber: 'FAN-001', + businessLicense: 'BL-001', + }, + provider: { + name: 'Ethio-Djibouti Standard Gauge Railway Share Company', + address: 'Nifas Silk Lafto Sub City, Addis Ababa, Ethiopia', + phone: '+251 11 872 0000', + email: 'info@edr.gov.et', + tinNumber: '—', + }, + schedule: { + originLabel: 'Nagad', + destinationLabel: 'Galaan Multipurpose Port', + tradeDirection: 'IMPORT', + freightType: 'BULK', + serviceType: 'Rail + clearance', + scheduledDate: '—', + contractType: 'GENERAL', + cargoDescription: 'Steel billets', + totalWeightVgm: '—', + equipmentReturn: '—', + hazardousLabel: 'No', + firstMilePickupAddress: '—', + lastMileDeliveryAddress: '—', + }, + pricing: { + displayMode: 'UNIT_RATES', + unitRates: [ + { label: 'Rail transport', unitPrice: 59.4, unit: 'ton', currency: 'USD' }, + ], + currency: 'USD', + equipmentReturn: '—', + originLabel: 'Nagad', + destinationLabel: 'Galaan Multipurpose Port', + } as unknown as ContractViewModel['pricing'], + signatures: [], + canSignCustomer: false, + canSignStaff: false, + hasContractDocument: false, + hasCustomerSignature: false, + hasStaffSignature: false, + dynamicTemplate: { + code: 'IMPORT_BULK', + name: 'Bulk Import Contract', + documentTitle: 'Bulk Cargo Transportation and Customs Clearance Services', + whereasClauses: ['The Client has agreed to engage the Service Provider.'], + articles: [ + { + id: 'objective', + title: 'Objective of the Services', + body: 'Integrated logistics services including:\n- Rail transport to GMP\n- Customs clearance', + order: 1, + }, + { + id: 'duration', + title: 'Duration', + body: 'Valid until August 31, {{contractYear}}.', + order: 2, + }, + ], + }, + }; + } + + it('renders numbered dynamic articles with bullets and interpolation', () => { + const html = renderer.render(dynamicView()); + expect(html).toContain('Bulk Cargo Transportation and Customs Clearance Services'); + expect(html).toContain('Article 1'); + expect(html).toContain('Objective of the Services'); + expect(html).toContain('Rail transport to GMP'); + expect(html).toContain('Valid until August 31, 2026.'); + expect(html).toContain('Abyssinia Trading PLC'); + expect(html).toContain('Annex A — Commercial Schedule'); + // Greenish theme marker from styles.hbs + expect(html).toContain('#1b9e7a'); + }); + + it('keeps the generic layout when no dynamic template is attached', () => { + const view = dynamicView(); + delete view.dynamicTemplate; + view.template = getTemplateMeta('IMP_BULK_USD_FORWARDING'); + const html = renderer.render(view); + expect(html).toContain('Article 5: Contract Price'); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts index dd539df25..7b3301c87 100644 --- a/apps/edr-freight-api/src/contracts/contract-renderer.service.ts +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts @@ -3,6 +3,11 @@ import * as fs from 'fs'; import * as path from 'path'; import Handlebars from 'handlebars'; +import { + interpolateTemplateText, + parseArticleBody, + RenderedArticle, +} from './contract-article.util'; import { ContractViewModel } from './contract-view-model.builder'; @Injectable() @@ -31,9 +36,40 @@ export class ContractRendererService implements OnModuleInit { return template({ ...view, paymentArticle: view.pricing.currency === 'ETB' ? 'ETB' : 'USD', + ...this.buildDynamicSections(view), }); } + /** + * Turn the DB-backed dynamic template (when present) into render-ready data: + * interpolate placeholders against the view model, then parse each article + * body into numbered clauses with nested bullets. + */ + private buildDynamicSections(view: ContractViewModel): { + dynamicDocumentTitle?: string; + dynamicWhereas?: string[]; + dynamicArticles?: RenderedArticle[]; + } { + const dyn = view.dynamicTemplate; + if (!dyn || dyn.articles.length === 0) return {}; + + const articles = [...dyn.articles] + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map((article, index) => ({ + number: index + 1, + title: interpolateTemplateText(article.title, view), + ...parseArticleBody(interpolateTemplateText(article.body, view)), + })); + + return { + dynamicDocumentTitle: interpolateTemplateText(dyn.documentTitle, view), + dynamicWhereas: dyn.whereasClauses.map((clause) => + interpolateTemplateText(clause, view), + ), + dynamicArticles: articles, + }; + } + private getCompiled(fileName: string): Handlebars.TemplateDelegate { const cached = this.compiled.get(fileName); if (cached) return cached; diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index 41a2f3b44..d90b8e709 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -17,6 +17,21 @@ export interface ContractSignatureView { signatureImageUrl?: string | null; } +/** + * DB-backed contract template (freight.contract_templates) attached to the + * view model when an active template matches the contract's direction/freight + * pair. The renderer turns its articles into numbered clauses and switches to + * the dedicated edr-dynamic.hbs layout; absent, the legacy generic layout with + * code-defined clause packs is used. + */ +export interface ContractDynamicTemplateView { + code: string; + name: string; + documentTitle: string; + whereasClauses: string[]; + articles: Array<{ id: string; title: string; body: string; order: number }>; +} + export interface ContractViewModel { bookingId: string; reference: string; @@ -65,6 +80,7 @@ export interface ContractViewModel { hasContractDocument: boolean; hasCustomerSignature: boolean; hasStaffSignature: boolean; + dynamicTemplate?: ContractDynamicTemplateView; } @Injectable() diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs new file mode 100644 index 000000000..717fbde8f --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs @@ -0,0 +1,23 @@ +{{#each dynamicArticles}} +
+

Article {{number}}{{title}}

+ {{#if paragraph}} +

{{paragraph}}

+ {{else}} +
    + {{#each clauses}} +
  1. + {{text}} + {{#if bullets.length}} +
      + {{#each bullets}} +
    • {{this}}
    • + {{/each}} +
    + {{/if}} +
  2. + {{/each}} +
+ {{/if}} +
+{{/each}} diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs index 43a5495ec..0207ff9fb 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs @@ -4,11 +4,11 @@ body { margin: 0; - background: #f5f7fb; - color: #111827; + background: #f3f8f5; + color: #16241d; font-family: "Times New Roman", Times, serif; font-size: 10.5pt; - line-height: 1.48; + line-height: 1.5; } .contract { @@ -21,25 +21,28 @@ h1, h2, h3, p { margin-top: 0; } h1 { - color: #0f2742; - font-size: 18pt; - line-height: 1.25; + color: #0a3d2e; + font-size: 17pt; + letter-spacing: 0.02em; + line-height: 1.3; margin-bottom: 10px; text-align: center; text-transform: uppercase; } h2 { - border-bottom: 1.5px solid #1e3a5f; - color: #1e3a5f; - font-size: 12pt; - letter-spacing: 0.03em; - margin: 18px 0 10px; + border-bottom: 1.5px solid #1b9e7a; + color: #0e5b45; + font-family: Arial, sans-serif; + font-size: 11.5pt; + letter-spacing: 0.04em; + margin: 20px 0 10px; padding-bottom: 5px; text-transform: uppercase; } h3 { - color: #0f2742; - font-size: 10.8pt; + color: #0a3d2e; + font-family: Arial, sans-serif; + font-size: 10.5pt; margin: 12px 0 6px; } p { margin-bottom: 8px; } @@ -52,16 +55,17 @@ page-break-inside: avoid; } + /* ── Brand header ─────────────────────────────────────────────────────── */ .brand-row { align-items: center; - border-bottom: 3px solid #1e3a5f; + border-bottom: 3px double #1b9e7a; display: flex; gap: 14px; padding-bottom: 14px; } .logo-mark { align-items: center; - background: #1e3a5f; + background: linear-gradient(135deg, #0e5b45 0%, #1b9e7a 100%); border-radius: 8px; color: #fff; display: flex; @@ -74,7 +78,7 @@ width: 72px; } .kicker { - color: #1e3a5f; + color: #0e5b45; font-family: Arial, sans-serif; font-size: 10pt; font-weight: 700; @@ -83,36 +87,74 @@ text-transform: uppercase; } .muted { - color: #6b7280; + color: #5c6f66; font-family: Arial, sans-serif; font-size: 9pt; margin: 0; } + .muted-note { + color: #5c6f66; + font-size: 9.5pt; + } + /* ── Cover page ───────────────────────────────────────────────────────── */ .cover { + display: flex; + flex-direction: column; min-height: 255mm; position: relative; } .cover-title { - margin: 54mm 0 34mm; + margin: 34mm 0 22mm; text-align: center; } + .cover-rule { + background: #1b9e7a; + height: 2px; + margin: 14px auto; + width: 46mm; + } .document-label { - color: #6b7280; + color: #1b9e7a; font-family: Arial, sans-serif; - font-size: 10pt; + font-size: 11pt; font-weight: 700; - letter-spacing: 0.12em; - margin-bottom: 10px; + letter-spacing: 0.18em; + margin-bottom: 6px; text-transform: uppercase; } + .cover-for, + .cover-between { + color: #5c6f66; + font-family: Arial, sans-serif; + font-size: 9.5pt; + font-style: italic; + margin: 10px 0 6px; + } + .cover-party { + color: #0a3d2e; + font-family: Arial, sans-serif; + font-size: 12pt; + font-weight: 700; + margin: 4px 0; + } .summary-line { - color: #374151; + color: #38493f; font-family: Arial, sans-serif; font-size: 9.5pt; margin-top: 12px; } + .cover-year { + color: #0e5b45; + font-family: Arial, sans-serif; + font-size: 13pt; + font-weight: 700; + letter-spacing: 0.1em; + margin-top: auto; + text-align: right; + } + /* ── Tables ───────────────────────────────────────────────────────────── */ table { border-collapse: collapse; width: 100%; @@ -129,7 +171,7 @@ .details-table td, .schedule th, .schedule td { - border: 1px solid #cbd5e1; + border: 1px solid #c9e4d9; padding: 7px 8px; text-align: left; vertical-align: top; @@ -137,35 +179,46 @@ .meta-grid th, .details-table th, .schedule th { - background: #eef4fb; - color: #1e3a5f; + background: #e9f6f0; + color: #0e5b45; font-family: Arial, sans-serif; font-size: 8.5pt; text-transform: uppercase; } - .schedule tbody tr:nth-child(even) td { background: #f8fafc; } + .schedule tbody tr:nth-child(even) td { background: #f5faf8; } .total-row td { - background: #e8f0f8 !important; - color: #0f2742; + background: #ddf2e9 !important; + color: #0a3d2e; font-weight: 700; } + /* ── Parties ──────────────────────────────────────────────────────────── */ .lead { - color: #374151; + color: #38493f; font-size: 10.5pt; } + .between-label { + color: #0e5b45; + font-family: Arial, sans-serif; + font-size: 9.5pt; + font-weight: 700; + letter-spacing: 0.08em; + margin: 10px 0 4px; + text-transform: uppercase; + } .party-grid { display: grid; gap: 12px; grid-template-columns: 1fr 1fr; + margin-top: 12px; } .party-card { - border: 1px solid #cbd5e1; + border: 1px solid #c9e4d9; border-radius: 8px; padding: 12px; } .party-card h3 { - background: #1e3a5f; + background: #0e5b45; border-radius: 5px; color: #fff; font-family: Arial, sans-serif; @@ -175,7 +228,7 @@ text-transform: uppercase; } .party-name { - color: #0f2742; + color: #0a3d2e; font-weight: 700; margin-bottom: 8px; } @@ -185,7 +238,7 @@ margin: 0; } dt { - color: #475569; + color: #47594f; font-family: Arial, sans-serif; font-size: 8.5pt; font-weight: 700; @@ -196,6 +249,81 @@ padding: 2px 0; } + /* ── Recitals ─────────────────────────────────────────────────────────── */ + .whereas-label { + color: #0e5b45; + font-family: Arial, sans-serif; + font-size: 9pt; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + } + .now-therefore { + color: #0a3d2e; + font-weight: 700; + margin-top: 10px; + } + + /* ── Dynamic articles ─────────────────────────────────────────────────── */ + .article-heading { + align-items: baseline; + display: flex; + gap: 10px; + } + .article-no { + color: #1b9e7a; + font-family: Arial, sans-serif; + font-size: 9.5pt; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; + } + .article-name { color: #0e5b45; } + .article-paragraph { margin: 4px 0 0; } + ol.clauses { + counter-reset: clause; + list-style: none; + margin: 6px 0 0; + padding-left: 0; + } + ol.clauses > li { + counter-increment: clause; + margin-bottom: 6px; + padding-left: 24px; + position: relative; + text-align: justify; + } + ol.clauses > li::before { + color: #0e5b45; + content: counter(clause) "."; + font-family: Arial, sans-serif; + font-size: 9.5pt; + font-weight: 700; + left: 0; + position: absolute; + top: 1px; + } + ul.clause-bullets { + margin: 5px 0 2px; + padding-left: 16px; + } + ul.clause-bullets > li { + list-style: none; + margin-bottom: 3px; + padding-left: 12px; + position: relative; + } + ul.clause-bullets > li::before { + color: #1b9e7a; + content: "▪"; + font-size: 8pt; + left: 0; + position: absolute; + top: 1px; + } + + /* ── Signatures ───────────────────────────────────────────────────────── */ .signatures { display: grid; gap: 18px; @@ -204,13 +332,13 @@ page-break-inside: avoid; } .sig-block { - border: 1.5px solid #1e3a5f; + border: 1.5px solid #1b9e7a; border-radius: 8px; min-height: 96mm; padding: 12px; } .sig-title { - color: #1e3a5f; + color: #0e5b45; font-family: Arial, sans-serif; font-size: 9pt; font-weight: 700; @@ -219,7 +347,7 @@ } .sig-image-box { align-items: center; - border: 1px dashed #94a3b8; + border: 1px dashed #7fbfa9; display: flex; height: 28mm; justify-content: center; @@ -231,21 +359,40 @@ max-width: 70mm; } .sig-placeholder { - color: #94a3b8; + color: #7fbfa9; font-family: Arial, sans-serif; font-size: 8.5pt; } .sig-line { - border-top: 1px solid #111827; + border-top: 1px solid #16241d; margin-top: 16px; padding-top: 5px; } .sig-meta { - color: #475569; + color: #47594f; font-size: 9pt; margin: 4px 0; } + /* ── Witnesses ────────────────────────────────────────────────────────── */ + .witnesses { margin-top: 20px; } + .witness-table { + font-size: 9.5pt; + margin-top: 6px; + } + .witness-table th, + .witness-table td { + border-bottom: 1px solid #c9e4d9; + padding: 9px 8px; + text-align: left; + } + .witness-table th { + color: #0e5b45; + font-family: Arial, sans-serif; + font-size: 8.5pt; + text-transform: uppercase; + } + @media print { body { background: #fff; } .contract { diff --git a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs new file mode 100644 index 000000000..4ba35ea3b --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs @@ -0,0 +1,184 @@ + + + + + {{dynamicDocumentTitle}} — {{reference}} + {{> styles}} + + +
+ + {{!-- ─────────────────────────── Cover page ─────────────────────────── --}} +
+
+
EDR
+
+

Ethio-Djibouti Standard Gauge Railway Share Company

+

Freight Transport Services

+
+
+ +
+

Contract Agreement

+
+

for

+

{{dynamicDocumentTitle}}

+

between

+

Ethio-Djibouti Standard Gauge Railway Share Company

+

and

+

{{client.companyName}}

+
+
+ +
+ + + + + + + + + + + + +
Contract Ref No.{{reference}}Contract Date{{contractDate}}
Trade Direction{{schedule.tradeDirection}}Freight Type{{schedule.freightType}}
+ +

{{contractYear}}

+ + + {{!-- ──────────────────────────── Preamble ──────────────────────────── --}} +
+

Parties to the Agreement

+

+ This Contract Agreement is made on {{contractDate}}. +

+

Between

+

+ Ethio-Djibouti Standard Gauge Railway Share Company (EDR), a share company + incorporated under the laws of the Federal Democratic Republic of Ethiopia (FDRE), having its + principal place of business at {{provider.address}} (hereinafter referred to as the + "Service Provider"); +

+

And

+

+ {{client.companyName}}, an organization incorporated under the laws of the + Federal Democratic Republic of Ethiopia (FDRE), having its principal place of business at + {{client.companyAddress}} (hereinafter referred to as the "Client"). +

+ +
+
+

Service Provider

+

{{provider.name}}

+
+
Address
{{provider.address}}
+
Phone
{{provider.phone}}
+
Email
{{provider.email}}
+
TIN
{{provider.tinNumber}}
+
+
+
+

Client

+

{{client.companyName}}

+
+
Address
{{client.companyAddress}}
+
Location
{{client.companyLocation}}
+
Phone
{{client.phone}}
+
Email
{{client.email}}
+
TIN
{{client.tinNumber}}
+
VAT
{{client.vatNumber}}
+
Business license
{{client.businessLicense}}
+
+
+
+
+ + {{#if dynamicWhereas.length}} +
+

Recitals

+ {{#each dynamicWhereas}} +

Whereas {{this}}

+ {{/each}} +

Now, therefore, the parties agree as follows:

+
+ {{/if}} + + {{!-- ──────────────────────── Dynamic articles ──────────────────────── --}} + {{> dynamic_articles}} + + {{!-- ─────────────────── Commercial schedule (annex) ─────────────────── --}} +
+

Annex A — Commercial Schedule

+ + + + + + + + + + + + + + + + + + + + + +
Route{{schedule.originLabel}} → {{schedule.destinationLabel}}Service type{{schedule.serviceType}}
Cargo{{schedule.cargoDescription}}Hazardous cargo{{schedule.hazardousLabel}}
Equipment return{{schedule.equipmentReturn}}Payment currency{{paymentArticle}}
+ + {{#if pricing.unitRates.length}} +

Agreed Unit Rates

+

+ The rates below are the frozen unit prices applicable to this contract. Quantities and resulting + totals are determined per shipment at booking time. +

+ + + + + + {{#each pricing.unitRates}} + + + + + {{/each}} + +
ItemUnit price
{{label}}{{currency}} {{unitPrice}} / {{unit}}
+ {{/if}} +
+ + {{!-- ────────────────────────── Signatures ───────────────────────────── --}} +
+

Execution

+

+ In witness whereof, the parties hereto have caused this contract to be signed in their respective + names as of the day and year first above written. The signatories confirm that they are fully + authorized to sign and execute this Contract Agreement. +

+ {{> signatures_block}} + +
+

Witnesses

+ + + + + + + + +
NameSignatureDate
1.
2.
+
+
+ + + diff --git a/apps/edr-freight-api/src/migrations/2040000000000-AddLocomotiveOverageTolerance.ts b/apps/edr-freight-api/src/migrations/2040000000000-AddLocomotiveOverageTolerance.ts new file mode 100644 index 000000000..4bda8dde1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2040000000000-AddLocomotiveOverageTolerance.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds locomotives.overage_tolerance_tons / overage_tolerance_meters: an + * optional per-locomotive deviation allowance above max_pull_weight_tons / + * max_train_length_meters. Nullable, defaults to no tolerance so existing + * strict-cap behavior is unchanged until staff sets a value. + */ +export class AddLocomotiveOverageTolerance2040000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.locomotives + ADD COLUMN IF NOT EXISTS overage_tolerance_tons NUMERIC(10, 3), + ADD COLUMN IF NOT EXISTS overage_tolerance_meters NUMERIC(10, 3); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.locomotives + DROP COLUMN IF EXISTS overage_tolerance_tons, + DROP COLUMN IF EXISTS overage_tolerance_meters; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2050000000000-AddCustomerTruckContainerLoadedAt.ts b/apps/edr-freight-api/src/migrations/2050000000000-AddCustomerTruckContainerLoadedAt.ts new file mode 100644 index 000000000..332df2423 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2050000000000-AddCustomerTruckContainerLoadedAt.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds customer_truck_containers.loaded_at so an assignment (customer planning + * which containers ride which truck) is distinct from the container actually + * being loaded. Stage LOADED now requires loaded_at; customer assignment alone + * keeps the container at its prior stage (RECEIVED/GRN) with its planned truck + * shown. Backfills containers on already-departed trucks (they left loaded). + */ +export class AddCustomerTruckContainerLoadedAt2050000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_containers + ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ; + `); + + await queryRunner.query(` + UPDATE freight.customer_truck_containers ctc + SET loaded_at = a.departed_at + FROM freight.customer_truck_assignments a + WHERE a.id = ctc.assignment_id + AND a.departed_at IS NOT NULL + AND ctc.deleted_at IS NULL + AND ctc.loaded_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_containers DROP COLUMN IF EXISTS loaded_at; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2050000000000-DropWagonTypeMaxWagonsPerTrain.ts b/apps/edr-freight-api/src/migrations/2050000000000-DropWagonTypeMaxWagonsPerTrain.ts new file mode 100644 index 000000000..927504ee6 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2050000000000-DropWagonTypeMaxWagonsPerTrain.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Drops wagon_types.max_wagons_per_train. Train wagon-count caps are already + * derived from locomotive + wagon length/weight (train-capacity.util.ts) and + * the global train_scheduling_global_rules row — this per-wagon-type override + * was unused by that derivation and only added a confusing "Max / train" + * field to the wagon type form. + */ +export class DropWagonTypeMaxWagonsPerTrain2050000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_types + DROP COLUMN IF EXISTS max_wagons_per_train; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_types + ADD COLUMN IF NOT EXISTS max_wagons_per_train INT; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2060000000000-SeedRailWagonTypes.ts b/apps/edr-freight-api/src/migrations/2060000000000-SeedRailWagonTypes.ts new file mode 100644 index 000000000..ed9e8fae2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2060000000000-SeedRailWagonTypes.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Upserts the 10 real EDR wagon types (code, name, capacity, length, tare + * weight) by code. Overwrites any existing row with the same code so + * previously-seeded demo values (e.g. NW5/PW2/CW3 from demo-bookings.seeder) + * are replaced with the real spec. + */ +export class SeedRailWagonTypes2060000000000 implements MigrationInterface { + private readonly wagonTypes = [ + { code: 'NW7', name: 'Double deck sedan wagon', capacityTons: 22, lengthMeters: 26.066, tareWeightTons: 37.1 }, + { code: 'NW5', name: 'Flat wagon', capacityTons: 70, lengthMeters: 13.966, tareWeightTons: 22.4 }, + { code: 'PW2', name: 'Box wagon', capacityTons: 70, lengthMeters: 17.066, tareWeightTons: 25.2 }, + { code: 'GW2', name: 'Tank wagon', capacityTons: 70, lengthMeters: 12.228, tareWeightTons: 23 }, + { code: 'CW4', name: 'Gondola covered wagon', capacityTons: 70, lengthMeters: 13.976, tareWeightTons: 24.8 }, + { code: 'CW3', name: 'Gondola open wagon', capacityTons: 70, lengthMeters: 13.976, tareWeightTons: 23.4 }, + { code: 'KW2', name: 'Hopper covered wagon', capacityTons: 69, lengthMeters: 16.466, tareWeightTons: 25.2 }, + { code: 'KW3', name: 'Hopper wagon open', capacityTons: 70, lengthMeters: 14.4, tareWeightTons: 24 }, + { code: 'NW6', name: 'Flat wagon (long)', capacityTons: 70, lengthMeters: 18.56, tareWeightTons: 25.3 }, + { code: 'BW1', name: 'Refrigerated wagon', capacityTons: 38, lengthMeters: 21.996, tareWeightTons: 32.1 }, + ]; + + public async up(queryRunner: QueryRunner): Promise { + for (const wt of this.wagonTypes) { + await queryRunner.query( + ` + INSERT INTO freight.wagon_types (code, name, capacity_tons, length_meters, tare_weight_tons, is_active) + VALUES ($1, $2, $3, $4, $5, true) + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + capacity_tons = EXCLUDED.capacity_tons, + length_meters = EXCLUDED.length_meters, + tare_weight_tons = EXCLUDED.tare_weight_tons; + `, + [wt.code, wt.name, wt.capacityTons, wt.lengthMeters, wt.tareWeightTons], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM freight.wagon_types WHERE code = ANY($1);`, + [this.wagonTypes.map((wt) => wt.code)], + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2070000000000-MakeWagonTypeTareWeightRequired.ts b/apps/edr-freight-api/src/migrations/2070000000000-MakeWagonTypeTareWeightRequired.ts new file mode 100644 index 000000000..ff85f3f24 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2070000000000-MakeWagonTypeTareWeightRequired.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Tare weight becomes mandatory on a wagon type. + * + * The locomotive's pull limit is a GROSS limit — it drags the wagon as well as + * the cargo — so capacity math cannot run without a tare. A NULL tare silently + * read as zero and let trains overbook by the tare fraction (~27% on a PW2 + * consist), so the column is now NOT NULL. + * + * Any row still missing a tare predates 2060000000000-SeedRailWagonTypes (which + * upserts the ten real EDR types). Backfill those by code first, and give any + * remaining custom/demo type the NW5 flat-wagon tare rather than fail the + * migration — a wrong-but-plausible tare is recoverable in the admin UI; a + * blocked deploy is not. + */ +export class MakeWagonTypeTareWeightRequired2070000000000 implements MigrationInterface { + private readonly tareByCode: Array<[string, number]> = [ + ['NW7', 37.1], + ['NW5', 22.4], + ['PW2', 25.2], + ['GW2', 23], + ['CW4', 24.8], + ['CW3', 23.4], + ['KW2', 25.2], + ['KW3', 24], + ['NW6', 25.3], + ['BW1', 32.1], + ]; + + /** NW5 flat wagon — the commonest type in the fleet (550 of 1100). */ + private readonly fallbackTareTons = 22.4; + + public async up(queryRunner: QueryRunner): Promise { + for (const [code, tareWeightTons] of this.tareByCode) { + await queryRunner.query( + `UPDATE freight.wagon_types + SET tare_weight_tons = $2 + WHERE code = $1 AND tare_weight_tons IS NULL;`, + [code, tareWeightTons], + ); + } + + await queryRunner.query( + `UPDATE freight.wagon_types + SET tare_weight_tons = $1 + WHERE tare_weight_tons IS NULL;`, + [this.fallbackTareTons], + ); + + await queryRunner.query( + `ALTER TABLE freight.wagon_types + ALTER COLUMN tare_weight_tons SET NOT NULL;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.wagon_types + ALTER COLUMN tare_weight_tons DROP NOT NULL;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2080000000000-DropWagonSpecColumns.ts b/apps/edr-freight-api/src/migrations/2080000000000-DropWagonSpecColumns.ts new file mode 100644 index 000000000..0fe3569f4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2080000000000-DropWagonSpecColumns.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Wagon spec belongs to the wagon TYPE, not to each physical wagon. + * + * `wagons.tare_weight` and `wagons.max_payload_weight` duplicated + * `wagon_types.tare_weight_tons` / `wagon_types.capacity_tons` on all 1100 rows, + * with nothing keeping them in step. They had drifted completely: every wagon + * disagreed with its type's tare (seeded ~20T against a real 22.4T NW5), and a + * third disagreed on payload (NW5 wagons claiming 22T–70T against a flat 70T). + * None of those numbers came from the railway. + * + * Nothing reads them for capacity — that math resolves tare and capacity through + * `wagon_type_id` — so dropping them removes a source of fiction rather than a + * source of truth. `wagon_type_id` is NOT NULL with no orphans, so the type is + * always reachable. + * + * A wagon re-tared after repair would need a nullable override column on + * `wagons` falling back to the type; deliberately not added, since no such + * per-wagon value exists today. + */ +export class DropWagonSpecColumns2080000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS tare_weight, + DROP COLUMN IF EXISTS max_payload_weight; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Re-add nullable, backfill from the owning type, then restore NOT NULL. + // The pre-drop values were drifted seed data and are not recoverable — the + // type's spec is what they should always have held. + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS tare_weight NUMERIC(10, 2), + ADD COLUMN IF NOT EXISTS max_payload_weight NUMERIC(10, 2); + `); + await queryRunner.query(` + UPDATE freight.wagons w + SET tare_weight = t.tare_weight_tons, + max_payload_weight = t.capacity_tons + FROM freight.wagon_types t + WHERE t.id = w.wagon_type_id; + `); + await queryRunner.query(` + ALTER TABLE freight.wagons + ALTER COLUMN tare_weight SET NOT NULL, + ALTER COLUMN max_payload_weight SET NOT NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2090000000000-CreateContractTemplates.ts b/apps/edr-freight-api/src/migrations/2090000000000-CreateContractTemplates.ts new file mode 100644 index 000000000..a553319cc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2090000000000-CreateContractTemplates.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults'; + +/** + * Creates freight.contract_templates — the six editable contract document + * templates (direction × freight type) whose dynamic articles drive the + * generated contract PDF — and seeds them from the EDR reference contract + * documents. Seeding is idempotent (ON CONFLICT (code) DO NOTHING) so admin + * edits are never overwritten by redeploys. + */ +export class CreateContractTemplates2090000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.contract_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(40) NOT NULL, + name VARCHAR(200) NOT NULL, + description TEXT, + document_title VARCHAR(300) NOT NULL, + whereas_clauses JSONB NOT NULL DEFAULT '[]', + articles JSONB NOT NULL DEFAULT '[]', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uq_contract_templates_code UNIQUE (code) + ); + `); + + for (const seed of CONTRACT_TEMPLATE_DEFAULTS) { + const articles = seed.articles.map((article, index) => ({ + ...article, + order: index + 1, + })); + await queryRunner.query( + ` + INSERT INTO freight.contract_templates + (code, name, description, document_title, whereas_clauses, articles) + VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb) + ON CONFLICT (code) DO NOTHING; + `, + [ + seed.code, + seed.name, + seed.description, + seed.documentTitle, + JSON.stringify(seed.whereasClauses), + JSON.stringify(articles), + ], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_templates;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2090000000000-RepairGrnNumberColumn.ts b/apps/edr-freight-api/src/migrations/2090000000000-RepairGrnNumberColumn.ts new file mode 100644 index 000000000..aa2f998a2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2090000000000-RepairGrnNumberColumn.ts @@ -0,0 +1,55 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Repairs `freight.warehouse_inventory.grn_number`. + * + * AddGrnNumberToWarehouseInventory1828000000000 is recorded in `migrations` but + * the column is absent on at least one environment - it was added, then dropped + * out-of-band (a stray `synchronize: true`, same class of damage that + * RepairSynchronizeDrift1870000000000 already had to undo). Because TypeORM has + * the original recorded, it will never re-run it. + * + * Without the column, everything that reads or writes a GRN fails with + * `column ... grn_number does not exist`: + * - bulkReceive() -> INSERT names grn_number (receive to warehouse) + * - importQueueByStatuses() -> Unloaded + Dispatch queues + * - exportInventoryByStatus() -> Received / Ready-To-Load / Loaded tabs + * - grnDocument() -> GRN PDF + * + * Idempotent: a no-op on environments where the column survived. + */ +export class RepairGrnNumberColumn2090000000000 implements MigrationInterface { + name = 'RepairGrnNumberColumn2090000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + ADD COLUMN IF NOT EXISTS grn_number VARCHAR(100) NULL + `); + + // Recover the GRN for rows received before the column existed: it was also + // written into the receive note as "GRN Number: ". + await queryRunner.query(` + UPDATE freight.warehouse_inventory + SET grn_number = substring(notes FROM 'GRN Number: ([^\\n\\r]+)') + WHERE grn_number IS NULL + AND notes IS NOT NULL + AND notes ~ 'GRN Number: ' + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_grn_number + ON freight.warehouse_inventory(grn_number) + WHERE grn_number IS NOT NULL + `); + } + + /** + * Deliberately a no-op. Dropping the column is what broke these environments + * in the first place, and the original 1828 migration already owns its own + * down(). Reverting this repair must not re-introduce the outage. + */ + public async down(): Promise { + // intentionally empty + } +} diff --git a/apps/edr-freight-api/src/migrations/2100000000000-CompanyProfileDefaultPending.ts b/apps/edr-freight-api/src/migrations/2100000000000-CompanyProfileDefaultPending.ts new file mode 100644 index 000000000..aafa9715f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2100000000000-CompanyProfileDefaultPending.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * `company_profiles.status` defaulted to 'active', so any insert that omitted + * the column produced an operational role that was approved without ever being + * reviewed. Every live write path already passes 'pending' explicitly; this + * closes the hole at the schema level. + * + * Deliberately no data backfill. A role approved through setCompanyProfileStatus + * always stamps `reviewed_at`, so `status = 'active' AND reviewed_at IS NULL` + * flags a role that skipped review — but it also matches rows approved before + * `reviewed_at` existed (migration 2000000000001). Auditing that set is a + * judgement call about real customers, not something to automate here. + */ +export class CompanyProfileDefaultPending2100000000000 + implements MigrationInterface +{ + name = 'CompanyProfileDefaultPending2100000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."company_profiles" ALTER COLUMN "status" SET DEFAULT 'pending'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."company_profiles" ALTER COLUMN "status" SET DEFAULT 'active'`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2100000000000-WarehouseLoadingTrainAssociation.ts b/apps/edr-freight-api/src/migrations/2100000000000-WarehouseLoadingTrainAssociation.ts new file mode 100644 index 000000000..26afdf82a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2100000000000-WarehouseLoadingTrainAssociation.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Auto-load onto a selected train: a warehouse_loadings row now records WHICH + * train the item was loaded onto (train_schedule_id), and wagon_id becomes + * nullable because a schedule-level load may not resolve to a single wagon. + */ +export class WarehouseLoadingTrainAssociation2100000000000 implements MigrationInterface { + name = 'WarehouseLoadingTrainAssociation2100000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_loadings + ADD COLUMN IF NOT EXISTS train_schedule_id UUID NULL + `); + await queryRunner.query(` + ALTER TABLE freight.warehouse_loadings + ALTER COLUMN wagon_id DROP NOT NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_train_schedule + ON freight.warehouse_loadings(train_schedule_id) + WHERE train_schedule_id IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_loadings_train_schedule`); + await queryRunner.query(` + ALTER TABLE freight.warehouse_loadings DROP COLUMN IF EXISTS train_schedule_id + `); + // wagon_id stays nullable on revert: restoring NOT NULL would fail on rows + // recorded without a wagon and re-introduce the outage this fixes. + } +} diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts new file mode 100644 index 000000000..2bf9f82fd --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts @@ -0,0 +1,48 @@ +import { + Body, + Controller, + NotFoundException, + Param, + ParseUUIDPipe, + Post, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto"; +import { CustomerResetService } from "./customer-reset.service"; + +/** + * Staff-triggered password reset. The customer receives the code and sets their + * own password — staff never see or handle a credential. + */ +@ApiTags("backoffice") +@Controller("backoffice/customers") +@ApiBearerAuth() +export class CustomerResetController { + constructor(private readonly customerResetService: CustomerResetService) {} + + @Post(":companyId/reset-password") + @BookingStaff(FREIGHT_PERMS.customers.resetPassword) + @ApiOperation({ + summary: "Send a password-reset code to a customer's primary contact", + }) + async resetPassword( + @Param("companyId", ParseUUIDPipe) companyId: string, + @Body() dto: BackofficeResetPasswordDto, + ) { + const maskedTarget = await this.customerResetService.sendResetToCustomer( + companyId, + dto.channel, + ); + + if (!maskedTarget) { + throw new NotFoundException( + `No active primary contact with ${dto.channel === "email" ? "an email address" : "a phone number"} for this customer`, + ); + } + + return { channel: dto.channel, maskedTarget }; + } +} diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts new file mode 100644 index 000000000..4c8f67599 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts @@ -0,0 +1,59 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { ExternalProfile } from "../companies/entities/external-profile.entity"; +import { ResetChannel } from "./dto/forgot-password.dto"; +import { ForgotPasswordService } from "./forgot-password.service"; + +@Injectable() +export class CustomerResetService { + private readonly logger = new Logger(CustomerResetService.name); + + constructor( + @InjectRepository(ExternalProfile) + private readonly externalProfileRepository: Repository, + private readonly forgotPasswordService: ForgotPasswordService, + ) {} + + /** + * Send a reset code to the company's primary contact. Returns the masked + * destination, or null when there is no eligible account for that channel. + * + * Unlike the public flow this reports failure honestly — the caller is an + * authenticated staff member, so there is nothing to enumerate. + */ + async sendResetToCustomer( + companyId: string, + channel: ResetChannel, + ): Promise { + const profile = await this.externalProfileRepository.findOne({ + where: { companyId, isPrimaryContact: true }, + }); + + if (!profile) { + this.logger.warn(`Company ${companyId} has no primary contact profile`); + return null; + } + + // Resolve through the same active-account gate the public flow uses, so a + // suspended customer cannot be reactivated by a staff-triggered reset. + const user = await this.forgotPasswordService.resolveActiveUserById( + profile.userId, + ); + if (!user) { + this.logger.warn( + `Primary contact ${profile.userId} of company ${companyId} is not an active account`, + ); + return null; + } + + const target = await this.forgotPasswordService.requestReset(user, channel); + if (!target) return null; + + this.logger.log( + `Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`, + ); + return this.forgotPasswordService.maskTarget(target); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts b/apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts new file mode 100644 index 000000000..be2f9bdac --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/dto/forgot-password.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEnum, IsNotEmpty, IsString } from "class-validator"; + +/** The channel the reset code is delivered over. */ +export enum ResetChannel { + Email = "email", + Phone = "phone", +} + +export class ForgotPasswordRequestDto { + @ApiProperty({ + description: "Email, username, or phone number of the account to reset", + example: "name@company.com", + }) + @IsString() + @IsNotEmpty() + identifier!: string; + + @ApiProperty({ enum: ResetChannel }) + @IsEnum(ResetChannel) + channel!: ResetChannel; +} + +export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto { + @ApiProperty({ description: "The 6-digit code sent to the chosen channel" }) + @IsString() + @IsNotEmpty() + otp!: string; +} + +export class BackofficeResetPasswordDto { + @ApiProperty({ enum: ResetChannel }) + @IsEnum(ResetChannel) + channel!: ResetChannel; +} diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts new file mode 100644 index 000000000..da49982d2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.controller.ts @@ -0,0 +1,69 @@ +import { Body, Controller, Logger, Post } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Public } from "@edr/api-common"; + +import { + ForgotPasswordRequestDto, + ForgotPasswordVerifyDto, +} from "./dto/forgot-password.dto"; +import { ForgotPasswordService, ResetTicket } from "./forgot-password.service"; + +/** + * Freight-owned reset flow. IAM ships a `forgot-password` route, but it only + * ever SMSes a magic link (no email channel, and it needs `FE_BASE_URL`, which + * this API does not set). These routes drive freight's own email-or-phone OTP + * service instead, then hand back a ticket for IAM's public `set-password`. + */ +@ApiTags("auth") +@Controller("auth") +@Public() +export class ForgotPasswordController { + private readonly logger = new Logger(ForgotPasswordController.name); + + constructor(private readonly forgotPasswordService: ForgotPasswordService) {} + + @Post("forgot-password/request") + @ApiOperation({ + summary: "Send a password-reset code over email or SMS", + description: + "Always reports success. An unknown, inactive, or channel-less account is " + + "indistinguishable from a real one, so this cannot be used to enumerate accounts.", + }) + async request(@Body() dto: ForgotPasswordRequestDto): Promise<{ success: true }> { + const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier); + + if (user) { + try { + await this.forgotPasswordService.requestReset(user, dto.channel); + } catch (error) { + // A delivery failure must not change the response shape either — log it + // and let the caller sit on the OTP screen. + this.logger.error( + `Reset code delivery failed for user ${user.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + error instanceof Error ? error.stack : undefined, + ); + } + } else { + this.logger.log("Reset requested for an unknown or inactive account"); + } + + return { success: true }; + } + + @Post("forgot-password/verify") + @ApiOperation({ + summary: "Exchange a valid reset code for a single-use set-password ticket", + description: + "The returned { userId, verificationCode } is the body for PATCH /api/auth/set-password, " + + "alongside the same identifier and the new password.", + }) + verify(@Body() dto: ForgotPasswordVerifyDto): Promise { + return this.forgotPasswordService.verifyAndMintTicket( + dto.identifier, + dto.channel, + dto.otp, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts new file mode 100644 index 000000000..42dc723d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts @@ -0,0 +1,169 @@ +import { randomBytes } from "node:crypto"; + +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import { InjectDataSource, InjectRepository } from "@nestjs/typeorm"; +import { DataSource, Repository } from "typeorm"; + +import { hashPassword } from "@tria-plc/api-common/utils/argon"; +import { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user-verification.entity"; + +import { OtpService, OtpTarget } from "../otp/otp.service"; +import { ResetChannel } from "./dto/forgot-password.dto"; + +/** + * How long the reset ticket minted for `PATCH /api/auth/set-password` stays + * valid. The IAM `setPassword` handler enforces this via `expiresAt`. + */ +const RESET_TICKET_TTL_MS = 10 * 60 * 1000; + +/** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */ +const RESET_OTP_TTL_MS = 10 * 60 * 1000; + +export interface ResetTicket { + userId: string; + verificationCode: string; +} + +@Injectable() +export class ForgotPasswordService { + private readonly logger = new Logger(ForgotPasswordService.name); + + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly otpService: OtpService, + ) {} + + /** + * Resolve an account that is actually eligible for a password reset. + * + * IAM's `set-password` handler flips `isActive: true` on the user as a side + * effect, so a reset on a deactivated account would silently resurrect it. + * Gating here — rather than at the set-password call — is what keeps that + * from being reachable. Mirrors IAM's own login lookup: match on any of + * email / username / phone, and require an active credential row. + */ + async resolveActiveUser(identifier: string): Promise { + const id = identifier.trim(); + if (!id) return null; + + return await this.activeUserQuery() + .andWhere( + "(LOWER(u.email) = LOWER(:id) OR u.username = :id OR u.phoneNumber = :id)", + { id }, + ) + .getOne(); + } + + /** Same eligibility gate as {@link resolveActiveUser}, keyed by IAM user id. */ + async resolveActiveUserById(userId: string): Promise { + if (!userId) return null; + return await this.activeUserQuery() + .andWhere("u.id = :userId", { userId }) + .getOne(); + } + + /** + * Base query for accounts eligible to reset. `.where()` is claimed here so + * callers must use `.andWhere()` — TypeORM's `.where()` resets the clause, + * which would silently drop the `isActive` gate. + */ + private activeUserQuery() { + return this.userRepository + .createQueryBuilder("u") + .innerJoin("u.userCredentials", "uc", "uc.isActive = true") + .where("u.isActive = true") + .orderBy("u.createdAt", "DESC"); + } + + /** The address the code goes to, taken from the account — never from input. */ + private targetFor(user: User, channel: ResetChannel): OtpTarget | null { + if (channel === ResetChannel.Email) { + return user.email ? { email: user.email } : null; + } + return user.phoneNumber ? { phone: user.phoneNumber } : null; + } + + /** + * Send a reset code to the account's own email/phone. Returns the target so + * authenticated (backoffice) callers can echo a masked version; unauthenticated + * callers must discard it. + * + * Note: `otp_verifications` keys rows by a unique phone/email, and `sendOtp` + * upserts. A reset request therefore overwrites any pending signup code for + * the same address — last code sent wins. That is the pre-existing behaviour + * between any two flows sharing this table. + */ + async requestReset( + user: User, + channel: ResetChannel, + ): Promise { + const target = this.targetFor(user, channel); + if (!target) return null; + + await this.otpService.sendOtp(target); + return target; + } + + /** + * Prove possession of the OTP, then mint an IAM reset ticket the caller can + * spend on the public `PATCH /api/auth/set-password`. + * + * Minting a `UserVerification` row rather than writing `UserCredential` + * ourselves keeps IAM as the single owner of the password write path (old + * credential deactivation, argon hashing, changed-at bookkeeping). + */ + async verifyAndMintTicket( + identifier: string, + channel: ResetChannel, + otp: string, + ): Promise { + const user = await this.resolveActiveUser(identifier); + const target = user && this.targetFor(user, channel); + + if (!user?.id || !target) { + // Same shape as a wrong code: a caller probing for accounts learns nothing + // beyond what the request step already (deliberately) refuses to tell them. + throw new BadRequestException("Invalid verification code"); + } + + await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS); + + const code = randomBytes(24).toString("base64url"); + const verificationCode = await hashPassword(code); + const userId = user.id; + + await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(UserVerification); + // Retire any outstanding codes so only the ticket we just minted can be + // spent — `findVerificationForPrimaryReset` reads the newest row. + await repo.update({ userId }, { isUsed: true }); + await repo.insert({ + userId, + otpType: EOtpType.RESET_PASSWORD, + verificationCode, + expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS), + isUsed: false, + attemptCount: 0, + }); + }); + + this.logger.log(`Reset ticket minted for user ${userId}`); + return { userId, verificationCode: code }; + } + + /** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */ + maskTarget(target: OtpTarget): string { + if (target.email) { + const [local, domain] = target.email.split("@"); + const head = local.slice(0, 1); + return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`; + } + const phone = target.phone ?? ""; + return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`; + } +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index 16fbeffda..6415cf4c1 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -2,15 +2,35 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; +import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity'; +import { ExternalProfile } from '../companies/entities/external-profile.entity'; +import { OtpModule } from '../otp/otp.module'; import { CheckAvailabilityController } from './check-availability.controller'; import { CheckAvailabilityService } from './check-availability.service'; +import { CustomerResetController } from './customer-reset.controller'; +import { CustomerResetService } from './customer-reset.service'; +import { ForgotPasswordController } from './forgot-password.controller'; +import { ForgotPasswordService } from './forgot-password.service'; import { FreightMeController } from './freight-me.controller'; import { FreightMeService } from './freight-me.service'; @Module({ - imports: [TypeOrmModule.forFeature([User])], - controllers: [FreightMeController, CheckAvailabilityController], - providers: [FreightMeService, CheckAvailabilityService], + imports: [ + TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]), + OtpModule, + ], + controllers: [ + FreightMeController, + CheckAvailabilityController, + ForgotPasswordController, + CustomerResetController, + ], + providers: [ + FreightMeService, + CheckAvailabilityService, + ForgotPasswordService, + CustomerResetService, + ], }) export class FreightAuthModule {} diff --git a/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts index b4f168e4e..d2a88b683 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts @@ -143,6 +143,20 @@ export function htmlToText(html: string): string { .trim(); } +/** + * Large rotated light-gray copy label (e.g. "Copy 1: Port Operations Copy"), + * drawn FIRST so the page content sits on top of it. 30-degree rotation via a + * text matrix; roughly centered on the page. + */ +export function watermarkOp(text: string, page: { width: number; height: number }): string { + const label = clipText(text, 46); + const size = 34; + const w = textWidth(label, size); + const x = page.width / 2 - (w * 0.866) / 2; + const y = page.height / 2 - (w * 0.5) / 2; + return `q BT 0.93 0.93 0.93 rg /F2 ${size} Tf 0.866 0.5 -0.5 0.866 ${x.toFixed(1)} ${y.toFixed(1)} Tm (${escapePdfText(label)}) Tj ET Q`; +} + /** * Parse a "summary tiles + one + notice + signature lines" document (the * marshalling / load-list layout the train-scheduling builders emit) and draw it as a @@ -150,11 +164,26 @@ export function htmlToText(html: string): string { * document, not a flat text dump. Switches to landscape when the table is wide. */ export function buildTabularFallbackPdf(html: string): Buffer { + // Documents printed in duplicate wrap each copy in
+ // (freight order: Port Operations copy + Gate Security copy). Render one + // page per copy, each with its own watermark and tile set — parsing the + // whole HTML at once would merge both copies' tiles and drop the watermarks. + const copies = [...html.matchAll(/
([\s\S]*?)<\/section>/gi)].map((m) => m[1]); + const fragments = copies.length ? copies : [html]; + return assemblePdf(fragments.flatMap((fragment) => buildTabularPageOps(fragment))); +} + +function buildTabularPageOps( + html: string, +): Array<{ ops: string[]; page: { width: number; height: number } }> { const pick = (re: RegExp) => html.match(re)?.[1]; const title = htmlToText(pick(/]*>([\s\S]*?)<\/h1>/i) ?? "Document"); const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? ""); const metaRef = htmlToText(pick(/class="meta"[\s\S]*?([\s\S]*?)<\/strong>/i) ?? ""); + const metaLabel = + htmlToText(pick(/class="meta"[^>]*>([\s\S]*?)]*>([\s\S]*?)<\/div>/i) ?? ""); const tiles: Array<[string, string]> = []; for (const m of html.matchAll( @@ -180,24 +209,49 @@ export function buildTabularFallbackPdf(html: string): Buffer { const M = 32; const contentW = page.width - M * 2; const right = page.width - M; - const ops: string[] = []; + const MAX_PAGES = 12; - // Header - ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4)); - ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray)); - ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark)); - if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray)); - if (metaRef) { - ops.push(textOpRight("TRAIN / SCHEDULE", right, page.height - 42, 7.5, "F2", PdfColor.gray)); - ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark)); - } - if (generated) { - ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray)); - } - ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1)); + const pagesOut: Array<{ ops: string[]; page: { width: number; height: number } }> = []; + let ops: string[] = []; + let y = 0; - // Summary tiles - let y = page.height - 100; + const drawFullHeader = () => { + ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4)); + ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray)); + ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark)); + if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray)); + if (metaRef) { + ops.push(textOpRight(clipText(metaLabel, 26), right, page.height - 42, 7.5, "F2", PdfColor.gray)); + ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark)); + } + if (generated) { + ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray)); + } + ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1)); + y = page.height - 100; + }; + + const drawContinuationHeader = (pageNo: number) => { + ops.push(lineOp(M, page.height - 24, right, page.height - 24, PdfColor.teal, 1.6)); + ops.push( + textOp(clipText(`${title} (continued — page ${pageNo})`, landscape ? 100 : 68), M, page.height - 42, 11, "F2", PdfColor.dark), + ); + if (metaRef) ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 42, 10, "F2", PdfColor.gray)); + y = page.height - 54; + }; + + const startPage = (first: boolean) => { + ops = []; + if (watermark) ops.push(watermarkOp(watermark, page)); + if (first) drawFullHeader(); + else drawContinuationHeader(pagesOut.length + 1); + }; + + const finishPage = () => pagesOut.push({ ops, page }); + + startPage(true); + + // Summary tiles (first page only) if (tiles.length) { const cols = landscape ? 6 : 4; const tileW = contentW / cols; @@ -213,21 +267,34 @@ export function buildTabularFallbackPdf(html: string): Buffer { y -= tileH + 12; } - // Table + // Table, paginated across as many pages as the rows need. if (headers.length) { const colW = contentW / headers.length; const headerH = 16; const rowH = 14; const cellChars = Math.max(4, Math.floor(colW / 3.9)); - ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6)); - headers.forEach((h, c) => - ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)), - ); - y -= headerH; + const bottomReserve = 46; // keep clear of the page edge on row-only pages - let shown = 0; - for (const row of rows) { - if (y < 96) break; + const drawTableHeader = () => { + ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6)); + headers.forEach((h, c) => + ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)), + ); + y -= headerH; + }; + + drawTableHeader(); + let truncated = 0; + for (const [index, row] of rows.entries()) { + if (y - rowH < bottomReserve) { + if (pagesOut.length + 1 >= MAX_PAGES) { + truncated = rows.length - index; + break; + } + finishPage(); + startPage(false); + drawTableHeader(); + } ops.push(rectOp(M, y - rowH, contentW, rowH, "1 1 1", PdfColor.line, 0.4)); headers.forEach((_h, c) => { if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3)); @@ -235,30 +302,33 @@ export function buildTabularFallbackPdf(html: string): Buffer { if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark)); }); y -= rowH; - shown += 1; } - if (shown < rows.length) { - ops.push(textOp(`... ${rows.length - shown} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray)); + if (truncated > 0) { + ops.push(textOp(`... ${truncated} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray)); } } - // Notice (verification clause) + // Notice + signatures live on the final page; give them a fresh page when the + // rows ran too deep for the fixed bottom band. + if (y < 110 && (notice || signatures.length)) { + finishPage(); + startPage(false); + } if (notice) { ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2)); wrapText(notice, landscape ? 155 : 104) .slice(0, 2) .forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray))); } - - // Signatures const sigW = contentW / signatures.length; - signatures.forEach((s, i) => { + signatures.forEach((sig, i) => { const x = M + i * sigW; ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7)); - ops.push(textOp(clipText(s, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray)); + ops.push(textOp(clipText(sig, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray)); }); + finishPage(); - return assembleSinglePagePdf(ops, page); + return pagesOut; } /** Greedy word-wrap to a maximum character width. */ @@ -320,3 +390,41 @@ export function assembleSinglePagePdf( pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; return Buffer.from(pdf, "latin1"); } + +/** Assemble a multi-page PDF; one content stream per page, shared Helvetica fonts. */ +export function assemblePdf( + pages: Array<{ ops: string[]; page: { width: number; height: number } }>, +): Buffer { + const kids = pages.map((_, i) => `${5 + i * 2} 0 R`).join(" "); + const objects: string[] = [ + "<< /Type /Catalog /Pages 2 0 R >>", + `<< /Type /Pages /Kids [${kids}] /Count ${pages.length} >>`, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>", + ]; + for (const [i, p] of pages.entries()) { + const stream = p.ops.join("\n"); + objects.push( + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${p.page.width} ${p.page.height}] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${6 + i * 2} 0 R >>`, + ); + objects.push(`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`); + } + + let pdf = "%PDF-1.4\n"; + const offsets: number[] = [0]; + objects.forEach((object, index) => { + offsets.push(Buffer.byteLength(pdf, "latin1")); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) { + pdf += "% fallback padding\n"; + } + const xrefOffset = Buffer.byteLength(pdf, "latin1"); + pdf += `xref\n0 ${objects.length + 1}\n`; + pdf += "0000000000 65535 f \n"; + for (const offset of offsets.slice(1)) { + pdf += `${String(offset).padStart(10, "0")} 00000 n \n`; + } + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + return Buffer.from(pdf, "latin1"); +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index d63106c2b..00cf55e17 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -400,8 +400,18 @@ export class BookingPricingService { * All three components are produced by RuleEngineService.evaluate, so submit * simply re-runs the engine — there is no extra submit-time inflation. */ - async computeSubmitPriorityScore(booking: Booking): Promise { + async computeSubmitPriorityScore( + booking: Booking, + totalWagonsOverride?: number, + ): Promise { const evalInput = await this.buildEvalInputForBooking(booking); + // BULK bookings have no container lines, so buildEvalInputForBooking yields + // totalWagons = 0 and every wagon-range priority config misses. The batch + // engine derives a bulk booking's wagon footprint from tonnage vs. live + // wagon capacity and passes it here to score the booking properly. + if (totalWagonsOverride != null && totalWagonsOverride > 0) { + evalInput.totalWagons = totalWagonsOverride; + } const ruleResult = await this.ruleEngineService.evaluate(evalInput); return ruleResult.priorityScore; } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 79d94f3de..f556751fb 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -68,6 +68,8 @@ import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto'; import { CustomerTruckService } from './customer-truck.service'; +import { FirstMileService } from '../first-mile/first-mile.service'; +import { LastMileService } from '../last-mile/last-mile.service'; import { GenerateGrnDto } from './dto/generate-grn.dto'; import { ContainerReceiptService } from './container-receipt.service'; import { SignContractDto } from './dto/sign-contract.dto'; @@ -81,6 +83,60 @@ import { hasFreightPermission, } from "../../common/freight-permission.util"; +interface MileVehicleSummary { + plate: string | null; + code: string | null; + driverName: string | null; + containerNumber: string | null; + distanceKm: number | null; +} + +interface MileLegSummary { + status: string; + exactKm: number | null; + remainingPayment: number | null; + currency: string; + invoiced: boolean; + vehicles: MileVehicleSummary[]; +} + +/** Trim a first/last-mile record down to a customer-safe operational summary. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function summarizeMileLeg(rec?: Record): MileLegSummary | null { + if (!rec) return null; + const num = (v: unknown) => (v == null ? null : Number(v)); + const assignments: Array> = rec.vehicleAssignments ?? []; // eslint-disable-line @typescript-eslint/no-explicit-any + const currency = + rec.vehicle?.currency ?? + assignments[0]?.vehicle?.currency ?? + rec.booking?.paymentCurrency ?? + 'ETB'; + const vehicles: MileVehicleSummary[] = assignments.map((a) => ({ + plate: a.vehicle?.plateNumber ?? null, + code: a.vehicle?.code ?? null, + driverName: a.vehicle?.assignedDriverName ?? null, + containerNumber: a.containerNumber ?? null, + distanceKm: num(a.distanceKm), + })); + if (!vehicles.length && rec.vehicle) { + vehicles.push({ + plate: rec.vehicle.plateNumber ?? null, + code: rec.vehicle.code ?? null, + driverName: rec.vehicle.assignedDriverName ?? null, + containerNumber: null, + distanceKm: num(rec.exactKm), + }); + } + return { + status: rec.status ?? '', + exactKm: num(rec.exactKm), + remainingPayment: num(rec.remainingPayment), + currency, + invoiced: Boolean(rec.invoice), + vehicles, + }; +} + @ApiTags("bookings") @Controller("bookings") @ApiBearerAuth() @@ -94,6 +150,8 @@ export class BookingsController { private readonly bookingClearanceService: BookingClearanceService, private readonly customerTruckService: CustomerTruckService, private readonly containerReceiptService: ContainerReceiptService, + private readonly firstMileService: FirstMileService, + private readonly lastMileService: LastMileService, ) {} @Post() @@ -290,6 +348,33 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Get(':id/mile-summary') + @ApiOperation({ + summary: 'First/last-mile operational summary for a booking (customer-safe)', + }) + async mileSummary( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + // Customers may only see their own booking's mile summary. + const booking = await this.bookingsService.findById(id); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) + ) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + + const [first, last] = await Promise.all([ + this.firstMileService.findAll({ bookingId: id, pageSize: 1 }), + this.lastMileService.findAll({ bookingId: id, pageSize: 1 }), + ]); + return { + firstMile: summarizeMileLeg(first.data[0]), + lastMile: summarizeMileLeg(last.data[0]), + }; + } + @Post(':id/customer-truck-assignment') @ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' }) async assignCustomerTruck( diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 48c5b628b..e7806c13c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -11,7 +11,9 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; import { SignaturesModule } from '../signatures/signatures.module'; import { BillingModule } from '../billing/billing.module'; +import { DocumentsModule } from '../billing/documents/documents.module'; import { FirstMileModule } from '../first-mile/first-mile.module'; +import { LastMileModule } from '../last-mile/last-mile.module'; import { BookingContractService } from './booking-contract.service'; import { BookingInvoiceService } from './booking-invoice.service'; // import { BookingPaymentController } from './booking-payment.controller'; @@ -67,9 +69,11 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; CustomerTruckContainer, ]), BillingModule, + DocumentsModule, NotificationsModule, NotificationInboxModule, forwardRef(() => FirstMileModule), + forwardRef(() => LastMileModule), forwardRef(() => TrainSchedulingModule), forwardRef(() => ContractsModule), forwardRef(() => ContractsModule), @@ -114,6 +118,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingPricingService, BookingInvoiceService, BookingLifecycleNotifierService, + BookingTransitionService, ConsolidationService, CustomerTruckService, ContainerReceiptService, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index fa11fc66c..a3009ec3c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -44,6 +44,11 @@ export interface BookingListFilterOptions { customsClearingEnabled?: boolean; createdFrom?: string; createdTo?: string; + scheduledFrom?: string; + scheduledTo?: string; + originYardId?: string; + destinationYardId?: string; + isGovernment?: 'true' | 'false'; consolidationPaired?: string; } @@ -546,6 +551,11 @@ export class BookingsRepository extends BaseRepository { if (!statuses.length) return []; return this.repository.find({ where: { status: In(statuses) }, + relations: { + company: true, + originYard: true, + destinationYard: true, + }, order: { createdAt: 'DESC' }, }); } @@ -794,9 +804,16 @@ export class BookingsRepository extends BaseRepository { }); } if (options.bookingType) { - qb.andWhere('booking.bookingType = :bookingType', { - bookingType: options.bookingType, - }); + // The stored booking_type column is 'ONE_TIME' for every row (contract + // drawdowns included — see contract-booking.service create), so the + // one-time vs general split keys on the denormalized contract_kind: + // GENERAL_CONTRACT tab = bookings under a GENERAL contract, ONE_TIME tab + // = everything else (ONE_TIME contracts and legacy contract-less rows). + if (options.bookingType === 'GENERAL_CONTRACT') { + qb.andWhere("booking.contract_kind = 'GENERAL'"); + } else { + qb.andWhere("booking.contract_kind IS DISTINCT FROM 'GENERAL'"); + } } if (options.createdFrom) { qb.andWhere('booking.created_at >= :createdFrom', { @@ -809,6 +826,32 @@ export class BookingsRepository extends BaseRepository { createdTo: options.createdTo, }); } + if (options.scheduledFrom) { + qb.andWhere('booking.scheduled_date >= :scheduledFrom', { + scheduledFrom: options.scheduledFrom, + }); + } + if (options.scheduledTo) { + // Inclusive end-of-day: callers pass a date; include the whole day. + qb.andWhere('booking.scheduled_date <= :scheduledTo', { + scheduledTo: options.scheduledTo, + }); + } + if (options.originYardId) { + qb.andWhere('booking.origin_yard_id = :originYardId', { + originYardId: options.originYardId, + }); + } + if (options.destinationYardId) { + qb.andWhere('booking.destination_yard_id = :destinationYardId', { + destinationYardId: options.destinationYardId, + }); + } + if (options.isGovernment === 'true') { + qb.andWhere('booking.is_government = TRUE'); + } else if (options.isGovernment === 'false') { + qb.andWhere('booking.is_government = FALSE'); + } if (options.tradeDirection) { qb.andWhere('booking.trade_direction = :tradeDirection', { tradeDirection: options.tradeDirection, @@ -993,6 +1036,8 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .andWhere('sb.id IS NULL') @@ -1023,6 +1068,8 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.origin_yard_id = :originYardId', { originYardId }) .andWhere('booking.destination_yard_id = :destinationYardId', { @@ -1061,6 +1108,8 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds }) .andWhere('booking.destination_yard_id IN (:...corridorYardIds)', { @@ -1125,6 +1174,8 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .orderBy('booking.is_government', 'DESC') .addOrderBy('booking.priority_score', 'DESC') @@ -1138,6 +1189,8 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) .getMany(); @@ -1167,6 +1220,8 @@ export class BookingsRepository extends BaseRepository { return this.repository .createQueryBuilder('booking') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .innerJoin( TrainScheduleBooking, 'sb', diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 578e73278..9f72c7409 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -50,7 +50,8 @@ import { Booking } from './entities/booking.entity'; import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { FileRecord } from '../files/entities/file.entity'; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; -import { ContractPdfService } from '../../contracts/contract-pdf.service'; +import { PdfRenderService } from '../billing/documents/pdf-render.service'; +import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util'; /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ export interface PaginatedBookings { @@ -99,7 +100,7 @@ export class BookingsService { private readonly containerTypesService: ContainerTypesService, private readonly consolidationService: ConsolidationService, private readonly vehiclesService: VehiclesService, - private readonly contractPdfService: ContractPdfService, + private readonly pdfRender: PdfRenderService, private readonly events: EventEmitter2, ) {} @@ -170,7 +171,12 @@ export class BookingsService { ); const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks); - const buffer = await this.contractPdfService.htmlToPdfBuffer(html); + // Chromium when available; otherwise the styled tabular fallback (never the + // generic text dump — the freight order is an outward-facing gate document). + const buffer = await this.pdfRender.htmlToPdfBuffer(html, { + label: 'freight order', + fallback: (prepared) => buildTabularFallbackPdf(prepared), + }); return { filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, buffer, @@ -262,21 +268,10 @@ export class BookingsService { containers: string | null; }>, ): string { + const esc = (v: unknown) => this.escapeHtml(String(v ?? '-')); const assignedAt = booking.customerTruckAssignedAt ? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB') : '-'; - const bookingRows: Array<[string, string | null | undefined]> = [ - ['Booking Reference', booking.reference], - ['Client Name', booking.company?.name], - ['Client ID', booking.companyId], - ['Trade Direction', booking.tradeDirection], - ['Freight Type', booking.freightType], - ['Assigned At', assignedAt], - ['Booking Status', booking.status], - ]; - const bookingRowHtml = bookingRows - .map(([label, value]) => `
`) - .join(''); // Fall back to the legacy single-truck booking columns when there are no // multi-truck rows (bookings assigned before the multi-truck feature). @@ -297,44 +292,63 @@ export class BookingsService { ] : []; - const truckBlocks = truckList - .map((t, i) => { - const rows: Array<[string, string | null | undefined]> = [ - ['Truck Plate Number', t.plateNumber], - ['Driver Name', t.driverName], - ['Truck Type', t.truckType], - ['Containers Loaded', t.containers], - [ - 'Arrival', - t.arrivedAt ? new Date(t.arrivedAt).toLocaleString('en-GB') : 'Awaiting arrival', - ], - ]; - const html = rows - .map( - ([label, value]) => - ``, - ) - .join(''); - return `

Truck ${i + 1}

${this.escapeHtml(label)}${this.escapeHtml(value || '-')}
${this.escapeHtml(label)}${this.escapeHtml(value || '-')}
${html}
`; - }) + const truckRows = truckList + .map( + (t, i) => ` + ${i + 1} + ${esc(t.plateNumber)} + ${esc(t.driverName)} + ${esc(t.truckType)} + ${esc(t.containers)} + ${t.arrivedAt ? esc(new Date(t.arrivedAt).toLocaleString('en-GB')) : 'Awaiting arrival'} + `, + ) .join(''); const copy = (watermark: string) => `
-
${this.escapeHtml(watermark)}
-
+
${esc(watermark)}
+
+
Ethio-Djibouti Railway S.C.

Freight Order

-

Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}

+
Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}
- ${this.escapeHtml(booking.reference)} -
- ${bookingRowHtml}
- ${truckBlocks} +
+ Booking + ${esc(booking.reference)} + Generated: ${esc(new Date().toLocaleString('en-GB'))} +
+ +
+
Client${esc(booking.company?.name)}
+
Client ID${esc(booking.companyId)}
+
Trade direction${esc(booking.tradeDirection)}
+
Freight type${esc(booking.freightType)}
+
Assigned at${esc(assignedAt)}
+
Booking status${esc(booking.status)}
+
+ + + + + + + + + + + + ${truckRows} +
#Truck plateDriverTruck typeContainers loadedArrival
+
+ Present this freight order at the warehouse gate. Each truck may only collect the + containers listed against it; the handover must be signed before any truck leaves. +
-
Customer / Carrier Signature
-
Port Operations Verification
-
Gate Security Verification
+
Customer / Carrier signature — date
+
Port operations verification — date
+
Gate security verification — date
`; @@ -342,21 +356,30 @@ export class BookingsService { + Freight Order @@ -1125,6 +1148,30 @@ export class BookingsService { ); } + /** + * Batched version of the findById flag: marks each page item whose booking + * has a generated-but-unsigned SELF_HAUL handover, so list rows (portal + * dashboard) can show "Approve delivery" for exactly the generated→signed + * window. One query for the whole page. + */ + private async attachHandoverFlags(bookings: Booking[]): Promise { + const ids = bookings.map((b) => b.id); + if (!ids.length) return; + const rows: Array<{ bookingId: string }> = await this.dataSource.query( + `SELECT DISTINCT booking_id AS "bookingId" + FROM freight.booking_handovers + WHERE booking_id = ANY($1::uuid[]) + AND signed_at IS NULL AND deleted_at IS NULL + AND mile_type = 'SELF_HAUL'`, + [ids], + ); + const pending = new Set(rows.map((r) => r.bookingId)); + for (const b of bookings) { + (b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature = + pending.has(b.id); + } + } + async findAll( filter: FilterBookingDto, forceCompanyId?: string, @@ -1135,7 +1182,7 @@ export class BookingsService { const statusFilter = this.parseStatusFilter(filter); const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter); - return this.bookingsRepository.findAllPaginated({ + const result = await this.bookingsRepository.findAllPaginated({ page, pageSize, ...statusFilter, @@ -1158,10 +1205,17 @@ export class BookingsService { paymentStatus: filter.paymentStatus, createdFrom: filter.createdFrom, createdTo: filter.createdTo, + scheduledFrom: filter.scheduledFrom, + scheduledTo: filter.scheduledTo, + originYardId: filter.originYardId, + destinationYardId: filter.destinationYardId, + isGovernment: filter.isGovernment, consolidationPaired: filter.consolidationPaired, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); + await this.attachHandoverFlags(result.items ?? []); + return result; } /** Booking statuses at which a customer can pay (mirrors booking-payment.service). */ @@ -1371,11 +1425,17 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, + bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, createdFrom: filter.createdFrom, createdTo: filter.createdTo, + scheduledFrom: filter.scheduledFrom, + scheduledTo: filter.scheduledTo, + originYardId: filter.originYardId, + destinationYardId: filter.destinationYardId, + isGovernment: filter.isGovernment, consolidationPaired: filter.consolidationPaired, }; @@ -1424,12 +1484,14 @@ export class BookingsService { schedule?.status ?? null; } - // A generated-but-unsigned handover means the customer must approve delivery. - // Surfaced so the portal shows "Approve delivery" as soon as the handover - // exists, independent of the truck-arrival flag. + // A generated-but-unsigned SELF_HAUL handover means the customer must approve + // delivery from the portal (booking-based, one per booking). EDR last-mile + // handovers are per delivering truck and signed by the receiver at the door, + // so they never surface the portal "Approve delivery" action. const [pendingHandover] = await this.dataSource.query( `SELECT 1 FROM freight.booking_handovers WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL + AND mile_type = 'SELF_HAUL' LIMIT 1`, [id], ); diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index 14402f830..4e364a03a 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -312,6 +312,13 @@ export class CustomerTruckService { if (assignment.departedAt) { throw new ConflictException('This truck has already left — its load is locked'); } + // Containers can only be loaded after the truck has physically arrived at the + // warehouse (arrival weighing recorded). Assignment alone is just planning. + if (!assignment.arrivedAt) { + throw new BadRequestException( + 'Record the truck arrival before loading — containers can only be loaded onto an arrived truck', + ); + } const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); if (!requested.length) { @@ -333,12 +340,16 @@ export class CustomerTruckService { const grossTons = await this.vgmTonsForContainers(bookingId, requested); await this.dataSource.transaction(async (manager) => { await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + // Operator loading the truck: stamp loaded_at so these containers move to + // the LOADED stage (customer assignment alone leaves loaded_at null). + const loadedAt = new Date(); await manager.getRepository(CustomerTruckContainer).save( requested.map((containerNumber) => manager.getRepository(CustomerTruckContainer).create({ assignmentId, bookingId, containerNumber, + loadedAt, }), ), ); diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index d189f5448..ae973b97d 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -81,6 +81,31 @@ export class FilterBookingDto { @IsDateString() createdTo?: string; + @ApiPropertyOptional({ description: 'Filter bookings scheduled on/after this date (ISO)' }) + @IsOptional() + @IsDateString() + scheduledFrom?: string; + + @ApiPropertyOptional({ description: 'Filter bookings scheduled on/before this date (ISO)' }) + @IsOptional() + @IsDateString() + scheduledTo?: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'Filter by origin yard' }) + @IsOptional() + @IsUUID() + originYardId?: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'Filter by destination yard' }) + @IsOptional() + @IsUUID() + destinationYardId?: string; + + @ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter government vs private bookings' }) + @IsOptional() + @IsIn(['true', 'false']) + isGovernment?: 'true' | 'false'; + @ApiPropertyOptional({ enum: TRADE_DIRECTIONS }) @IsOptional() @IsIn([...TRADE_DIRECTIONS]) diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts index 110e31671..8b6ecc8b9 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-container.entity.ts @@ -23,4 +23,12 @@ export class CustomerTruckContainer extends BaseEntity { @Column({ name: 'container_number', type: 'varchar', length: 64 }) containerNumber!: string; + + /** + * When the container was actually loaded onto the truck by the operator. + * Null = customer-assigned (planned) but not yet loaded. Stage LOADED requires + * this to be set, so customer assignment alone does not mark a container loaded. + */ + @Column({ name: 'loaded_at', type: 'timestamptz', nullable: true }) + loadedAt?: Date | null; } diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index f8fbb26b0..8db9eba66 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -34,7 +34,10 @@ import { ResponseCompanyDto, ResponseCompanyProfileDto, } from "./dto/response-company.dto"; -import { ProfileLicenseFileView } from "./entities/company-profile.entity"; +import { + CompanyDocumentFileView, + ProfileLicenseFileView, +} from "./entities/company-profile.entity"; import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto"; import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; import { UpdateProfileDto } from "./dto/update-profile.dto"; @@ -218,7 +221,7 @@ export class CompaniesController { @Post("company-profile") @ApiOperation({ summary: - "Create a single operational profile for the current user's company and make it the active mode", + "Create a single operational profile for the current user's company. The role starts pending and does not become the active mode", }) async createCompanyProfile( @CurrentUser() user: CurrentIamUser, @@ -306,6 +309,49 @@ export class CompaniesController { return this.companiesService.listProfileLicenseFiles(user.id, profileId); } + @Get("poa-delegation") + @ApiOperation({ + summary: + "List the Power of Attorney delegation letter (with review state) for the current user's company", + }) + async listPoaDelegation( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.listPoaDelegationFiles(user.id); + } + + @Post("poa-delegation") + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: + "Upload the Power of Attorney delegation letter, replacing any existing one. " + + "For an approved company the upload is staged for backoffice review; during " + + "onboarding it goes live.", + }) + async uploadPoaDelegation( + @CurrentUser() user: CurrentIamUser, + @UploadedFiles() files: Array, + ): Promise { + const file = files?.[0]; + if (!file) { + throw new BadRequestException("A delegation letter file is required"); + } + return this.companiesService.uploadPoaDelegationLetter(user.id, file); + } + + @Delete("poa-delegation/:fileId") + @ApiOperation({ + summary: + "Remove the Power of Attorney delegation letter (staged for review on an approved company).", + }) + async removePoaDelegation( + @CurrentUser() user: CurrentIamUser, + @Param("fileId", ParseUUIDPipe) fileId: string, + ): Promise { + return this.companiesService.removePoaDelegationLetter(user.id, fileId); + } + @Patch("active-mode") @ApiOperation({ summary: "Switch the current user's active operational mode (importer/exporter)", diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 646d01d47..73826689a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -1,9 +1,11 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { HttpModule } from "@nestjs/axios"; import { FilesModule } from "../files/files.module"; import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module"; import { MinioModule } from "../minio/minio.module"; +import { NotificationsModule } from "../notifications/notifications.module"; +import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module"; import { CompaniesController } from "./companies.controller"; import { CompaniesService } from "./companies.service"; import { CompaniesRepository } from "./companies.repository"; @@ -17,6 +19,7 @@ import { Booking } from "../bookings/entities/booking.entity"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { ETradeService } from "./services/etrade.service"; +import { CompanyNotifierService } from "./company-notifier.service"; @Module({ imports: [ @@ -31,6 +34,10 @@ import { ETradeService } from "./services/etrade.service"; FilesModule, FileUploadSettingsModule, MinioModule, + // Account-status notifications (CompanyNotifierService). The inbox module + // imports this module back for portal recipient targeting, hence forwardRef. + NotificationsModule, + forwardRef(() => NotificationInboxModule), ], controllers: [CompaniesController], providers: [ @@ -41,6 +48,7 @@ import { ETradeService } from "./services/etrade.service"; CompanyChangeRequestRepository, CompanyDashboardRepository, ETradeService, + CompanyNotifierService, ], exports: [ CompaniesService, diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index e31851aef..1027de955 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -17,6 +17,7 @@ import { FilesService } from "../files/files.service"; import { FileRecord } from "../files/entities/file.entity"; import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; import { ETradeService } from "./services/etrade.service"; +import { CompanyNotifierService } from "./company-notifier.service"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; import { CreateCompanyDto } from "./dto/create-company.dto"; @@ -37,6 +38,7 @@ import { import { ExternalProfile } from "./entities/external-profile.entity"; import { BusinessLicenseFile, + CompanyDocumentFileView, CompanyProfile, ProfileLicenseFileView, ProfileType, @@ -45,6 +47,7 @@ import { import { ChangeRequestStatus, CompanyChangeRequest, + DocumentChangeIntent, LicenseChangeIntent, } from "./entities/company-change-request.entity"; @@ -54,6 +57,27 @@ const LICENSE_CODE = "business_license"; /** Code for a license file staged in an open change request (not yet live). */ const LICENSE_PENDING_CODE = "business_license_pending"; +/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */ +const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; +/** Code for a PoA letter staged in an open change request (not yet live). */ +const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending"; +/** FileRecord resource that company-level documents are stored under. */ +const COMPANY_RESOURCE = "companies"; +/** company.attributes keys that together mean "a PoA was entered". */ +const POA_ATTRIBUTES = [ + "poaName", + "poaPhone", + "poaEmail", + "poaLocation", + "poaAddress", +] as const; +/** Mandatory once the company operates as a freight forwarder. */ +const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ + { key: "poaName", label: "PoA name" }, + { key: "poaEmail", label: "PoA email" }, + { key: "poaPhone", label: "PoA phone" }, +]; + export interface UserIdentity { userId: string; firstName: string; @@ -73,6 +97,7 @@ export class CompaniesService { private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly etradeService: ETradeService, + private readonly companyNotifier: CompanyNotifierService, ) { } /** @@ -562,9 +587,13 @@ export class CompaniesService { } async updateCompany(id: string, dto: UpdateCompanyDto): Promise { - await this.findCompanyById(id); + const before = await this.findCompanyById(id); const updated = await this.companiesRepo.update(id, dto); if (!updated) throw new NotFoundException(`Company ${id} not found`); + + // Suspending or blacklisting locks the customer out, so they must be told. + // This is the only path that writes those statuses. + this.companyNotifier.statusChanged(updated, before.status); return updated; } @@ -765,6 +794,7 @@ export class CompaniesService { const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot); await this.companiesRepo.update(company.id, companyUpdates); await this.applyLicenseChanges(request); + await this.applyDocumentChanges(request); return ( (await this.changeRequestRepo.update(id, { @@ -817,7 +847,12 @@ export class CompaniesService { if (existing) { const prev = existing.documents?.documentFileIds ?? []; await this.changeRequestRepo.update(existing.id, { - documents: { documentFileIds: [...prev, ...fileIds] }, + // Spread the existing documents blob: a bare object would drop any + // licenseChanges/documentChanges already staged on this request. + documents: { + ...existing.documents, + documentFileIds: [...prev, ...fileIds], + }, submittedBy: submittedBy ?? existing.submittedBy ?? null, submittedAt: now, note: null, @@ -849,12 +884,17 @@ export class CompaniesService { ); } await this.discardLicenseChanges(request); + await this.discardDocumentChanges(request); return ( (await this.changeRequestRepo.update(id, { status: ChangeRequestStatus.Rejected, - // Staged license uploads were just discarded; drop their intents so an - // amended resubmit never re-references deleted files. - documents: { ...request.documents, licenseChanges: [] }, + // Staged license/document uploads were just discarded; drop their intents + // so an amended resubmit never re-references deleted files. + documents: { + ...request.documents, + licenseChanges: [], + documentChanges: [], + }, note, reviewedBy: reviewerId ?? null, reviewedAt: new Date(), @@ -1017,13 +1057,12 @@ export class CompaniesService { ); } - const reference = await this.companyProfilesRepo.generateReference(type); - + // No reference is minted here: it is issued by setCompanyProfileStatus when + // a reviewer approves the role. Creating it Active would bypass that review. return this.companyProfilesRepo.create({ companyId, type, - reference, - status: ProfileStatus.Active, + status: ProfileStatus.Pending, }); } @@ -1097,9 +1136,11 @@ export class CompaniesService { } /** - * Create a single operational profile for the current user's company and - * make it the active mode in the same call. Powers the header "Switch to - * Exporter/Importer" flow when the target profile doesn't exist yet. + * Create a single operational profile for the current user's company. The new + * role starts Pending, so it deliberately does NOT become the active mode: + * switching onto an unapproved profile would strip the user of `canBook` and + * block them from creating contracts under the role they already had approved. + * Callers switch explicitly via {@link setActiveMode} once the role is Active. */ async createCompanyProfileForUser( userId: string, @@ -1122,8 +1163,7 @@ export class CompaniesService { let created = await this.companyProfilesRepo.findByType(companyId, type); if (!created) { // New self-service roles start Pending (awaiting backoffice approval) and - // carry no reference until approved. The customer can select this mode but - // can't book under it until it's cleared. + // carry no reference until approved. created = await this.companyProfilesRepo.create({ companyId, type, @@ -1132,8 +1172,6 @@ export class CompaniesService { }); } - await this.profilesRepo.update(profile.id, { activeProfileType: type }); - return created; } @@ -1240,6 +1278,29 @@ export class CompaniesService { ); const missingLicenses = licenseProfiles.filter((p) => !p.uploaded); + // 4. Power of Attorney. Optional in general, but a freight forwarder acts on + // other companies' behalf so its PoA is mandatory. Either way, a PoA that + // has been entered must be evidenced by the delegation letter. + const poaRequired = (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ); + const poaProvided = POA_ATTRIBUTES.some((k) => + (company.attributes?.[k] as string | undefined)?.trim(), + ); + const missingPoaFields = poaRequired + ? REQUIRED_POA_FIELDS.filter( + (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), + ) + : []; + // Only gate on the letter once the document set actually carries the field. + const delegationField = (setting?.fields ?? []).find( + (f) => f.fileKey === POA_DELEGATION_FILE_KEY, + ); + const missingDelegation = + Boolean(delegationField) && + (poaRequired || poaProvided) && + !uploadedCodes.has(POA_DELEGATION_FILE_KEY); + const outstanding = [ ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), ...missingDocs.map((d) => `Upload your ${d.fileLabel}`), @@ -1247,18 +1308,31 @@ export class CompaniesService { (p) => `Upload a business license for your ${p.type.replace(/_/g, " ")} profile`, ), + ...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`), + ...(missingDelegation + ? ["Upload the delegation letter for your Power of Attorney"] + : []), ]; // Progress spans every required item the user has to satisfy: company-info - // fields, required documents and one license per operational profile. + // fields, required documents, one license per operational profile, and the + // PoA details/letter whenever those are mandatory. const requiredDocCount = documents.filter((d) => d.isRequired).length; + const poaItemCount = + (poaRequired ? REQUIRED_POA_FIELDS.length : 0) + + (delegationField && (poaRequired || poaProvided) ? 1 : 0); const total = this.REQUIRED_COMPANY_INFO.length + requiredDocCount + - licenseProfiles.length; + licenseProfiles.length + + poaItemCount; const completed = total - - (missingInfo.length + missingDocs.length + missingLicenses.length); + (missingInfo.length + + missingDocs.length + + missingLicenses.length + + missingPoaFields.length + + (missingDelegation ? 1 : 0)); return new OnboardingRequirementsResponseDto({ documentSettingCode, @@ -1266,6 +1340,13 @@ export class CompaniesService { companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo }, documents, licenseProfiles, + poa: { + required: poaRequired, + provided: poaProvided, + delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY), + missingFields: missingPoaFields, + complete: missingPoaFields.length === 0 && !missingDelegation, + }, progress: { completed, total }, isComplete: outstanding.length === 0, onboardingCompleted: profile.onboardingCompleted, @@ -1365,10 +1446,12 @@ export class CompaniesService { // browser (which fails on the internal bucket endpoint). /** - * Upload business-license file(s) for one of the user's profiles. During - * onboarding (company not yet Active) they go live immediately; for an Active - * company they're staged under the pending code and recorded as `add` intents - * on a pending change request for backoffice review. Returns the updated view. + * Upload business-license file(s) for one of the user's profiles. For a role + * not yet approved (a fresh onboarding profile, or a newly added service on an + * already-active company) they go live immediately and are reviewed together + * with the role itself. Only for an already-approved role are they staged under + * the pending code and recorded as `add` intents on a pending change request — + * a licence swap on a live role is a change; a licence on a new role is not. */ async addProfileLicenseFiles( userId: string, @@ -1377,7 +1460,7 @@ export class CompaniesService { ): Promise { const profile = await this.resolveOwnedProfile(userId, profileId); const company = await this.findCompanyById(profile.companyId); - const gated = company.status === CompanyStatus.Active; + const gated = profile.status === ProfileStatus.Active; const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE; const uploaded = await Promise.all( @@ -1409,9 +1492,9 @@ export class CompaniesService { /** * Remove a license file. A staged (pending) file is withdrawn outright - * (soft-deleted, its `add` intent dropped). A live file on an Active company - * is kept and recorded as a `remove` intent for review; during onboarding it - * is deleted immediately. + * (soft-deleted, its `add` intent dropped). A live file on an already-approved + * role is kept and recorded as a `remove` intent for review; on a role still + * awaiting approval it is deleted immediately. */ async removeProfileLicenseFile( userId: string, @@ -1427,7 +1510,7 @@ export class CompaniesService { throw new NotFoundException(`License file ${fileId} not found`); } const company = await this.findCompanyById(profile.companyId); - const gated = company.status === CompanyStatus.Active; + const gated = profile.status === ProfileStatus.Active; if (record.code === LICENSE_PENDING_CODE) { // Withdraw a not-yet-approved upload: delete it and drop its add intent. @@ -1449,7 +1532,7 @@ export class CompaniesService { /** * Replace a live license file with a freshly uploaded one — recorded as a * `remove` of the old file plus an `add` of the new, so approval swaps them - * atomically. During onboarding the swap is applied immediately. + * atomically. On a role still awaiting approval the swap is applied immediately. */ async replaceProfileLicenseFile( userId: string, @@ -1463,7 +1546,7 @@ export class CompaniesService { throw new NotFoundException(`License file ${fileId} not found`); } const company = await this.findCompanyById(profile.companyId); - const gated = company.status === CompanyStatus.Active; + const gated = profile.status === ProfileStatus.Active; const created = await this.filesService.upload({ resourceId: profileId, @@ -1671,6 +1754,254 @@ export class CompaniesService { } } + // --------------------------------------------------------------------------- + // Power of Attorney delegation letter + // + // A company-level document that follows the same staged-review model as the + // business license: on an approved (Active) company an upload lands under the + // pending code and the live letter is flagged for removal, so the reviewer + // sees both and approval swaps them atomically. During onboarding it goes live. + // --------------------------------------------------------------------------- + + /** The company's PoA letter(s), with each file's review status resolved. */ + async listPoaDelegationFiles( + userId: string, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + return this.getPoaDelegationView(company.id); + } + + /** + * Upload the PoA delegation letter, replacing whatever is already on file. + * On an Active company this stages an `add` for the new file plus a `remove` + * for each live one; a letter still awaiting approval is withdrawn outright + * rather than stacking a second pending upload. + */ + async uploadPoaDelegationLetter( + userId: string, + file: Express.Multer.File, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const gated = company.status === CompanyStatus.Active; + + const records = await this.filesService.findByResource( + company.id, + COMPANY_RESOURCE, + ); + const live = records.filter((r) => r.code === POA_DELEGATION_FILE_KEY); + const staged = records.filter( + (r) => r.code === POA_DELEGATION_PENDING_CODE, + ); + + // Supersede an unreviewed upload instead of queueing another one. + for (const r of staged) { + await this.filesService.remove(r.id); + await this.withdrawDocumentIntent(company.id, r.id); + } + + const created = await this.filesService.upload({ + resourceId: company.id, + resource: COMPANY_RESOURCE, + code: gated ? POA_DELEGATION_PENDING_CODE : POA_DELEGATION_FILE_KEY, + file, + }); + + if (gated) { + await this.stageDocumentIntent( + company.id, + [ + ...live.map((r) => ({ + op: "remove" as const, + fileId: r.id, + code: POA_DELEGATION_FILE_KEY, + fileName: r.name, + })), + { + op: "add" as const, + fileId: created.id, + code: POA_DELEGATION_FILE_KEY, + fileName: created.name, + }, + ], + userId, + ); + } else { + // Onboarding: no review, so the old letter is simply replaced. + for (const r of live) await this.filesService.remove(r.id); + } + + return this.getPoaDelegationView(company.id); + } + + /** + * Remove the PoA letter. A staged upload is withdrawn outright; a live file on + * an Active company is kept and flagged for deletion on approval; during + * onboarding it is deleted immediately. + */ + async removePoaDelegationLetter( + userId: string, + fileId: string, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const record = await this.filesService.findById(fileId); + if ( + record.resource !== COMPANY_RESOURCE || + record.resourceId !== company.id || + (record.code !== POA_DELEGATION_FILE_KEY && + record.code !== POA_DELEGATION_PENDING_CODE) + ) { + throw new NotFoundException(`Delegation letter ${fileId} not found`); + } + + if (record.code === POA_DELEGATION_PENDING_CODE) { + await this.filesService.remove(fileId); + await this.withdrawDocumentIntent(company.id, fileId); + } else if (company.status === CompanyStatus.Active) { + await this.stageDocumentIntent( + company.id, + [ + { + op: "remove", + fileId, + code: POA_DELEGATION_FILE_KEY, + fileName: record.name, + }, + ], + userId, + ); + } else { + await this.filesService.remove(fileId); + } + + return this.getPoaDelegationView(company.id); + } + + private async getPoaDelegationView( + companyId: string, + ): Promise { + const pending = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + const removeIds = new Set( + (pending?.documents?.documentChanges ?? []) + .filter((c) => c.op === "remove") + .map((c) => c.fileId), + ); + const records = await this.filesService.findByResource( + companyId, + COMPANY_RESOURCE, + ); + return records + .filter( + (r) => + r.code === POA_DELEGATION_FILE_KEY || + r.code === POA_DELEGATION_PENDING_CODE, + ) + .map((r) => ({ + id: r.id, + name: r.name, + size: r.size, + mimeType: r.mimeType, + status: + r.code === POA_DELEGATION_PENDING_CODE + ? ("pending_add" as const) + : removeIds.has(r.id) + ? ("pending_remove" as const) + : ("live" as const), + })); + } + + /** Open or append a pending change request recording document add/remove intents. */ + private async stageDocumentIntent( + companyId: string, + changes: DocumentChangeIntent[], + submittedBy?: string, + ): Promise { + if (changes.length === 0) return; + const now = new Date(); + const existing = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + if (existing) { + const prev = existing.documents?.documentChanges ?? []; + // Re-uploading twice before review would otherwise stage a second `remove` + // for the same live file, and the duplicate would fail on approval. + const seen = new Set(prev.map((c) => `${c.op}:${c.fileId}`)); + const fresh = changes.filter((c) => !seen.has(`${c.op}:${c.fileId}`)); + if (fresh.length === 0) return; + await this.changeRequestRepo.update(existing.id, { + documents: { + ...existing.documents, + documentChanges: [...prev, ...fresh], + }, + submittedBy: submittedBy ?? existing.submittedBy ?? null, + submittedAt: now, + note: null, + }); + } else { + await this.changeRequestRepo.create({ + companyId, + snapshot: {}, + documents: { documentChanges: changes }, + status: ChangeRequestStatus.Pending, + submittedBy: submittedBy ?? null, + submittedAt: now, + }); + } + } + + /** + * Drop a staged document intent referencing `fileId`. If that empties the + * request entirely, delete it so the customer's settings page unlocks. + */ + private async withdrawDocumentIntent( + companyId: string, + fileId: string, + ): Promise { + const existing = + await this.changeRequestRepo.findPendingByCompanyId(companyId); + if (!existing) return; + const remaining = (existing.documents?.documentChanges ?? []).filter( + (c) => c.fileId !== fileId, + ); + const docs = existing.documents ?? {}; + const stillHasWork = + remaining.length > 0 || + (docs.licenseChanges?.length ?? 0) > 0 || + (docs.documentFileIds?.length ?? 0) > 0 || + Object.keys(existing.snapshot ?? {}).length > 0; + + if (stillHasWork) { + await this.changeRequestRepo.update(existing.id, { + documents: { ...docs, documentChanges: remaining }, + }); + } else { + await this.changeRequestRepo.softDelete(existing.id); + } + } + + /** Apply a request's staged document changes: promote adds, delete removes. */ + private async applyDocumentChanges( + request: CompanyChangeRequest, + ): Promise { + for (const change of request.documents?.documentChanges ?? []) { + if (change.op === "add") { + await this.filesService.setCode(change.fileId, change.code); + } else { + await this.filesService.remove(change.fileId); + } + } + } + + /** Discard a rejected request's staged document uploads (adds only). */ + private async discardDocumentChanges( + request: CompanyChangeRequest, + ): Promise { + for (const change of request.documents?.documentChanges ?? []) { + if (change.op === "add") { + await this.filesService.remove(change.fileId); + } + } + } + /** * Resolve which company_profile a new booking belongs to, from the company * and the booking's trade direction. IMPORT → importer profile, EXPORT → @@ -1719,13 +2050,17 @@ export class CompaniesService { } async fetchETradeData(tin: string) { - const { businessInfo } = await this.etradeService.resolveCompanyData(tin); + const { businessInfo, companyInfo } = + await this.etradeService.resolveCompanyData(tin); if (!businessInfo) { throw new BadRequestException( "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", ); } - const registrationData = this.etradeService.extractRegistrationData(businessInfo); + const registrationData = this.etradeService.extractRegistrationData( + businessInfo, + companyInfo, + ); const tinTaken = await this.companiesRepo.existsByTin(tin); return { ...registrationData, tinTaken }; } diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts new file mode 100644 index 000000000..73271e0af --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts @@ -0,0 +1,88 @@ +import { Repository } from "typeorm"; + +import { CompanyChangeRequestRepository } from "./company-change-request.repository"; +import { + ChangeRequestStatus, + CompanyChangeRequest, +} from "./entities/company-change-request.entity"; + +type Row = Pick & { createdAt: Date }; + +const COMPANY_ID = "company-1"; + +/** + * Stands in for the TypeORM repository over a fixed set of rows, honouring the + * `where.status` filter and the `createdAt DESC` ordering findOne relies on. + */ +function mockRepositoryOver(rows: Row[]) { + return { + findOne: jest.fn( + ({ where }: { where: Partial & { companyId: string } }) => + Promise.resolve( + rows + .filter( + (row) => + where.companyId === COMPANY_ID && + (where.status === undefined || row.status === where.status), + ) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ?? + null, + ), + ), + } as unknown as Repository; +} + +function subject(rows: Row[]) { + return new CompanyChangeRequestRepository(mockRepositoryOver(rows)); +} + +describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => { + const rejected: Row = { + id: "rejected", + status: ChangeRequestStatus.Rejected, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }; + + it("returns the pending request when one is open", async () => { + const pending: Row = { + id: "pending", + status: ChangeRequestStatus.Pending, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + }; + + const result = await subject([rejected, pending]).findLatestOpenByCompanyId( + COMPANY_ID, + ); + + expect(result?.id).toBe("pending"); + }); + + it("returns the latest rejected request when nothing is pending", async () => { + const result = await subject([rejected]).findLatestOpenByCompanyId( + COMPANY_ID, + ); + + expect(result?.id).toBe("rejected"); + }); + + it("returns null once a resubmit of a rejected request is approved", async () => { + const approved: Row = { + id: "approved", + status: ChangeRequestStatus.Approved, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + }; + + const result = await subject([ + rejected, + approved, + ]).findLatestOpenByCompanyId(COMPANY_ID); + + expect(result).toBeNull(); + }); + + it("returns null when the company has no requests", async () => { + const result = await subject([]).findLatestOpenByCompanyId(COMPANY_ID); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts index 24d988452..eb44d56cb 100644 --- a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts @@ -28,18 +28,23 @@ export class CompanyChangeRequestRepository extends BaseRepository { const pending = await this.findPendingByCompanyId(companyId); if (pending) return pending; - return this.repository.findOne({ - where: { companyId, status: ChangeRequestStatus.Rejected }, + const latest = await this.repository.findOne({ + where: { companyId }, order: { createdAt: "DESC" }, }); + return latest?.status === ChangeRequestStatus.Rejected ? latest : null; } async findById(id: string): Promise { diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts new file mode 100644 index 000000000..167526988 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -0,0 +1,86 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { + NotificationAudience, + NotificationPriority, + NotificationType, +} from "@edr/types"; + +import { Company, CompanyStatus } from "./entities/company.entity"; +import { NotificationsService } from "../notifications/notifications.service"; +import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; + +/** Account statuses that lock the customer out and therefore must be told to them. */ +const PUNITIVE_STATUSES: readonly CompanyStatus[] = [ + CompanyStatus.Suspended, + CompanyStatus.Blacklisted, +]; + +/** + * Customer notifications for company account-status changes. Mirrors + * {@link ContractNotifierService}: SMS + email direct to the company contact, + * plus a persisted in-app item. Every send is fire-and-forget and never throws — + * a notification failure must not roll back the status change itself. + */ +@Injectable() +export class CompanyNotifierService { + private readonly logger = new Logger(CompanyNotifierService.name); + + constructor( + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) {} + + /** Send SMS + email to the company contact; log-only on failure. */ + private async notifyContact(company: Company, message: string): Promise { + const phone = company.contactPersonPhone ?? company.phone ?? null; + const email = company.email ?? company.generalManagerEmail ?? null; + + if (phone) { + try { + await this.notifications.directSend("sms", phone, message); + } catch (err) { + this.logger.warn(`SMS failed for ${company.id}: ${(err as Error).message}`); + } + } + if (email) { + try { + await this.notifications.directSend("email", email, message); + } catch (err) { + this.logger.warn(`Email failed for ${company.id}: ${(err as Error).message}`); + } + } + if (!phone && !email) { + this.logger.warn(`No contact on file for ${company.id} — not notified`); + } + } + + /** + * Tell the customer their account was suspended or blacklisted. Called only on + * a real transition into one of those statuses; other status writes are silent. + */ + statusChanged(company: Company, previous: CompanyStatus): void { + const status = company.status; + if (status === previous) return; + if (!PUNITIVE_STATUSES.includes(status)) return; + + const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted"; + const title = `Account ${label}`; + const body = + `Your company account has been ${label}. ` + + `You will not be able to submit new contracts or bookings. ` + + `Please contact EDR support for assistance.`; + + this.logger.log(`ACCOUNT_${label.toUpperCase()} — ${company.id}`); + void this.notifyContact(company, `${title}. ${body}`); + void this.inbox.notify({ + recipients: { companyId: company.id }, + audience: NotificationAudience.PORTAL, + type: NotificationType.ACCOUNT_STATUS, + title, + body, + link: "/settings", + data: { companyId: company.id, status }, + priority: NotificationPriority.HIGH, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts index 15aec5ac6..9bf14cd61 100644 --- a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, InternalServerErrorException } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; import { BaseRepository } from "@edr/api-common"; @@ -20,6 +20,11 @@ const PREFIX_MAP: Record = { [ProfileType.transporter]: "TR", }; +const SERIES_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +/** Numbers per series letter: A00001..A99999, then B00001. */ +const SERIES_SIZE = 99_999; + @Injectable() export class CompanyProfileRepository extends BaseRepository { constructor( @@ -38,9 +43,20 @@ export class CompanyProfileRepository extends BaseRepository { const result = await this.repository.query( `SELECT nextval('${seqName}') AS next_id`, ); - const nextId = result[0].next_id as number; + const nextId = Number(result[0].next_id); + const offset = nextId - 1; + const seriesIndex = Math.floor(offset / SERIES_SIZE); + + if (seriesIndex >= SERIES_LETTERS.length) { + throw new InternalServerErrorException( + `Company profile reference series exhausted for type "${type}"`, + ); + } + + const letter = SERIES_LETTERS[seriesIndex]; + const number = (offset % SERIES_SIZE) + 1; const prefix = PREFIX_MAP[type]; - return `${prefix}-${String(nextId).padStart(5, "0")}`; + return `${prefix}-${letter}${String(number).padStart(5, "0")}`; } async findByCompanyId(companyId: string): Promise { diff --git a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts index 579ac6ddd..4a931dae3 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/change-request-response.dto.ts @@ -1,6 +1,7 @@ import { ChangeRequestStatus, CompanyChangeRequest, + DocumentChangeIntent, LicenseChangeIntent, } from "../entities/company-change-request.entity"; @@ -18,6 +19,8 @@ export class ChangeRequestResponseDto { documentFileIds: string[]; /** Staged business-license add/remove intents attached to this request. */ licenseChanges: LicenseChangeIntent[]; + /** Staged company-document add/remove intents (e.g. the PoA letter). */ + documentChanges: DocumentChangeIntent[]; note: string | null; submittedBy: string | null; submittedAt: Date | null; @@ -33,6 +36,7 @@ export class ChangeRequestResponseDto { this.snapshot = req.snapshot ?? {}; this.documentFileIds = req.documents?.documentFileIds ?? []; this.licenseChanges = req.documents?.licenseChanges ?? []; + this.documentChanges = req.documents?.documentChanges ?? []; this.note = req.note ?? null; this.submittedBy = req.submittedBy ?? null; this.submittedAt = req.submittedAt ?? null; diff --git a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts index ef7eb2a21..bfe1f9b72 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts @@ -1,6 +1,7 @@ import { CompanyRegistrationData } from "@edr/types"; export class ETradeResponseDto implements CompanyRegistrationData { + companyName!: string; licenceNumber!: string; statusDescription!: string; dateRegistered!: string; @@ -20,6 +21,7 @@ export class ETradeResponseDto implements CompanyRegistrationData { tinTaken?: boolean; constructor(data: CompanyRegistrationData) { + this.companyName = data.companyName; this.licenceNumber = data.licenceNumber; this.statusDescription = data.statusDescription; this.dateRegistered = data.dateRegistered; diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index 92f9fa513..da908a177 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -35,6 +35,19 @@ export interface OnboardingLicenseProfile { uploaded: boolean; } +export interface OnboardingPoaState { + /** True when the company operates as a freight forwarder — PoA is mandatory. */ + required: boolean; + /** True once any PoA detail has been entered. */ + provided: boolean; + /** True when the delegation letter is stored for the company. */ + delegationLetterUploaded: boolean; + /** PoA details still missing (only populated when `required`). */ + missingFields: OnboardingInfoField[]; + /** False while the PoA step still owes details or a delegation letter. */ + complete: boolean; +} + export class OnboardingRequirementsResponseDto { /** Resolved document setting code (by nationality) the docs were drawn from. */ documentSettingCode: string; @@ -52,6 +65,9 @@ export class OnboardingRequirementsResponseDto { /** Per-operational-profile business-license requirements. */ licenseProfiles: OnboardingLicenseProfile[]; + /** Power of Attorney state, so the wizard needn't re-derive the rule. */ + poa: OnboardingPoaState; + /** Overall setup progress across fields + documents + licenses. */ progress: { completed: number; total: number }; @@ -70,6 +86,7 @@ export class OnboardingRequirementsResponseDto { this.companyInfo = init.companyInfo; this.documents = init.documents; this.licenseProfiles = init.licenseProfiles; + this.poa = init.poa; this.progress = init.progress; this.isComplete = init.isComplete; this.onboardingCompleted = init.onboardingCompleted; diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts index cec670787..5ee6739de 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts @@ -30,12 +30,34 @@ export interface LicenseChangeIntent { fileName?: string; } +/** + * A staged change to a company-level document, awaiting review. Same semantics + * as {@link LicenseChangeIntent} but keyed by the document's FileRecord `code` + * (e.g. `poa_delegation_letter`) rather than a profile: `add` → uploaded under + * the pending code, promoted to `code` on approval; `remove` → a live file that + * is deleted on approval. A replace is a `remove` plus an `add`. + */ +export interface DocumentChangeIntent { + op: "add" | "remove"; + fileId: string; + /** The live FileRecord code this op targets (the upload setting's fileKey). */ + code: string; + /** File name, snapshotted for the backoffice review screen. */ + fileName?: string; +} + /** File references staged alongside a change request (documents/licenses). */ export interface ChangeRequestDocuments { - /** FileRecord ids uploaded against the company while this request was open. */ + /** + * FileRecord ids uploaded against the company while this request was open. + * These go live immediately — only their ids are recorded, for the reviewer. + * Contrast `documentChanges`, which stages the file behind the pending code. + */ documentFileIds?: string[]; /** Staged per-profile business-license add/remove intents. */ licenseChanges?: LicenseChangeIntent[]; + /** Staged company-level document add/remove intents (e.g. the PoA letter). */ + documentChanges?: DocumentChangeIntent[]; } @Entity({ schema: "freight", name: "company_change_request" }) diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index 72696766f..ebda7a0b9 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -31,17 +31,28 @@ export interface BusinessLicenseFile { mimeType?: string; } +/** + * `live` — approved & in effect; `pending_add` — uploaded, awaiting approval; + * `pending_remove` — live but flagged for deletion on approval. + */ +export type StagedFileStatus = "live" | "pending_add" | "pending_remove"; + /** A business-license file plus its change-review state, surfaced to clients. */ export interface ProfileLicenseFileView { id: string; name: string; size: number; mimeType: string; - /** - * `live` — approved & in effect; `pending_add` — uploaded, awaiting approval; - * `pending_remove` — live but flagged for deletion on approval. - */ - status: "live" | "pending_add" | "pending_remove"; + status: StagedFileStatus; +} + +/** A company-level document (e.g. the PoA letter) with its change-review state. */ +export interface CompanyDocumentFileView { + id: string; + name: string; + size: number; + mimeType: string; + status: StagedFileStatus; } @Entity({ schema: "freight", name: "company_profiles" }) @@ -60,7 +71,7 @@ export class CompanyProfile extends BaseEntity { type!: ProfileType; /** - * Official profile reference (e.g. "EX-00001"). Minted only when the profile + * Official profile reference (e.g. "EX-A00001"). Minted only when the profile * is approved (status → Active); pending/unapproved profiles carry NULL. * The unique index tolerates this because Postgres treats NULLs as distinct. * API responses surface it as "" when absent — see ResponseCompanyProfileDto. @@ -73,11 +84,17 @@ export class CompanyProfile extends BaseEntity { }) reference!: string | null; + /** + * A newly requested operational role is unreviewed, so it defaults to Pending. + * Only {@link CompaniesService.setCompanyProfileStatus} may promote it to + * Active — an approved-by-default role would let a customer self-grant a + * service (e.g. importer) without any documentation review. + */ @Column({ name: "status", type: "varchar", length: 32, - default: ProfileStatus.Active, + default: ProfileStatus.Pending, }) status!: ProfileStatus; diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts index 15054c701..b588c241f 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -87,12 +87,21 @@ export class ETradeService { } } + /** + * `companyInfo` carries the registered organization name (`BusinessName`); + * `businessInfo` only carries the licence's `TradeName`. Pass both so the + * company name resolves to the legal entity rather than the trade name — and + * never to `ManagerNameEng`, which is the manager's personal name. + */ extractRegistrationData( businessInfo: ETradeBusinessInfo, + companyInfo?: ETradeCompanyInfo, ): CompanyRegistrationData { const primaryManager = businessInfo.AssociateShortInfos?.[0]; return { + companyName: + companyInfo?.BusinessName?.trim() || businessInfo.TradeName?.trim() || "", licenceNumber: businessInfo.LicenceNumber, statusDescription: businessInfo.StatusDescription, dateRegistered: businessInfo.DateRegistered, diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts new file mode 100644 index 000000000..8cf1c5671 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts @@ -0,0 +1,97 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Put, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { FreightAdmin } from "../../common/booking-guards"; +import { ContractTemplatesService } from "./contract-templates.service"; +import { + CreateArticleDto, + PreviewContractTemplateDto, + ReplaceArticlesDto, + UpdateArticleDto, + UpdateContractTemplateDto, +} from "./dto/contract-template.dto"; + +@ApiTags("contract-templates") +@Controller("contract-templates") +export class ContractTemplatesController { + constructor(private readonly service: ContractTemplatesService) {} + + // Reads stay open to authenticated staff (the backoffice Templates tab); + // writes are admin-guarded like other freight configuration resources. + + @Get() + @ApiOperation({ summary: "List the six contract document templates" }) + list() { + return this.service.list(); + } + + @Get(":code") + @ApiOperation({ summary: "Get one contract template by code" }) + getByCode(@Param("code") code: string) { + return this.service.getByCode(code); + } + + @Patch(":code") + @FreightAdmin() + @ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" }) + update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) { + return this.service.update(code, dto); + } + + @Post(":code/preview") + @ApiOperation({ + summary: "Render an HTML preview of the template against mock contract data", + }) + preview( + @Param("code") code: string, + @Body() dto: PreviewContractTemplateDto, + ) { + return this.service.preview(code, dto); + } + + /* ------------------------- article routes ------------------------- */ + + @Put(":code/articles") + @FreightAdmin() + @ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" }) + replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) { + return this.service.replaceArticles(code, dto.articles); + } + + @Post(":code/articles") + @FreightAdmin() + @ApiOperation({ summary: "Add an article to the template" }) + addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) { + return this.service.addArticle(code, dto); + } + + @Patch(":code/articles/:articleId") + @FreightAdmin() + @ApiOperation({ summary: "Update an article's title or body" }) + updateArticle( + @Param("code") code: string, + @Param("articleId") articleId: string, + @Body() dto: UpdateArticleDto, + ) { + return this.service.updateArticle(code, articleId, dto); + } + + @Delete(":code/articles/:articleId") + @FreightAdmin() + @ApiOperation({ summary: "Remove an article from the template" }) + removeArticle( + @Param("code") code: string, + @Param("articleId") articleId: string, + ) { + return this.service.removeArticle(code, articleId); + } +} diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.module.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.module.ts new file mode 100644 index 000000000..d2a658ca8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.module.ts @@ -0,0 +1,21 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { ContractRendererService } from "../../contracts/contract-renderer.service"; +import { ContractTemplatesController } from "./contract-templates.controller"; +import { ContractTemplatesRepository } from "./contract-templates.repository"; +import { ContractTemplatesService } from "./contract-templates.service"; +import { ContractTemplate } from "./entities/contract-template.entity"; + +@Module({ + imports: [TypeOrmModule.forFeature([ContractTemplate])], + controllers: [ContractTemplatesController], + providers: [ + ContractTemplatesRepository, + ContractTemplatesService, + // Stateless Handlebars renderer reused from src/contracts for previews. + ContractRendererService, + ], + exports: [ContractTemplatesService], +}) +export class ContractTemplatesModule {} diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts new file mode 100644 index 000000000..2f4fb0117 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts @@ -0,0 +1,31 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { + ContractTemplate, + ContractTemplateCode, +} from "./entities/contract-template.entity"; + +@Injectable() +export class ContractTemplatesRepository extends BaseRepository { + constructor( + @InjectRepository(ContractTemplate) + repository: Repository, + ) { + super(repository); + } + + findByCode(code: ContractTemplateCode): Promise { + return this.repository.findOne({ where: { code } }); + } + + override findAll(): Promise { + return this.repository.find({ order: { code: "ASC" } }); + } + + async saveTemplate(template: ContractTemplate): Promise { + return this.repository.save(template); + } +} diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.spec.ts new file mode 100644 index 000000000..0478db102 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.spec.ts @@ -0,0 +1,66 @@ +import { ContractRendererService } from "../../contracts/contract-renderer.service"; +import { CONTRACT_TEMPLATE_DEFAULTS } from "../../seed/data/contract-template-defaults"; +import { ContractTemplatesService } from "./contract-templates.service"; +import { ContractTemplatesRepository } from "./contract-templates.repository"; +import { + ContractTemplate, + contractTemplateCodeFor, +} from "./entities/contract-template.entity"; + +function seededTemplate(code: string): ContractTemplate { + const seed = CONTRACT_TEMPLATE_DEFAULTS.find((t) => t.code === code)!; + return { + id: "00000000-0000-0000-0000-000000000001", + code: seed.code, + name: seed.name, + description: seed.description, + documentTitle: seed.documentTitle, + whereasClauses: seed.whereasClauses, + articles: seed.articles.map((article, index) => ({ ...article, order: index + 1 })), + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + } as ContractTemplate; +} + +describe("contractTemplateCodeFor", () => { + it("maps every direction/freight pair to one of the six codes", () => { + expect(contractTemplateCodeFor("IMPORT", "BULK")).toBe("IMPORT_BULK"); + expect(contractTemplateCodeFor("EXPORT", "CONTAINER")).toBe("EXPORT_CONTAINER"); + expect(contractTemplateCodeFor("DOMESTIC", "CONTAINER")).toBe("INTERCITY_CONTAINER"); + expect(contractTemplateCodeFor("DOMESTIC", "BULK")).toBe("INTERCITY_BULK"); + expect(contractTemplateCodeFor(null, null)).toBe("INTERCITY_CONTAINER"); + }); +}); + +describe("ContractTemplatesService.preview", () => { + const renderer = new ContractRendererService(); + renderer.onModuleInit(); + + const repository = { + findByCode: jest.fn((code: string) => Promise.resolve(seededTemplate(code))), + } as unknown as ContractTemplatesRepository; + + const service = new ContractTemplatesService(repository, renderer); + + it.each(CONTRACT_TEMPLATE_DEFAULTS.map((t) => [t.code] as const))( + "renders a complete mock preview for %s", + async (code) => { + const { html } = await service.preview(code); + expect(html).toContain("Article 1"); + expect(html).toContain("Article 13"); + expect(html).toContain("Abyssinia Trading PLC"); + expect(html).toContain("Annex A — Commercial Schedule"); + // No unrendered handlebars placeholders may leak into the document. + expect(html).not.toContain("{{"); + // Greenish theme applied. + expect(html).toContain("#1b9e7a"); + }, + ); + + it("interpolates {{contractYear}} inside seeded article bodies", async () => { + const { html } = await service.preview("IMPORT_BULK"); + expect(html).toContain(`August 31, ${new Date().getFullYear()}`); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts new file mode 100644 index 000000000..d2aea9bc7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts @@ -0,0 +1,276 @@ +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { randomUUID } from "node:crypto"; + +import { ContractRendererService } from "../../contracts/contract-renderer.service"; +import { getTemplateMeta } from "../../contracts/contract-template.registry"; +import { + ContractDynamicTemplateView, + ContractViewModel, +} from "../../contracts/contract-view-model.builder"; +import { ContractTemplatesRepository } from "./contract-templates.repository"; +import { + CreateArticleDto, + PreviewContractTemplateDto, + ReplaceArticleDto, + UpdateArticleDto, + UpdateContractTemplateDto, +} from "./dto/contract-template.dto"; +import { + CONTRACT_TEMPLATE_CODES, + ContractTemplate, + ContractTemplateArticle, + ContractTemplateCode, + contractTemplateCodeFor, +} from "./entities/contract-template.entity"; + +/** Registry keys used to derive labels for the mock preview per template code. */ +const PREVIEW_TEMPLATE_KEYS: Record = { + IMPORT_BULK: "IMP_BULK_USD_FORWARDING", + EXPORT_BULK: "EXP_BULK_USD_TRANSPORT_ONLY", + INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY", + IMPORT_CONTAINER: "IMP_CON_USD_TRANSPORT_ONLY", + EXPORT_CONTAINER: "EXP_CON_USD_FORWARDING", + INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY", +}; + +@Injectable() +export class ContractTemplatesService { + constructor( + private readonly repository: ContractTemplatesRepository, + private readonly renderer: ContractRendererService, + ) {} + + async list(): Promise { + const templates = await this.repository.findAll(); + const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const)); + return templates.sort( + (a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99), + ); + } + + async getByCode(code: string): Promise { + const template = await this.repository.findByCode(this.assertCode(code)); + if (!template) { + throw new NotFoundException(`Contract template ${code} not found`); + } + return template; + } + + /** + * The active template used when generating a contract document for the given + * direction/freight pair; null when missing or deactivated (the renderer then + * falls back to the built-in generic layout). + */ + async findActiveForContract( + tradeDirection?: string | null, + freightType?: string | null, + ): Promise { + const code = contractTemplateCodeFor(tradeDirection, freightType); + const template = await this.repository.findByCode(code); + return template?.isActive ? template : null; + } + + async update(code: string, dto: UpdateContractTemplateDto): Promise { + const template = await this.getByCode(code); + if (dto.name !== undefined) template.name = dto.name; + if (dto.description !== undefined) template.description = dto.description; + if (dto.documentTitle !== undefined) template.documentTitle = dto.documentTitle; + if (dto.whereasClauses !== undefined) template.whereasClauses = dto.whereasClauses; + if (dto.isActive !== undefined) template.isActive = dto.isActive; + return this.repository.saveTemplate(template); + } + + async addArticle(code: string, dto: CreateArticleDto): Promise { + const template = await this.getByCode(code); + const articles = this.sorted(template.articles); + const article: ContractTemplateArticle = { + id: randomUUID(), + title: dto.title, + body: dto.body, + order: 0, + }; + const index = + dto.position && dto.position <= articles.length ? dto.position - 1 : articles.length; + articles.splice(index, 0, article); + template.articles = this.renumber(articles); + return this.repository.saveTemplate(template); + } + + async updateArticle( + code: string, + articleId: string, + dto: UpdateArticleDto, + ): Promise { + const template = await this.getByCode(code); + const article = template.articles.find((item) => item.id === articleId); + if (!article) { + throw new NotFoundException(`Article ${articleId} not found on template ${code}`); + } + if (dto.title !== undefined) article.title = dto.title; + if (dto.body !== undefined) article.body = dto.body; + template.articles = this.renumber(this.sorted(template.articles)); + return this.repository.saveTemplate(template); + } + + async removeArticle(code: string, articleId: string): Promise { + const template = await this.getByCode(code); + const remaining = template.articles.filter((item) => item.id !== articleId); + if (remaining.length === template.articles.length) { + throw new NotFoundException(`Article ${articleId} not found on template ${code}`); + } + template.articles = this.renumber(this.sorted(remaining)); + return this.repository.saveTemplate(template); + } + + /** Replace the full ordered article list (also how the editor reorders). */ + async replaceArticles( + code: string, + articles: ReplaceArticleDto[], + ): Promise { + const template = await this.getByCode(code); + template.articles = this.renumber( + articles.map((item) => ({ + id: item.id ?? randomUUID(), + title: item.title, + body: item.body, + order: 0, + })), + ); + return this.repository.saveTemplate(template); + } + + /** + * Render the template against a representative mock contract so admins can + * see the final document without touching a real contract. Draft overrides + * allow previewing unsaved editor state. + */ + async preview( + code: string, + overrides?: PreviewContractTemplateDto, + ): Promise<{ html: string }> { + const template = await this.getByCode(code); + + const dynamicTemplate: ContractDynamicTemplateView = { + code: template.code, + name: overrides?.name ?? template.name, + documentTitle: overrides?.documentTitle ?? template.documentTitle, + whereasClauses: overrides?.whereasClauses ?? template.whereasClauses, + articles: overrides?.articles + ? overrides.articles.map((item, index) => ({ + id: item.id ?? randomUUID(), + title: item.title, + body: item.body, + order: index + 1, + })) + : this.sorted(template.articles), + }; + + const view = this.buildMockView(template.code, dynamicTemplate); + return { html: this.renderer.render(view) }; + } + + private buildMockView( + code: ContractTemplateCode, + dynamicTemplate: ContractDynamicTemplateView, + ): ContractViewModel { + const meta = getTemplateMeta(PREVIEW_TEMPLATE_KEYS[code]); + const isBulk = code.endsWith("_BULK"); + const now = new Date(); + + const unitRates = isBulk + ? [ + { label: "Rail transport — per metric ton", unitPrice: 59.4, unit: "ton", currency: "USD" }, + { label: "Origin handling and documentation", unitPrice: 18, unit: "ton", currency: "USD" }, + { label: "Lashing material (when provided by EDR)", unitPrice: 150, unit: "unit", currency: "USD" }, + ] + : [ + { label: "Rail transport — 40ft container", unitPrice: 1916, unit: "container", currency: "USD" }, + { label: "Rail transport — 2 × 20ft containers", unitPrice: 1944, unit: "container", currency: "USD" }, + { label: "Excess tonnage surcharge", unitPrice: 10, unit: "ton", currency: "USD" }, + ]; + + return { + bookingId: "00000000-0000-0000-0000-000000000000", + reference: "EDR/CT/2026/0042", + status: "CONTRACT_READY", + templateKey: PREVIEW_TEMPLATE_KEYS[code], + template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" }, + contractDate: now.toLocaleDateString("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + }), + contractYear: now.getFullYear(), + client: { + companyName: "Abyssinia Trading PLC", + companyAddress: "Bole Sub-city, Woreda 03, H.No 1234, Addis Ababa", + companyLocation: "Ethiopia", + phone: "+251 91 123 4567", + email: "logistics@abyssiniatrading.et", + tinNumber: "0011223344", + vatNumber: "VAT-556677", + fanNumber: "FAN-889900", + businessLicense: "BL/AA/12/345678", + }, + provider: { + name: "Ethio-Djibouti Standard Gauge Railway Share Company", + address: "Nifas Silk Lafto Sub City, Addis Ababa, Ethiopia", + phone: "+251 11 872 0000", + email: "info@edr.gov.et", + tinNumber: "—", + }, + schedule: { + originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station", + destinationLabel: "Galaan Multipurpose Port (GMP)", + tradeDirection: code.startsWith("IMPORT") + ? "IMPORT" + : code.startsWith("EXPORT") + ? "EXPORT" + : "DOMESTIC", + freightType: isBulk ? "BULK" : "CONTAINER", + serviceType: "Rail transport and customs clearance", + scheduledDate: "—", + contractType: "GENERAL", + cargoDescription: isBulk ? "Steel billets — 2,800 MT" : "40ft containers — FMCG cargo", + totalWeightVgm: "—", + equipmentReturn: isBulk ? "—" : "With empty return", + hazardousLabel: "No", + firstMilePickupAddress: "—", + lastMileDeliveryAddress: "—", + }, + pricing: { + displayMode: "UNIT_RATES", + unitRates, + currency: "USD", + equipmentReturn: isBulk ? "—" : "With empty return", + originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station", + destinationLabel: "Galaan Multipurpose Port (GMP)", + } as unknown as ContractViewModel["pricing"], + signatures: [], + canSignCustomer: false, + canSignStaff: false, + hasContractDocument: false, + hasCustomerSignature: false, + hasStaffSignature: false, + dynamicTemplate, + }; + } + + private assertCode(code: string): ContractTemplateCode { + const upper = code?.toUpperCase() as ContractTemplateCode; + if (!CONTRACT_TEMPLATE_CODES.includes(upper)) { + throw new BadRequestException( + `Unknown contract template code "${code}". Valid codes: ${CONTRACT_TEMPLATE_CODES.join(", ")}`, + ); + } + return upper; + } + + private sorted(articles: ContractTemplateArticle[]): ContractTemplateArticle[] { + return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + } + + private renumber(articles: ContractTemplateArticle[]): ContractTemplateArticle[] { + return articles.map((article, index) => ({ ...article, order: index + 1 })); + } +} diff --git a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts new file mode 100644 index 000000000..0ea69f262 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts @@ -0,0 +1,134 @@ +import { ApiPropertyOptional, ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsArray, + IsBoolean, + IsInt, + IsOptional, + IsString, + MaxLength, + Min, + MinLength, + ValidateNested, +} from "class-validator"; + +export class UpdateContractTemplateDto { + @ApiPropertyOptional({ description: "Display name of the template" }) + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(200) + name?: string; + + @ApiPropertyOptional({ description: "Short description shown on the template card" }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ description: "Cover-page service title of the generated document" }) + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(300) + documentTitle?: string; + + @ApiPropertyOptional({ description: "WHEREAS recitals", type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + whereasClauses?: string[]; + + @ApiPropertyOptional({ description: "Whether the template is used for generation" }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class CreateArticleDto { + @ApiProperty({ description: "Article heading (without the Article N prefix)" }) + @IsString() + @MinLength(2) + @MaxLength(200) + title!: string; + + @ApiProperty({ + description: + 'Article body. One clause per line; prefix a line with "- " to nest it as a bullet under the previous clause.', + }) + @IsString() + @MinLength(2) + body!: string; + + @ApiPropertyOptional({ description: "1-based position to insert at (appends when omitted)" }) + @IsOptional() + @IsInt() + @Min(1) + position?: number; +} + +export class UpdateArticleDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(200) + title?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MinLength(2) + body?: string; +} + +export class ReplaceArticleDto { + @ApiPropertyOptional({ description: "Existing article id (new id assigned when omitted)" }) + @IsOptional() + @IsString() + id?: string; + + @ApiProperty() + @IsString() + @MinLength(2) + @MaxLength(200) + title!: string; + + @ApiProperty() + @IsString() + @MinLength(2) + body!: string; +} + +export class ReplaceArticlesDto { + @ApiProperty({ type: [ReplaceArticleDto], description: "Full ordered article list" }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ReplaceArticleDto) + articles!: ReplaceArticleDto[]; +} + +/** Optional draft overrides so the editor can preview unsaved changes. */ +export class PreviewContractTemplateDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + documentTitle?: string; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + whereasClauses?: string[]; + + @ApiPropertyOptional({ type: [ReplaceArticleDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ReplaceArticleDto) + articles?: ReplaceArticleDto[]; +} diff --git a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts new file mode 100644 index 000000000..73729fbb6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts @@ -0,0 +1,77 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index } from "typeorm"; + +/** + * The six canonical contract document templates, one per + * (trade direction × freight type) combination. Contracts store DOMESTIC for + * intercity movements; the template layer labels those INTERCITY to match the + * commercial vocabulary used on the printed documents. + */ +export const CONTRACT_TEMPLATE_CODES = [ + "IMPORT_BULK", + "EXPORT_BULK", + "INTERCITY_BULK", + "IMPORT_CONTAINER", + "EXPORT_CONTAINER", + "INTERCITY_CONTAINER", +] as const; + +export type ContractTemplateCode = (typeof CONTRACT_TEMPLATE_CODES)[number]; + +/** + * One dynamic article on a contract template. `body` is plain multiline text: + * each non-empty line renders as a numbered clause; lines prefixed with "- " + * render as bullet points nested under the preceding clause. A single-line + * body renders as an unnumbered paragraph. Handlebars placeholders (e.g. + * {{client.companyName}}, {{contractDate}}, {{contractYear}}, {{reference}}) + * are interpolated against the contract view model at render time. + */ +export interface ContractTemplateArticle { + id: string; + title: string; + body: string; + order: number; +} + +/** Map a contract's stored direction/freight pair onto a template code. */ +export function contractTemplateCodeFor( + tradeDirection?: string | null, + freightType?: string | null, +): ContractTemplateCode { + const direction = + tradeDirection === "IMPORT" + ? "IMPORT" + : tradeDirection === "EXPORT" + ? "EXPORT" + : "INTERCITY"; + const freight = + (freightType ?? "").toUpperCase().includes("BULK") ? "BULK" : "CONTAINER"; + return `${direction}_${freight}` as ContractTemplateCode; +} + +@Entity({ schema: "freight", name: "contract_templates" }) +@Index(["code"], { unique: true }) +export class ContractTemplate extends BaseEntity { + @Column({ name: "code", type: "varchar", length: 40, unique: true }) + code!: ContractTemplateCode; + + @Column({ name: "name", type: "varchar", length: 200 }) + name!: string; + + @Column({ name: "description", type: "text", nullable: true }) + description?: string | null; + + /** Cover-page service line, e.g. "Steel Billet Transportation and Customs Clearance Services". */ + @Column({ name: "document_title", type: "varchar", length: 300 }) + documentTitle!: string; + + /** WHEREAS recitals rendered between the parties block and the articles. */ + @Column({ name: "whereas_clauses", type: "jsonb", default: () => "'[]'" }) + whereasClauses!: string[]; + + @Column({ name: "articles", type: "jsonb", default: () => "'[]'" }) + articles!: ContractTemplateArticle[]; + + @Column({ name: "is_active", type: "boolean", default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts index 0ae829529..d0705bfb1 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts @@ -22,12 +22,15 @@ export class BookingRequestRepository extends BaseRepository { }); } - /** GL queue: pending requests across all contracts, oldest first. */ - async findPending(): Promise { + /** + * GL queue: every request across all contracts, newest first. The queue page + * filters by status client-side (pending work vs accepted/rejected history), + * and surfaces the customer — so the contract's company rides along. + */ + async findQueue(): Promise { return this.repository.find({ - where: { status: 'PENDING' }, - order: { createdAt: 'ASC' }, - relations: { contract: true }, + order: { createdAt: 'DESC' }, + relations: { contract: { company: true } }, }); } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index 4752b004f..17270c738 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -51,6 +51,11 @@ export class BookingRequestService { const contract = await this.contractsService.findById(contractId); await this.contractsService.assertCustomerCanAccessContract(userId, contract); this.assertGeneralCustoms(contract); + if (contract.status === 'CONTRACT_CLOSED') { + throw new ConflictException( + 'This contract is completed — the full contracted quantity has been booked.', + ); + } if (contract.status !== 'CONTRACT_ACTIVE') { throw new ConflictException( 'The contract must be active before requesting a shipment.', @@ -134,7 +139,7 @@ export class BookingRequestService { } queue(): Promise { - return this.repo.findPending(); + return this.repo.findQueue(); } private async findPending(requestId: string): Promise { diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts new file mode 100644 index 000000000..eb77533ac --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts @@ -0,0 +1,68 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; + +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import type { ClearanceMilestone } from './entities/clearance-milestone.entity'; + +type Status = 'PENDING' | 'COMPLETED' | 'SKIPPED'; + +/** + * Risk assignment is gated on the T1 being closed (catalog order + * T1_CLOSED → RISK_ASSIGNED): customs cannot rate cargo still under transit. + */ +function makeService(t1Status: Status | 'MISSING') { + const rows = new Map(); + if (t1Status !== 'MISSING') { + rows.set('T1_CLOSED', { milestoneCode: 'T1_CLOSED', status: t1Status } as ClearanceMilestone); + } + const risk = { milestoneCode: 'RISK_ASSIGNED', status: 'PENDING' } as ClearanceMilestone; + rows.set('RISK_ASSIGNED', risk); + + const repo = { + findOne: jest.fn(({ where }: { where: { milestoneCode: string } }) => + Promise.resolve(rows.get(where.milestoneCode) ?? null), + ), + save: jest.fn((m: ClearanceMilestone) => Promise.resolve(m)), + }; + const dataSource = { getRepository: () => repo } as unknown as DataSource; + return { service: new ClearanceMilestoneService(dataSource), repo, risk }; +} + +describe('ClearanceMilestoneService.assignRisk', () => { + it('rejects the assignment while the T1 is still open', async () => { + const { service, repo } = makeService('PENDING'); + + await expect(service.assignRisk('b-1', 'GREEN')).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('rejects the assignment when the booking has no T1_CLOSED milestone', async () => { + const { service, repo } = makeService('MISSING'); + + await expect(service.assignRisk('b-1', 'GREEN')).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('assigns the risk level once the T1 is closed', async () => { + const { service, risk } = makeService('COMPLETED'); + + const saved = await service.assignRisk('b-1', 'RED', 'user-1'); + + expect(saved.status).toBe('COMPLETED'); + expect(saved.metadata?.riskLevel).toBe('RED'); + expect(risk.triggeredByUserId).toBe('user-1'); + }); + + it('assigns the risk level when the T1 step was skipped', async () => { + const { service } = makeService('SKIPPED'); + + const saved = await service.assignRisk('b-1', 'YELLOW'); + + expect(saved.status).toBe('COMPLETED'); + expect(saved.metadata?.riskLevel).toBe('YELLOW'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index 4a58e50be..81ed305e4 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { @@ -181,6 +181,10 @@ export class ClearanceMilestoneService { * Assign a customs risk level (GREEN/YELLOW/RED) and complete the RISK_ASSIGNED * milestone on a booking (GL Import US-04 / §11.3 #19). Stores the level in the * milestone metadata so the timeline shows it. + * + * Customs cannot risk-rate cargo still moving under transit: the T1 must be + * closed (accepted by GL Ethiopia after the train arrives) first, which is the + * catalog order T1_CLOSED → RISK_ASSIGNED. */ async assignRisk( bookingId: string, @@ -188,9 +192,22 @@ export class ClearanceMilestoneService { userId?: string, note?: string, ): Promise { + await this.assertT1Closed(bookingId); return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note); } + /** Guard: the booking's T1 must be closed before customs risk can be assigned. */ + private async assertT1Closed(bookingId: string): Promise { + const t1 = await this.repo.findOne({ + where: { bookingId, milestoneCode: 'T1_CLOSED' }, + }); + if (t1?.status !== 'COMPLETED' && t1?.status !== 'SKIPPED') { + throw new BadRequestException( + 'The T1 must be closed before a customs risk level can be assigned.', + ); + } + } + /** * Advise duty & tax (amount + declaration serial) and complete the * DUTY_TAXES_ADVISED milestone (§11.3 #6). The customer then uploads the diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts new file mode 100644 index 000000000..4e21078ff --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -0,0 +1,150 @@ +import { BadRequestException } from '@nestjs/common'; + +import { ContractBookingService } from './contract-booking.service'; +import { Contract } from './entities/contract.entity'; + +/** + * Contract auto-completion by quantity cap. Once a GENERAL contract's capped + * scope is fully consumed (e.g. a split remainder rebooked), the contract moves + * to CONTRACT_CLOSED even inside its validity window, and further bookings are + * blocked — including while a booking window is open. Released capacity + * (cancelled/expired booking) reopens the contract on the next attempt. + */ +describe('ContractBookingService — quantity-cap completion', () => { + function makeService() { + const contractsRepository = { + findByIdWithRelations: jest.fn(), + update: jest.fn().mockResolvedValue(undefined), + }; + const service = new ContractBookingService( + contractsRepository as never, + {} as never, // bookingsRepository + {} as never, // bookingPricingService + {} as never, // consolidationService + {} as never, // containerTypesService + {} as never, // ruleEngineService + {} as never, // milestoneService + {} as never, // workflowService + {} as never, // invoiceService + {} as never, // dataSource + {} as never, // trainSchedulingService + {} as never, // bookingTransitionService + ); + return { service, contractsRepository }; + } + + type WithPrivate = { + maybeCompleteContract: (c: Contract) => Promise; + }; + + const generalContract = (status: string): Contract => + ({ + id: 'c-1', + reference: 'CTR-1', + contractKind: 'GENERAL', + status, + }) as Contract; + + it('closes a GENERAL contract when every capped line is exhausted', async () => { + const { service, contractsRepository } = makeService(); + jest.spyOn(service, 'computeCapacity').mockResolvedValue([ + { containerSize: '20FT', cap: 10, booked: 10, remaining: 0 }, + { containerSize: '40FT', cap: 4, booked: 4, remaining: 0 }, + ]); + + await (service as never as WithPrivate).maybeCompleteContract( + generalContract('CONTRACT_ACTIVE'), + ); + + expect(contractsRepository.update).toHaveBeenCalledWith('c-1', { + status: 'CONTRACT_CLOSED', + }); + }); + + it('absorbs bulk-ton float dust when judging exhaustion', async () => { + const { service, contractsRepository } = makeService(); + jest + .spyOn(service, 'computeCapacity') + .mockResolvedValue([{ cap: 100, booked: 99.9995, remaining: 0.0005 }]); + + await (service as never as WithPrivate).maybeCompleteContract( + generalContract('FULLY_EXECUTED'), + ); + + expect(contractsRepository.update).toHaveBeenCalledWith('c-1', { + status: 'CONTRACT_CLOSED', + }); + }); + + it('keeps the contract open while any capped line has capacity left', async () => { + const { service, contractsRepository } = makeService(); + jest.spyOn(service, 'computeCapacity').mockResolvedValue([ + { containerSize: '20FT', cap: 10, booked: 10, remaining: 0 }, + { containerSize: '40FT', cap: 4, booked: 3, remaining: 1 }, + ]); + + await (service as never as WithPrivate).maybeCompleteContract( + generalContract('CONTRACT_ACTIVE'), + ); + + expect(contractsRepository.update).not.toHaveBeenCalled(); + }); + + it('never closes an uncapped contract', async () => { + const { service, contractsRepository } = makeService(); + jest.spyOn(service, 'computeCapacity').mockResolvedValue([]); + + await (service as never as WithPrivate).maybeCompleteContract( + generalContract('CONTRACT_ACTIVE'), + ); + + expect(contractsRepository.update).not.toHaveBeenCalled(); + }); + + it('never closes a ONE_TIME contract (single-slot rule governs it)', async () => { + const { service, contractsRepository } = makeService(); + const spy = jest.spyOn(service, 'computeCapacity'); + + await (service as never as WithPrivate).maybeCompleteContract({ + id: 'c-1', + contractKind: 'ONE_TIME', + status: 'FULLY_EXECUTED', + } as Contract); + + expect(spy).not.toHaveBeenCalled(); + expect(contractsRepository.update).not.toHaveBeenCalled(); + }); + + it('rejects a new booking on a completed contract even inside an open window', async () => { + const { service, contractsRepository } = makeService(); + contractsRepository.findByIdWithRelations.mockResolvedValue( + generalContract('CONTRACT_CLOSED'), + ); + jest + .spyOn(service, 'computeCapacity') + .mockResolvedValue([{ cap: 10, booked: 10, remaining: 0 }]); + + await expect( + service.createUnderContract('c-1', {} as never, null, null), + ).rejects.toThrow(BadRequestException); + expect(contractsRepository.update).not.toHaveBeenCalled(); + }); + + it('reopens a completed contract when capacity was released', async () => { + const { service, contractsRepository } = makeService(); + contractsRepository.findByIdWithRelations.mockResolvedValue( + generalContract('CONTRACT_CLOSED'), + ); + jest + .spyOn(service, 'computeCapacity') + .mockResolvedValue([{ cap: 10, booked: 8, remaining: 2 }]); + + // The create path continues past the gate and dies later on the bare mocks — + // only the reopen transition is under test here. + await service.createUnderContract('c-1', {} as never, null, null).catch(() => undefined); + + expect(contractsRepository.update).toHaveBeenCalledWith('c-1', { + status: 'CONTRACT_ACTIVE', + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index a83837350..fa851b409 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -58,6 +58,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => { invoiceService as never, {} as never, // dataSource {} as never, // trainSchedulingService + {} as never, // bookingTransitionService ); return { service, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 55dd5c29f..74d9cb1ea 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -16,6 +16,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity' import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; +import { BookingTransitionService } from '../bookings/booking-transition.service'; import { ConsolidationService } from '../bookings/consolidation.service'; import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; @@ -72,6 +73,8 @@ export class ContractBookingService { private readonly dataSource: DataSource, @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, + @Inject(forwardRef(() => BookingTransitionService)) + private readonly bookingTransitionService: BookingTransitionService, ) {} async createUnderContract( @@ -83,6 +86,24 @@ export class ContractBookingService { const contract = await this.contractsRepository.findByIdWithRelations(contractId); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); + // A contract whose quantity cap was fully booked is completed — no further + // bookings, even while contract validity and a booking window are still + // open. Capacity released after closure (a cancelled/expired booking) + // reopens the contract on the next booking attempt. + if (contract.status === 'CONTRACT_CLOSED') { + const capacity = await this.computeCapacity(contract); + const hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0); + if (!hasRoom) { + throw new BadRequestException( + 'This contract is completed — the full contracted quantity has been booked.', + ); + } + await this.contractsRepository.update(contract.id, { + status: 'CONTRACT_ACTIVE', + } as never); + contract.status = 'CONTRACT_ACTIVE'; + } + // GL Ethiopia is identified by the dedicated contract create-booking permission // (granted to the edr_gl_ethiopia preset). const isGlActor = @@ -122,6 +143,15 @@ export class ContractBookingService { const generalCustoms = contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); + // GENERAL without customs (Path A) ALSO clears per booking: the customer + // uploads his own clearance proof on each booking and Operations reviews it + // (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY → + // requestOperation machine). DOMESTIC has no border, so no gate. + const generalSelfClear = + contract.contractKind === 'GENERAL' && + !contract.customsClearingEnabled && + contract.tradeDirection !== 'DOMESTIC'; + // Intercity (DOMESTIC) bookings ride on a passing import/export train: // there is no window and no date — staff accept them onto a train at // finalize time, so both the window gate and scheduledDate are skipped. @@ -140,9 +170,10 @@ export class ContractBookingService { // Booking-window gate (config-driven): an operations booking may only be // created while the route's booking window is open — import: the day's window // (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours); - // export: within exportBookingLeadHours of departure. Customs Path B bookings - // enter clearance first and are scheduled later, so they are not gated here. - if (!generalCustoms && !isIntercity) { + // export: within exportBookingLeadHours of departure. Bookings that enter the + // clearance gate first (Path B customs AND Path A per-booking self-clearance) + // are scheduled later, so they are not gated here. + if (!generalCustoms && !generalSelfClear && !isIntercity) { await this.trainSchedulingService.assertBookingWindowOpen({ originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, @@ -174,7 +205,10 @@ export class ContractBookingService { companyProfileId: contract.companyProfileId ?? null, isGovernment: contract.isGovernment, governmentInstitution: contract.governmentInstitution ?? null, - status: generalCustoms ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING', + status: + generalCustoms || generalSelfClear + ? 'AWAITING_DOCUMENTS' + : 'OPERATION_REQUEST_PENDING', bookingType: 'ONE_TIME', contractId: contract.id, contractRouteId: route?.id ?? null, @@ -187,7 +221,7 @@ export class ContractBookingService { contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, - equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN', + equipmentReturn: dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN', originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, tradeDirection: contract.tradeDirection, @@ -259,9 +293,10 @@ export class ContractBookingService { const withContainers = await this.bookingsRepository.findByIdWithFiles( booking.id, ); - const intendedStatus = generalCustoms - ? 'AWAITING_DOCUMENTS' - : 'OPERATION_REQUEST_PENDING'; + const intendedStatus = + generalCustoms || generalSelfClear + ? 'AWAITING_DOCUMENTS' + : 'OPERATION_REQUEST_PENDING'; if ( withContainers && freightType === 'CONTAINER' && @@ -277,6 +312,9 @@ export class ContractBookingService { if (!parked.paired) { // Waiting for a partner — stop here. The booking sits in // PENDING_CONSOLIDATION, unbilled and unscheduled, until it pairs. + // A parked booking still holds contract capacity, so the cap may + // already be exhausted by it. + await this.maybeCompleteContract(contract); const pendingResult = await this.bookingsRepository.findByIdWithFiles( booking.id, ); @@ -290,10 +328,233 @@ export class ContractBookingService { generalCustoms, ); + await this.maybeCompleteContract(contract); + const result = await this.bookingsRepository.findByIdWithFiles(booking.id); return { booking: result ?? booking, warnings }; } + /** + * Initiate a BARE booking instance under a GENERAL non-customs contract + * (Path A per-booking self-clearance). One click, zero input: no schedule + * date, no cargo, no window check, no pricing. The instance starts in the + * clearance gate (AWAITING_DOCUMENTS); the customer uploads clearance docs, + * Operations reviews and finalizes, and only then does the customer complete + * the booking (cargo + binding day + window check) via + * {@link completeUnderContract} — the same machinery a one-time shipment uses. + */ + async initiateUnderContract( + contractId: string, + dto: Pick, + user?: { id?: string } | null, + actorPermissions?: unknown, + ): Promise { + const contract = await this.contractsRepository.findByIdWithRelations(contractId); + if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); + + const generalSelfClear = + contract.contractKind === 'GENERAL' && + !contract.customsClearingEnabled && + contract.tradeDirection !== 'DOMESTIC'; + if (!generalSelfClear) { + throw new BadRequestException( + 'Initiate booking applies only to general import/export contracts without customs clearing.', + ); + } + + if (contract.status === 'CONTRACT_CLOSED') { + throw new BadRequestException( + 'This contract is completed — the full contracted quantity has been booked.', + ); + } + + const isGlActor = + actorPermissions != null && + hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking); + const createdByRole = await this.assertGate(contract, isGlActor); + + if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { + throw new BadRequestException('Contract validity has expired — no new bookings.'); + } + + const route = await this.resolveRoute(contract, dto.contractRouteId); + + // Bare instance: no cargo, no date, no price. Draws no contract capacity + // until the customer completes it after clearance. + const booking = await insertWithGeneratedReference( + () => this.generateReference(), + (reference) => + this.bookingsRepository.create({ + reference, + companyId: contract.companyId ?? null, + companyProfileId: contract.companyProfileId ?? null, + isGovernment: contract.isGovernment, + governmentInstitution: contract.governmentInstitution ?? null, + status: 'AWAITING_DOCUMENTS', + bookingType: 'ONE_TIME', + contractId: contract.id, + contractRouteId: route?.id ?? null, + contractKind: contract.contractKind, + createdByRole, + createdByUserId: user?.id ?? null, + scheduledDate: null, + serviceTypeId: contract.serviceTypeId, + paymentCurrency: contract.paymentCurrency, + contractType: 'NEW', + customsClearingEnabled: contract.customsClearingEnabled, + customsClearingAgent: contract.customsClearingAgent ?? null, + equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN', + originYardId: route?.originYardId ?? null, + destinationYardId: route?.destinationYardId ?? null, + tradeDirection: contract.tradeDirection, + freightType: contract.freightType, + cargoTypeId: this.resolveCargoTypeId(contract, {}), + isHazardous: contract.isHazardous, + isReefer: contract.isReefer, + cargoTotalWeightVgm: 0, + firstMilePickupAddress: contract.firstMilePickupAddress ?? null, + firstMilePickupLat: contract.firstMilePickupLat ?? null, + firstMilePickupLng: contract.firstMilePickupLng ?? null, + lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, + lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null, + lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null, + } as never), + ); + + const result = await this.bookingsRepository.findByIdWithFiles(booking.id); + return { booking: result ?? booking, warnings: [] }; + } + + /** + * Complete a bare initiated booking after Operations finalized its per-booking + * clearance (CLEARANCE_READY) or returned it for changes + * (OPERATION_CHANGES_REQUESTED). This is the deferred half of + * {@link createUnderContract}: cargo lines, quantity-cap drawdown, booking + * window + open-departure checks, pricing, consolidation and invoicing all run + * here — the same gates a one-time shipment passes at creation. + */ + async completeUnderContract( + contractId: string, + bookingId: string, + dto: CreateBookingUnderContractDto, + ): Promise { + const contract = await this.contractsRepository.findByIdWithRelations(contractId); + if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); + + const booking = await this.bookingsRepository.findByIdWithFiles(bookingId); + if (!booking || booking.contractId !== contract.id) { + throw new NotFoundException(`Booking ${bookingId} not found on this contract`); + } + if (!['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED'].includes(booking.status)) { + throw new BadRequestException( + 'Clearance must be finalized before the booking can be completed.', + ); + } + if (!dto.scheduledDate) { + throw new BadRequestException('A binding shipment day is required'); + } + if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { + throw new BadRequestException('Contract validity has expired — no new bookings.'); + } + + const freightType = contract.freightType; + const hasCargo = + (booking.bookingContainers?.length ?? 0) > 0 || + Number(booking.cargoTotalWeightVgm) > 0; + const warnings: string[] = []; + + // First completion persists cargo and draws contract capacity; a resubmit + // after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks + // the shipment day. + if (!hasCargo) { + await this.assertWithinQuantityCap(contract, dto); + if (freightType === 'CONTAINER') { + await this.assertWithinMaxCapacity(contract, dto); + await this.assert20ftPairableAtCreate(dto); + await this.persistContainers(booking.id, contract, dto); + } + await this.bookingsRepository.update(booking.id, { + cargoTypeId: this.resolveCargoTypeId(contract, dto), + cargoTotalWeightVgm: this.resolveBulkTons(dto), + ...(dto.equipmentReturn ? { equipmentReturn: dto.equipmentReturn } : {}), + } as never); + + const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); + if (loaded) { + if (freightType === 'CONTAINER') { + await this.applyWeightResults(loaded); + } + const computed = await this.bookingPricingService.computePriceForBooking(loaded); + // A zero price means no contract rate matches — roll the cargo back so + // the instance stays CLEARANCE_READY and can be completed again once + // the contract rates are fixed (the clearance work is not lost). + if (!(computed.totalAmount > 0)) { + await this.bookingsRepository.deleteContainers(booking.id); + await this.bookingsRepository.update(booking.id, { + cargoTotalWeightVgm: 0, + } as never); + throw new BadRequestException( + 'Booking price came out as 0 — no contract rate matches this ' + + 'route/cargo. Set the contract rate and try again.', + ); + } + await this.bookingsRepository.update(booking.id, { + totalAmount: computed.totalAmount, + priorityScore: computed.priorityScore, + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + } as never); + await this.bookingPricingService.createPricingSnapshots( + booking.id, + computed.usedRates, + computed.appliedModifiers, + ); + warnings.push(...computed.warnings); + } + + // Wagon consolidation gate — a partial-wagon 20ft set parks for a partner + // exactly like a drawdown created with cargo does. The shipment day is + // stored first so the pairing event can resume straight into the + // operations queue. + const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id); + if ( + withContainers && + freightType === 'CONTAINER' && + (await this.consolidationService.needsConsolidationFromBooking(withContainers)) + ) { + await this.bookingsRepository.update(booking.id, { + scheduledDate: new Date(dto.scheduledDate), + } as never); + const parked = await this.consolidateDrawdown( + withContainers, + 'OPERATION_REQUEST_PENDING', + ); + warnings.push(parked.message); + if (!parked.paired) { + await this.maybeCompleteContract(contract); + const pendingResult = await this.bookingsRepository.findByIdWithFiles(booking.id); + return { booking: pendingResult ?? booking, warnings }; + } + } + + // Invoice the now-priced booking (idempotent, non-blocking). + await this.finalizeContractBooking(booking.id, contract, false); + await this.maybeCompleteContract(contract); + } + + // Binding day + open-departure validation, status OPERATION_REQUEST_PENDING + // and the staff notification — the exact machine a one-time booking uses. + const completed = await this.bookingTransitionService.requestOperation( + booking.id, + dto.scheduledDate, + ); + return { booking: completed, warnings }; + } + /** * Search for a complementary partner for a parked-eligible drawdown, pair it or * park it in PENDING_CONSOLIDATION with the resume status it should return to. @@ -592,6 +853,44 @@ export class ContractBookingService { }); } + /** + * Complete the contract once its quantity cap is fully consumed. Runs after + * every booking created under a GENERAL contract (including a split remainder + * being rebooked): when no capped scope line has capacity left, the contract + * moves to CONTRACT_CLOSED even though its validity window is still open — + * blocking further bookings and shipment requests, including inside an open + * booking window. Never throws: a status hiccup must not undo the booking + * that was just created. + */ + private async maybeCompleteContract(contract: Contract): Promise { + try { + // ONE_TIME contracts are governed by the single-active-booking slot (and + // are promoted to GENERAL on split), so only GENERAL completes by cap. + if (contract.contractKind !== 'GENERAL') return; + if (!['CONTRACT_ACTIVE', 'FULLY_EXECUTED'].includes(contract.status)) return; + const capacity = await this.computeCapacity(contract); + if (capacity.length === 0) return; // uncapped — completes only by expiry + // 0.001 tolerance absorbs bulk-ton float rounding (split weights round to + // 3 decimals); container caps are integers and unaffected. + const exhausted = capacity.every( + (c) => c.remaining != null && c.remaining <= 0.001, + ); + if (!exhausted) return; + await this.contractsRepository.update(contract.id, { + status: 'CONTRACT_CLOSED', + } as never); + this.logger.log( + `Contract ${contract.reference} quantity cap fully booked — completed; no further bookings within validity.`, + ); + } catch (err) { + this.logger.error( + `Could not evaluate completion for contract ${contract.id}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + /** * Quantities already booked under a contract that still hold capacity. Excludes * bookings that never shipped (CANCELLED / REJECTED / EXPIRED). diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 9f12b937d..4da2677bd 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -350,6 +350,17 @@ export class ContractTransitionService { const updated = await this.contractsService.findById(contractId); if (allDone) { this.notifier.approved(updated); + // Final approval step also generates the contract document from the + // template matching the contract's direction/freight pair. Best-effort: + // a rendering hiccup must not roll back the approval — the document can + // still be generated manually or lazily on view/download. + try { + return await this.generateContract(contractId); + } catch (err) { + this.logger.warn( + `Auto contract generation after final approval failed for ${updated.reference}: ${err}`, + ); + } } return updated; } @@ -582,7 +593,7 @@ export class ContractTransitionService { if (!dto.otpPhone || !dto.otp) { throw new BadRequestException('OTP verification is required to sign the contract'); } - await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp); + await this.otpService.verifyOtpForAction({ phone: dto.otpPhone }, dto.otp); await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', @@ -632,16 +643,15 @@ export class ContractTransitionService { contract.customsClearingEnabled ?? false, ); - // GENERAL + customs (Path B) runs clearance PER BOOKING, not at the contract - // level: there is no contract clearance cycle. The contract just becomes - // active; the customer then files shipment requests and GL books + clears - // each one. ONE_TIME customs and Path A self-clearance keep the contract - // cycle below. - const isGeneralCustoms = - contract.contractKind === 'GENERAL' && - Boolean(contract.customsClearingEnabled); + // GENERAL contracts run clearance PER BOOKING, not at the contract level — + // both paths. Customs (Path B): the customer files shipment requests, GL + // books each one and the booking carries its own clearance. Self-clearance + // (Path A): the customer books, then uploads the clearance docs on that + // booking for Operations to review. Only ONE_TIME contracts keep the + // contract-level cycle below. + const isGeneral = contract.contractKind === 'GENERAL'; - if (clearanceCode && !isGeneralCustoms) { + if (clearanceCode && !isGeneral) { // Open a clearance cycle, seed the pre-booking milestones, and route the // customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the // distinction is enforced at the review/finalize endpoints, not here. @@ -652,8 +662,8 @@ export class ContractTransitionService { updates.clearanceStatus = 'AWAITING_DOCUMENTS'; updates.clearanceCycleNumber = cycleNumber; } else { - // No contract-level clearance gate — DOMESTIC, or GENERAL+customs (which - // clears per booking). Ready for shipment requests / direct booking. + // No contract-level clearance gate — DOMESTIC, or any GENERAL contract + // (which clears per booking). Ready for shipment requests / direct booking. updates.status = contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED'; updates.clearanceStatus = 'NOT_APPLICABLE'; diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 4ea7634b6..d5dc9793e 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -107,7 +107,7 @@ export class ContractsController { @Get('booking-requests/queue') @BookingStaff(FREIGHT_PERMS.contracts.createBooking) - @ApiOperation({ summary: 'GL queue: pending shipment requests across contracts' }) + @ApiOperation({ summary: 'GL queue: shipment requests across contracts (all statuses, newest first)' }) bookingRequestQueue() { return this.bookingRequestService.queue(); } @@ -799,6 +799,37 @@ export class ContractsController { ); } + @Post(':id/bookings/initiate') + @ApiOperation({ + summary: + 'Initiate a bare booking instance under a GENERAL non-customs contract — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS).', + }) + initiateBooking( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CreateBookingUnderContractDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.contractBookingService.initiateUnderContract( + id, + { contractRouteId: dto?.contractRouteId }, + { id: user?.id ?? user?.sub }, + user, + ); + } + + @Post(':id/bookings/:bookingId/complete') + @ApiOperation({ + summary: + 'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.', + }) + completeBooking( + @Param('id', ParseUUIDPipe) id: string, + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: CreateBookingUnderContractDto, + ) { + return this.contractBookingService.completeUnderContract(id, bookingId, dto); + } + @Post(':id/validate-shipment') @ApiOperation({ summary: diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 33a547a9f..96bdf22b1 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -16,6 +16,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { BookingsModule } from '../bookings/bookings.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; +import { ContractTemplatesModule } from '../contract-templates/contract-templates.module'; import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; @@ -81,6 +82,9 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum NotificationsModule, NotificationInboxModule, CompaniesModule, + // Provides the admin-editable contract document templates consumed by + // ContractDocumentViewModelBuilder when rendering contract PDFs. + ContractTemplatesModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). forwardRef(() => BookingsModule), diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 095857959..517d818bf 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -265,10 +265,11 @@ export class ContractsService { } } - // Attach the company profile's onboarding / business-license documents to the - // contract by reference. The separate "Documents" intake step was removed — - // the profile documents are simply carried onto every contract automatically. - await this.attachProfileDocuments(contract.id, companyProfileId); + // Attach the company's onboarding documents (TIN, licenses, IDs) and the + // profile's business-license documents to the contract by reference. The + // separate "Documents" intake step was removed — the profile documents are + // simply carried onto every contract automatically. + await this.attachProfileDocuments(contract.id, companyId ?? null, companyProfileId); return { contract: await this.findById(contract.id), warnings }; } @@ -316,51 +317,95 @@ export class ContractsService { } /** - * Copy a company profile's stored business-license / onboarding documents onto - * a contract by reference (no byte re-upload). Codes are slugged from each - * document name so they group under "Profile documents" on the contract detail - * page. No-op when the contract has no profile or the profile has no documents. + * Copy the company's onboarding documents (TIN certificate, commercial / + * investment license, national ID, passport — resource "companies", coded by + * the upload-setting fileKey) and the company profile's business-license + * documents (resource "company_profiles") onto a contract by reference (no + * byte re-upload). Idempotent: codes already present on the contract — user + * uploads or an earlier carry — are never duplicated or overwritten, so it is + * safe to run on every create and update. No-op when there is nothing to copy. */ private async attachProfileDocuments( contractId: string, + companyId: string | null, companyProfileId: string | null, ): Promise { - if (!companyProfileId) return; - // Business-license files are FileRecords (resource "company_profiles"); carry - // the live ones by reference. Staged/pending uploads are excluded by code. - const records = await this.filesService.findByResource( - companyProfileId, - 'company_profiles', + if (!companyId && !companyProfileId) return; + + const existingCodes = new Set( + (await this.filesService.findByResource(contractId, 'contracts')).map( + (r) => r.code, + ), ); - const docs = records - .filter((r) => r.code === 'business_license') - .map((r) => ({ - name: r.name, - url: r.url, - size: r.size, - mimeType: r.mimeType, - })); + const docs: Array<{ + code: string; + name: string; + url: string; + size: number; + mimeType?: string; + }> = []; + + if (companyId) { + // Company onboarding documents keep their fileKey codes (tin_certificate, + // commercial_license, …) so the portal can match them against the + // onboarding upload-setting fields. Re-uploads append rows, so keep only + // the newest record per code. + const companyRecords = await this.filesService.findByResource( + companyId, + 'companies', + ); + const latestByCode = new Map(); + for (const r of companyRecords) { + const prev = latestByCode.get(r.code); + if (!prev || r.createdAt > prev.createdAt) latestByCode.set(r.code, r); + } + for (const r of latestByCode.values()) { + if (existingCodes.has(r.code)) continue; + docs.push({ + code: r.code, + name: r.name, + url: r.url, + size: r.size, + mimeType: r.mimeType, + }); + } + } + + if (companyProfileId) { + // Business-license files are FileRecords (resource "company_profiles"); + // carry the live ones by reference. Staged/pending uploads are excluded by + // code. Codes are slugged from each document name so they group under + // "Profile documents" on the contract detail page. + const records = await this.filesService.findByResource( + companyProfileId, + 'company_profiles', + ); + const slug = (name: string) => + name + .toLowerCase() + .replace(/\.[a-z0-9]+$/, '') + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') || 'profile_document'; + + records + .filter((r) => r.code === 'business_license') + .forEach((r, i) => { + const code = `${slug(r.name)}_${i + 1}`; + if (existingCodes.has(code)) return; + docs.push({ + code, + name: r.name, + url: r.url, + size: r.size, + mimeType: r.mimeType, + }); + }); + } + if (docs.length === 0) return; - const slug = (name: string) => - name - .toLowerCase() - .replace(/\.[a-z0-9]+$/, '') - .replace(/[^a-z0-9]+/g, '_') - .replace(/^_+|_+$/g, '') || 'profile_document'; - try { - await this.filesService.attachExistingFiles( - contractId, - 'contracts', - docs.map((d, i) => ({ - code: `${slug(d.name)}_${i + 1}`, - name: d.name, - url: d.url, - size: d.size, - mimeType: d.mimeType, - })), - ); + await this.filesService.attachExistingFiles(contractId, 'contracts', docs); } catch { // Non-fatal — the contract is still valid without the carried documents. } @@ -481,6 +526,15 @@ export class ContractsService { await this.filesService.uploadMany(id, 'contracts', files); } + // Re-carry any company/profile document that is still missing from the + // contract (runs after the upload so fresh replacements keep their slot). + // Backfills contracts created before profile documents were carried over. + await this.attachProfileDocuments( + id, + existing.companyId ?? null, + existing.companyProfileId ?? null, + ); + return { contract: await this.findById(id), warnings }; } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index e3130da95..870817365 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -4,19 +4,28 @@ import { IsArray, IsBoolean, IsDateString, + IsIn, IsInt, IsNumber, IsOptional, IsString, IsUUID, + Matches, Min, ValidateNested, } from 'class-validator'; +/** Per-shipment equipment return — "NA" stays contract-level only. */ +const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const; + /** One physical container under a booking line — entered at booking time. */ export class CreateContainerUnitDto { - @ApiProperty() + @ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' }) @IsString() + @Transform(({ value }) => (typeof value === 'string' ? value.trim().toUpperCase() : value)) + @Matches(/^[A-Z]{4}\d{7}$/, { + message: 'containerNumber must match ISO container format, e.g. ABCD1234567', + }) containerNumber!: string; @ApiPropertyOptional() @@ -129,6 +138,15 @@ export class CreateBookingUnderContractDto { @IsDateString() scheduledDate?: string; + @ApiPropertyOptional({ + enum: SHIPMENT_EQUIPMENT_RETURNS, + description: + 'Per-shipment equipment return override; omitted → the contract default applies.', + }) + @IsOptional() + @IsIn([...SHIPMENT_EQUIPMENT_RETURNS]) + equipmentReturn?: string; + @ApiPropertyOptional({ type: [CreateBookingContainerLineDto] }) @IsOptional() @IsArray() diff --git a/apps/edr-freight-api/src/modules/gps-tracking/README.md b/apps/edr-freight-api/src/modules/gps-tracking/README.md new file mode 100644 index 000000000..668b581b8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/README.md @@ -0,0 +1,150 @@ +# GPS Tracking (GT06) — Operations & Device Configuration + +GT06 trackers speak a **raw TCP binary protocol**, not HTTP/HTTPS. This shapes +everything about how the service is deployed and how devices are pointed at it. + +--- + +## 1. Why GPS needs its own dedicated TCP port + +- **Not HTTP.** GT06 devices send binary frames + (`0x78 0x78 | len | protocol | payload | serial | CRC16 | 0x0D 0x0A`). + An HTTP server receiving these answers `400 Bad Request` and closes. +- **Dedicated port required.** A listening socket is keyed on `(IP, port)`; two + listeners on the same pair collide (`EADDRINUSE`). The REST API already owns + its port, so GPS traffic needs a separate one. +- **No hostname routing.** GT06 frames carry no `Host` header and no TLS SNI, so + L7 proxies (Nginx `http`, AWS ALB, Cloudflare proxy) cannot route them by + domain. Routing must happen at **Layer 4 (TCP)** by port. +- **DNS carries no port.** An A record maps a name to an IP only. The tracker + config must state the port explicitly (e.g. `gps.example.com:5023`). + +### Operational requirements + +| Item | Value | +| --- | --- | +| Protocol | Raw TCP (not HTTP, not TLS) | +| Default port | `5023` (configurable via `GT06_TCP_PORT`) | +| Listener bind | `0.0.0.0` inside the `freight-gps` container | +| Edge terminator | **L4** — AWS NLB or Nginx `stream {}`. **Not** ALB / Cloudflare proxy. | + +--- + +## 2. Port configuration + +`5023` is only this project's default — **not** a GT06 protocol requirement. The +listener binds whatever `GT06_TCP_PORT` says, as long as trackers are configured +with the same number. + +Host and container ports are decoupled in `docker-compose.yaml`: + +```yaml +freight-gps: + ports: + - "${GT06_TCP_PORT:-5023}:5023" # host is configurable; container fixed + environment: + GT06_TCP_PORT: "5023" # pinned inside the container +``` + +- The **container** always listens on `5023`. +- The **host/public** port is configurable (443, 5023, 9000, …) via the root + `.env`'s `GT06_TCP_PORT`. +- This split is required because the image runs as a **non-root** user + (`nestjs`, uid 1001), which cannot bind ports `<1024`. Docker (root) binds the + host port and forwards to `5023` inside. +- Running **outside Docker** (`pnpm dev:gps`, systemd), `GT06_TCP_PORT` is the + actual bind port, so `<1024` needs root or `CAP_NET_BIND_SERVICE`. +- **443 is allowed but risky:** GT06 stays raw TCP, not TLS. Middleboxes that + expect a TLS handshake on 443 may drop the connection. + +--- + +## 3. Deployment topology + +The GT06 listener runs as its own process (`dist/main.gps.js`, module +`GpsIngestModule`) — DB + GPS only, no HTTP server. It shares the `edr_freight` +DB with the API; the DB is the seam (ingester writes `gps_devices` / +`gps_positions`, API reads them). + +``` +freight-api HTTP :3001 GT06_TCP_PORT=0 (listener off, applies migrations) +freight-gps TCP :5023 DB_MIGRATIONS_RUN=false (owns the tracker socket) +``` + +`DB_MIGRATIONS_RUN=false` keeps the second process from racing migrations. + +Horizontal scale: each tracker holds one long-lived TCP connection with +per-socket session state, so N `freight-gps` replicas can run behind an L4 LB — +each device sticks to one replica. `ensureDevice` is safe under concurrency +(unique IMEI). + +--- + +## 4. Device configuration (GT06 side) + +Config is done by **SMS to the tracker's SIM**. Commands below are the canonical +Concox/GT06 set — **verify against your unit's sheet**, syntax varies by firmware. +Default command password is usually `123456`. + +Prep: data-enabled SIM, SMS on, **SIM PIN off**, know your carrier APN. + +``` +STATUS# # 1. sanity check — returns GSM/GPS/batt/GPRS +APN,# # 2. carrier data APN (add ,user,pass if needed) +SERVER,1,gps.example.com,5023,0# # 3. point at server (1=domain). Port MUST match GT06_TCP_PORT +GPRSON,1# # 4. enable data +GPSON,1# # enable GPS +TIMER,10# # 5. upload interval, seconds (some use UPLOAD,10#) +RESET# # 6. reboot so it reconnects (many cache DNS until reboot) +``` + +Raw-IP variant of step 3: `SERVER,0,203.0.113.50,5023,0#` +Custom host port (e.g. 443): `SERVER,1,gps.example.com,443,0#` + +### Verify from the server + +```bash +docker compose logs -f freight-gps | grep -Ei "login|Auto-registering|ingester up" +nc -vz gps.example.com 5023 +curl -H "Authorization: Bearer " https://api.example.com/api/gps/positions/latest +``` + +First login packet **auto-registers** the IMEI (no manual step). `online:true` +only when `lastSeenAt` < 5 min (computed at read time). + +### Link a tracker to a vehicle (optional) + +Auto-register leaves `vehicleId` null. Attach it (needs `tracking.manage`): + +``` +PATCH /api/gps/devices/:id { "vehicleId": "", "name": "Truck 03-ET" } +``` + +### Failure map + +| Symptom | Cause | +| --- | --- | +| No SMS reply | SIM PIN on / no signal / wrong number | +| Replies but never connects | APN wrong, or `SERVER` port ≠ `GT06_TCP_PORT` | +| Connects then drops | server not ACKing, or middlebox on 443 expecting TLS | +| Registered but `online:false` | packets blocked by firewall — open inbound TCP | +| Wrong location / `positioned:false` | no GPS fix yet — open sky, cold start ~1–2 min | + +--- + +## 5. Security + +- GT06 authenticates with **IMEI only**, which is **spoofable**. Anyone who can + reach the port can inject fake positions. +- **Do not** expose the port to `0.0.0.0/0`. Restrict at the firewall / security + group to the SIM provider's **APN / IP range**. +- Trackers must use the same host+port as the server: + `SERVER,1,gps.example.com,,0#`. + +--- + +## 6. Edge (L4) termination + +See [`infrastructure/nginx/gps-stream.conf`](../../../../../infrastructure/nginx/gps-stream.conf) +for an Nginx `stream {}` example, and the AWS NLB notes in the same file. +Reminder: **L4 only** — an HTTP proxy cannot route GT06. diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts index e380e541e..8bd4a31d8 100644 --- a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts @@ -11,14 +11,15 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { FleetManage, FleetView } from '../../common/booking-guards'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { GpsTrackingService } from './gps-tracking.service'; import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto'; @ApiTags('gps-tracking') @ApiBearerAuth() @Controller('gps') -@FleetView() +@BookingStaff(FREIGHT_PERMS.tracking.view) export class GpsTrackingController { constructor(private readonly gps: GpsTrackingService) {} @@ -44,21 +45,21 @@ export class GpsTrackingController { } @Post('devices') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.tracking.manage) @ApiOperation({ summary: 'Register a GPS tracker' }) register(@Body() dto: RegisterDeviceDto) { return this.gps.registerDevice(dto); } @Patch('devices/:id') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.tracking.manage) @ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) { return this.gps.updateDevice(id, dto); } @Delete('devices/:id') - @FleetManage() + @BookingStaff(FREIGHT_PERMS.tracking.manage) @ApiOperation({ summary: 'Delete a GPS tracker' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.gps.removeDevice(id); diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts index 66d0100d0..d46622ba4 100644 --- a/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts +++ b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts @@ -49,6 +49,23 @@ export class CreateLocomotiveDto { @Min(0) maxTrainLengthMeters!: number; + // Allowed deviation above maxPullWeightTons before scheduling blocks the train + // (e.g. 90 lets a 3,500T-rated locomotive pull up to 3,590T). Omit/0 = strict cap. + @ApiPropertyOptional({ example: 90 }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsNumber() + @Min(0) + overageToleranceTons?: number; + + // Allowed deviation above maxTrainLengthMeters before scheduling blocks the train. + @ApiPropertyOptional({ example: 0 }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsNumber() + @Min(0) + overageToleranceMeters?: number; + @ApiPropertyOptional({ example: 4200 }) @IsOptional() @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) diff --git a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts index 6dcd9ad3e..d3214b6c4 100644 --- a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts +++ b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts @@ -39,6 +39,26 @@ export class Locomotive extends BaseEntity { @Column({ name: 'max_train_length_meters', type: 'numeric', precision: 10, scale: 3, default: 760 }) maxTrainLengthMeters!: number; + /** Allowed deviation above maxPullWeightTons before a train is blocked (e.g. the 37th PW2 wagon in the fertilizer example runs 90T over 3,500T and is still accepted). Null/0 = no tolerance. */ + @Column({ + name: 'overage_tolerance_tons', + type: 'numeric', + precision: 10, + scale: 3, + nullable: true, + }) + overageToleranceTons?: number | null; + + /** Allowed deviation above maxTrainLengthMeters before a train is blocked. Null/0 = no tolerance. */ + @Column({ + name: 'overage_tolerance_meters', + type: 'numeric', + precision: 10, + scale: 3, + nullable: true, + }) + overageToleranceMeters?: number | null; + @Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' }) status!: LocomotiveStatus; diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts index ae9a41608..65700fd93 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -64,6 +64,8 @@ export class LocomotivesService { maxPullWeightTons: dto.maxPullWeightTons ?? LocomotivesService.DEFAULT_MAX_PULL_WEIGHT_TONS, maxTrainLengthMeters: dto.maxTrainLengthMeters, + overageToleranceTons: dto.overageToleranceTons ?? null, + overageToleranceMeters: dto.overageToleranceMeters ?? null, powerKw: dto.powerKw ?? null, tractionForceKn: dto.tractionForceKn ?? null, maxSpeedKmh: dto.maxSpeedKmh ?? null, diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts index 1d7fd27fc..80fecf1bf 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.controller.ts @@ -13,8 +13,10 @@ import { Patch, Post, Query, + UseGuards, } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; import { AuthUserPayload, @@ -24,6 +26,8 @@ import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto"; import { NotificationInboxService } from "./notification-inbox.service"; @ApiTags("notifications") +@ApiBearerAuth() +@UseGuards(JwtGuard) @Controller("notifications") export class NotificationInboxController { constructor(private readonly service: NotificationInboxService) {} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts index 4981a9486..d30ebe3a9 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts @@ -1,4 +1,4 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; @@ -17,8 +17,9 @@ import { WsAuthService } from "./ws-auth.service"; @Module({ imports: [ TypeOrmModule.forFeature([Notification, User, Session]), - // ExternalProfileRepository + CompanyProfileRepository (portal targeting) - CompaniesModule, + // ExternalProfileRepository + CompanyProfileRepository (portal targeting). + // CompaniesModule imports this module back for CompanyNotifierService. + forwardRef(() => CompaniesModule), // BackofficeService.getOrganizationEmployees (staff targeting) BackofficeModule, // EmailClientService + SmsClientService (HIGH-priority fan-out) diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts index c14dcbaa5..0c4704170 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notifications.gateway.ts @@ -15,9 +15,10 @@ import { WsAuthService } from "./ws-auth.service"; /** * Server → client push for in-app notifications. Clients only *listen* (no - * `@SubscribeMessage` handlers), so the global HTTP JwtGuard never applies here; - * the handshake is authenticated in `handleConnection` and each socket joins a - * private `user:` room the service targets. + * `@SubscribeMessage` handlers), and `@UseGuards(JwtGuard)` on the REST + * controller does not cover WebSockets; the handshake is authenticated in + * `handleConnection` and each socket joins a private `user:` room the + * service targets. */ @WebSocketGateway({ namespace: NOTIFICATION_WS_NAMESPACE, diff --git a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts index d8404e84c..cddc67b2d 100644 --- a/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts +++ b/apps/edr-freight-api/src/modules/notifications/strategies/notification.sms.strategy.ts @@ -22,6 +22,10 @@ export class SmsNotificationStrategy implements NotificationStrategy { this.logger.debug(`Sending SMS to ${recipient} via ${url}`); + // axios defaults to no timeout — a hanging gateway would block the caller + // (and any transaction it sits in) indefinitely. Always bound the wait. + const timeout = Number(this.configService.get("SMS_TIMEOUT_MS") ?? 8000); + try { const response = await axios.post( url, @@ -34,6 +38,7 @@ export class SmsNotificationStrategy implements NotificationStrategy { callbackUrl: "", }, { + timeout, headers: { accept: "*/*", "Content-Type": "application/json", diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index e26ed35c9..b98b41d8d 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -50,6 +50,9 @@ export class OtpService { await this.otpRepository.createOtp(target, otp); } + // A freshly issued code gets a fresh guess budget. + this.actionAttempts.delete(this.targetKey(target)); + if (target.email) { // send email (queued to RabbitMQ via the shared Email service) await this.emailClient.sendEmail({ @@ -121,24 +124,44 @@ export class OtpService { // --------------------------------------------------------------------------- // Fresh, single-use challenge gating a sensitive action (e.g. applying a - // contract signature). Unlike verifyOtp above — which marks a phone verified - // and leaves the code in place — this enforces a short TTL and consumes the - // code on success so it can never be replayed. + // contract signature, resetting a forgotten password). Unlike verifyOtp above + // — which marks a target verified and leaves the code in place — this enforces + // a TTL and consumes the code on success so it can never be replayed. private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000; - async verifyOtpForAction(phone: string, otp: string) { - const otpData = await this.otpRepository.findByPhone(phone); + // Without a cap, a 6-digit code guarding a password reset is brute-forceable + // within its own TTL. `otp_verifications` has no attempt column, so the + // counter lives here and the code is burned once the budget is spent. + // Per-process: it resets on restart and is not shared across replicas — a + // persisted counter needs a migration on OtpVerification. + private readonly MAX_ACTION_ATTEMPTS = 5; + private readonly actionAttempts = new Map(); + + private targetKey(target: OtpTarget): string { + return target.email ? `email:${target.email}` : `phone:${target.phone}`; + } + + async verifyOtpForAction( + target: OtpTarget, + otp: string, + ttlMs: number = this.ACTION_OTP_TTL_MS, + ) { + const otpData = await this.otpRepository.findByTarget(target); + const key = this.targetKey(target); if (!otpData) { throw new BadRequestException( - "No verification code was requested for this phone", + target.email + ? "No verification code was requested for this email" + : "No verification code was requested for this phone", ); } const ageMs = Date.now() - new Date(otpData.updatedAt).getTime(); - if (ageMs > this.ACTION_OTP_TTL_MS) { + if (ageMs > ttlMs) { await this.otpRepository.deleteOtp(otpData); + this.actionAttempts.delete(key); throw new BadRequestException( "Verification code has expired. Request a new one.", @@ -146,11 +169,24 @@ export class OtpService { } if (otpData.otp !== otp) { + const attempts = (this.actionAttempts.get(key) ?? 0) + 1; + + if (attempts >= this.MAX_ACTION_ATTEMPTS) { + await this.otpRepository.deleteOtp(otpData); + this.actionAttempts.delete(key); + + throw new BadRequestException( + "Too many incorrect attempts. Request a new code.", + ); + } + + this.actionAttempts.set(key, attempts); throw new BadRequestException("Invalid verification code"); } // single-use: consume on success await this.otpRepository.deleteOtp(otpData); + this.actionAttempts.delete(key); return { success: true }; } diff --git a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts index a79a54503..238129a1d 100644 --- a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts +++ b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts @@ -39,12 +39,32 @@ export class Route extends BaseEntity { milestones?: RouteMilestone[]; } +/** + * Human-readable route label: yard names, not yard codes — "Addis Ababa → Dire Dawa", + * not "ADDIS_ABABA → DIRE_DAWA". A yard's display name is its `label`; `code` is the + * machine identifier and is only a fallback for a yard missing one. + * + * When the route's milestones are loaded (with their yards), the label is the FULL + * ordered corridor — "Addis Ababa → Adama → Dire Dawa" — since milestones already + * include the origin (first) and destination (last). Without milestones it falls + * back to origin → destination. + */ export function formatRouteLabel(route: { - originYard?: { code?: string; name?: string } | null; - destinationYard?: { code?: string; name?: string } | null; + originYard?: { code?: string; label?: string } | null; + destinationYard?: { code?: string; label?: string } | null; + milestones?: Array<{ + sequenceNo: number; + yard?: { code?: string; label?: string } | null; + }> | null; }): string { - const origin = route.originYard?.code ?? route.originYard?.name ?? 'Origin'; - const dest = route.destinationYard?.code ?? route.destinationYard?.name ?? 'Destination'; + const stops = [...(route.milestones ?? [])] + .sort((a, b) => a.sequenceNo - b.sequenceNo) + .map((m) => m.yard?.label ?? m.yard?.code) + .filter((name): name is string => Boolean(name)); + if (stops.length >= 2) return stops.join(' → '); + + const origin = route.originYard?.label ?? route.originYard?.code ?? 'Origin'; + const dest = route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination'; return `${origin} → ${dest}`; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index 700a38983..6a8aced53 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -22,7 +22,10 @@ export class TrainSchedulesRepository extends BaseRepository { return this.repo(manager).findOne({ where: { id }, relations: { - route: true, + // Yards carry the route's display name; without them formatRouteLabel + // degrades to the literal "Origin → Destination". Milestones (with + // their yards) give it the full corridor path. + route: { originYard: true, destinationYard: true, milestones: { yard: true } }, trainSet: { locomotive: true, locomotives: { locomotive: true }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index d8dc6b116..62ed50c0a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -24,3 +24,23 @@ export const DEFAULT_CONTAINER_WAGON_LENGTH_METERS = 14; /** Default CW3 covered wagon length for bulk bookings (m). */ export const DEFAULT_BULK_WAGON_LENGTH_METERS = 14; + +/** + * Fallback tare weights (T) matching the length fallbacks above. The locomotive + * pull limit is a GROSS limit, so a booking's weight budget must include the + * empty weight of every wagon it occupies — not just its cargo. + */ +export const DEFAULT_CONTAINER_WAGON_TARE_TONS = 22.4; + +/** Default CW3 gondola tare for bulk bookings (T). */ +export const DEFAULT_BULK_WAGON_TARE_TONS = 23.4; + +/** + * Fallback rated payloads (T) matching the tare fallbacks above. A bulk booking's + * wagon count is its cargo divided by this, so a zero here would make the count + * infinite — callers must floor it at a positive number. + */ +export const DEFAULT_CONTAINER_WAGON_CAPACITY_TONS = 70; + +/** Default CW3 gondola rated payload for bulk bookings (T). */ +export const DEFAULT_BULK_WAGON_CAPACITY_TONS = 60; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 31d3c8855..24e908761 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -31,6 +31,7 @@ describe('BookingBatchService — PAID reconcile', () => { createMany: jest.Mock; }; let trainSchedulesRepository: { + findById: jest.Mock; findByIdWithFullGraph: jest.Mock; findAll: jest.Mock; }; @@ -65,6 +66,11 @@ describe('BookingBatchService — PAID reconcile', () => { createMany: jest.fn().mockResolvedValue(undefined), }; trainSchedulesRepository = { + findById: jest.fn().mockResolvedValue({ + id: scheduleId, + bookingWindowStatus: 'OPEN', + windowPhase: null, + }), findByIdWithFullGraph: jest.fn().mockResolvedValue({ id: scheduleId, maxWagons: 10, @@ -130,6 +136,7 @@ describe('BookingBatchService — PAID reconcile', () => { expirePayable: jest.fn().mockResolvedValue(undefined), } as never, { emitPhase: jest.fn() } as never, + { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, ); }); @@ -163,7 +170,7 @@ describe('BookingBatchService — PAID reconcile', () => { }); it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => { - const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(undefined); + const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0); const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined); const reconcileSpy = jest.spyOn(service, 'reconcilePaidUnlinked').mockResolvedValue(undefined); @@ -181,6 +188,77 @@ describe('BookingBatchService — PAID reconcile', () => { expect(reconcileOrder).toBeLessThan(wagonOrder); }); + describe('extendPaymentPhaseForTopUp', () => { + const schedRepo = () => dataSource.getRepository(); + + it('pushes paymentPhaseEndsAt out when a fresh window exceeds it', async () => { + const soon = new Date(Date.now() + 5_000); // phase almost over + const departure = new Date(Date.now() + 24 * 3_600_000); + schedRepo().findOne.mockResolvedValueOnce({ + id: scheduleId, + windowPhase: 'PAYMENT', + paymentPhaseEndsAt: soon, + scheduledDepartureDate: departure, + }); + + await service.extendPaymentPhaseForTopUp(scheduleId); + + // paymentWindowMinutes = 60 (mock) → new end ≈ now + 1h, which is > soon. + expect(schedRepo().update).toHaveBeenCalledWith( + scheduleId, + expect.objectContaining({ paymentPhaseEndsAt: expect.any(Date) }), + ); + const [, patch] = schedRepo().update.mock.calls.at(-1)!; + expect((patch.paymentPhaseEndsAt as Date).getTime()).toBeGreaterThan( + soon.getTime(), + ); + }); + + it('does not pull the deadline in when the current end is already later', async () => { + const far = new Date(Date.now() + 10 * 3_600_000); // 10h out, beyond a 1h window + schedRepo().findOne.mockResolvedValueOnce({ + id: scheduleId, + windowPhase: 'PAYMENT', + paymentPhaseEndsAt: far, + scheduledDepartureDate: new Date(Date.now() + 24 * 3_600_000), + }); + + await service.extendPaymentPhaseForTopUp(scheduleId); + + expect(schedRepo().update).not.toHaveBeenCalled(); + }); + + it('is a no-op outside the PAYMENT phase', async () => { + schedRepo().findOne.mockResolvedValueOnce({ + id: scheduleId, + windowPhase: 'OPEN', + paymentPhaseEndsAt: null, + scheduledDepartureDate: new Date(Date.now() + 24 * 3_600_000), + }); + + await service.extendPaymentPhaseForTopUp(scheduleId); + + expect(schedRepo().update).not.toHaveBeenCalled(); + }); + + it('never extends past departure', async () => { + const departure = new Date(Date.now() + 60_000); // 1 min away + schedRepo().findOne.mockResolvedValueOnce({ + id: scheduleId, + windowPhase: 'PAYMENT', + paymentPhaseEndsAt: new Date(Date.now() + 1_000), + scheduledDepartureDate: departure, + }); + + await service.extendPaymentPhaseForTopUp(scheduleId); + + const [, patch] = schedRepo().update.mock.calls.at(-1)!; + expect((patch.paymentPhaseEndsAt as Date).getTime()).toBeLessThanOrEqual( + departure.getTime(), + ); + }); + }); + describe('fillRouteDay — day-level distribution', () => { const originYardId = 'yard-origin'; const destinationYardId = 'yard-dest'; @@ -336,6 +414,49 @@ describe('BookingBatchService — PAID reconcile', () => { // Never reserved — waits for its partner in a later cycle. expect(notifier.payNow).not.toHaveBeenCalled(); }); + + it('clears a stale FULL flag and fills a train whose bookings all expired', async () => { + // The deadlock: train A filled once, every booking then expired, but + // bookingWindowStatus stayed FULL. isFillable() rejects FULL before it ever + // reads the budget, so the batch skipped the train forever — it just cycled + // PRE_WINDOW→DOC_REVIEW→PAYMENT with an empty consist, and only the odd + // already-pinned booking got settled, one per cycle. + const staleFull = { + id: trainA, + maxWagons: 1, + bookingWindowStatus: 'FULL', + // The batch runs while the customer window is closed. + windowPhase: 'PAYMENT', + direction: 'IMPORT', + trainSetId: `set-${trainA}`, + trainSet: { locomotive: smallLoco }, + scheduleBookings: [], + scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), + originStationId: originYardId, + destinationStationId: destinationYardId, + }; + trainSchedulesRepository.findAll.mockResolvedValue([{ ...staleFull }]); + // Live capacity says the train is empty: 1 free wagon, nothing allocated. + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(staleFull); + // refreshWindowStatus writes CLOSED (mid-PAYMENT, not a customer-open phase); + // the re-read reports it, and isFillable() admits CLOSED during PAYMENT. + trainSchedulesRepository.findById.mockResolvedValue({ + id: trainA, + bookingWindowStatus: 'CLOSED', + windowPhase: 'PAYMENT', + }); + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([ + commercial('waiting', 30), + ]); + + const touched = await service.fillRouteDay(originYardId, destinationYardId, day); + + // The train was reopened to the batch and actually filled, not skipped. + expect(touched).toEqual([trainA]); + expect(notifier.payNow).toHaveBeenCalledTimes(1); + expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting'); + expect(notifier.unplaced).not.toHaveBeenCalled(); + }); }); describe('expireUnacceptedForRouteDay — doc-review sweep', () => { @@ -443,6 +564,7 @@ describe('BookingBatchService — PAID reconcile', () => { trainSchedulingService as never, { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, { emitPhase: jest.fn() } as never, + { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, { findOpenOffer: jest.fn() } as never, ); @@ -465,6 +587,7 @@ describe('BookingBatchService — PAID reconcile', () => { trainSchedulingService as never, { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, { emitPhase: jest.fn() } as never, + { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, { findOpenOffer: jest.fn() } as never, ); @@ -495,6 +618,7 @@ describe('BookingBatchService — PAID reconcile', () => { trainSchedulingService as never, { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, { emitPhase: jest.fn() } as never, + { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, { findOpenOffer: jest.fn() } as never, ); @@ -510,4 +634,236 @@ describe('BookingBatchService — PAID reconcile', () => { expect(check({ ...importGeneral, contractKind: null } as Booking, false)).toBe(false); }); }); + + describe('settleDueReservations — expire then promote the waiting list', () => { + const originYardId = 'yard-origin'; + const destinationYardId = 'yard-dest'; + const trainId = 'train-a'; + // 14m / 70t default wagon → two wagon slots on this locomotive. + const smallLoco = { maxPullWeightTons: 200, maxTrainLengthMeters: 28 }; + + const booking = (id: string, priority: number, overrides = {}): Booking => + ({ + id, + reference: id, + isGovernment: false, + priorityScore: priority, + status: 'FULLY_EXECUTED', + wagonsRequired: 1, + cargoTotalWeightVgm: 10, + freightType: 'CONTAINER', + bookingContainers: [], + originYardId, + destinationYardId, + trainScheduleId: trainId, + ...overrides, + }) as unknown as Booking; + + beforeEach(() => { + const scheduleRow = { + id: trainId, + maxWagons: 2, + bookingWindowStatus: 'CLOSED', + windowPhase: 'PAYMENT', + direction: 'IMPORT', + trainSetId: `set-${trainId}`, + trainSet: { locomotive: smallLoco }, + scheduleBookings: [], + scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), + originStationId: originYardId, + destinationStationId: destinationYardId, + }; + trainSchedulesRepository.findAll.mockResolvedValue([{ ...scheduleRow }]); + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleRow); + trainSchedulesRepository.findById.mockResolvedValue({ + id: trainId, + bookingWindowStatus: 'CLOSED', + windowPhase: 'PAYMENT', + scheduledDepartureDate: scheduleRow.scheduledDepartureDate, + originStationId: originYardId, + destinationStationId: destinationYardId, + }); + }); + + it('promotes a waiting booking into the wagons an expired reservation frees', async () => { + // One reservation whose pay window lapsed, and one booking on the waiting list. + const lapsed = booking('lapsed', 50, { + status: 'SELECTED_FOR_BATCH', + paymentDeadline: new Date(Date.now() - 60_000), + }); + const waiting = booking('waiting', 10, { trainScheduleId: null }); + + bookingsRepository.findReservedForSchedule + .mockResolvedValueOnce([lapsed]) // settleReserved sees the lapsed one + .mockResolvedValue([]); // afterwards nothing is reserved + // The day pool the top-up draws from: only the waiting booking is eligible. + bookingsRepository.findBatchPoolByCorridorDay + .mockResolvedValueOnce([waiting]) + .mockResolvedValue([]); + + await service.settleDueReservations(trainId); + + // The lapsed reservation expired... + expect(notifier.expired).toHaveBeenCalledTimes(1); + expect((notifier.expired.mock.calls[0][0] as Booking).id).toBe('lapsed'); + // ...and the waiting booking was promoted in the SAME settle, not next cycle. + expect(notifier.payNow).toHaveBeenCalledTimes(1); + expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting'); + }); + + it('serialises concurrent settles so the same reservation is not settled twice', async () => { + const lapsed = booking('lapsed', 50, { + status: 'SELECTED_FOR_BATCH', + paymentDeadline: new Date(Date.now() - 60_000), + }); + // Both callers read the reservation; the lock must stop the second from + // acting on rows the first already expired. (The PAYMENT transition and the + // tick's overdue backstop do exactly this, in the same second.) + let reads = 0; + bookingsRepository.findReservedForSchedule.mockImplementation(() => { + reads += 1; + return Promise.resolve(reads === 1 ? [lapsed] : []); + }); + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]); + + await Promise.all([ + service.settleDueReservations(trainId), + service.settleDueReservations(trainId), + ]); + + expect(notifier.expired).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('BookingBatchService — wagonsFor', () => { + // wagonsFor is pure arithmetic over its two arguments and touches no injected + // dependency, so the service can be built with none. + const service = new BookingBatchService( + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + null as never, + ) as unknown as { + wagonsFor(booking: unknown, dims: unknown): number; + needFor(booking: unknown, dims: unknown): { + wagons: number; + weightTons: number; + lengthMeters: number; + }; + }; + + // PW2 box wagon: 70T rated payload, 25.2T tare, 17.066m. + const dims = { + container: { lengthMeters: 13.966, tareWeightTons: 22.4, capacityTons: 70 }, + bulk: { lengthMeters: 17.066, tareWeightTons: 25.2, capacityTons: 70 }, + byWagonTypeId: new Map(), + }; + + const bulk = (cargoTons: number, over: Record = {}) => ({ + freightType: 'BULK', + cargoTotalWeightVgm: cargoTons, + bookingContainers: [], + ...over, + }); + + it('sizes a bulk booking by cargo ÷ rated payload, not a flat 1 wagon', () => { + // 37 × 1400 fertilizer packages × 50kg = 2590T of cargo. + expect(service.wagonsFor(bulk(2590), dims)).toBe(37); + }); + + it('rounds a partial wagon up', () => { + expect(service.wagonsFor(bulk(70.1), dims)).toBe(2); + expect(service.wagonsFor(bulk(70), dims)).toBe(1); + }); + + it('still floors at one wagon when a bulk booking has no recorded cargo', () => { + expect(service.wagonsFor(bulk(0), dims)).toBe(1); + }); + + it('honours an explicit wagonsRequired override', () => { + expect(service.wagonsFor(bulk(2590, { wagonsRequired: 40 }), dims)).toBe(40); + }); + + it('ignores a stale undersized wagonsRequired: 700T of sugar rides 10 wagons, not 1', () => { + // Rows written while sumWagonsRequired hardcoded BULK to 1 are still in the + // DB; trusting them charged one tare for the whole consist (700 + 25.2 + // instead of 700 + 10 × 25.2 gross). + expect(service.wagonsFor(bulk(700, { wagonsRequired: 1 }), dims)).toBe(10); + }); + + it('takes the binding axis for containers: weight can exceed TEU geometry', () => { + // Two 40ft units => 2 wagons by TEU geometry, but 210T needs 3 at 70T each. + const booking = { + freightType: 'CONTAINER', + cargoTotalWeightVgm: 210, + bookingContainers: [ + { quantity: 2, wagonsRequired: 2, containerType: { wagonsPerUnit: 1, sizeFt: 40 } }, + ], + }; + expect(service.wagonsFor(booking, dims)).toBe(3); + }); + + it('keeps TEU geometry when it binds before weight', () => { + // Four 20ft units => 2 wagons by geometry; 40T of cargo needs only 1 by weight. + const booking = { + freightType: 'CONTAINER', + cargoTotalWeightVgm: 40, + bookingContainers: [ + { quantity: 4, wagonsRequired: 2, containerType: { wagonsPerUnit: 0.5, sizeFt: 20 } }, + ], + }; + expect(service.wagonsFor(booking, dims)).toBe(2); + }); + + describe('per-booking wagon type (cargo/container type FK)', () => { + // The booking's cargo type rides PW2 (25.2T tare / 70T), but the + // representative bulk fallback is a CW3-ish 23.4T tare. Measuring the + // booking on the fallback under-charged its gross (2100 + 30 × 23.4 = + // 2802 instead of 2856), so the fill loop admitted sets that allocation's + // real-consist check later rejected — after the customer had paid. + const dimsWithTypes = { + container: { lengthMeters: 13.966, tareWeightTons: 22.4, capacityTons: 70 }, + bulk: { lengthMeters: 17.066, tareWeightTons: 23.4, capacityTons: 70 }, + byWagonTypeId: new Map([ + ['pw2-id', { lengthMeters: 17.066, tareWeightTons: 25.2, capacityTons: 70 }], + ]), + }; + + it('charges a bulk booking the tare of ITS wagon type, not the representative', () => { + const booking = bulk(2100, { cargoType: { wagonTypeId: 'pw2-id' } }); + const need = service.needFor(booking, dimsWithTypes); + expect(need.wagons).toBe(30); + expect(need.weightTons).toBe(2856); // 2100 + 30 × 25.2 — matches allocation + }); + + it('falls back to the representative dims when no wagon type is configured', () => { + const need = service.needFor(bulk(2100), dimsWithTypes); + expect(need.weightTons).toBe(2802); // 2100 + 30 × 23.4 (legacy behavior) + }); + + it('resolves a container booking through its container type', () => { + const booking = { + freightType: 'CONTAINER', + cargoTotalWeightVgm: 140, + bookingContainers: [ + { + quantity: 2, + wagonsRequired: 2, + containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypeId: 'pw2-id' }, + }, + ], + }; + const need = service.needFor(booking, dimsWithTypes); + expect(need.wagons).toBe(2); + expect(need.weightTons).toBe(190.4); // 140 + 2 × 25.2 + expect(need.lengthMeters).toBeCloseTo(34.132, 3); // 2 × 17.066, not NW5's 13.966 + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 26f8749be..d51108043 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -1,6 +1,8 @@ import { BadRequestException, ConflictException, + forwardRef, + Inject, Injectable, Logger, NotFoundException, @@ -9,10 +11,19 @@ import { } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { SchedulerRegistry } from '@nestjs/schedule'; -import { DataSource, In } from 'typeorm'; +import { + Between, + DataSource, + FindOptionsWhere, + ILike, + In, + LessThanOrEqual, + MoreThanOrEqual, +} from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingPricingService } from '../bookings/booking-pricing.service'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { formatRouteLabel } from '../routes/entities/route.entity'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; @@ -20,38 +31,58 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; -import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService } from './train-scheduling.service'; import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; +import { + BATCH_BOARD_STATUSES, + BatchBoardQueryDto, +} from './dto/batch-board-query.dto'; import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types"; import { BillingService } from "../billing/billing.service"; import { + DEFAULT_BULK_WAGON_CAPACITY_TONS, DEFAULT_BULK_WAGON_LENGTH_METERS, + DEFAULT_BULK_WAGON_TARE_TONS, + DEFAULT_CONTAINER_WAGON_CAPACITY_TONS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + DEFAULT_CONTAINER_WAGON_TARE_TONS, DEFAULT_WAGONS_PER_BOOKING, } from "./booking-batch.constants"; import { - bookingTrainLengthMeters, + WagonTypeDimensions, + bookingGrossWeightTons, deriveTrainCapacityFromLocomotive, + sizePartialOfferWagons, + trainHardCaps, wagonTypeDimensionsFromEntity, } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { BookingSplitService } from './booking-split.service'; import { BookingWindowGateway } from './booking-window.gateway'; -import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util'; +import { + MAX_TEU_SLOTS_PER_WAGON, + containerWagonsForLines, +} from './wagon-plan.util'; import { Capacity, CorridorBudget, CorridorLeg, + OverageTolerance, stopYardsFor, } from './corridor-capacity.util'; export type { Capacity } from './corridor-capacity.util'; +/** + * A train's fill limits: the base caps the corridor budget spends from, plus + * the locomotive overage tolerance spendable only on whole-booking admission. + */ +type TrainLimits = { base: Capacity; tolerance: OverageTolerance }; + /** A day-level pool key: all trains on this route departing on this EAT day. */ interface RouteDayGroup { originYardId: string; @@ -60,7 +91,21 @@ interface RouteDayGroup { day: string; } -type WagonLengths = { container: number; bulk: number }; +/** One wagon type's footprint: its length on the train, the tare it adds to the + * locomotive's gross load, and the payload it carries. */ +type PerWagonDims = { lengthMeters: number; tareWeightTons: number; capacityTons: number }; + +/** + * Wagon dimensions used to size a booking's capacity draw. `byWagonTypeId` holds + * every wagon type so a booking is measured on the type its cargo/container type + * actually rides (the same FK resolution allocation uses); `container`/`bulk` are + * representative fallbacks for bookings whose type has no wagon type configured. + */ +type WagonDims = { + container: PerWagonDims; + bulk: PerWagonDims; + byWagonTypeId: Map; +}; export type BatchBoardBookingState = | "ALLOCATED" @@ -124,6 +169,8 @@ export interface BatchWindowGroup { export interface BatchBoardScheduleDetail { scheduleId: string; + /** Human-facing schedule reference (S-YYYY-NNNNN). */ + scheduleReference: string | null; trainNumber: string | null; routeName: string | null; origin: string | null; @@ -148,11 +195,14 @@ export interface BatchBoardScheduleDetail { export interface BatchBoardSchedule { scheduleId: string; + /** Human-facing schedule reference (S-YYYY-NNNNN). */ + scheduleReference: string | null; trainNumber: string | null; routeName: string | null; origin: string | null; destination: string | null; scheduleDate: string | null; + createdAt: string | null; status: string; bookingWindowStatus: string; direction: string | null; @@ -191,6 +241,16 @@ export interface BatchBoardSchedule { bookings: BatchBoardBooking[]; } +/** Paginated batch-board list. `items` (not `data`) — the API response wrapper + * already uses `data`, and the frontend's unwrap() strips one `data` level. */ +export interface BatchBoardListResponse { + items: BatchBoardSchedule[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + /** * Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool * by priority, greedily fills the train to capacity (skipping bookings that don't fit), @@ -204,6 +264,14 @@ export interface BatchBoardSchedule { export class BookingBatchService implements OnModuleInit { private readonly logger = new Logger(BookingBatchService.name); + /** + * Serialises settle/top-up per schedule. The PAYMENT phase transition and the + * tick's overdue backstop both call settleDueReservations for the same schedule + * in the same second; without this they interleave and the top-up runs against a + * schedule whose phase has already been concluded. + */ + private readonly scheduleLocks = new Map>(); + constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly bookingsRepository: BookingsRepository, @@ -214,6 +282,8 @@ export class BookingBatchService implements OnModuleInit { private readonly trainSchedulingService: TrainSchedulingService, private readonly billing: BillingService, private readonly bookingWindowGateway: BookingWindowGateway, + @Inject(forwardRef(() => BookingPricingService)) + private readonly pricingService: BookingPricingService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, @Optional() private readonly splitService?: BookingSplitService, @@ -410,7 +480,7 @@ export class BookingBatchService implements OnModuleInit { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( booking.trainScheduleId, ); - if (schedule && (await this.remainingWagons(schedule)) <= 0) { + if (schedule && (await this.isTrainFull(schedule))) { await this.setWindow(booking.trainScheduleId, "FULL"); } @@ -497,9 +567,8 @@ export class BookingBatchService implements OnModuleInit { ); } - const rules = await this.loadGlobalRules(); - const wagonLengths = await this.loadWagonLengths(); - const required = need ?? this.needFor(booking, wagonLengths); + const wagonDims = await this.loadWagonDims(); + const required = need ?? this.needFor(booking, wagonDims); let corridorMatched = false; for (const candidate of candidates) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( @@ -507,8 +576,8 @@ export class BookingBatchService implements OnModuleInit { ); const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !locomotive) continue; - const limits = await this.capacityLimits(locomotive, rules); - const budget = await this.remainingBudget(schedule, limits, wagonLengths); + const limits = await this.capacityLimits(locomotive); + const budget = await this.remainingBudget(schedule, limits, wagonDims); const leg = budget.legOf(booking.originYardId, booking.destinationYardId); if (!leg) continue; // this train's route doesn't carry the booking's leg corridorMatched = true; @@ -540,14 +609,21 @@ export class BookingBatchService implements OnModuleInit { const partner = await this.dataSource .getRepository(Booking) - .findOne({ where: { id: partnerId }, relations: { company: true, bookingContainers: true } }); + .findOne({ + where: { id: partnerId }, + relations: { + company: true, + bookingContainers: { containerType: true }, + cargoType: true, + }, + }); // Partner not yet accepted → this booking is now FULLY_EXECUTED and simply // waits; the partner's later accept will reserve the pair. if (!partner || partner.status !== 'FULLY_EXECUTED') { return; } - const wagonLengths = await this.loadWagonLengths(); - const need = this.combinedNeed(booking, partner, wagonLengths); + const wagonDims = await this.loadWagonDims(); + const need = this.combinedNeed(booking, partner, wagonDims); const scheduleId = await this.pickExportSchedule(booking, need); await this.reserveOnExport([booking, partner], scheduleId); } @@ -561,7 +637,7 @@ export class BookingBatchService implements OnModuleInit { this.armSettle(scheduleId); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (schedule && (await this.remainingWagons(schedule)) <= 0) { + if (schedule && (await this.isTrainFull(schedule))) { await this.setWindow(scheduleId, 'FULL'); } } @@ -602,37 +678,94 @@ export class BookingBatchService implements OnModuleInit { // ---- monitoring board ----------------------------------------------------- /** - * Read model for the batch monitoring page: every still-relevant schedule (not arrived/ - * cancelled) with its locomotive, capacity usage and its bookings grouped by lifecycle - * state (allocated / awaiting payment / paid-waiting / pending contract / expired). + * Read model for the batch monitoring page: every import schedule — including + * dispatched, arrived and cancelled history — with its locomotive, capacity + * usage and its bookings grouped by lifecycle state (allocated / awaiting + * payment / paid-waiting / pending contract / expired). Paginated and + * filterable; per-schedule booking summaries are only computed for the + * requested page. */ - async getBatchBoard(): Promise { - const schedules = await this.trainSchedulesRepository.findAll({ + async getBatchBoard( + query: BatchBoardQueryDto = {}, + ): Promise { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 12; + + // Status filter: any subset of the lifecycle. Omitted = all statuses, so + // arrived / cancelled / dispatched schedules stay visible as history. + const allowedStatuses = new Set(BATCH_BOARD_STATUSES); + const statuses = (query.statuses ?? "") + .split(",") + .map((v) => v.trim().toUpperCase()) + .filter((v) => allowedStatuses.has(v)); + + const dateRange = (from?: string, to?: string) => { + const f = from ? new Date(from) : null; + const t = to ? new Date(to) : null; + if (f && t) return Between(f, t); + if (f) return MoreThanOrEqual(f); + if (t) return LessThanOrEqual(t); + return undefined; + }; + + // Batch board is IMPORT-only: export is FCFS with no batch/priority calc, + // and domestic/legacy schedules run the legacy fill, not the window batch. + const base: FindOptionsWhere = { direction: "IMPORT" }; + if (statuses.length) base.status = In(statuses) as never; + if (query.bookingWindowStatus) { + base.bookingWindowStatus = query.bookingWindowStatus; + } + const departure = dateRange(query.departureFrom, query.departureTo); + if (departure) base.scheduledDepartureDate = departure as never; + const created = dateRange(query.createdFrom, query.createdTo); + if (created) base.createdAt = created as never; + + // Search fans out across every human-recognizable label. Each OR variant + // repeats the base filters so the search never widens them. + const term = query.search?.trim(); + let where: FindOptionsWhere | FindOptionsWhere[] = + base; + if (term) { + const like = ILike(`%${term}%`); + where = [ + { ...base, trainNumber: like as never }, + { ...base, originStation: { label: like } }, + { ...base, destinationStation: { label: like } }, + { ...base, route: { originYard: { label: like } } }, + { ...base, route: { destinationYard: { label: like } } }, + { ...base, trainSet: { locomotive: { code: like } } }, + ] as FindOptionsWhere[]; + } + + const sortBy = query.sortBy ?? "createdAt"; + const sortOrder = query.sortOrder ?? "DESC"; + + const [schedules, total] = await this.trainSchedulesRepository.findAndCount({ + where, relations: { trainSet: { locomotive: true }, originStation: true, destinationStation: true, - route: true, + // Yards supply the route's display name for `routeName` below; + // milestones (with yards) give it the full corridor path. + route: { originYard: true, destinationYard: true, milestones: { yard: true } }, }, - order: { scheduledDepartureDate: "ASC" }, + order: { [sortBy]: sortOrder } as never, + skip: (page - 1) * pageSize, + take: pageSize, }); - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); const board: BatchBoardSchedule[] = []; for (const s of schedules) { - if (s.status === "ARRIVED" || s.status === "CANCELLED") continue; - // Batch board is IMPORT-only: export is FCFS with no batch/priority calc, - // and domestic/legacy schedules run the legacy fill, not the window batch. - if (s.direction !== "IMPORT") continue; - const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); const bookings = await this.bookingsRepository.findAllBySchedule(s.id); const items: BatchBoardBooking[] = bookings.map((b) => { - const need = this.needFor(b, wagonLengths); + const need = this.needFor(b, wagonDims); return { id: b.id, reference: b.reference ?? b.id.slice(0, 8), @@ -654,7 +787,14 @@ export class BookingBatchService implements OnModuleInit { board.push(this.buildScheduleSummary(s, items)); } - return board; + + return { + items: board, + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }; } /** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */ @@ -665,9 +805,8 @@ export class BookingBatchService implements OnModuleInit { await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!s) throw new NotFoundException(`Train schedule ${scheduleId} not found`); - if (s.status === "ARRIVED" || s.status === "CANCELLED") { - throw new BadRequestException("Schedule is no longer active"); - } + // Arrived / cancelled schedules stay viewable — the board is also the + // historical record of what each train carried. // Batch board is IMPORT-only (export is FCFS, no batch/priority calc). if (s.direction !== "IMPORT") { throw new BadRequestException( @@ -675,7 +814,7 @@ export class BookingBatchService implements OnModuleInit { ); } - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); @@ -721,7 +860,7 @@ export class BookingBatchService implements OnModuleInit { } const items: BatchBoardBookingDetail[] = bookings.map((b) => { - const need = this.needFor(b, wagonLengths); + const need = this.needFor(b, wagonDims); const alloc = allocationByBooking.get(b.id); return { id: b.id, @@ -841,6 +980,7 @@ export class BookingBatchService implements OnModuleInit { return { scheduleId: s.id, + scheduleReference: s.reference ?? null, trainNumber: s.trainNumber ?? null, routeName: s.route ? formatRouteLabel(s.route) : null, origin: s.originStation?.label ?? s.originStation?.code ?? null, @@ -899,6 +1039,14 @@ export class BookingBatchService implements OnModuleInit { return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); } + /** + * Board capacity figures. `usedWeightTons` is GROSS (each item's weight already + * includes the tare of the wagons it occupies), so the ceiling it is measured + * against must be the same one the fill loop spends from: the locomotive's own + * limits widened by its overage tolerance (global rule caps do not apply, same + * as {@link capacityLimits}). Reading the raw `loco.maxPullWeightTons` here + * showed staff a ceiling the batch engine did not use. + */ private computeBoardCapacity( items: Array<{ state: BatchBoardBookingState; @@ -913,17 +1061,24 @@ export class BookingBatchService implements OnModuleInit { const committed = items.filter( (i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH", ); + const caps = loco + ? trainHardCaps({ + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + overageToleranceTons: Number(loco.overageToleranceTons) || 0, + overageToleranceMeters: Number(loco.overageToleranceMeters) || 0, + }) + : null; + const round2 = (value: number) => Math.round(value * 100) / 100; + return { allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0), - allocatedLengthMeters: - Math.round( - allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100, - ) / 100, - maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null, - usedWeightTons: - Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) / - 100, - maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null, + allocatedLengthMeters: round2( + allocated.reduce((sum, i) => sum + i.lengthMeters, 0), + ), + maxLengthMeters: caps ? caps.maxLengthMeters : null, + usedWeightTons: round2(committed.reduce((sum, i) => sum + i.weightTons, 0)), + maxWeightTons: caps ? caps.maxWeightTons : null, maxWagons: maxWagons ?? null, }; } @@ -936,6 +1091,7 @@ export class BookingBatchService implements OnModuleInit { return { scheduleId: s.id, + scheduleReference: s.reference ?? null, trainNumber: s.trainNumber ?? null, routeName: s.route ? formatRouteLabel(s.route) : null, origin: s.originStation?.label ?? s.originStation?.code ?? null, @@ -944,6 +1100,7 @@ export class BookingBatchService implements OnModuleInit { scheduleDate: s.scheduledDepartureDate ? s.scheduledDepartureDate.toISOString() : null, + createdAt: s.createdAt ? s.createdAt.toISOString() : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, direction: s.direction ?? null, @@ -1014,33 +1171,43 @@ export class BookingBatchService implements OnModuleInit { return schedule.windowPhase === "DOC_REVIEW" || schedule.windowPhase === "PAYMENT"; } - /** Fill one schedule from its priority-ordered pool until full. */ - async fillSchedule(scheduleId: string): Promise { + /** + * Fill one schedule from its priority-ordered pool until full. Returns the + * number of commercial units it RESERVED this pass (0 for government-only or + * no-fit passes) so a top-up caller can extend the payment phase only when a + * fresh pay window actually opened. + */ + async fillSchedule(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (!schedule || !this.isFillable(schedule)) return; + if (!schedule || !this.isFillable(schedule)) return 0; const locomotive = schedule.trainSet?.locomotive; if (!schedule.trainSetId || !locomotive) { this.logger.warn( `Schedule ${scheduleId} has no locomotive/train set — skipped.`, ); - return; + return 0; } - const rules = await this.loadGlobalRules(); - const wagonLengths = await this.loadWagonLengths(); - const limits = await this.capacityLimits(locomotive, rules); - await this.syncScheduleMaxWagons(schedule, locomotive, rules); - const budget = await this.remainingBudget(schedule, limits, wagonLengths); - if (budget.maxRemaining().wagons <= 0) { + const wagonDims = await this.loadWagonDims(); + const limits = await this.capacityLimits(locomotive); + await this.syncScheduleMaxWagons(schedule, locomotive); + const budget = await this.remainingBudget(schedule, limits, wagonDims); + const minPerWagon = this.minPerWagonNeed(wagonDims); + if (budget.isExhausted(minPerWagon)) { await this.setWindow(scheduleId, "FULL"); - return; + return 0; } const pool = await this.bookingsRepository.findBatchPool(scheduleId); + // Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill + // must rank bulk bookings by their wagon-derived priority too. + await this.recomputeBulkPriorities(pool, wagonDims); + this.resortPoolByPriority(pool); const units = this.groupConsolidatedPool(pool); let armed = false; let reservedThisPass = 0; + let commercialReserved = 0; // Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when // reservations trickle instead of landing in one pass (a reserve() throwing @@ -1055,8 +1222,8 @@ export class BookingBatchService implements OnModuleInit { const { primary: booking, partner } = unit; const isPair = partner != null; const need = isPair - ? this.combinedNeed(booking, partner, wagonLengths) - : this.needFor(booking, wagonLengths); + ? this.combinedNeed(booking, partner, wagonDims) + : this.needFor(booking, wagonDims); const isGov = booking.isGovernment || (partner?.isGovernment ?? false); // Consolidated partners always share one corridor, so the primary's leg // stands for the pair. @@ -1075,7 +1242,7 @@ export class BookingBatchService implements OnModuleInit { need, leg, budget, - wagonLengths, + wagonDims, ); if (!freed) continue; // still doesn't fit even after preempt } else { @@ -1106,6 +1273,7 @@ export class BookingBatchService implements OnModuleInit { await this.reserve(booking, scheduleId); if (partner) await this.reserve(partner, scheduleId); armed = true; + commercialReserved += 1; } budget.subtract(need, leg); reservedThisPass += 1; @@ -1122,9 +1290,10 @@ export class BookingBatchService implements OnModuleInit { this.logger.log( `[fillSchedule ${scheduleId}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`, ); - if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL"); + if (budget.isExhausted(minPerWagon)) await this.setWindow(scheduleId, "FULL"); if (armed) this.armSettle(scheduleId); void this.triggerWagonAllocation(scheduleId); + return commercialReserved; } /** @@ -1140,6 +1309,42 @@ export class BookingBatchService implements OnModuleInit { destinationYardId: string, day: string, ): Promise { + const { scheduleIds } = await this.fillRouteDayInternal( + originYardId, + destinationYardId, + day, + ); + return scheduleIds; + } + + /** + * Route-day top-up for a single schedule: re-run the DAY pool over the whole + * corridor the schedule belongs to, and report how many commercial units got a + * fresh pay window. + * + * `fillSchedule` cannot do this job. Its pool (`findBatchPool`) is keyed on + * `booking.train_schedule_id = :scheduleId`, but under day-level pooling a + * booking that has not been reserved yet has a NULL `train_schedule_id` — it is + * only pinned by `reserve()`. So the schedule-scoped top-up returned zero rows + * and the waiting list never boarded after an expiry freed capacity; bookings + * trickled in one per window cycle instead. + */ + private async topUpFill(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule?.scheduledDepartureDate) return 0; + const { commercialReserved } = await this.fillRouteDayInternal( + schedule.originStationId, + schedule.destinationStationId, + eatDay(schedule.scheduledDepartureDate), + ); + return commercialReserved; + } + + private async fillRouteDayInternal( + originYardId: string, + destinationYardId: string, + day: string, + ): Promise<{ scheduleIds: string[]; commercialReserved: number }> { // The day's fillable schedules on this exact corridor, earliest first. Fillable // covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT — // the batch must run while the customer window is closed. @@ -1157,23 +1362,35 @@ export class BookingBatchService implements OnModuleInit { }, ], }); - const scheduleIds = corridor + const onDay = corridor .filter( (s) => s.scheduledDepartureDate != null && - eatDay(s.scheduledDepartureDate) === day && - this.isFillable(s), + eatDay(s.scheduledDepartureDate) === day, ) .sort( (a, b) => a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(), - ) - .map((s) => s.id); + ); - if (scheduleIds.length === 0) return []; + // A schedule flagged FULL is rejected by isFillable() before its budget is + // ever consulted. Re-derive that flag from live capacity first, so a train + // whose bookings all expired is not skipped forever with an empty consist. + for (const s of onDay) { + if (s.bookingWindowStatus === "FULL") { + await this.refreshWindowStatus(s.id); + const fresh = await this.trainSchedulesRepository.findById(s.id); + if (fresh) s.bookingWindowStatus = fresh.bookingWindowStatus; + } + } - const rules = await this.loadGlobalRules(); - const wagonLengths = await this.loadWagonLengths(); + const scheduleIds = onDay.filter((s) => this.isFillable(s)).map((s) => s.id); + + if (scheduleIds.length === 0) { + return { scheduleIds: [], commercialReserved: 0 }; + } + + const wagonDims = await this.loadWagonDims(); // Live per-schedule corridor budget + arm flag, in departure order. const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = []; @@ -1187,12 +1404,12 @@ export class BookingBatchService implements OnModuleInit { ); continue; } - const limits = await this.capacityLimits(locomotive, rules); - await this.syncScheduleMaxWagons(schedule, locomotive, rules); - const budget = await this.remainingBudget(schedule, limits, wagonLengths); + const limits = await this.capacityLimits(locomotive); + await this.syncScheduleMaxWagons(schedule, locomotive); + const budget = await this.remainingBudget(schedule, limits, wagonDims); trains.push({ id, budget, armed: false }); } - if (trains.length === 0) return []; + if (trains.length === 0) return { scheduleIds, commercialReserved: 0 }; // The day pool covers every booking whose leg lies somewhere on one of the // day's corridors — full-route AND sub-corridor (e.g. Dire→Djibouti on an @@ -1203,6 +1420,10 @@ export class BookingBatchService implements OnModuleInit { corridorYards, day, ); + // BULK bookings only get their real (wagon-derived) priority score now, at + // batch time — stamp it and re-rank before the fill consumes the pool. + await this.recomputeBulkPriorities(pool, wagonDims); + this.resortPoolByPriority(pool); // Consolidated partners collapse into one atomic unit (both-or-neither); a // consolidated booking whose partner isn't ready this cycle is skipped. const units = this.groupConsolidatedPool(pool); @@ -1214,13 +1435,14 @@ export class BookingBatchService implements OnModuleInit { `poolSize=${pool.length} units=${units.length}`, ); let reservedThisPass = 0; + let commercialReserved = 0; for (const unit of units) { const { primary: booking, partner } = unit; const isPair = partner != null; const need = isPair - ? this.combinedNeed(booking, partner, wagonLengths) - : this.needFor(booking, wagonLengths); + ? this.combinedNeed(booking, partner, wagonDims) + : this.needFor(booking, wagonDims); const isGov = booking.isGovernment || (partner?.isGovernment ?? false); const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null => @@ -1256,7 +1478,7 @@ export class BookingBatchService implements OnModuleInit { need, leg, t.budget, - wagonLengths, + wagonDims, ); if (freed) { target = t; @@ -1273,7 +1495,12 @@ export class BookingBatchService implements OnModuleInit { // non-import never split — isSplitEligible guards that. Passing the live // `trains` entries lets maybeOfferPartial mutate the chosen budget/armed. const offered = await this.maybeOfferPartial(booking, isPair, trains, need); - if (offered) continue; + if (offered) { + // A partial offer opens a real commercial pay window, same as reserve(). + commercialReserved += 1; + reservedThisPass += 1; + continue; + } // Stays in the pool, retried next batch/window cycle. this.notifier.unplaced(booking, day); if (partner) this.notifier.unplaced(partner, day); @@ -1294,6 +1521,7 @@ export class BookingBatchService implements OnModuleInit { await this.reserve(booking, target.id); if (partner) await this.reserve(partner, target.id); target.armed = true; + commercialReserved += 1; } target.budget.subtract(need, legOn(target)!); reservedThisPass += 1; @@ -1309,13 +1537,14 @@ export class BookingBatchService implements OnModuleInit { `[fillRouteDay ${originYardId}->${destinationYardId} ${day}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`, ); + const minPerWagon = this.minPerWagonNeed(wagonDims); for (const t of trains) { - if (t.budget.maxRemaining().wagons <= 0) await this.setWindow(t.id, "FULL"); + if (t.budget.isExhausted(minPerWagon)) await this.setWindow(t.id, "FULL"); if (t.armed) this.armSettle(t.id); void this.triggerWagonAllocation(t.id); } - return trains.map((t) => t.id); + return { scheduleIds: trains.map((t) => t.id), commercialReserved }; } /** @@ -1385,23 +1614,40 @@ export class BookingBatchService implements OnModuleInit { if (booking.consolidationPartnerId) return null; if (await this.splitService.findOpenOffer(booking.id)) return null; - const wagonLengths = await this.loadWagonLengths(); - const bulkCapacityTons = await this.loadBulkWagonCapacityTons(); + const wagonDims = await this.loadWagonDims(); + + // The wagon-slot axis alone under-constrains the offer. On a weight- or + // length-limited train (slots to spare, but e.g. only 798T of pull weight + // left) sizing by slots either produced an offer the fits() check below + // rejected, or — when the free slots exceeded the booking's own wagon + // count — sizeOffer refused outright, so a bulk booking on a weight-bound + // train was never offered a split at all. Size across all three axes, + // measured on the booking's REAL wagon type — the same one allocation + // validates against. Bulk splits ride FULL wagons only: the offer never + // part-loads its last wagon. + const perWagon = this.dimsFor(booking, wagonDims); + const partial = sizePartialOfferWagons(budget, need.wagons, perWagon, { + fullWagonsOnly: booking.freightType === "BULK", + }); + if (!partial) return null; + const sized = await this.splitService.sizeOffer( booking, - budget.wagons, + partial.wagons, need.wagons, - bulkCapacityTons, + perWagon.capacityTons, + partial.maxCargoTons, ); if (!sized) return null; const offeredNeed: Capacity = { wagons: sized.offeredWagons, - weightTons: sized.offeredWeightTons, - lengthMeters: bookingTrainLengthMeters(booking.freightType, sized.offeredWagons, { - container: wagonLengths.container, - bulk: wagonLengths.bulk, - }), + weightTons: bookingGrossWeightTons( + sized.offeredWeightTons, + sized.offeredWagons, + perWagon.tareWeightTons, + ), + lengthMeters: sized.offeredWagons * perWagon.lengthMeters, }; if (!this.fits(offeredNeed, budget)) return null; @@ -1419,14 +1665,6 @@ export class BookingBatchService implements OnModuleInit { return offeredNeed; } - private async loadBulkWagonCapacityTons(): Promise { - const cw3 = await this.dataSource - .getRepository(WagonType) - .findOne({ where: { code: "CW3" } }); - const capacity = cw3 ? wagonTypeDimensionsFromEntity(cw3).capacityTons : 60; - return capacity > 0 ? capacity : 60; - } - /** * Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides * how to treat a reservation with no deadline (durable path: leave it; timeout @@ -1490,16 +1728,93 @@ export class BookingBatchService implements OnModuleInit { return anySettled; } - /** Durable settle: allocate paid / expire overdue reservations, then top up. */ + /** + * Durable settle: allocate paid / expire overdue reservations, then top up the + * freed capacity from the waiting list. + * + * Serialised per schedule. Two callers race here every time a payment phase + * ends: `advanceImport`'s PAYMENT branch and the tick's `settleOverdueReservations` + * backstop. Both read the same reserved rows in the same second, so without the + * lock the second caller re-settles rows the first is mid-way through expiring, + * and `concludeCycle` observes capacity that is neither pre- nor post-expiry. + */ async settleDueReservations(scheduleId: string): Promise { - const anySettled = await this.settleReserved(scheduleId, false); - // A settle that allocated/expired anything frees or fills capacity → re-run the - // fill so the next waiting-list bookings get a fresh pay window (top-up). - if (anySettled) { + await this.withScheduleLock(scheduleId, () => + this.settleAndTopUp(scheduleId, false), + ); + } + + /** + * Settle, then keep promoting the waiting list until the train can take no more. + * Returns whether anything settled. + * + * One top-up pass is not enough: expiring an N-wagon booking can free room for + * several smaller ones, and reserving those can in turn leave room for the next + * size down. Loop until a pass reserves nothing, so the batch ends with the train + * as full as the pool allows — rather than leaving a booking stranded until the + * next window cycle. + * + * Each round that opens a fresh pay window pushes `paymentPhaseEndsAt` out, so + * `concludeCycle` cannot fire before the promoted customers' deadlines. + */ + private async settleAndTopUp( + scheduleId: string, + expireUnpaidUnknownDeadline: boolean, + ): Promise { + const anySettled = await this.settleReserved( + scheduleId, + expireUnpaidUnknownDeadline, + ); + if (!anySettled) return false; + + this.logger.log( + `[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`, + ); + + // Bounded: every round either reserves at least one unit (shrinking the pool) + // or breaks. The cap is a backstop against a pathological reserve/expire cycle. + let promoted = 0; + for (let round = 0; round < 10; round += 1) { + const reservedThisRound = await this.topUpFill(scheduleId); + if (reservedThisRound <= 0) break; + promoted += reservedThisRound; + await this.extendPaymentPhaseForTopUp(scheduleId); + } + + if (promoted > 0) { this.logger.log( - `[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`, + `[BATCH] top-up promoted ${promoted} waiting booking(s) onto ${scheduleId} ` + + `— payment phase extended for them`, ); - await this.fillSchedule(scheduleId); + } + return true; + } + + /** + * Run `fn` with exclusive access to `scheduleId`. Concurrent callers await the + * in-flight run rather than interleaving with it. Single-process only — a second + * API replica would need a row lock on the schedule instead. + */ + private async withScheduleLock( + scheduleId: string, + fn: () => Promise, + ): Promise { + const inFlight = this.scheduleLocks.get(scheduleId) ?? Promise.resolve(); + // Chain onto the previous holder; swallow its rejection so one failure does + // not poison every later caller's lock. + const run = inFlight.catch(() => undefined).then(fn); + const gate = run.then( + () => undefined, + () => undefined, + ); + this.scheduleLocks.set(scheduleId, gate); + try { + return await run; + } finally { + // Last one out clears the slot so the map does not grow without bound. + if (this.scheduleLocks.get(scheduleId) === gate) { + this.scheduleLocks.delete(scheduleId); + } } } @@ -1508,8 +1823,9 @@ export class BookingBatchService implements OnModuleInit { /** Allocate paid reservations, expire the rest, then top up. */ async settleBatch(scheduleId: string): Promise { this.removeTimeout(scheduleId); - await this.settleReserved(scheduleId, true); - await this.fillSchedule(scheduleId); + await this.withScheduleLock(scheduleId, () => + this.settleAndTopUp(scheduleId, true), + ); void this.triggerWagonAllocation(scheduleId); } @@ -1544,7 +1860,7 @@ export class BookingBatchService implements OnModuleInit { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( booking.trainScheduleId, ); - if (schedule && (await this.remainingWagons(schedule)) <= 0) { + if (schedule && (await this.isTrainFull(schedule))) { await this.setWindow(booking.trainScheduleId, "FULL"); } void this.triggerWagonAllocation(booking.trainScheduleId!); @@ -1612,9 +1928,16 @@ export class BookingBatchService implements OnModuleInit { .getRepository(Booking) .findOne({ where: { id: bookingId } }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + // Capture the train before expire() detaches the booking from it — the + // top-up has to run against the schedule whose wagons were just freed. + const freedScheduleId = booking.trainScheduleId; await this.expire(booking); - if (booking.trainScheduleId) - await this.fillSchedule(booking.trainScheduleId); + if (freedScheduleId) { + const topUpReserved = await this.topUpFill(freedScheduleId); + if (topUpReserved > 0) { + await this.extendPaymentPhaseForTopUp(freedScheduleId); + } + } } // ---- intercity ride-along API --------------------------------------------- @@ -1634,11 +1957,10 @@ export class BookingBatchService implements OnModuleInit { await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !locomotive) return null; - const rules = await this.loadGlobalRules(); - const wagonLengths = await this.loadWagonLengths(); - const limits = await this.capacityLimits(locomotive, rules); - const budget = await this.remainingBudget(schedule, limits, wagonLengths); - return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) }; + const wagonDims = await this.loadWagonDims(); + const limits = await this.capacityLimits(locomotive); + const budget = await this.remainingBudget(schedule, limits, wagonDims); + return { budget, needFor: (booking) => this.needFor(booking, wagonDims) }; } /** @@ -1671,6 +1993,28 @@ export class BookingBatchService implements OnModuleInit { * so the engine sets it as it picks the train. */ private async reserve(booking: Booking, scheduleId: string): Promise { + // Idempotency guard: a booking already reserved (pay window open) or already + // paid on THIS schedule must never be re-reserved — that would fire a second + // `payNow` and reset its deadline, the "asked to pay again after paying" + // symptom. Read fresh state (the in-memory `booking` may be stale from the + // pooled query). Only bookings not yet committed to this train pass through. + const fresh = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: booking.id } }); + if ( + fresh && + fresh.trainScheduleId === scheduleId && + (fresh.status === "SELECTED_FOR_BATCH" || + fresh.status === "AWAITING_PAYMENT" || + fresh.status === "PAID" || + fresh.paymentStatus === "PAID") + ) { + this.logger.debug( + `[BATCH] reserve skipped for ${booking.reference} — already ` + + `${fresh.status}/${fresh.paymentStatus} on schedule ${scheduleId}`, + ); + return; + } const now = new Date(); const deadline = new Date(now.getTime() + (await this.paymentWindowMs())); await this.bookingsRepository.update(booking.id, { @@ -1689,8 +2033,9 @@ export class BookingBatchService implements OnModuleInit { "PREPAID", ); await this.notifier.payNow(booking, deadline); + const reservedWagons = this.wagonsFor(booking, await this.loadWagonDims()); this.logger.log( - `[BATCH] RESERVED ${booking.reference} (${this.wagonsFor(booking)}w, ` + + `[BATCH] RESERVED ${booking.reference} (${reservedWagons}w, ` + `priority ${booking.priorityScore ?? 0}) on schedule ${scheduleId} — ` + `pay by ${deadline.toISOString()}`, ); @@ -1781,6 +2126,7 @@ export class BookingBatchService implements OnModuleInit { * it failed to pay for — it's back in the day pool for staff to act on. */ private async expire(booking: Booking): Promise { + const freedScheduleId = booking.trainScheduleId; await this.bookingsRepository.update(booking.id, { trainScheduleId: null, status: "EXPIRED", @@ -1789,6 +2135,9 @@ export class BookingBatchService implements OnModuleInit { selectedForBatchAt: null, } as never); booking.trainScheduleId = null; + // The wagons this reservation held are back — a schedule parked at FULL + // because of it must reopen, or it can never be filled again. + if (freedScheduleId) await this.refreshWindowStatus(freedScheduleId); // An unpaid partial offer dies with the reservation — the booking stays whole. if (this.splitService) { await this.splitService.expireOpenOffer(booking.id); @@ -1893,7 +2242,7 @@ export class BookingBatchService implements OnModuleInit { need: Capacity, leg: CorridorLeg, budget: CorridorBudget, - wagonLengths: WagonLengths, + wagonDims: WagonDims, ): Promise { if (budget.fits(need, leg)) return true; const reservedCommercial = ( @@ -1941,7 +2290,10 @@ export class BookingBatchService implements OnModuleInit { ); }); this.notifier.displaced(victim); - budget.add(this.needFor(victim, wagonLengths), victimLeg); + budget.add(this.needFor(victim, wagonDims), victimLeg); + // Displacing frees wagons the same way an expiry does — don't leave the + // schedule stuck at FULL. + await this.refreshWindowStatus(scheduleId); } return budget.fits(need, leg); } @@ -1995,51 +2347,145 @@ export class BookingBatchService implements OnModuleInit { private combinedNeed( primary: Booking, partner: Booking, - wagonLengths: WagonLengths, + wagonDims: WagonDims, ): Capacity { const containers = (b: Booking): number => (b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0); const totalContainers = containers(primary) + containers(partner); - const sharedWagons = + const cargoTons = + Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0); + + // Consolidation shares TEU slots, never rated payload: the pair still needs + // enough wagons to carry its combined cargo, so the weight axis bounds the + // shared count exactly as it bounds an individual booking's. A pair shares + // wagons, so the primary's wagon type stands for both partners. + const dims = this.dimsFor(primary, wagonDims); + const capacityTons = dims.capacityTons; + const byWeight = + cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; + const byLength = totalContainers > 0 ? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON) - : this.wagonsFor(primary) + this.wagonsFor(partner); - const weightTons = - Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0); + : this.wagonsFor(primary, wagonDims) + this.wagonsFor(partner, wagonDims); + const sharedWagons = Math.max(byLength, byWeight); + return { wagons: sharedWagons, - weightTons, - lengthMeters: bookingTrainLengthMeters(primary.freightType, sharedWagons, { - container: wagonLengths.container, - bulk: wagonLengths.bulk, - }), + // Consolidation saves tare as well as slots: the pair rides `sharedWagons` + // wagons, so it is charged `sharedWagons` tares, not one per booking. + weightTons: bookingGrossWeightTons( + cargoTons, + sharedWagons, + dims.tareWeightTons, + ), + lengthMeters: sharedWagons * dims.lengthMeters, }; } - private wagonsFor(booking: Booking): number { - if (booking.wagonsRequired && booking.wagonsRequired > 0) { - return Math.ceil(booking.wagonsRequired); + /** + * Stamp real priority scores on the pool's BULK bookings before the batch + * ranks it. Submit-time scoring runs with totalWagons = 0 for bulk (a bulk + * booking has no container lines to carry a wagon count), so every + * wagon-range priority config missed and bulk import bookings entered the + * batch at score 0 — they were never prioritized. Their wagon footprint is + * derivable from tonnage vs. live wagon capacity (wagonsFor), so the score + * is computed here — when doc review closes and the batch runs — and + * persisted so the priority board shows the same ranking. The pool arrives + * SQL-ordered by the old scores; the caller must re-sort after this. + */ + private async recomputeBulkPriorities( + pool: Booking[], + wagonDims: WagonDims, + ): Promise { + for (const booking of pool) { + if (booking.freightType !== 'BULK') continue; + try { + const wagons = this.wagonsFor(booking, wagonDims); + const score = await this.pricingService.computeSubmitPriorityScore( + booking, + wagons, + ); + if (Number(booking.priorityScore ?? 0) === score) continue; + await this.dataSource + .getRepository(Booking) + .update(booking.id, { priorityScore: score }); + booking.priorityScore = score; + } catch (err) { + // A failed recompute keeps the stored score — never blocks the batch. + this.logger.warn( + `Bulk priority recompute failed for ${booking.reference ?? booking.id}: ` + + `${(err as Error).message}`, + ); + } } - const fromContainers = (booking.bookingContainers ?? []).reduce( - (sum, c) => sum + Number(c.quantity ?? 0), - 0, - ); - return Math.max( - DEFAULT_WAGONS_PER_BOOKING, - fromContainers || DEFAULT_WAGONS_PER_BOOKING, + } + + /** Restore the batch pool ordering (mirrors findBatchPool's ORDER BY) after scores changed. */ + private resortPoolByPriority(pool: Booking[]): void { + pool.sort( + (a, b) => + Number(b.isGovernment) - Number(a.isGovernment) || + Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) || + (a.fullyExecutedAt?.getTime() ?? Infinity) - + (b.fullyExecutedAt?.getTime() ?? Infinity) || + a.createdAt.getTime() - b.createdAt.getTime(), ); } - /** What one booking consumes along all three capacity axes. */ - private needFor(booking: Booking, wagonLengths: WagonLengths): Capacity { - const wagons = this.wagonsFor(booking); + /** + * Wagons a booking occupies. Two axes bind independently and the booking needs + * enough wagons to satisfy BOTH, so the count is the larger of: + * + * weight — ceil(cargoTons / wagonType.capacityTons), the rated payload + * length — TEU geometry, two 20ft to a wagon (container bookings only) + * + * The weight axis was missing entirely. A BULK booking carries no container + * lines, so `containerWagonsForLines` returned 0 and every bulk booking + * collapsed to a single wagon no matter its tonnage — a 2590T fertilizer + * booking counted as 1 wagon, and `needFor` then charged 1 tare instead of 37. + * That under-reported the board and let the fill loop overbook the train. + */ + private wagonsFor(booking: Booking, wagonDims: WagonDims): number { + // Stored wagonsRequired is a candidate, never an early return: rows written + // while sumWagonsRequired hardcoded BULK to 1 wagon are still in the DB, and + // trusting them charged one tare for a whole bulk consist (a 700T booking on + // 70T wagons read 700 + 1 tare instead of 700 + 10 tares). + const stored = + booking.wagonsRequired && booking.wagonsRequired > 0 + ? Math.ceil(booking.wagonsRequired) + : 0; + + // TEU-aware: two 20ft share one wagon (wagonsPerUnit = 0.5). The old fallback + // summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10. + const byLength = containerWagonsForLines(booking.bookingContainers ?? []); + + const capacityTons = this.dimsFor(booking, wagonDims).capacityTons; + const cargoTons = Number(booking.cargoTotalWeightVgm ?? 0); + const byWeight = + cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; + + return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight); + } + + /** + * What one booking consumes along all three capacity axes. + * + * The weight axis is GROSS — cargo plus the tare of every wagon the booking + * occupies — because it is spent against the locomotive's pull limit, which + * governs the whole train and not just its payload. Charging cargo alone let a + * 37-wagon box-wagon train read 2590T when it really weighed 3522T. + */ + private needFor(booking: Booking, wagonDims: WagonDims): Capacity { + const wagons = this.wagonsFor(booking, wagonDims); + const dims = this.dimsFor(booking, wagonDims); return { wagons, - weightTons: Number(booking.cargoTotalWeightVgm ?? 0), - lengthMeters: bookingTrainLengthMeters(booking.freightType, wagons, { - container: wagonLengths.container, - bulk: wagonLengths.bulk, - }), + weightTons: bookingGrossWeightTons( + Number(booking.cargoTotalWeightVgm ?? 0), + wagons, + dims.tareWeightTons, + ), + lengthMeters: wagons * dims.lengthMeters, }; } @@ -2051,81 +2497,143 @@ export class BookingBatchService implements OnModuleInit { ); } - /** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */ - private async capacityLimits( - locomotive: Locomotive, - rules: TrainSchedulingGlobalRules | null, - ): Promise { + /** + * Caps for a schedule's train: gross pull weight, train length, and the + * length-derived wagon slot count (never a fixed 53). Bookings spend against + * `base` via {@link needFor}, whose weight axis is gross. The locomotive's + * overage tolerance is returned separately — the corridor budget spends it + * only to admit a booking whole, never to size a split. + * + * Limits come from the LOCOMOTIVE ALONE — the global-rules weight/length + * caps deliberately do not apply here (a mis-set global row once capped + * every train at 14m and no export booking could board). + */ + private async capacityLimits(locomotive: Locomotive): Promise { const wagonTypes = await this.loadWagonTypeDimensions(); const derived = deriveTrainCapacityFromLocomotive( { maxPullWeightTons: Number(locomotive.maxPullWeightTons), maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters), + overageToleranceTons: Number(locomotive.overageToleranceTons) || 0, + overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0, }, wagonTypes, - { - maxTrainWeightTons: rules?.maxTrainWeightTons - ? Number(rules.maxTrainWeightTons) - : undefined, - maxTrainLengthMeters: rules?.maxTrainLengthMeters - ? Number(rules.maxTrainLengthMeters) - : undefined, - }, ); return { - wagons: derived.maxWagonSlots, - weightTons: derived.maxWeightTons, - lengthMeters: derived.maxLengthMeters, + base: { + wagons: derived.maxWagonSlots, + weightTons: derived.baseWeightTons, + lengthMeters: derived.baseLengthMeters, + }, + tolerance: { + weightTons: derived.toleranceTons, + lengthMeters: derived.toleranceMeters, + }, }; } - /** Keep schedule.max_wagons aligned with locomotive physical limits. */ + /** + * Keep schedule.max_wagons aligned with the train's boarding limit: the + * locomotive's length-derived slot count. The physical wagons currently in + * the train set do NOT cap this — bookings are admitted on length/weight + * alone and yard staff attach the wagons manually before departure. + */ private async syncScheduleMaxWagons( schedule: TrainSchedule, locomotive: Locomotive, - rules: TrainSchedulingGlobalRules | null, ): Promise { - const limits = await this.capacityLimits(locomotive, rules); - if ((schedule.maxWagons ?? 0) !== limits.wagons) { + const limits = await this.capacityLimits(locomotive); + const maxWagons = limits.base.wagons; + if ((schedule.maxWagons ?? 0) !== maxWagons) { await this.dataSource .getRepository(TrainSchedule) - .update(schedule.id, { maxWagons: limits.wagons }); - schedule.maxWagons = limits.wagons; + .update(schedule.id, { maxWagons }); + schedule.maxWagons = maxWagons; } } - private async loadWagonTypeDimensions(): Promise< - Array<{ lengthMeters: number; capacityTons: number }> - > { - const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: "NW5" }, { code: "CW3" }], - }); + /** + * Every active wagon type, so the slot count is derived from the shortest wagon + * the fleet can actually marshal rather than from an arbitrary two-code sample. + */ + private async loadWagonTypeDimensions(): Promise { + const types = await this.dataSource + .getRepository(WagonType) + .find({ where: { isActive: true } }); if (types.length) return types.map(wagonTypeDimensionsFromEntity); return [ - { lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 }, - { lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 }, + { + lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + capacityTons: 70, + tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS, + }, + { + lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, + capacityTons: 60, + tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS, + }, ]; } - private async loadWagonLengths(): Promise { - const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: "NW5" }, { code: "CW3" }], - }); + /** + * Every wagon type keyed by id (drives per-booking dims via the cargo/container + * type's wagon_type_id FK), plus representative fallbacks per freight type + * (NW5 flat for containers, CW3 gondola for bulk) for bookings whose type has + * no wagon type configured yet. + */ + private async loadWagonDims(): Promise { + const types = await this.dataSource.getRepository(WagonType).find(); const byCode = new Map( types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]), ); + const byWagonTypeId = new Map( + types.map((t) => [t.id, wagonTypeDimensionsFromEntity(t)]), + ); + const nw5 = byCode.get("NW5"); + const cw3 = byCode.get("CW3"); + // capacityTons divides a bulk booking's cargo, so a 0 or missing rated payload + // must fall back rather than yield an infinite wagon count. + const payload = (value: number | undefined, fallback: number): number => + value && value > 0 ? value : fallback; return { - container: - byCode.get("NW5")?.lengthMeters ?? - DEFAULT_CONTAINER_WAGON_LENGTH_METERS, - bulk: byCode.get("CW3")?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, + container: { + lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS, + capacityTons: payload(nw5?.capacityTons, DEFAULT_CONTAINER_WAGON_CAPACITY_TONS), + }, + bulk: { + lengthMeters: cw3?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS, + tareWeightTons: cw3?.tareWeightTons ?? DEFAULT_BULK_WAGON_TARE_TONS, + capacityTons: payload(cw3?.capacityTons, DEFAULT_BULK_WAGON_CAPACITY_TONS), + }, + byWagonTypeId, }; } - private async loadGlobalRules(): Promise { - return this.dataSource - .getRepository(TrainSchedulingGlobalRules) - .findOne({ where: {} }); + /** + * Dimensions of the wagon type THIS booking rides: bulk resolves through its + * cargo type's wagon_type_id, container through the first container line's + * type — the same FK resolution `resolveWagonType` applies when the paid + * booking is allocated. Board/fill math measured on a representative wagon + * while allocation validated the real one let a selected batch flunk the + * post-payment gross-weight check; sharing the resolution closes that gap. + * Falls back to the representative dims when the FK or relation is absent. + */ + private dimsFor(booking: Booking, wagonDims: WagonDims): PerWagonDims { + const fallback = + booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container; + const wagonTypeId = + booking.freightType === "BULK" + ? booking.cargoType?.wagonTypeId + : (booking.bookingContainers ?? []) + .map((line) => line.containerType?.wagonTypeId) + .find((id): id is string => Boolean(id)); + const dims = wagonTypeId ? wagonDims.byWagonTypeId.get(wagonTypeId) : undefined; + if (!dims) return fallback; + return { + ...dims, + capacityTons: dims.capacityTons > 0 ? dims.capacityTons : fallback.capacityTons, + }; } /** @@ -2151,14 +2659,19 @@ export class BookingBatchService implements OnModuleInit { * Remaining capacity per corridor edge = hard caps minus what allocated + * reserved bookings already use ON THEIR OWN LEGS. A booking riding only * Dire→Djibouti leaves the Addis→Dire edges untouched. + * + * The wagon axis is the locomotive's length-derived slot count only — the + * physical wagons currently marshalled in the train set do NOT cap it. + * Bookings are admitted on length/weight capacity and yard staff attach + * the missing wagons manually before wagon assignment. */ private async remainingBudget( schedule: TrainSchedule, - limits: Capacity, - wagonLengths: WagonLengths, + limits: TrainLimits, + wagonDims: WagonDims, ): Promise { const stops = await this.stopsForSchedule(schedule); - const budget = new CorridorBudget(stops, limits); + const budget = new CorridorBudget(stops, limits.base, limits.tolerance); const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); @@ -2167,7 +2680,7 @@ export class BookingBatchService implements OnModuleInit { ); for (const b of [...allocated, ...reserved]) { budget.subtract( - this.needFor(b, wagonLengths), + this.needFor(b, wagonDims), budget.legForYards(b.originYardId, b.destinationYardId), ); } @@ -2176,18 +2689,23 @@ export class BookingBatchService implements OnModuleInit { /** * Wagon slots still boardable somewhere on the corridor (most-open edge). - * ≤ 0 means no leg can take another booking — the train-wide FULL signal. + * ≤ 0 means no leg can take another booking. Slot axis ONLY — the train-wide + * FULL signal is {@link isTrainFull}, which also closes weight/length-bound + * trains that still show free slots. */ private async remainingWagons(schedule: TrainSchedule): Promise { - const wagonLengths = await this.loadWagonLengths(); + const wagonDims = await this.loadWagonDims(); const budget = await this.remainingBudget( schedule, { - wagons: schedule.maxWagons ?? 0, - weightTons: Number.POSITIVE_INFINITY, - lengthMeters: Number.POSITIVE_INFINITY, + base: { + wagons: schedule.maxWagons ?? 0, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + tolerance: { weightTons: 0, lengthMeters: 0 }, }, - wagonLengths, + wagonDims, ); return budget.maxRemaining().wagons; } @@ -2212,12 +2730,109 @@ export class BookingBatchService implements OnModuleInit { } } - /** No wagon slots left for allocated + reserved bookings. */ + /** + * A reservation on this schedule still has time left to pay. + * + * The PAYMENT phase ends a hair BEFORE its own reservations do: `paymentPhaseEndsAt` + * is stamped when the phase starts, then `reserve()` gives each booking + * `now + paymentWindow` a few hundred milliseconds later, one booking at a time. So + * the first settle after the phase deadline finds every reservation still in date, + * expires nothing, reports `anySettled = false`, runs no top-up — and the caller + * concludes the cycle out from under customers who still had time to pay. The next + * tick then expires them with no cycle left to promote the waiting list into. + * + * Callers must not conclude the cycle while this returns true. + */ + async hasLiveReservations(scheduleId: string): Promise { + const reserved = + await this.bookingsRepository.findReservedForSchedule(scheduleId); + const now = Date.now(); + return reserved.some( + (b) => + b.paymentStatus !== "PAID" && + b.status !== "PAID" && + b.paymentDeadline != null && + b.paymentDeadline.getTime() > now, + ); + } + + /** + * FULL on ANY capacity axis: out of wagon slots, or out of pull weight / + * train length for even one more loaded wagon. The old slot-only check let + * a weight-bound train (PW2: weight binds at 37 wagons = 3522.4T of + * 3500+90T, slots bind at 44) cycle its booking window forever instead of + * finalizing — 7 phantom slots kept it "not full" while nothing could board. + */ async isScheduleFull(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) return false; - return (await this.remainingWagons(schedule)) <= 0; + return this.isTrainFull(schedule); + } + + /** See {@link isScheduleFull} — same check for callers that already hold the full graph. */ + private async isTrainFull(schedule: TrainSchedule): Promise { + if ((await this.remainingWagons(schedule)) <= 0) return true; + const locomotive = schedule.trainSet?.locomotive; + if (!locomotive) return false; // no weight/length limits to bind against + const wagonDims = await this.loadWagonDims(); + const limits = await this.capacityLimits(locomotive); + const budget = await this.remainingBudget(schedule, limits, wagonDims); + return budget.isExhausted(this.minPerWagonNeed(wagonDims)); + } + + /** + * Smallest gross weight / shortest length one more wagon could add: the + * lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted, + * so FULL is only declared when not even this wagon fits anywhere. + */ + private minPerWagonNeed(wagonDims: WagonDims): { + grossWeightTons: number; + lengthMeters: number; + } { + const all = [ + wagonDims.container, + wagonDims.bulk, + ...wagonDims.byWagonTypeId.values(), + ]; + return { + grossWeightTons: Math.min( + ...all.map((d) => d.tareWeightTons + d.capacityTons), + ), + lengthMeters: Math.min(...all.map((d) => d.lengthMeters)), + }; + } + + /** + * Re-derive `bookingWindowStatus` from live capacity after wagons were freed + * (a reservation expired, a booking was displaced, a link was removed). + * + * FULL used to be a one-way door: `isFillable()` rejects a FULL schedule before + * it ever looks at the budget, and the only writers of OPEN skip a FULL row. So + * a train that filled once and then lost every booking to expiry stayed FULL + * with all its wagons free — permanently unfillable, cycling PRE_WINDOW→PAYMENT + * forever while `concludeCycle` (which reads real capacity, not the flag) kept + * reopening it. Clearing FULL here is what lets the next batch actually run. + * + * Only the customer-facing OPEN phases may go back to OPEN; a schedule mid + * DOC_REVIEW/PAYMENT drops to CLOSED, which `isFillable()` still admits. + */ + async refreshWindowStatus(scheduleId: string): Promise { + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule || schedule.bookingWindowStatus !== "FULL") return; + // Symmetric with isScheduleFull: a weight/length-bound FULL is not stale + // just because slots remain — clearing it here would reopen a train + // nothing can board. + if (await this.isTrainFull(schedule)) return; + + const customerWindowOpen = + schedule.windowPhase == null || schedule.windowPhase === "OPEN"; + await this.setWindow(scheduleId, customerWindowOpen ? "OPEN" : "CLOSED"); + this.logger.log( + `[BATCH] ${scheduleId} cleared stale FULL — wagons freed, window is now ` + + `${customerWindowOpen ? "OPEN" : "CLOSED"} and the batch can fill it again`, + ); } // ---- timer plumbing ------------------------------------------------------- @@ -2256,6 +2871,45 @@ export class BookingBatchService implements OnModuleInit { ); } + /** + * A top-up reservation (settle freed capacity mid-cycle, so the next waiting + * booking got a fresh pay window) sets a NEW paymentDeadline. But the schedule's + * `paymentPhaseEndsAt` — which the window tick watches to end PAYMENT and run + * concludeCycle — was frozen when the phase started. Without this, concludeCycle + * fires before the top-up customer's deadline and expires a booking that still + * had time to pay. Push `paymentPhaseEndsAt` to at least cover a full payment + * window from now, but never past departure. Only while the schedule is still + * in the PAYMENT phase (a reopened cycle manages its own phase). + */ + async extendPaymentPhaseForTopUp(scheduleId: string): Promise { + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: scheduleId } }); + if (!schedule || schedule.windowPhase !== "PAYMENT") return; + const windowMs = await this.paymentWindowMs(); + let target = new Date(Date.now() + windowMs); + if ( + schedule.scheduledDepartureDate && + target > schedule.scheduledDepartureDate + ) { + target = schedule.scheduledDepartureDate; + } + // Only ever push the deadline OUT, never pull it in. + if ( + schedule.paymentPhaseEndsAt && + schedule.paymentPhaseEndsAt.getTime() >= target.getTime() + ) { + return; + } + await this.dataSource + .getRepository(TrainSchedule) + .update(scheduleId, { paymentPhaseEndsAt: target }); + this.logger.log( + `[BATCH] extended PAYMENT phase for ${scheduleId} to ${target.toISOString()} ` + + `(top-up reservation opened a fresh pay window)`, + ); + } + private removeTimeout(scheduleId: string): void { const name = this.timeoutName(scheduleId); try { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index cdc58083c..2df741bd3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -28,6 +28,9 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; * unload at its destination yard (IN_TRANSIT → ARRIVED for import/export, * → COMPLETED for intercity), possibly long before the train's final arrival. * Both are gated on the train's latest recorded checkpoint being at that yard. + * Unload also fires automatically: recording a checkpoint at a yard auto- + * unloads every booking destined there (autoUnloadAtYard), so the manual + * unload endpoint remains only a fallback. * * Unloading also settles the physical wagons: each wagon that alights with the * booking is released at that yard and the move is written to the @@ -198,6 +201,47 @@ export class BookingJourneyService { }; } + /** + * Auto-unload on checkpoint: every IN_TRANSIT booking on this schedule whose + * destination is the yard the train just reached alights automatically, so + * the customer's booking flips to ARRIVED (COMPLETED for intercity) the + * moment the train is recorded at their yard — no separate operator unload. + * Runs through the same per-booking unload path (wagon settle + ledger + + * milestones); one booking's failure is logged and never blocks the + * checkpoint or the other bookings. Returns the unloaded booking ids. + */ + async autoUnloadAtYard( + scheduleId: string, + yardId: string, + userId?: string | null, + ): Promise { + const bookings = await this.dataSource + .getRepository(Booking) + .createQueryBuilder('booking') + .innerJoin( + 'freight.train_schedule_bookings', + 'tsb', + 'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL', + { scheduleId }, + ) + .where('booking.destination_yard_id = :yardId', { yardId }) + .andWhere(`booking.status = 'IN_TRANSIT'`) + .getMany(); + + const unloaded: string[] = []; + for (const booking of bookings) { + try { + await this.unloadBooking(scheduleId, booking.id, userId); + unloaded.push(booking.id); + } catch (err) { + this.logger.warn( + `Auto-unload failed for booking ${booking.id} at yard ${yardId}: ${(err as Error).message}`, + ); + } + } + return unloaded; + } + /** * Bulk fallback at the train's FINAL arrival: any booking destined for the * final yard that operators didn't unload individually gets its per-booking diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 48985bdf0..c189825fd 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { NotificationAudience, + NotificationPriority, NotificationType, NotifyInput, } from '@edr/types'; @@ -114,11 +115,15 @@ export class BookingNotifierService { const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); const msg = `Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` + - `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` + - `(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`; + `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now. ` + + `The remaining ${totalWagons - offeredWagons} return${totalWagons - offeredWagons === 1 ? 's' : ''} to your contract — book them yourself in a later window. ` + + `If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`; await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)'); + // HIGH: a split is a change to what the customer ordered AND a live payment + // deadline — it must reach email/SMS, not just the portal inbox. this.inApp(b, 'Partial allocation offer', msg, { type: NotificationType.INVOICE_ISSUED, + priority: NotificationPriority.HIGH, }); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts index 44886f116..ad0967cf0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts @@ -36,6 +36,9 @@ export interface SizedOffer { * rows, so reducing the lines releases it automatically) and can be rebooked in * any later window within contract validity. A ONE_TIME contract is promoted to * GENERAL on split (see applySplit) so its remainder is actually rebookable. + * Once the remainder is rebooked and the cap hits zero, ContractBookingService + * completes the contract (CONTRACT_CLOSED): no further bookings or shipment + * requests, even while validity and a booking window are still open. */ @Injectable() export class BookingSplitService { @@ -55,12 +58,17 @@ export class BookingSplitService { * Size the largest part of the booking that fits `freeWagons`, priced via an * in-memory clone. Returns null when nothing meaningful fits (no whole * container unit / no bulk tonnage, or pricing failed). + * + * `maxOfferedWeightTons` caps the offered CARGO tonnage (bulk only) — on a + * weight-limited train the wagons' own tare eats into the locomotive's + * remaining pull weight, so the caller passes the room left after tare. */ async sizeOffer( booking: Booking, freeWagons: number, totalWagons: number, bulkWagonCapacityTons: number, + maxOfferedWeightTons?: number, ): Promise { if (freeWagons < 1 || freeWagons >= totalWagons) return null; @@ -110,10 +118,15 @@ export class BookingSplitService { if (!offeredLines.length || offeredWagons <= 0) return null; clone.bookingContainers = clonedContainers; } else { - // Bulk: split by weight — the offered part is what freeWagons can carry. + // Bulk: split by weight — the offered part is what freeWagons can carry, + // further capped by the caller's weight room when the pull limit binds. const totalWeight = Number(booking.cargoTotalWeightVgm ?? 0); if (totalWeight <= 0 || bulkWagonCapacityTons <= 0) return null; - offeredWeightTons = Math.min(totalWeight, freeWagons * bulkWagonCapacityTons); + offeredWeightTons = Math.min( + totalWeight, + freeWagons * bulkWagonCapacityTons, + maxOfferedWeightTons ?? Number.POSITIVE_INFINITY, + ); if (offeredWeightTons <= 0) return null; offeredWagons = Math.min( freeWagons, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts index f96c388f8..3286da0eb 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts @@ -17,6 +17,8 @@ describe('BookingWindowService — window state machine', () => { expireUnacceptedForRouteDay: jest.Mock; settleDueReservations: jest.Mock; isScheduleFull: jest.Mock; + hasLiveReservations: jest.Mock; + refreshWindowStatus: jest.Mock; }; let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock }; let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock }; @@ -68,6 +70,9 @@ describe('BookingWindowService — window state machine', () => { expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined), settleDueReservations: jest.fn().mockResolvedValue(undefined), isScheduleFull: jest.fn().mockResolvedValue(false), + // No reservation is mid-pay-window by default, so the cycle concludes. + hasLiveReservations: jest.fn().mockResolvedValue(false), + refreshWindowStatus: jest.fn().mockResolvedValue(undefined), }; trainSchedulesRepository = { findById: jest.fn().mockResolvedValue(null), @@ -154,6 +159,26 @@ describe('BookingWindowService — window state machine', () => { expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId); }); + it('PAYMENT holds the cycle open while a reservation is still inside its pay window', async () => { + // `paymentPhaseEndsAt` is stamped when the phase starts; reserve() then sets each + // booking's own deadline milliseconds later. So the phase deadline always passes + // first, and concluding here would kill customers who still had time to pay — and + // leave no cycle for the waiting-list top-up to run in. + batch.hasLiveReservations.mockResolvedValue(true); + const s = baseSchedule({ + windowPhase: 'PAYMENT', + paymentPhaseEndsAt: new Date('2026-07-01T02:30:00.000Z'), + }); + + const advanced = await advanceImport(s, new Date('2026-07-01T02:30:01.000Z')); + + expect(advanced).toBe(true); + expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId); + // Still PAYMENT — the cycle was NOT concluded and the window did not reopen. + expect(s.windowPhase).toBe('PAYMENT'); + expect(batch.isScheduleFull).not.toHaveBeenCalled(); + }); + it('conclude: train FULL → window FULL + phase DONE + auto-finalize', async () => { batch.isScheduleFull.mockResolvedValue(true); const s = baseSchedule({ windowPhase: 'PAYMENT' }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 0d9087419..a730b6aa6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -304,6 +304,38 @@ export class BookingWindowService implements OnModuleInit { `(allocate paid / expire unpaid) then concluding the cycle`, ); await this.bookingBatchService.settleDueReservations(schedule.id); + + // The settle expires unpaid reservations and promotes the waiting list into + // the wagons they free. Those promoted customers get a fresh pay window, and + // `extendPaymentPhaseForTopUp` pushes `paymentPhaseEndsAt` past `now` to + // cover it. Concluding here on the STALE in-memory timestamp would end the + // cycle the top-up just extended and expire them before they could pay — so + // re-read, and stay in PAYMENT if the deadline moved. + const settled = await this.trainSchedulesRepository.findById(schedule.id); + if (settled?.paymentPhaseEndsAt && now < settled.paymentPhaseEndsAt) { + schedule.paymentPhaseEndsAt = settled.paymentPhaseEndsAt; + this.logger.log( + `[WINDOW] ${schedule.id} PAYMENT extended to ` + + `${settled.paymentPhaseEndsAt.toISOString()} — waiting-list bookings were ` + + `promoted into the freed wagons; not concluding this cycle yet`, + ); + return true; + } + + // `paymentPhaseEndsAt` is stamped when the phase starts; each reservation's own + // deadline is set milliseconds later, per booking, so the phase always expires + // a fraction before the reservations it opened. Concluding here would end the + // cycle while customers still had time to pay, and the settle that finally + // expires them (next tick) would have no cycle left to promote the waiting + // list into. Hold in PAYMENT until every reservation has actually resolved. + if (await this.bookingBatchService.hasLiveReservations(schedule.id)) { + this.logger.log( + `[WINDOW] ${schedule.id} PAYMENT phase past its deadline but reservations ` + + `are still within their pay windows — holding the cycle open`, + ); + return true; + } + await this.concludeCycle(schedule, cfg, now); return true; } @@ -328,6 +360,18 @@ export class BookingWindowService implements OnModuleInit { return; } + // Not full, so any FULL flag left over from a batch whose bookings later + // expired is stale. Clear it here too: the PRE_WINDOW→OPEN transition below + // refuses to reopen a FULL schedule, which is how a train with an empty + // consist used to cycle forever without ever being fillable again. Re-read + // the flag onto the in-memory row — advanceSchedule keeps looping on this + // same object, and PRE_WINDOW→OPEN reads it. + if (schedule.bookingWindowStatus === 'FULL') { + await this.bookingBatchService.refreshWindowStatus(schedule.id); + const fresh = await this.trainSchedulesRepository.findById(schedule.id); + if (fresh) schedule.bookingWindowStatus = fresh.bookingWindowStatus; + } + // Doc review + payment have already run, so the desk is ready to reopen NOW — // office hours decide whether that is this afternoon or tomorrow morning. Past // the last cycle before departure, nextCycleOpensAt returns null and we finish. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts new file mode 100644 index 000000000..15d065d09 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts @@ -0,0 +1,120 @@ +import { Capacity, CorridorBudget } from './corridor-capacity.util'; +import { sizePartialOfferWagons } from './train-capacity.util'; + +describe('corridor-capacity.util — overage tolerance', () => { + const pw2 = { lengthMeters: 17.066, capacityTons: 70, tareWeightTons: 25.2 }; + const stops = ['yard-a', 'yard-b']; + const base: Capacity = { wagons: 44, weightTons: 3500, lengthMeters: 760 }; + const tolerance = { weightTons: 90, lengthMeters: 0 }; + + const need = (weightTons: number, wagons = 1, lengthMeters = 17): Capacity => ({ + wagons, + weightTons, + lengthMeters, + }); + + const budgetAt = (usedWeightTons: number): CorridorBudget => { + const budget = new CorridorBudget(stops, base, tolerance); + budget.subtract(need(usedWeightTons, 10, 170), budget.fullLeg()); + return budget; + }; + + it('admits a whole booking that overflows the base cap by less than the tolerance', () => { + // 3500T train, 90T tolerance, 3560T committed: a 25T booking still boards + // entire (3585 ≤ 3590). + const budget = budgetAt(3560); + expect(budget.fits(need(25), budget.fullLeg())).toBe(true); + }); + + it('rejects a whole booking that overflows past the tolerance — no partial admission', () => { + // Same train at 3560T: a 210T booking would need 3770 > 3590 — skipped. + const budget = budgetAt(3560); + expect(budget.fits(need(210), budget.fullLeg())).toBe(false); + }); + + it('caps stacked overage admissions at base + tolerance', () => { + // Small units may keep boarding inside the overage zone, but never past it. + const budget = budgetAt(3560); + budget.subtract(need(25), budget.fullLeg()); // now 3585 committed + expect(budget.fits(need(5), budget.fullLeg())).toBe(true); // 3590 exactly + expect(budget.fits(need(6), budget.fullLeg())).toBe(false); // 3591 > 3590 + }); + + it('excludes the tolerance from remainingFor, so split room never reaches into it', () => { + const budget = budgetAt(3400); + expect(budget.remainingFor(budget.fullLeg()).weightTons).toBe(100); + // Once a whole-unit admission spends the tolerance, base room goes negative. + const over = budgetAt(3560); + expect(over.remainingFor(over.fullLeg()).weightTons).toBe(-60); + }); + + it('yields no split offer once the base cap is spent — tolerance is whole-bookings-only', () => { + // The batch engine sizes splits from remainingFor; at/over base capacity + // that room cannot carry even one part-loaded wagon, so no offer opens. + const over = budgetAt(3560); + const room = over.remainingFor(over.fullLeg()); + expect(sizePartialOfferWagons(room, 15, pw2)).toBeNull(); + }); + + it('still offers a split while committed weight is under the base cap', () => { + // 744T of base room left: the boundary booking is offered the part that + // fits up to 3500, not up to 3590. + const budget = budgetAt(2756); + const room = budget.remainingFor(budget.fullLeg()); + expect(sizePartialOfferWagons(room, 15, pw2)).toEqual({ + wagons: 8, + maxCargoTons: 542.4, + }); + }); + + it('leaves fits() strict when no tolerance is configured', () => { + const strict = new CorridorBudget(stops, base); + strict.subtract(need(3500, 10, 170), strict.fullLeg()); + expect(strict.fits(need(1), strict.fullLeg())).toBe(false); + }); + + describe('isExhausted — train-wide FULL across all axes', () => { + // Lightest wagon at rated payload: PW2 25.2T tare + 70T = 95.2T gross. + const perWagon = { + grossWeightTons: pw2.tareWeightTons + pw2.capacityTons, + lengthMeters: pw2.lengthMeters, + }; + + it('reports FULL when weight binds first, with wagon slots still free', () => { + // 37 loaded PW2 wagons = 3522.4T of 3500+90T. 7 length-derived slots + // remain, but wagon 38 would need 95.2T against 67.6T of room — the + // schedule must finalize and its window must disappear. + const budget = new CorridorBudget(stops, base, tolerance); + budget.subtract(need(3522.4, 37, 631.442), budget.fullLeg()); + expect(budget.maxRemaining().wagons).toBeGreaterThan(0); // slot check alone says "not full" + expect(budget.isExhausted(perWagon)).toBe(true); + }); + + it('is not FULL while one more loaded wagon still fits within base + tolerance', () => { + const budget = budgetAt(3300); // 200T base room + 90T tolerance ≥ 95.2T + expect(budget.isExhausted(perWagon)).toBe(false); + }); + + it('reports FULL when wagon slots run out regardless of weight room', () => { + const budget = new CorridorBudget(stops, base, tolerance); + budget.subtract(need(1000, 44, 700), budget.fullLeg()); + expect(budget.isExhausted(perWagon)).toBe(true); + }); + + it('reports FULL when length room cannot take one more wagon', () => { + const budget = new CorridorBudget(stops, base, tolerance); + budget.subtract(need(1000, 30, 750), budget.fullLeg()); // 10m left < 17.066m + expect(budget.isExhausted(perWagon)).toBe(true); + }); + + it('only counts an edge as open when EVERY axis has room on that same edge', () => { + // Three stops → two edges. Edge 0 has weight but no slots; edge 1 has + // slots but no weight. Neither can board a wagon, so the train is FULL + // even though the per-axis maxima both look open. + const budget = new CorridorBudget(['a', 'b', 'c'], base, tolerance); + budget.subtract(need(0, 44, 0), { fromEdge: 0, toEdge: 1 }); + budget.subtract(need(3522.4, 0, 0), { fromEdge: 1, toEdge: 2 }); + expect(budget.isExhausted(perWagon)).toBe(true); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts index b16b25179..f2602a157 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts @@ -63,18 +63,40 @@ export function stopYardsFor( return [originStationId, destinationStationId]; } -/** Per-edge capacity budget along a schedule's stop list. */ +/** Overage a locomotive may absorb beyond its base caps. */ +export interface OverageTolerance { + weightTons: number; + lengthMeters: number; +} + +/** + * Per-edge capacity budget along a schedule's stop list. + * + * `initial` must be the BASE caps (locomotive floored by rule caps, WITHOUT the + * overage tolerance). The tolerance is passed separately and is spendable only + * by admitting a unit WHOLE via {@link fits} — e.g. base 3500T + 90T tolerance, + * 3560T already committed: a 25T booking still boards entire (3585 ≤ 3590), a + * 210T booking does not. {@link remainingFor} deliberately excludes the + * tolerance (and goes negative once it is consumed), so split/partial offers + * sized from it can only fill up to the base cap and never spend the tolerance. + */ export class CorridorBudget { private readonly edges: Capacity[]; private readonly stopIndex: Map; + private readonly tolerance: OverageTolerance; constructor( readonly stops: string[], initial: Capacity, + tolerance?: Partial | null, ) { const edgeCount = Math.max(1, stops.length - 1); this.edges = Array.from({ length: edgeCount }, () => ({ ...initial })); this.stopIndex = new Map(stops.map((yardId, i) => [yardId, i])); + this.tolerance = { + weightTons: tolerance?.weightTons ?? 0, + lengthMeters: tolerance?.lengthMeters ?? 0, + }; } /** The leg between two stops, or null when they aren't on this corridor in order. */ @@ -99,7 +121,12 @@ export class CorridorBudget { return this.legOf(originYardId, destinationYardId) ?? this.fullLeg(); } - /** Remaining capacity usable by this leg = min across its edges. */ + /** + * Remaining BASE capacity usable by this leg = min across its edges. Excludes + * the overage tolerance and goes negative once a whole-unit admission has + * spent it — sizing a split from this can therefore never reach into the + * tolerance, and yields nothing at all once the base cap is exhausted. + */ remainingFor(leg: CorridorLeg): Capacity { let min = { ...this.edges[leg.fromEdge] }; for (let i = leg.fromEdge + 1; i < leg.toEdge; i++) { @@ -113,8 +140,20 @@ export class CorridorBudget { return min; } + /** + * Whether a unit fits WHOLE on this leg. This is the only place the overage + * tolerance may be spent: the unit boards entirely or not at all, so weight + * and length may dip into the tolerance. Admission keeps the invariant + * `remaining ≥ -tolerance` on every edge, i.e. the train never exceeds + * base + tolerance no matter how many small units board in the overage zone. + */ fits(need: Capacity, leg: CorridorLeg): boolean { - return capacityFits(need, this.remainingFor(leg)); + const remaining = this.remainingFor(leg); + return ( + need.wagons <= remaining.wagons && + need.weightTons <= remaining.weightTons + this.tolerance.weightTons && + need.lengthMeters <= remaining.lengthMeters + this.tolerance.lengthMeters + ); } subtract(need: Capacity, leg: CorridorLeg): void { @@ -129,6 +168,25 @@ export class CorridorBudget { } } + /** + * Train-wide FULL across ALL capacity axes: true when no edge can board even + * one more loaded wagon. `perWagon` is the smallest gross weight and length + * a future wagon could add (lightest wagon type at rated payload); weight and + * length may dip into the overage tolerance, mirroring {@link fits}. Checked + * per edge — an edge with slots free but no pull weight is just as closed as + * one with no slots. A slot-only check misses weight-bound trains: PW2 at + * 37 × 95.2T = 3522.4T of 3500+90T has 7 length-derived slots free but no + * weight room for wagon 38, and its window must read FULL. + */ + isExhausted(perWagon: { grossWeightTons: number; lengthMeters: number }): boolean { + return this.edges.every( + (e) => + e.wagons <= 0 || + e.weightTons + this.tolerance.weightTons < perWagon.grossWeightTons || + e.lengthMeters + this.tolerance.lengthMeters < perWagon.lengthMeters, + ); + } + /** * The most open edge — when even this has no wagon slots left, nothing can * board anywhere and the schedule's window is genuinely FULL. (A train can be diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts index b5e93f5da..1fd1e9a08 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts @@ -51,7 +51,7 @@ export class AssignBookingsDto { @IsUUID('4', { each: true }) bookingIds!: string[]; - @ApiPropertyOptional({ description: 'Bypass soft hold and overweight warnings' }) + @ApiPropertyOptional({ description: 'Suppress soft hold and overweight warnings' }) @IsOptional() @IsBoolean() forceAssign?: boolean; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/batch-board-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/batch-board-query.dto.ts new file mode 100644 index 000000000..11bc8ceb5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/batch-board-query.dto.ts @@ -0,0 +1,99 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsIn, + IsInt, + IsISO8601, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; + +export const BATCH_BOARD_STATUSES = [ + 'DRAFT', + 'SCHEDULED', + 'DISPATCHED', + 'ARRIVED', + 'CANCELLED', +] as const; + +export const BATCH_BOARD_SORT_FIELDS = [ + 'createdAt', + 'scheduledDepartureDate', + 'trainNumber', + 'status', +] as const; +export type BatchBoardSortField = (typeof BATCH_BOARD_SORT_FIELDS)[number]; + +/** Filters for the batch monitoring board list (import schedules, all statuses). */ +export class BatchBoardQueryDto { + @ApiPropertyOptional({ default: 1, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ default: 12, minimum: 1, maximum: 100 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize?: number; + + @ApiPropertyOptional({ + description: + 'Comma-separated schedule statuses (DRAFT,SCHEDULED,DISPATCHED,ARRIVED,CANCELLED). Omit for all.', + example: 'DISPATCHED,ARRIVED', + }) + @IsOptional() + @IsString() + statuses?: string; + + @ApiPropertyOptional({ enum: ['OPEN', 'FULL', 'CLOSED'] }) + @IsOptional() + @IsIn(['OPEN', 'FULL', 'CLOSED']) + bookingWindowStatus?: 'OPEN' | 'FULL' | 'CLOSED'; + + @ApiPropertyOptional({ + description: + 'Case-insensitive match on train number, route yards, stations, or locomotive code.', + }) + @IsOptional() + @IsString() + @MaxLength(120) + search?: string; + + @ApiPropertyOptional({ description: 'Departure date lower bound (ISO 8601).' }) + @IsOptional() + @IsISO8601() + departureFrom?: string; + + @ApiPropertyOptional({ description: 'Departure date upper bound (ISO 8601).' }) + @IsOptional() + @IsISO8601() + departureTo?: string; + + @ApiPropertyOptional({ description: 'Created-at lower bound (ISO 8601).' }) + @IsOptional() + @IsISO8601() + createdFrom?: string; + + @ApiPropertyOptional({ description: 'Created-at upper bound (ISO 8601).' }) + @IsOptional() + @IsISO8601() + createdTo?: string; + + @ApiPropertyOptional({ enum: BATCH_BOARD_SORT_FIELDS, default: 'createdAt' }) + @IsOptional() + @IsIn(BATCH_BOARD_SORT_FIELDS as unknown as string[]) + sortBy?: BatchBoardSortField; + + @ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' }) + @IsOptional() + @IsIn(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts index 76ea00422..54bf7a181 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts @@ -15,7 +15,6 @@ const nw5: WagonType = { name: 'Flat Wagon', capacityTons: 70, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['CONTAINER'], isActive: true, supportsContainer: true, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index 3b00abd0a..6173da6b1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -4,6 +4,7 @@ import { buildBulkWagonPlan, buildContainerWagonPlan, buildMixedWagonPlan, + containerWagonsForLines, roundTons, type WagonPlanSlot, } from './wagon-plan.util'; @@ -43,11 +44,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n return Math.max(1, Math.ceil(weight / capacity)); } - const lineSlots = (booking.bookingContainers ?? []).reduce( - (sum, line) => sum + Number(line.wagonsRequired ?? 0), - 0, - ); - return Math.max(1, lineSlots); + // TEU-aware, ceiled once at the booking level (40ft = 1 wagon, two 20ft = 1 + // wagon). Honors containerType.wagonsPerUnit; falls back to the line's stored + // fraction. Ceiling per line would over-count split 20ft lines. + return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); } export function countSlotsByType(wagonPlan: WagonPlanSlot[]): Map { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/gatepass-payment-gate.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/gatepass-payment-gate.spec.ts new file mode 100644 index 000000000..912f437f4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/gatepass-payment-gate.spec.ts @@ -0,0 +1,118 @@ +import { TrainSchedulingService } from './train-scheduling.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; + +type Row = Pick & { + metadata?: Record | null; + triggeredAt?: Date | null; +}; + +/** + * The gate pass is secured once per train schedule, but each booking only earns + * its GATEPASS_GRANTED milestone after settling freight payment. An unpaid + * booking must not ride a paid neighbour's grant — the train proceeds, that + * booking stays pending. + */ +function makeService(bookings: Array>, rows: Row[]) { + const milestoneRepo = { + find: jest.fn().mockResolvedValue(rows), + save: jest.fn((row: Row) => Promise.resolve(row)), + }; + const bookingRepo = { find: jest.fn().mockResolvedValue(bookings) }; + const dataSource = { + getRepository: (entity: unknown) => + entity === Booking ? bookingRepo : milestoneRepo, + }; + + const service = Object.create( + TrainSchedulingService.prototype, + ) as TrainSchedulingService; + Object.assign(service, { + dataSource, + logger: { warn: jest.fn(), log: jest.fn() }, + }); + return { service, milestoneRepo }; +} + +/** Reach the private bridge write under test. */ +function grant(service: TrainSchedulingService, at: Date): Promise { + return ( + service as unknown as { + completeGatepassMilestoneForSchedule(id: string, at: Date): Promise; + } + ).completeGatepassMilestoneForSchedule('sched-1', at); +} + +const securedAt = new Date('2026-07-09T08:00:00.000Z'); + +describe('gate pass is withheld from bookings that have not paid freight', () => { + it('grants the paid booking and leaves the unpaid one pending', async () => { + const rows: Row[] = [ + { bookingId: 'paid', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'COMPLETED' }, + { bookingId: 'paid', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' }, + { bookingId: 'unpaid', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'PENDING' }, + { bookingId: 'unpaid', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' }, + ]; + const { service, milestoneRepo } = makeService( + [ + { id: 'paid', status: 'CONFIRMED', paymentStatus: 'PENDING' }, + { id: 'unpaid', status: 'CONFIRMED', paymentStatus: 'PENDING' }, + ], + rows, + ); + + await grant(service, securedAt); + + const saved = milestoneRepo.save.mock.calls.map(([r]: [Row]) => r); + expect(saved).toHaveLength(1); + expect(saved[0]!.bookingId).toBe('paid'); + expect(saved[0]!.status).toBe('COMPLETED'); + expect(saved[0]!.triggeredAt).toBe(securedAt); + + const unpaid = rows.find( + (r) => r.bookingId === 'unpaid' && r.milestoneCode === 'GATEPASS_GRANTED', + ); + expect(unpaid!.status).toBe('PENDING'); + }); + + it('treats a booking paid outside the milestone path as paid', async () => { + // Some payment paths settle the invoice without writing the milestone; the + // clearance views self-heal it on read, so the gate pass must not lag. + const rows: Row[] = [ + { bookingId: 'b-1', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'PENDING' }, + { bookingId: 'b-1', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' }, + ]; + const { service, milestoneRepo } = makeService( + [{ id: 'b-1', status: 'CONFIRMED', paymentStatus: 'PAID' }], + rows, + ); + + await grant(service, securedAt); + + expect(milestoneRepo.save).toHaveBeenCalledTimes(1); + expect(milestoneRepo.save.mock.calls[0]![0].bookingId).toBe('b-1'); + }); + + it('leaves an already-granted milestone untouched', async () => { + const rows: Row[] = [ + { bookingId: 'b-1', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'COMPLETED' }, + { bookingId: 'b-1', milestoneCode: 'GATEPASS_GRANTED', status: 'COMPLETED' }, + ]; + const { service, milestoneRepo } = makeService( + [{ id: 'b-1', status: 'PAID', paymentStatus: 'PAID' }], + rows, + ); + + await grant(service, securedAt); + + expect(milestoneRepo.save).not.toHaveBeenCalled(); + }); + + it('does nothing when the schedule carries no customs bookings', async () => { + const { service, milestoneRepo } = makeService([], []); + + await grant(service, securedAt); + + expect(milestoneRepo.save).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index bce4207d3..d2f1dd7b1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -107,7 +107,13 @@ export class IntercityService { for (const bookingId of bookingIds) { const booking = await this.dataSource .getRepository(Booking) - .findOne({ where: { id: bookingId }, relations: { bookingContainers: true } }); + .findOne({ + where: { id: bookingId }, + relations: { + bookingContainers: { containerType: true }, + cargoType: true, + }, + }); if (!booking) { rejected.push({ bookingId, reason: 'Booking not found' }); continue; @@ -205,6 +211,8 @@ export class IntercityService { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .where(`booking.trade_direction = 'DOMESTIC'`) @@ -230,6 +238,8 @@ export class IntercityService { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .where(`booking.trade_direction = 'DOMESTIC'`) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index d72f3d311..dd9234bdb 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -1,41 +1,299 @@ import { + bookingGrossWeightTons, bookingTrainLengthMeters, + consistUsage, + consistViolations, deriveTrainCapacityFromLocomotive, + grossWagonWeightTons, + minLocomotiveLimits, + sizePartialOfferWagons, } from './train-capacity.util'; describe('train-capacity.util', () => { - const nw5 = { lengthMeters: 14, capacityTons: 70 }; + // Real EDR wagon specs. + const nw5 = { lengthMeters: 13.966, capacityTons: 70, tareWeightTons: 22.4 }; + const pw2 = { lengthMeters: 17.066, capacityTons: 70, tareWeightTons: 25.2 }; + const gw2 = { lengthMeters: 12.228, capacityTons: 70, tareWeightTons: 23 }; - it('derives wagon slots from locomotive length and weight, not a fixed 53', () => { - const shortLoco = deriveTrainCapacityFromLocomotive( - { maxPullWeightTons: 2000, maxTrainLengthMeters: 280 }, - [nw5], - ); - expect(shortLoco.maxWagonSlots).toBe(20); // 280 / 14 - expect(shortLoco.maxWagonSlots).not.toBe(53); - - const heavyLoco = deriveTrainCapacityFromLocomotive( - { maxPullWeightTons: 2100, maxTrainLengthMeters: 760 }, - [nw5], - ); - expect(heavyLoco.maxWagonSlots).toBe(30); // min(54, 30) from weight 2100/70 + const caps = (over = {}) => ({ + maxWeightTons: 3500, + maxLengthMeters: 760, + maxWagonSlots: 54, + ...over, }); - it('uses shortest wagon type when mixed types are present', () => { - const longBulk = { lengthMeters: 18, capacityTons: 80 }; - const mixed = deriveTrainCapacityFromLocomotive( - { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, - [nw5, longBulk], - ); - expect(mixed.maxWagonSlots).toBe( - Math.min(Math.floor(760 / 14), Math.floor(3500 / 70)), - ); + const slots = (n: number, type: typeof nw5, cargoTons: number) => + Array.from({ length: n }, () => ({ + lengthMeters: type.lengthMeters, + tareWeightTons: type.tareWeightTons, + cargoTons, + })); + + describe('deriveTrainCapacityFromLocomotive', () => { + it('derives wagon slots from train length, not a fixed 53', () => { + const shortLoco = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 2000, maxTrainLengthMeters: 280 }, + [nw5], + ); + expect(shortLoco.maxWagonSlots).toBe(20); // floor(280 / 13.966) + expect(shortLoco.maxWagonSlots).not.toBe(53); + }); + + it('does not shrink slots by assuming every wagon rides at full payload', () => { + // A 2100T loco could only pull 30 fully-laden 70T wagons, but slots are a + // LENGTH figure — the cargo that decides weight does not exist yet. + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 2100, maxTrainLengthMeters: 760 }, + [nw5], + ); + expect(derived.maxWagonSlots).toBe(54); // floor(760 / 13.966), not 30 + expect(derived.maxWeightTons).toBe(2100); + }); + + it('admits the railway 53-wagon NW5 marshalling figure', () => { + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, + [nw5], + ); + expect(derived.maxWagonSlots).toBeGreaterThanOrEqual(53); + }); + + it('uses the shortest wagon type when mixed types are present', () => { + const mixed = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, + [nw5, pw2, gw2], + ); + expect(mixed.maxWagonSlots).toBe(Math.floor(760 / gw2.lengthMeters)); // 62 + }); + + it('extends weight/length caps by the locomotive overage tolerance', () => { + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, + [pw2], + ); + expect(derived.maxWeightTons).toBe(3590); + }); + + it('reports the base caps and tolerance separately so filling can budget on base', () => { + const derived = deriveTrainCapacityFromLocomotive( + { + maxPullWeightTons: 3500, + maxTrainLengthMeters: 760, + overageToleranceTons: 90, + overageToleranceMeters: 20, + }, + [pw2], + ); + expect(derived.baseWeightTons).toBe(3500); + expect(derived.baseLengthMeters).toBe(760); + expect(derived.toleranceTons).toBe(90); + expect(derived.toleranceMeters).toBe(20); + expect(derived.baseWeightTons + derived.toleranceTons).toBe(derived.maxWeightTons); + expect(derived.baseLengthMeters + derived.toleranceMeters).toBe(derived.maxLengthMeters); + }); + + it('ignores overage tolerance when unset (strict cap)', () => { + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, + [nw5], + ); + expect(derived.maxWeightTons).toBe(3500); + expect(derived.maxLengthMeters).toBe(760); + }); + + it('floors the locomotive by the global rule caps', () => { + const derived = deriveTrainCapacityFromLocomotive( + { maxPullWeightTons: 5000, maxTrainLengthMeters: 900 }, + [nw5], + { maxTrainWeightTons: 3500, maxTrainLengthMeters: 760 }, + ); + expect(derived.maxWeightTons).toBe(3500); + expect(derived.maxLengthMeters).toBe(760); + }); + }); + + describe('gross weight', () => { + it('counts the wagon as well as its cargo', () => { + expect(grossWagonWeightTons({ tareWeightTons: 25.2, cargoTons: 70 })).toBe(95.2); + }); + + it('charges a booking one tare per wagon it occupies', () => { + // 3 flat wagons carrying 100T of cargo still drag 3 × 22.4T of steel. + expect(bookingGrossWeightTons(100, 3, 22.4)).toBe(167.2); + }); + + it('is cargo alone when the wagon type has no tare on record', () => { + expect(bookingGrossWeightTons(100, 3, 0)).toBe(100); + }); + }); + + describe('consistUsage', () => { + it('sums each wagon own length and tare rather than averaging a type', () => { + const mixed = [...slots(2, nw5, 10), ...slots(1, pw2, 20)]; + const usage = consistUsage(mixed, caps()); + + expect(usage.wagonCount).toBe(3); + expect(usage.usedLengthMeters).toBe(44.998); // 2×13.966 + 17.066 + expect(usage.usedTareWeightTons).toBe(70); // 2×22.4 + 25.2 + expect(usage.usedCargoWeightTons).toBe(40); + expect(usage.usedGrossWeightTons).toBe(110); + expect(usage.remainingGrossWeightTons).toBe(3390); + expect(usage.remainingWagons).toBe(51); + }); + + it('reports an empty consist as fully available', () => { + const usage = consistUsage([], caps()); + expect(usage.usedGrossWeightTons).toBe(0); + expect(usage.remainingLengthMeters).toBe(760); + expect(usage.remainingWagons).toBe(54); + }); + }); + + describe('consistViolations', () => { + it('accepts 37 fully-laden PW2 box wagons only via the overage tolerance', () => { + // 37 × (25.2 + 70) = 3522.4T — over 3500T, inside 3590T. + const consist = slots(37, pw2, 70); + + expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).toEqual([ + expect.stringContaining('3522.4T'), + ]); + expect( + consistViolations(consist, caps({ maxWeightTons: 3590, maxWagonSlots: 44 })), + ).toEqual([]); + }); + + it('blocks a train the old cargo-only math would have waved through', () => { + // Cargo alone is 2590T — comfortably "under" 3500T. Gross is 3522.4T. + const consist = slots(37, pw2, 70); + const cargoOnly = consist.reduce((sum, s) => sum + s.cargoTons, 0); + + expect(cargoOnly).toBeLessThan(3500); + expect(consistViolations(consist, caps({ maxWagonSlots: 44 }))).not.toEqual([]); + }); + + it('lets 53 NW5 flat wagons pass when the cargo is what the railway really loads', () => { + // 53 × 13.966 = 740.2m < 760m; 53 × (22.4 + 40) = 3307.2T < 3500T. + expect(consistViolations(slots(53, nw5, 40), caps({ maxWagonSlots: 54 }))).toEqual([]); + }); + + it('flags an over-length consist', () => { + const violations = consistViolations(slots(50, pw2, 5), caps({ maxWagonSlots: 60 })); + expect(violations).toEqual([expect.stringContaining('exceeds max train length')]); + }); + + it('flags an over-count consist', () => { + const violations = consistViolations(slots(10, nw5, 1), caps({ maxWagonSlots: 9 })); + expect(violations).toEqual([expect.stringContaining('exceeds max wagons per train')]); + }); + + it('reports every broken axis at once', () => { + expect(consistViolations(slots(60, pw2, 70), caps())).toHaveLength(3); + }); }); it('computes booking length by freight type', () => { - expect( - bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 }), - ).toBe(28); + expect(bookingTrainLengthMeters('CONTAINER', 2, { container: 14, bulk: 14 })).toBe(28); expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54); }); + + it('takes the weakest locomotive across a multi-locomotive set', () => { + const limits = minLocomotiveLimits([ + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, + { maxPullWeightTons: 4000, maxTrainLengthMeters: 760, overageToleranceTons: 20 }, + ]); + expect(limits?.maxPullWeightTons).toBe(3500); + expect(limits?.overageToleranceTons).toBe(20); + }); + + describe('sizePartialOfferWagons', () => { + it('sizes a bulk split by the WEIGHT axis when the pull limit binds, not wagon slots', () => { + // The 3500T-train scenario: two 1000T bookings boarded gross (each 15 PW2 + // wagons: 1000 + 378 tare = 1378), leaving 744T of pull weight but plenty + // of slots/length. The boundary 1000T booking (15 wagons) must be offered + // the largest part 744T can carry: 8 wagons whose tare is 201.6T, hauling + // 542.4T of cargo — gross exactly 744. + const offer = sizePartialOfferWagons( + { wagons: 40, weightTons: 744, lengthMeters: 500 }, + 15, + pw2, + ); + expect(offer).toEqual({ wagons: 8, maxCargoTons: 542.4 }); + }); + + it('still sizes by wagon slots when they bind first (legacy behavior)', () => { + const offer = sizePartialOfferWagons( + { wagons: 3, weightTons: 100000, lengthMeters: 100000 }, + 15, + pw2, + ); + expect(offer?.wagons).toBe(3); + }); + + it('sizes by the LENGTH axis when it binds first', () => { + // 60m of train left → 3 PW2 (17.066m) fit, the 4th does not. + const offer = sizePartialOfferWagons( + { wagons: 40, weightTons: 100000, lengthMeters: 60 }, + 15, + pw2, + ); + expect(offer?.wagons).toBe(3); + }); + + it('never offers all of the booking — a split is a strict subset', () => { + const offer = sizePartialOfferWagons( + { wagons: 40, weightTons: 100000, lengthMeters: 100000 }, + 15, + pw2, + ); + expect(offer?.wagons).toBe(14); + }); + + it('returns null when not even one part-loaded wagon fits the weight room', () => { + expect( + sizePartialOfferWagons({ wagons: 5, weightTons: 20, lengthMeters: 500 }, 15, pw2), + ).toBeNull(); + }); + + describe('fullWagonsOnly (bulk)', () => { + it('offers only whole full wagons — each costs capacity + tare of gross room', () => { + // 704T of pull weight left. A full PW2 wagon is 70 + 25.2 = 95.2T gross, + // so 7 fit (666.4T) and the 8th (761.6T) does not. Cargo is exactly + // 7 × 70 = 490T — the last wagon is never part-loaded into the leftover. + const offer = sizePartialOfferWagons( + { wagons: 40, weightTons: 704, lengthMeters: 500 }, + 9, + pw2, + { fullWagonsOnly: true }, + ); + expect(offer).toEqual({ wagons: 7, maxCargoTons: 490 }); + }); + + it('never squeezes a part-loaded wagon into leftover weight room', () => { + // Same 744T room as the part-load scenario above: the scan would pick + // 8 wagons hauling 542.4T (last wagon at 52.4/70). Full-wagon sizing + // stops at 7 fully loaded wagons. + const offer = sizePartialOfferWagons( + { wagons: 40, weightTons: 744, lengthMeters: 500 }, + 15, + pw2, + { fullWagonsOnly: true }, + ); + expect(offer).toEqual({ wagons: 7, maxCargoTons: 490 }); + }); + + it('returns null when the room cannot take even one FULL wagon', () => { + // 67.6T left (3590 cap − 3522.4 boarded): a part-loaded wagon would fit + // (25.2 tare + 42.4 cargo) but a full one (95.2 gross) does not — the + // booking must be skipped entirely, not trimmed onto the train. + expect( + sizePartialOfferWagons( + { wagons: 40, weightTons: 67.6, lengthMeters: 500 }, + 3, + pw2, + { fullWagonsOnly: true }, + ), + ).toBeNull(); + }); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 5ec385924..463ae7ea8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -1,73 +1,238 @@ +/** + * Train capacity is a THREE-AXIS constraint, and the axes are not interchangeable: + * + * count — how many wagons fit end to end on the longest allowed train + * length — Σ wagonType.lengthMeters over the real consist + * weight — Σ (wagonType.tareWeightTons + cargoTons) over the real consist + * + * The weight axis is GROSS: a locomotive pulls the wagon as well as what is in it. + * The old code compared the locomotive's pull limit against cargo payload alone + * and so overbooked every train by roughly the tare fraction (~27% on PW2). + * + * The weight axis is also driven by ACTUAL booked cargo, never by an assumed + * full payload. That is what makes the real EDR numbers fall out: + * + * NW5 13.966m tare 22.4T → 760 / 13.966 = 54 slots by length; the 53-wagon + * marshalling figure is length-bound, and those trains never carry 53×70T. + * PW2 17.066m tare 25.2T → 44 slots by length, but 37 × (25.2 + 70) = 3522.4T, + * which clears 3500T only via the locomotive's overage tolerance. Weight + * binds first, hence "37 wagons per train". + * + * So: `maxWagonSlots` is a LENGTH-derived planning number, shown before any cargo + * exists. Weight is enforced against the consist as bookings are allocated. + */ + /** Physical dimensions used when deriving how many wagons a locomotive can pull. */ export type WagonTypeDimensions = { lengthMeters: number; capacityTons: number; + tareWeightTons: number; +}; + +/** One occupied wagon slot in a real consist. */ +export type ConsistSlot = { + lengthMeters: number; + tareWeightTons: number; + /** Actual cargo/container weight riding on this wagon, not its rated capacity. */ + cargoTons: number; }; export type LocomotiveLimits = { maxPullWeightTons: number; maxTrainLengthMeters: number; + /** Allowed deviation above maxPullWeightTons before scheduling blocks the train. */ + overageToleranceTons?: number | null; + /** Allowed deviation above maxTrainLengthMeters before scheduling blocks the train. */ + overageToleranceMeters?: number | null; }; export type DerivedTrainCapacity = { + /** Gross (tare + cargo) tons the train may weigh, tolerance included. */ maxWeightTons: number; maxLengthMeters: number; + /** Length-derived slot count. Weight is enforced separately against real cargo. */ maxWagonSlots: number; + /** Caps WITHOUT the overage tolerance — what batch filling budgets against. */ + baseWeightTons: number; + baseLengthMeters: number; + /** Overage spendable only by admitting a booking whole, never by a split. */ + toleranceTons: number; + toleranceMeters: number; }; +/** What a consist currently uses, and what is left on each axis. */ +export type ConsistUsage = { + wagonCount: number; + usedLengthMeters: number; + /** Σ (tare + cargo). */ + usedGrossWeightTons: number; + usedTareWeightTons: number; + usedCargoWeightTons: number; + remainingLengthMeters: number; + remainingGrossWeightTons: number; + remainingWagons: number; +}; + +export const MAX_FALLBACK_WEIGHT = 3500; +export const MAX_FALLBACK_LENGTH = 760; + const DEFAULT_WAGON_LENGTH_M = 14; const DEFAULT_WAGON_CAPACITY_T = 70; +/** NW5's tare — the commonest wagon — used only when a type predates the NOT NULL backfill. */ +const DEFAULT_WAGON_TARE_T = 22.4; + +function num(value: unknown, fallback = 0): number { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +/** Gross weight of one loaded wagon: it hauls itself plus its cargo. */ +export function grossWagonWeightTons(slot: Pick): number { + return num(slot.tareWeightTons) + num(slot.cargoTons); +} /** - * Derive train capacity from locomotive pull weight and train length. - * Wagon count is NOT a fixed 53 — it is the minimum of: - * - floor(maxLength / shortest wagon type length) - * - floor(maxWeight / lightest wagon type capacity) + * Hard caps for a train: the locomotive's own limits, floored by the global rule + * caps, then widened by the locomotive's overage tolerance. + * + * `base*` are the caps BEFORE the tolerance is added. The tolerance is not + * general-purpose headroom: batch filling budgets against the base caps and may + * spend the tolerance only to admit a booking WHOLE (never to size a split), so + * both figures are returned. `base + tolerance === max` always holds, including + * the fallback path. + */ +export function trainHardCaps( + locomotive: LocomotiveLimits, + ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number }, +): { + maxWeightTons: number; + maxLengthMeters: number; + baseWeightTons: number; + baseLengthMeters: number; + toleranceTons: number; + toleranceMeters: number; +} { + const overageTons = num(locomotive.overageToleranceTons); + const overageMeters = num(locomotive.overageToleranceMeters); + + const baseWeight = Math.min( + num(locomotive.maxPullWeightTons, Infinity) || Infinity, + ruleCaps?.maxTrainWeightTons ?? Infinity, + ); + const baseLength = Math.min( + num(locomotive.maxTrainLengthMeters, Infinity) || Infinity, + ruleCaps?.maxTrainLengthMeters ?? Infinity, + ); + + const baseWeightTons = Number.isFinite(baseWeight) ? baseWeight : MAX_FALLBACK_WEIGHT; + const baseLengthMeters = Number.isFinite(baseLength) ? baseLength : MAX_FALLBACK_LENGTH; + const toleranceTons = Number.isFinite(baseWeight) ? overageTons : 0; + const toleranceMeters = Number.isFinite(baseLength) ? overageMeters : 0; + + return { + maxWeightTons: baseWeightTons + toleranceTons, + maxLengthMeters: baseLengthMeters + toleranceMeters, + baseWeightTons, + baseLengthMeters, + toleranceTons, + toleranceMeters, + }; +} + +/** + * Derive the planning capacity of a train from its locomotive. + * + * `maxWagonSlots` counts how many of the SHORTEST allowed wagon type fit within + * the train-length cap — the optimistic slot count, since a mixed consist of + * longer wagons will hit the length cap sooner. It is deliberately NOT reduced by + * weight: with no bookings yet there is no cargo, and assuming every wagon rides + * at full rated payload would report 37 NW5 slots where the railway marshals 53. + * Weight is enforced by {@link consistUsage} / {@link consistViolations} against + * the cargo actually allocated. */ export function deriveTrainCapacityFromLocomotive( locomotive: LocomotiveLimits, wagonTypes: WagonTypeDimensions[], ruleCaps?: { maxTrainWeightTons?: number; maxTrainLengthMeters?: number }, ): DerivedTrainCapacity { - const maxWeightTons = Math.min( - Number(locomotive.maxPullWeightTons) || Infinity, - ruleCaps?.maxTrainWeightTons ?? Infinity, - ); - const maxLengthMeters = Math.min( - Number(locomotive.maxTrainLengthMeters) || Infinity, - ruleCaps?.maxTrainLengthMeters ?? Infinity, - ); + const caps = trainHardCaps(locomotive, ruleCaps); - const types = - wagonTypes.length > 0 - ? wagonTypes - : [{ lengthMeters: DEFAULT_WAGON_LENGTH_M, capacityTons: DEFAULT_WAGON_CAPACITY_T }]; + const lengths = wagonTypes + .map((w) => num(w.lengthMeters)) + .filter((l) => l > 0); + const minLength = lengths.length ? Math.min(...lengths) : DEFAULT_WAGON_LENGTH_M; - const minLength = Math.min(...types.map((w) => Number(w.lengthMeters) || DEFAULT_WAGON_LENGTH_M)); - const minCapacity = Math.min( - ...types.map((w) => Number(w.capacityTons) || DEFAULT_WAGON_CAPACITY_T), - ); + const maxWagonSlots = + minLength > 0 ? Math.max(0, Math.floor(caps.maxLengthMeters / minLength)) : 0; - const byLength = - minLength > 0 && Number.isFinite(maxLengthMeters) - ? Math.floor(maxLengthMeters / minLength) - : 0; - const byWeight = - minCapacity > 0 && Number.isFinite(maxWeightTons) - ? Math.floor(maxWeightTons / minCapacity) - : byLength; + return { ...caps, maxWagonSlots }; +} - const maxWagonSlots = Math.max(0, Math.min(byLength, byWeight)); +/** + * What a real, mixed-type consist uses on all three axes, and what is left. + * Every wagon contributes its own length and its own tare — no averaging over a + * representative wagon type. + */ +export function consistUsage( + slots: ConsistSlot[], + caps: { maxWeightTons: number; maxLengthMeters: number; maxWagonSlots: number }, +): ConsistUsage { + let usedLengthMeters = 0; + let usedTareWeightTons = 0; + let usedCargoWeightTons = 0; + + for (const slot of slots) { + usedLengthMeters += num(slot.lengthMeters); + usedTareWeightTons += num(slot.tareWeightTons); + usedCargoWeightTons += num(slot.cargoTons); + } + + const usedGrossWeightTons = usedTareWeightTons + usedCargoWeightTons; return { - maxWeightTons: Number.isFinite(maxWeightTons) ? maxWeightTons : MAX_FALLBACK_WEIGHT, - maxLengthMeters: Number.isFinite(maxLengthMeters) ? maxLengthMeters : MAX_FALLBACK_LENGTH, - maxWagonSlots, + wagonCount: slots.length, + usedLengthMeters: round3(usedLengthMeters), + usedGrossWeightTons: round3(usedGrossWeightTons), + usedTareWeightTons: round3(usedTareWeightTons), + usedCargoWeightTons: round3(usedCargoWeightTons), + remainingLengthMeters: round3(caps.maxLengthMeters - usedLengthMeters), + remainingGrossWeightTons: round3(caps.maxWeightTons - usedGrossWeightTons), + remainingWagons: caps.maxWagonSlots - slots.length, }; } -export const MAX_FALLBACK_WEIGHT = 3500; -export const MAX_FALLBACK_LENGTH = 760; +/** Human-readable reasons a consist breaks its train's limits. Empty = it fits. */ +export function consistViolations( + slots: ConsistSlot[], + caps: { maxWeightTons: number; maxLengthMeters: number; maxWagonSlots: number }, +): string[] { + const usage = consistUsage(slots, caps); + const violations: string[] = []; + + if (usage.usedGrossWeightTons > caps.maxWeightTons) { + violations.push( + `Total train gross weight ${usage.usedGrossWeightTons}T ` + + `(${usage.usedTareWeightTons}T tare + ${usage.usedCargoWeightTons}T cargo) ` + + `exceeds max pull weight ${round3(caps.maxWeightTons)}T`, + ); + } + if (usage.usedLengthMeters > caps.maxLengthMeters) { + violations.push( + `Total wagon length ${usage.usedLengthMeters}m exceeds max train length ${round3(caps.maxLengthMeters)}m`, + ); + } + if (usage.wagonCount > caps.maxWagonSlots) { + violations.push( + `Wagon count ${usage.wagonCount} exceeds max wagons per train (${caps.maxWagonSlots})`, + ); + } + + return violations; +} + +function round3(value: number): number { + return Number.isFinite(value) ? Number(value.toFixed(3)) : value; +} /** * Effective pull limits for a train set with multiple locomotives: the weakest @@ -75,16 +240,22 @@ export const MAX_FALLBACK_LENGTH = 760; * across all assigned locomotives. Returns null when no locomotives are given. */ export function minLocomotiveLimits( - locomotives: Array>, + locomotives: Array< + Pick & + Partial> + >, ): LocomotiveLimits | null { if (!locomotives.length) return null; return { maxPullWeightTons: Math.min( - ...locomotives.map((l) => Number(l.maxPullWeightTons) || Infinity), + ...locomotives.map((l) => num(l.maxPullWeightTons, Infinity) || Infinity), ), maxTrainLengthMeters: Math.min( - ...locomotives.map((l) => Number(l.maxTrainLengthMeters) || Infinity), + ...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity), ), + // Weakest locomotive's tolerance governs the set, same as its caps. + overageToleranceTons: Math.min(...locomotives.map((l) => num(l.overageToleranceTons))), + overageToleranceMeters: Math.min(...locomotives.map((l) => num(l.overageToleranceMeters))), }; } @@ -98,12 +269,79 @@ export function bookingTrainLengthMeters( return wagonCount * perWagon; } +/** + * Gross weight a booking adds to its train: its cargo plus the tare of every + * wagon it occupies. A booking is never weightless just because it is light — + * the empty wagons still have to be pulled. + */ +export function bookingGrossWeightTons( + cargoTons: number, + wagonCount: number, + tarePerWagonTons: number, +): number { + return round3(num(cargoTons) + wagonCount * num(tarePerWagonTons)); +} + +/** + * Size a partial (split-on-payment) offer against the room left on a train, + * across ALL THREE capacity axes — not just wagon slots. Each wagon adds + * `capacityTons` of payload headroom but its own tare spends the same weight + * room the cargo needs, so on a weight-limited train more wagons is not always + * more cargo. Scans wagon counts (the last wagon may run part-loaded) and + * returns the count that maximizes the cargo carried, with the cargo cap the + * caller should apply. Null when not even one part-loaded wagon fits. The + * offer is a strict subset of the booking: never all `bookingWagons`. + * + * `fullWagonsOnly` (bulk): every offered wagon rides at its full rated payload, + * so each wagon costs `capacityTons + tareWeightTons` of gross weight room and + * the offer is the largest whole-wagon count whose gross fits — never a + * part-loaded last wagon squeezed into leftover pull weight. + */ +export function sizePartialOfferWagons( + room: { wagons: number; weightTons: number; lengthMeters: number }, + bookingWagons: number, + perWagon: { capacityTons: number; tareWeightTons: number; lengthMeters: number }, + opts?: { fullWagonsOnly?: boolean }, +): { wagons: number; maxCargoTons: number } | null { + const maxByLength = + perWagon.lengthMeters > 0 + ? Math.floor(room.lengthMeters / perWagon.lengthMeters) + : room.wagons; + const ceiling = Math.min(room.wagons, maxByLength, bookingWagons - 1); + + if (opts?.fullWagonsOnly) { + const grossPerWagon = perWagon.capacityTons + perWagon.tareWeightTons; + const maxByWeight = + grossPerWagon > 0 ? Math.floor(room.weightTons / grossPerWagon) : 0; + const wagons = Math.min(ceiling, maxByWeight); + if (wagons < 1) return null; + return { wagons, maxCargoTons: round3(wagons * perWagon.capacityTons) }; + } + + let wagons = 0; + let bestCargoTons = 0; + for (let w = 1; w <= ceiling; w += 1) { + const cargoAt = Math.min( + w * perWagon.capacityTons, + room.weightTons - w * perWagon.tareWeightTons, + ); + if (cargoAt > bestCargoTons) { + bestCargoTons = cargoAt; + wagons = w; + } + } + if (wagons < 1) return null; + return { wagons, maxCargoTons: round3(room.weightTons - wagons * perWagon.tareWeightTons) }; +} + export function wagonTypeDimensionsFromEntity(wt: { lengthMeters?: number | string | null; capacityTons?: number | string | null; + tareWeightTons?: number | string | null; }): WagonTypeDimensions { return { - lengthMeters: Number(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M, - capacityTons: Number(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T, + lengthMeters: num(wt.lengthMeters) || DEFAULT_WAGON_LENGTH_M, + capacityTons: num(wt.capacityTons) || DEFAULT_WAGON_CAPACITY_T, + tareWeightTons: num(wt.tareWeightTons) || DEFAULT_WAGON_TARE_T, }; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 7dba44f83..fd41d4a29 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -39,6 +39,7 @@ import { UploadImportDjiboutiDocumentDto, } from "./dto/import-djibouti-operation.dto"; import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto"; +import { BatchBoardQueryDto } from "./dto/batch-board-query.dto"; import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto"; import { AvailableDaysQueryDto } from "./dto/available-days-query.dto"; import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto"; @@ -124,10 +125,11 @@ export class TrainSchedulingController { @Get("batch-board") @TrainSchedulingView() @ApiOperation({ - summary: "Batch monitoring board: schedules with bookings grouped by state", + summary: + "Batch monitoring board: paginated import schedules (all statuses) with bookings grouped by state", }) - getBatchBoard() { - return this.bookingBatchService.getBatchBoard(); + getBatchBoard(@Query() query: BatchBoardQueryDto) { + return this.bookingBatchService.getBatchBoard(query); } @Get("batch-board/:scheduleId") diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 04a972ed7..e97b3de56 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -14,7 +14,6 @@ const nw5 = { name: 'Flat Wagon', capacityTons: 70, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['CONTAINER'], isActive: true, supportsContainer: true, @@ -35,7 +34,6 @@ const cw3 = { name: 'Covered Wagon', capacityTons: 60, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['BULK'], isActive: true, supportsContainer: false, @@ -74,7 +72,7 @@ const makeBooking = ( wagonsRequired, vgmPerUnitTons: weight / quantity, isOverweight: false, - containerType: { code: containerCode, label: containerCode }, + containerType: { code: containerCode, label: containerCode, wagonTypeId: nw5.id }, }, ], ...extra, @@ -284,7 +282,7 @@ describe('TrainSchedulingService', () => { expect(result.warnings[0]).toContain('soft hold window'); }); - it('flags the overweight booking as invalid', async () => { + it('warns on the overweight booking but still allows scheduling', async () => { const bookings = [ makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT', 80, undefined, undefined, undefined, { bookingContainers: [ @@ -295,7 +293,7 @@ describe('TrainSchedulingService', () => { wagonsRequired: 80, vgmPerUnitTons: 45, isOverweight: true, - containerType: { code: '40FT', label: '40FT' }, + containerType: { code: '40FT', label: '40FT', wagonTypeId: nw5.id }, }, ], }), @@ -313,8 +311,8 @@ describe('TrainSchedulingService', () => { destinationStationId: 'yard-destination', }); - expect(result.valid).toBe(false); - expect(result.violations.some((v) => v.includes('overweight'))).toBe(true); + expect(result.violations.some((v) => v.includes('overweight'))).toBe(false); + expect(result.warnings.some((w) => w.includes('overweight'))).toBe(true); }); it('allows preview when bookings are already on the target schedule', async () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 754a302d3..eee880a6b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -17,7 +17,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, EntityManager, In, IsNull, Not, QueryFailedError } from 'typeorm'; +import { DataSource, EntityManager, In, Not, QueryFailedError } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; @@ -43,8 +43,6 @@ import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-all import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository'; import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -import { CargoType } from '../rule-engine/entities/cargo-type.entity'; -import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; import { Wagon } from '../wagons/entities/wagon.entity'; import { AssignBookingsDto } from './dto/assign-bookings.dto'; @@ -104,10 +102,13 @@ import { deriveTrainCapacityFromLocomotive, minLocomotiveLimits, wagonTypeDimensionsFromEntity, + WagonTypeDimensions, } from './train-capacity.util'; import { DEFAULT_BULK_WAGON_LENGTH_METERS, + DEFAULT_BULK_WAGON_TARE_TONS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + DEFAULT_CONTAINER_WAGON_TARE_TONS, } from './booking-batch.constants'; import { computeExportWindowTimes, @@ -1004,13 +1005,19 @@ export class TrainSchedulingService { throw new BadRequestException('Schedule train set has no locomotives'); } // forceAssign lets staff overload the locomotive set knowingly — the - // validator has already surfaced it as a warning in that case. - if (!dto.forceAssign && limitLoco.maxPullWeightTons < totalWeightTons) { + // validator has already surfaced it as a warning in that case. Each + // locomotive's overageToleranceTons/Meters extends the hard cap before that + // override is even needed (e.g. the fertilizer example's +90T deviation). + const weightCapWithOverage = + limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0); + const lengthCapWithOverage = + limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0); + if (!dto.forceAssign && weightCapWithOverage < totalWeightTons) { throw new BadRequestException( `Train set locomotives cannot pull ${totalWeightTons}T`, ); } - if (!dto.forceAssign && limitLoco.maxTrainLengthMeters < totalLengthMeters) { + if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) { throw new BadRequestException( `Train set locomotives cannot support ${totalLengthMeters}m`, ); @@ -1066,12 +1073,17 @@ export class TrainSchedulingService { containerPlacements ?? [], ); + // The link above puts these bookings on the train: they are SCHEDULED, not + // ELIGIBLE. Leaving them ELIGIBLE re-offers an allocated booking to the next + // batch fill, which unlinks it and frees its wagons on the next window cycle. + const scheduledAt = new Date(); for (const booking of bookings) { await this.bookingsRepository.updateSchedulingFields( booking.id, { - schedulingStatus: SchedulingStatus.Eligible, - wagonsRequired: sumWagonsRequired(booking), + schedulingStatus: SchedulingStatus.Scheduled, + scheduledAt, + wagonsRequired: sumWagonsRequired(booking, wagonPlan), }, manager, ); @@ -1648,6 +1660,13 @@ export class TrainSchedulingService { * clearance views still reading that milestone (older deployed builds) see * the gate pass as done. Drop once every clearance-api deployment reads * ImportDjiboutiOperation.gatepassGrantedAt directly. + * + * A booking only earns its gate pass once the customer has settled the freight + * charges (FREIGHT_PAYMENT_SETTLED). The gate pass itself is secured per train + * schedule, so an unpaid booking must not ride a paid neighbour's grant: it + * keeps GATEPASS_GRANTED pending — and therefore cannot upload T1 — while the + * train and its paid bookings proceed. Re-securing the gate pass after payment + * settles picks the booking up; so does any later call to this bridge. */ private async completeGatepassMilestoneForSchedule( scheduleId: string, @@ -1659,20 +1678,49 @@ export class TrainSchedulingService { if (bookings.length === 0) return; const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone); + const bookingIds = bookings.map((b) => b.id); const rows = await milestoneRepo.find({ where: { - bookingId: In(bookings.map((b) => b.id)), - milestoneCode: 'GATEPASS_GRANTED', + bookingId: In(bookingIds), + milestoneCode: In(['GATEPASS_GRANTED', 'FREIGHT_PAYMENT_SETTLED']), }, }); + const paidBookingIds = new Set( + rows + .filter( + (r) => r.milestoneCode === 'FREIGHT_PAYMENT_SETTLED' && r.status === 'COMPLETED', + ) + .map((r) => r.bookingId), + ); + // A booking whose payment settled through a path that never wrote the + // milestone still counts as paid — the clearance views self-heal the row on + // read, and the gate pass must not lag behind that. + for (const booking of bookings) { + if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') { + paidBookingIds.add(booking.id); + } + } + + const skipped: string[] = []; for (const row of rows) { + if (row.milestoneCode !== 'GATEPASS_GRANTED') continue; if (row.status === 'COMPLETED') continue; + if (!row.bookingId || !paidBookingIds.has(row.bookingId)) { + skipped.push(row.bookingId ?? '(unknown)'); + continue; + } row.status = 'COMPLETED'; row.triggeredAt = securedAt; row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() }; await milestoneRepo.save(row); } + + if (skipped.length > 0) { + this.logger.warn( + `Gate pass secured for schedule ${scheduleId}, but ${skipped.length} booking(s) have not settled freight payment and stay pending: ${skipped.join(', ')}`, + ); + } } async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { @@ -1857,7 +1905,7 @@ export class TrainSchedulingService { ${esc(wagon.physicalWagon?.wagonNumber)} ${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)} ${esc(Number(wagon.lengthMeters || 0).toFixed(3))} - ${esc(Number(wagon.physicalWagon?.tareWeight ?? 0).toFixed(2))} + ${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))} ${esc(Number(wagon.capacityTons || 0).toFixed(3))} ${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)} ${esc(booking?.companyId)} @@ -2466,6 +2514,12 @@ export class TrainSchedulingService { if (dto.sequenceNo === finalSeq) { await this.arriveSchedule(scheduleId); + } else { + // Mid-corridor auto-unload: bookings destined for this yard alight the + // moment the train is recorded here — the yard operator no longer has to + // unload each one by hand. The final station is covered by + // arriveSchedule's bulk fallback above. + await this.bookingJourneyService.autoUnloadAtYard(scheduleId, station.yardId); } return this.getScheduleCheckpoints(scheduleId); @@ -2591,7 +2645,9 @@ export class TrainSchedulingService { const schedules = await this.trainSchedulesRepository.findAll({ relations: { trainSet: { locomotive: true, locomotives: { locomotive: true } }, - route: true, + // Yards carry the route's display name used by mapScheduleListItem; + // milestones (with yards) let it show the full corridor path. + route: { originYard: true, destinationYard: true, milestones: { yard: true } }, originStation: true, destinationStation: true, scheduleBookings: { booking: true }, @@ -2758,10 +2814,14 @@ export class TrainSchedulingService { `Booking ${booking.reference} is within the soft hold window (expires ${booking.holdExpiresAt?.toISOString()})`, ); } + // Overweight is the soft threshold (maxVgmTons): the customer already + // paid the overweight surcharge at booking. The hard ceiling + // (maxCapacityTons) blocks booking creation, so anything reaching + // scheduling is shippable — warn the planner, never block allocation. const overweightLines = (booking.bookingContainers ?? []).filter((c) => c.isOverweight); if (overweightLines.length) { - violations.push( - `Booking ${booking.reference} has overweight container lines; use forceAssign to override`, + warnings.push( + `Booking ${booking.reference} has ${overweightLines.length} overweight container line(s); overweight surcharge applied`, ); } } @@ -2947,8 +3007,10 @@ export class TrainSchedulingService { } if ( setLimits && - (setLimits.maxPullWeightTons < totalWeightTons || - setLimits.maxTrainLengthMeters < totalLengthMeters) + (setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) < + totalWeightTons || + setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) < + totalLengthMeters) ) { pushLimit([ 'Assigned locomotives cannot support the total train weight and length', @@ -2966,8 +3028,10 @@ export class TrainSchedulingService { if ( !inServiceLocomotives.some( (l) => - Number(l.maxPullWeightTons) >= totalWeightTons && - Number(l.maxTrainLengthMeters) >= totalLengthMeters, + Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >= + totalWeightTons && + Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >= + totalLengthMeters, ) ) { pushLimit(['No locomotive can support the total train weight and length']); @@ -3022,7 +3086,10 @@ export class TrainSchedulingService { maxTrainLengthMeters?: number; maxWagonsPerTrain?: number; }, - locomotive?: Pick, + locomotive?: Pick< + Locomotive, + 'maxPullWeightTons' | 'maxTrainLengthMeters' | 'overageToleranceTons' | 'overageToleranceMeters' + >, ): Promise> { const row = await this.loadGlobalRulesRow(); const configured = this.configService?.get<{ @@ -3045,15 +3112,21 @@ export class TrainSchedulingService { const wagonTypes = await this.loadSchedulingWagonTypeDimensions(); if (locomotive) { + // With a locomotive assigned its own limits are the single source of + // truth — global-rules / env caps do not floor them (a mis-set global + // row once capped every train at 14m). Only an explicit per-request dto + // override still applies. const derived = deriveTrainCapacityFromLocomotive( { maxPullWeightTons: Number(locomotive.maxPullWeightTons), maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters), + overageToleranceTons: Number(locomotive.overageToleranceTons) || 0, + overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0, }, wagonTypes, { - maxTrainWeightTons: ruleWeightCap, - maxTrainLengthMeters: ruleLengthCap, + maxTrainWeightTons: dto?.maxTrainWeightTons, + maxTrainLengthMeters: dto?.maxTrainLengthMeters, }, ); return { @@ -3112,16 +3185,27 @@ export class TrainSchedulingService { }; } - private async loadSchedulingWagonTypeDimensions(): Promise< - Array<{ lengthMeters: number; capacityTons: number }> - > { - const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: 'NW5' }, { code: 'CW3' }], - }); + /** + * Every active wagon type: the slot count derives from the shortest wagon the + * fleet can marshal, so sampling only NW5/CW3 would miss a shorter type (GW2 at + * 12.228m) and under-report how many wagons the train length allows. + */ + private async loadSchedulingWagonTypeDimensions(): Promise { + const types = await this.dataSource + .getRepository(WagonType) + .find({ where: { isActive: true } }); if (types.length) return types.map(wagonTypeDimensionsFromEntity); return [ - { lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 }, - { lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 }, + { + lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, + capacityTons: 70, + tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS, + }, + { + lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, + capacityTons: 60, + tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS, + }, ]; } @@ -3431,37 +3515,6 @@ export class TrainSchedulingService { return wagonType; } - /** - * Soft wagon-type resolution for the customer-facing availability preview - * (getAvailableDaysForCargo). Reads the configured FK by cargo/container type; - * returns null (→ "no days") instead of throwing when nothing is configured, - * since this only estimates which days have wagons and creates no booking. - */ - private async resolveWagonTypeForPreview( - freightType: 'CONTAINER' | 'BULK', - cargoTypeCode: string | null, - ): Promise { - if (freightType === 'BULK') { - if (!cargoTypeCode) return null; - const cargoType = await this.dataSource.getRepository(CargoType).findOne({ - where: { code: cargoTypeCode }, - relations: { wagonType: true }, - }); - return cargoType?.wagonType?.isActive ? cargoType.wagonType : null; - } - - // Container preview: the input carries no specific container type, so use the - // wagon type of the first configured (active) container type. - const containerType = await this.dataSource - .getRepository(ContainerType) - .findOne({ - where: { isActive: true, wagonTypeId: Not(IsNull()) }, - relations: { wagonType: true }, - order: { displayOrder: 'ASC' }, - }); - return containerType?.wagonType?.isActive ? containerType.wagonType : null; - } - /** * Stamp each plan slot with the leg it occupies (dynamic consist): the * boarding/alighting yards of the bookings it carries. Null means the @@ -3719,10 +3772,17 @@ export class TrainSchedulingService { if (locomotive.status !== 'AVAILABLE') { throw new BadRequestException(`Locomotive ${locomotive.code} is not available`); } - if (Number(locomotive.maxPullWeightTons) < totalWeightTons) { + if ( + Number(locomotive.maxPullWeightTons) + (Number(locomotive.overageToleranceTons) || 0) < + totalWeightTons + ) { throw new BadRequestException(`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`); } - if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) { + if ( + Number(locomotive.maxTrainLengthMeters) + + (Number(locomotive.overageToleranceMeters) || 0) < + totalLengthMeters + ) { throw new BadRequestException( `Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`, ); @@ -4215,13 +4275,14 @@ export class TrainSchedulingService { } /** - * Cargo-aware day pool: the EAT days that are actually FEASIBLE for the given - * cargo. A day is selectable only when ≥1 OPEN schedule on the route that day - * has BOTH (a) enough AVAILABLE wagons of the cargo's matching type at that - * schedule's origin yard, and (b) remaining train capacity (not fully - * allocated). Days with trains but not enough matching wagons are excluded. - * Same `{ days: string[] }` shape as getAvailableDays — the customer still - * picks a DAY, not a train. + * Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day + * is selectable when ≥1 OPEN schedule on the route that day still has remaining + * train capacity (not fully allocated). Wagon availability is deliberately NOT + * checked here: whether a matching wagon currently sits in the right yard is an + * operational question staff resolve when they approve or reject the booking, + * not something the customer can act on while choosing a date. Same + * `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY, + * not a train. */ async getAvailableDaysForCargo(input: { originYardId?: string; @@ -4237,85 +4298,17 @@ export class TrainSchedulingService { ); if (schedules.length === 0) return { days: [] }; - // Resolve the wagon type this cargo needs via the cargo/container-type FK. - // Soft (customer availability preview): no days if unresolved, never throws. - const requiredType = await this.resolveWagonTypeForPreview( - input.freightType, - input.cargoTypeCode ?? null, - ); - if (!requiredType) return { days: [] }; - - // How many wagons of that type the cargo needs. - const slotsNeeded = this.wagonsNeededForCargo(input, requiredType); - void slotsNeeded; // TEMP: unused while the wagon-availability filter is off. - - // TEMP (per request): wagon-availability filtering is DISABLED. A day is now - // offered whenever a bookable schedule that day has remaining train capacity - // — regardless of whether matching wagons are actually available at the - // origin / boarding yard. This surfaces days even when no wagon is on hand. - // Restore the block below to bring back the "enough matching wagons" gate. - // - // // AVAILABLE wagons of the required type, counted once per origin yard. - // const availableByYard = new Map(); - // const availableAt = async (yardId: string): Promise => { - // const cached = availableByYard.get(yardId); - // if (cached !== undefined) return cached; - // const counts = await this.countFleetAvailability(yardId); - // const n = - // counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0; - // availableByYard.set(yardId, n); - // return n; - // }; - const days = new Set(); for (const s of schedules) { const hasCapacity = Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0; if (!hasCapacity) continue; - // TEMP (per request): wagon-availability check commented out — see note - // above. Dynamic consist: wagons may ride from the train's origin OR - // already sit at the booking's own boarding yard and attach when the train - // arrives — either pool can serve a sub-corridor booking. - // let enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded; - // if ( - // !enoughWagons && - // input.originYardId && - // input.originYardId !== s.originStationId - // ) { - // enoughWagons = (await availableAt(input.originYardId)) >= slotsNeeded; - // } - // if (!enoughWagons) continue; if (s.scheduledDepartureDate) days.add(eatDay(new Date(s.scheduledDepartureDate))); } return { days: [...days].sort() }; } - /** - * Wagons needed for a cargo (pre-booking estimate). BULK: ceil(weight / - * capacity). CONTAINER: TEU packing — 40ft = 2 TEU, 20ft = 1 TEU, 2 TEU per - * wagon. Mirrors wagon-plan.util without fabricating Booking entities. - */ - private wagonsNeededForCargo( - input: { - freightType: 'CONTAINER' | 'BULK'; - totalWeightTons?: number; - containers?: Array<{ containerSize: string; quantity: number }>; - }, - wagonType: WagonType, - ): number { - if (input.freightType === 'BULK') { - const capacity = Number(wagonType.capacityTons) || 1; - const weight = Number(input.totalWeightTons ?? 0); - return Math.max(1, Math.ceil(weight / capacity)); - } - const teu = (input.containers ?? []).reduce((sum, c) => { - const per = c.containerSize === '40ft' ? 2 : 1; - return sum + per * Math.max(0, Number(c.quantity ?? 0)); - }, 0); - return Math.max(1, Math.ceil(teu / 2)); - } - /** * Ordered stop yards of a schedule's route: origin → milestones → destination, * de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule @@ -4536,6 +4529,11 @@ export class TrainSchedulingService { capacityTons: roundTons(Number(wagon.capacityTons)), lengthMeters: roundTons(Number(wagon.lengthMeters)), assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)), + // Empty-wagon weight — the pull limit hauls tare + cargo, so the + // frontend needs it to show the gross train weight. + tareWeightTons: wagon.wagonType + ? roundTons(Number(wagon.wagonType.tareWeightTons)) + : null, status: wagon.status, physicalWagonId: wagon.physicalWagonId ?? null, physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts index 824c45e6e..19e19dca3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts @@ -6,6 +6,7 @@ import { buildBulkWagonPlan, buildContainerWagonPlan, buildMixedWagonPlan, + containerWagonsForLines, expandBookingContainerUnits, expandContainerItems, roundTons, @@ -20,7 +21,6 @@ const nw5: WagonType = { name: 'Flat Wagon', capacityTons: 70, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['CONTAINER'], isActive: true, supportsContainer: true, @@ -32,7 +32,6 @@ const cw3: WagonType = { name: 'Covered Wagon', capacityTons: 60, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['BULK'], isActive: true, supportsContainer: false, @@ -83,6 +82,29 @@ describe('wagon-plan.util', () => { expect(plan).toHaveLength(2); }); + it('counts a bulk booking\'s wagons from the plan, not a flat 1', () => { + // 700T of sugar on 60T CW3 gondolas = 12 wagons; the stored wagonsRequired + // must carry all of them so gross weight charges 12 tares downstream. + const booking = { + id: 'bulk-700', + reference: 'bulk-700', + freightType: 'BULK', + cargoTotalWeightVgm: 700, + bookingContainers: [], + } as unknown as Booking; + const plan = buildBulkWagonPlan([booking], cw3); + expect(plan).toHaveLength(12); + expect(sumWagonsRequired(booking, plan)).toBe(12); + // Without a plan the pre-plan fallback still applies. + expect(sumWagonsRequired(booking)).toBe(1); + }); + + it('counts container wagons from the plan TEU packing', () => { + const booking = makeContainerBooking('c-plan', [{ quantity: 6, wagonsRequired: 3 }]); + const plan = buildContainerWagonPlan([booking], nw5); + expect(sumWagonsRequired(booking, plan)).toBe(3); + }); + it('6×20ft containers = 3 wagon slots (2 per wagon)', () => { // 20ft containers have wagonsPerUnit = 0.5, so 6 * 0.5 = 3 wagons const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]); @@ -200,3 +222,60 @@ describe('wagon-plan.util', () => { expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1); }); }); + +describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => { + const line = (quantity: number, wagonsPerUnit: number, wagonsRequired?: number) => ({ + quantity, + wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit, + containerType: { wagonsPerUnit, sizeFt: wagonsPerUnit >= 1 ? 40 : 20 }, + }); + + it('20×20ft = 10 wagons (not 20)', () => { + expect(containerWagonsForLines([line(20, 0.5)])).toBe(10); + }); + + it('38×20ft = 19 wagons', () => { + expect(containerWagonsForLines([line(38, 0.5)])).toBe(19); + }); + + it('2×20ft = 1 wagon', () => { + expect(containerWagonsForLines([line(2, 0.5)])).toBe(1); + }); + + it('odd 3×20ft = 2 wagons (single line ceils)', () => { + expect(containerWagonsForLines([line(3, 0.5)])).toBe(2); + }); + + it('3×20ft + 3×20ft = 3 wagons (ceil TOTAL, not per line)', () => { + // per-line ceil would give 2 + 2 = 4; the booking total is ceil(1.5+1.5)=3. + expect(containerWagonsForLines([line(3, 0.5), line(3, 0.5)])).toBe(3); + }); + + it('three 1×20ft lines = 2 wagons (ceil TOTAL)', () => { + // per-line ceil would give 1+1+1 = 3; total is ceil(0.5*3)=ceil(1.5)=2. + expect( + containerWagonsForLines([line(1, 0.5), line(1, 0.5), line(1, 0.5)]), + ).toBe(2); + }); + + it('5×20ft + 2×40ft = 5 wagons', () => { + expect(containerWagonsForLines([line(5, 0.5), line(2, 1)])).toBe(5); + }); + + it('21×40ft = 21 wagons', () => { + expect(containerWagonsForLines([line(21, 1)])).toBe(21); + }); + + it('falls back to line wagonsRequired when containerType/wagonsPerUnit missing', () => { + // No containerType relation loaded → use the stored (0.5-aware) fraction. + expect( + containerWagonsForLines([ + { quantity: 20, wagonsRequired: 10 } as never, + ]), + ).toBe(10); + }); + + it('empty line set = 0 wagons', () => { + expect(containerWagonsForLines([])).toBe(0); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 21dd7b985..2af606cdc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -2,6 +2,7 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { consistViolations } from './train-capacity.util'; export const MAX_TRAIN_WEIGHT_TONS = 3500; export const MAX_TRAIN_LENGTH_METERS = 760; @@ -35,6 +36,9 @@ export type WagonPlanSlot = { wagonTypeCode: string; capacityTons: number; lengthMeters: number; + /** Empty weight of this wagon — the locomotive pulls it whether or not it is loaded. */ + tareWeightTons: number; + /** Cargo tons on this wagon. Gross weight = tareWeightTons + assignedWeightTons. */ assignedWeightTons: number; allocations: WagonAllocationRecord[]; slotLoadType?: SlotLoadType; @@ -78,6 +82,14 @@ export function roundTons(value: number | string | null | undefined): number { return Number(numericValue.toFixed(3)); } +/** + * Tare of a wagon type. Nullable only on rows predating the NOT NULL backfill; + * a missing tare must read as 0 rather than silently inventing dead weight. + */ +export function tareTonsOf(wagonType: Pick): number { + return roundTons(wagonType.tareWeightTons ?? 0); +} + /** TEU slots on a wagon: 40ft = 2, 20ft = 1 (max 2 TEU / wagon). */ export function teuSlotsForSizeFt(sizeFt: number): number { return sizeFt >= 40 ? 2 : 1; @@ -89,18 +101,38 @@ export function containersPerWagonFromType(wagonsPerUnit: number): number { return Math.max(1, Math.round(1 / wpu)); } -function lineWagonsRequired(line: { +type ContainerLine = { quantity?: number | null; wagonsRequired?: number | null; containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null; -}): number { +}; + +/** + * RAW (un-ceiled) wagon fraction one container line occupies: qty × wagonsPerUnit + * (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept fractional so + * the BOOKING total is ceiled once — ceiling per line over-counts a booking that + * splits its 20ft units across several lines (3×20 + 3×20 = 3 wagons, not 4). + */ +function lineWagonsRaw(line: ContainerLine): number { const qty = Number(line.quantity ?? 0); if (qty <= 0) return 0; const wpu = Number(line.containerType?.wagonsPerUnit); if (Number.isFinite(wpu) && wpu > 0) { - return Math.ceil(qty * wpu); + return qty * wpu; } - return Math.max(1, Math.ceil(Number(line.wagonsRequired ?? 1))); + // No wagonsPerUnit on the type: fall back to the line's stored fraction, else + // treat the whole line as one wagon. + const stored = Number(line.wagonsRequired); + return Number.isFinite(stored) && stored > 0 ? stored : 1; +} + +/** + * Whole wagons a set of container lines needs: ceil the summed RAW fraction so a + * half-full 20ft wagon rounds up ONCE at the booking level. Empty set → 0. + */ +export function containerWagonsForLines(lines: ContainerLine[]): number { + const raw = lines.reduce((sum, line) => sum + lineWagonsRaw(line), 0); + return raw > 0 ? Math.ceil(raw) : 0; } /** @@ -110,21 +142,23 @@ export function buildContainerWagonPlan( bookings: Booking[], wagonType: WagonType, ): WagonPlanSlot[] { + // Whole wagons PER BOOKING (ceil each booking's total TEU once — a 20ft unit + // can share a wagon with another 20ft of the SAME booking, never across + // bookings), then sum. Ceiling per line instead would over-count a booking + // that splits its 20ft units across several lines. const totalSlots = bookings.reduce((sum, booking) => { - const lineSlots = (booking.bookingContainers ?? []).reduce( - (lineSum, line) => lineSum + lineWagonsRequired(line), - 0, - ); - return sum + Math.max(lineSlots, 1); + const bookingSlots = containerWagonsForLines(booking.bookingContainers ?? []); + return sum + Math.max(bookingSlots, 1); }, 0); - const slots = Math.max(1, Math.ceil(totalSlots)); + const slots = Math.max(1, totalSlots); const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({ sequenceNo: index + 1, wagonTypeId: wagonType.id, wagonTypeCode: wagonType.code, capacityTons: Number(wagonType.capacityTons), lengthMeters: Number(wagonType.lengthMeters), + tareWeightTons: tareTonsOf(wagonType), assignedWeightTons: 0, allocations: [], })); @@ -154,6 +188,7 @@ export function buildBulkWagonPlan( wagonTypeCode: wagonType.code, capacityTons: capacity, lengthMeters: Number(wagonType.lengthMeters), + tareWeightTons: tareTonsOf(wagonType), assignedWeightTons: 0, allocations: [], })); @@ -193,6 +228,7 @@ export function buildMixedWagonPlan( wagonTypeCode: containerWagonType.code, capacityTons: Number(containerWagonType.capacityTons), lengthMeters: Number(containerWagonType.lengthMeters), + tareWeightTons: tareTonsOf(containerWagonType), assignedWeightTons: 0, allocations: [], slotLoadType: 'CONTAINER', @@ -400,7 +436,21 @@ export function expandContainerItems( return items; } -export function sumWagonsRequired(booking: Booking): number { +/** + * Wagons a booking actually occupies. Prefer counting the built wagon plan's + * slots that carry one of the booking's allocations — for BULK that is its + * tonnage spread over real wagons (a 700T booking on 70T wagons rides 10 + * wagons, and downstream gross-weight math charges 10 tares, not 1). Without + * a plan there is no capacity to divide by, so fall back to the pre-plan + * estimates: 1 for bulk, the lines' stored counts for containers. + */ +export function sumWagonsRequired(booking: Booking, wagonPlan?: WagonPlanSlot[]): number { + const occupiedSlots = (wagonPlan ?? []).filter((slot) => + slot.allocations.some((allocation) => allocation.bookingId === booking.id), + ).length; + if (occupiedSlots > 0) { + return occupiedSlots; + } if (booking.freightType === 'BULK') { return 1; } @@ -422,47 +472,46 @@ export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string return violations; } +/** + * Check a consist against its train's three limits. Weight is GROSS — every slot + * contributes its own tare plus the cargo assigned to it — because the locomotive + * pull limit governs what it drags, not what was sold. Length and tare are summed + * per slot, so a mixed consist is measured as it actually stands rather than + * through one representative wagon type. + * + * `wagonType` only supplies the fallback wagon count when `limits.maxWagonsPerTrain` + * is absent; slot dimensions always win over it. + */ export function validateTrainLimits( wagonPlan: WagonPlanSlot[], - wagonType: WagonType, + wagonType: Pick, limits?: TrainLimitConfig, ): string[] { - const violations: string[] = []; const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS; const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS; const wagonLength = Number(wagonType.lengthMeters) || 14; - const maxWagonsPerTrain = - limits?.maxWagonsPerTrain ?? - Math.floor(maxLengthMeters / wagonLength); + const maxWagonSlots = + limits?.maxWagonsPerTrain ?? Math.floor(maxLengthMeters / wagonLength); - const totalWeightTons = roundTons( - wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0), + const violations = consistViolations( + wagonPlan.map((slot) => ({ + lengthMeters: Number(slot.lengthMeters), + tareWeightTons: Number(slot.tareWeightTons ?? 0), + cargoTons: Number(slot.assignedWeightTons), + })), + { maxWeightTons, maxLengthMeters, maxWagonSlots }, ); - const totalLengthMeters = roundTons( - wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0), - ); - - if (totalWeightTons > maxWeightTons) { - violations.push( - `Total booking weight ${totalWeightTons}T exceeds max train weight ${maxWeightTons}T`, - ); - } - if (totalLengthMeters > maxLengthMeters) { - violations.push( - `Total wagon length ${totalLengthMeters}m exceeds max train length ${maxLengthMeters}m`, - ); - } - if (wagonPlan.length > maxWagonsPerTrain) { - violations.push( - `Wagon count ${wagonPlan.length} exceeds max wagons per train (${maxWagonsPerTrain})`, - ); - } violations.push(...validateBulkWagonSlotWeights(wagonPlan)); return violations; } +/** + * Mixed consist: the wagon-count fallback uses the shortest type present, since + * that is the most wagons that could ever fit. Weight and length still come from + * the slots themselves. + */ export function validateMixedTrainLimits( wagonPlan: WagonPlanSlot[], wagonTypes: WagonType[], @@ -478,7 +527,7 @@ export function validateMixedTrainLimits( return validateTrainLimits( wagonPlan, - { maxWagonsPerTrain } as WagonType, + { lengthMeters: minWagonLength }, { ...limits, maxWagonsPerTrain }, ); } diff --git a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts index 6de5debda..49bbd1f5f 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/dto/create-wagon-type.dto.ts @@ -3,7 +3,6 @@ import { Transform } from 'class-transformer'; import { IsArray, IsBoolean, - IsInt, IsNumber, IsOptional, IsString, @@ -14,9 +13,6 @@ import { const toNumber = ({ value }: { value: unknown }) => value === '' || value == null ? value : Number(value); -const toOptionalNumber = ({ value }: { value: unknown }) => - value === '' || value == null ? undefined : Number(value); - const toBoolean = ({ value }: { value: unknown }) => { if (typeof value === 'boolean') return value; if (value === 'true') return true; @@ -60,12 +56,16 @@ export class CreateWagonTypeDto { @Min(0.001) lengthMeters!: number; - @ApiPropertyOptional({ description: 'Maximum wagons of this type per train', example: 53 }) - @IsOptional() - @Transform(toOptionalNumber) - @IsInt() - @Min(1) - maxWagonsPerTrain?: number; + @ApiProperty({ + description: + 'Empty (unladen) wagon weight in metric tons. Required: the locomotive pull ' + + 'limit applies to gross weight (tare + cargo), so capacity cannot be computed without it.', + example: 22.4, + }) + @Transform(toNumber) + @IsNumber() + @Min(0.001) + tareWeightTons!: number; @ApiPropertyOptional({ description: 'Supported load types, e.g. CONTAINER,BULK', diff --git a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts index 2181a2bd1..b3e410f4b 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts @@ -19,9 +19,6 @@ export class WagonType extends BaseEntity { @Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 }) lengthMeters!: number; - @Column({ name: 'max_wagons_per_train', type: 'int', nullable: true }) - maxWagonsPerTrain?: number | null; - @Column({ name: 'supported_load_types', type: 'text', array: true, default: '{}' }) supportedLoadTypes!: string[]; @@ -31,8 +28,9 @@ export class WagonType extends BaseEntity { @Column({ name: 'equated_length_m', type: 'numeric', precision: 10, scale: 3, nullable: true }) equatedLengthM?: number | null; - @Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3, nullable: true }) - tareWeightTons?: number | null; + /** Empty wagon weight. Required: the locomotive's pull limit is a gross limit. */ + @Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3 }) + tareWeightTons!: number; @Column({ name: 'supports_container', type: 'boolean', default: false }) supportsContainer!: boolean; diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts index ec69bb76a..a61880f06 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts @@ -80,7 +80,7 @@ export class WagonTypesService { name: dto.name.trim(), capacityTons: dto.capacityTons, lengthMeters: dto.lengthMeters, - maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null, + tareWeightTons: dto.tareWeightTons ?? null, supportedLoadTypes: dto.supportedLoadTypes ?? [], isActive: dto.isActive ?? true, }); @@ -101,8 +101,6 @@ export class WagonTypesService { ...dto, ...(nextCode ? { code: nextCode } : {}), ...(dto.name ? { name: dto.name.trim() } : {}), - maxWagonsPerTrain: - dto.maxWagonsPerTrain === undefined ? undefined : dto.maxWagonsPerTrain ?? null, supportedLoadTypes: dto.supportedLoadTypes ?? undefined, }); diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts index 03a930b11..d1939c9f5 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts @@ -1,5 +1,5 @@ import { WagonStatus } from '@edr/types'; -import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsEnum } from 'class-validator'; +import { IsString, IsUUID, IsOptional, IsInt, Min, IsEnum } from 'class-validator'; export class CreateWagonDto { @IsString() @@ -17,13 +17,8 @@ export class CreateWagonDto { @Min(1) sequenceNumber?: number; - @IsNumber() - @Min(0) - tareWeight!: number; - - @IsNumber() - @Min(0) - maxPayloadWeight!: number; + // Tare weight and payload capacity are not accepted here: they belong to the + // wagon type and are resolved through wagonTypeId. @IsOptional() @IsEnum(WagonStatus) diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index 195b4932b..9f2d41416 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -7,6 +7,7 @@ import { TrainSchedule } from '../../train-schedules/entities/train-schedule.ent import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; import { Container } from '../../container-management/entities/container.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; export const WAGON_STATUSES = [ WagonStatus.Available, @@ -28,17 +29,19 @@ export class Wagon extends BaseEntity { @Column({ name: 'wagon_type_id', type: 'uuid' }) wagonTypeId!: string; + /** Owns this wagon's spec: tare weight, payload capacity, length. */ + @ManyToOne(() => WagonType) + @JoinColumn({ name: 'wagon_type_id' }) + wagonType?: WagonType; + @Column({ name: 'train_id', type: 'uuid', nullable: true }) trainId!: string | null; @Column({ name: 'sequence_number', type: 'int', nullable: true }) sequenceNumber!: number | null; - @Column({ name: 'tare_weight', type: 'decimal', precision: 10, scale: 2 }) - tareWeight!: number; - - @Column({ name: 'max_payload_weight', type: 'decimal', precision: 10, scale: 2 }) - maxPayloadWeight!: number; + // Tare weight and payload capacity are properties of the wagon TYPE — read them + // through `wagonType`, never off the individual wagon. @Column({ type: 'varchar', length: 20, default: WagonStatus.Available }) status!: WagonStatusType; diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 9d1f1b41f..b010c0351 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -52,14 +52,23 @@ export class WagonsService { }); } - const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'currentYardId', 'sequenceNumber'].includes(query.sortBy ?? '') + // Spec columns (tare, payload) are no longer sortable here — they live on the + // wagon type, so sorting by them is sorting by wagonTypeId. + const sortable: Array = [ + 'wagonNumber', + 'status', + 'currentYardId', + 'sequenceNumber', + 'wagonTypeId', + ]; + const sortBy = sortable.includes((query.sortBy ?? '') as keyof Wagon) ? (query.sortBy as keyof Wagon) : 'wagonNumber'; const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; return this.wagonRepo.find({ where: search ? where : filters, - relations: { currentYard: true }, + relations: { currentYard: true, wagonType: true }, order: { [sortBy]: sortOrder } as FindOptionsOrder, skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, take: query.limit ? Number(query.limit) : undefined, @@ -69,7 +78,7 @@ export class WagonsService { async findById(id: string): Promise { const wagon = await this.wagonRepo.findOne({ where: { id }, - relations: { currentYard: true }, + relations: { currentYard: true, wagonType: true }, }); if (!wagon) throw new NotFoundException(`Wagon ${id} not found`); return wagon; diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts index c81550cd0..3c9c6c85f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts @@ -6,6 +6,11 @@ export class LoadInventoryDto { @IsUUID() wagonId!: string; + @ApiPropertyOptional({ format: 'uuid', description: 'Train schedule this load belongs to (recorded on the loading).' }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + @ApiPropertyOptional({ description: 'Weight loaded onto the wagon (t)' }) @IsOptional() @IsNumber() diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts index 681b30284..17b75b424 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator'; +import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator'; /** Records a DO / release order being sent to the customer for import pickup. */ export class ReleaseOrderDto { @@ -90,4 +90,13 @@ export class ReleaseOrderDto { @IsOptional() @IsDateString() gateOutTime?: string; + + @ApiPropertyOptional({ + description: + 'Container bookings only: the operator chose not to weigh this truck. ' + + 'Tare/gross become optional and the container weight match is skipped. Bulk always weighs.', + }) + @IsOptional() + @IsBoolean() + weighingSkipped?: boolean; } diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index a54f40973..b2c4ca3c4 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -36,7 +36,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record { + try { + const rows: Array<{ bookingId: string; reference: string }> = await this.dataSource.query( + `SELECT DISTINCT ON (booking_id) + booking_id AS "bookingId", reference + FROM freight.booking_handovers + WHERE signed_at IS NULL + AND deleted_at IS NULL + AND mile_type = 'SELF_HAUL' + ORDER BY booking_id, generated_at DESC`, + ); + if (!rows.length) return; + this.logger.log(`Handover sign reminder: ${rows.length} booking(s) still unsigned`); + for (const row of rows) { + await this.notifySignNeeded(row.bookingId, row.reference); + } + } catch (err) { + this.logger.warn(`Handover sign reminder tick failed: ${(err as Error).message}`); + } + } + /** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */ async signForBooking(bookingId: string, userId?: string | null): Promise { await this.dataSource diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 9eb4ea502..070a267a4 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -77,11 +77,6 @@ export class WarehouseInventoryController { return this.inventoryService.bulkReceive(dto); } - @Post('load-passed-export') - @ApiOperation({ summary: 'Bulk-load all EXPORT inventory that passed inspection (READY_FOR_LOADING)' }) - loadPassedExport(@Body('performedBy') performedBy?: string) { - return this.inventoryService.loadPassedExport(performedBy); - } @Get('ready-to-load-export') @ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' }) @@ -361,6 +356,16 @@ export class WarehouseInventoryController { return this.handoverService.requestSignature(bookingId); } + @Get('bookings/:bookingId/handover-document') + @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' }) + async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { + const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + @Get('bookings/:bookingId/container-items') @ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' }) containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 14f4e27f3..281d3f620 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -207,6 +207,7 @@ export interface EligibleBookingRow { customerTin: string | null; customerPhone: string | null; containerNumber: string | null; + sealNumbers: string | null; containerQuantity: number | null; containerPackagingType: string | null; cargoDescription: string | null; @@ -242,11 +243,6 @@ export interface BulkReceiveResult { results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[]; } -export interface LoadPassedExportResult { - loadedCount: number; - skippedCount: number; - results: { inventoryId: string; status: string; reason?: string }[]; -} export interface BulkInspectResult { inspectedCount: number; @@ -774,7 +770,8 @@ export class WarehouseInventoryService { company.name AS "customer", company.tin AS "customerTin", COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", - bc.container_numbers AS "containerNumber", + COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber", + bcu.seal_numbers AS "sealNumbers", bc.container_quantity AS "containerQuantity", bc.container_packaging_type AS "containerPackagingType", (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL @@ -831,6 +828,14 @@ export class WarehouseInventoryService { LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL ) bc ON true + LEFT JOIN LATERAL ( + SELECT string_agg(NULLIF(unit.container_number, ''), ', ' ORDER BY unit.container_number) AS unit_numbers, + string_agg(DISTINCT NULLIF(unit.seal_number, ''), ', ') AS seal_numbers + FROM freight.booking_container_units unit + JOIN freight.booking_container line + ON line.id = unit.booking_container_id AND line.deleted_at IS NULL + WHERE line.booking_id = b.id AND unit.deleted_at IS NULL + ) bcu ON true LEFT JOIN LATERAL ( SELECT first_mile.id, first_mile.status, first_mile.vehicle_id FROM freight.first_mile first_mile @@ -860,13 +865,39 @@ export class WarehouseInventoryService { /** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */ async bulkReceive(dto: BulkReceiveDto): Promise { const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] }; + /** Sent after the transaction commits so the gateway never blocks the receive. */ + const pendingNotifications: Array<{ + owner: { + phone?: string | null; + ownerName?: string | null; + bookingReference?: string | null; + grnNumber: string; + direction?: string | null; + warehouseId?: string | null; + }; + booking: { + companyId?: string | null; + reference?: string | null; + hasFirstMile?: boolean; + hasLastMile?: boolean; + customerTruckAssignedAt?: string | null; + }; + bookingId: string; + }> = []; await this.dataSource.transaction(async (manager) => { - await this.validateLocation(manager, { + const { warehouse, yard, zone } = await this.validateLocation(manager, { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, }); + // The receive location is whatever the operator selected above — never a + // hand-typed string. Stamp it on the truck entrance for the GRN/notes. + if (dto.truckEntrance && !dto.truckEntrance.warehouseCodeLocation) { + dto.truckEntrance.warehouseCodeLocation = [warehouse.code, yard.code, zone.code] + .filter(Boolean) + .join(' / '); + } for (const bookingId of dto.bookingIds) { const skip = (reason: string) => { @@ -1032,65 +1063,37 @@ export class WarehouseInventoryService { manager, ); - await this.notifyOwnerInventoryReceived({ - phone: truckEntrance?.customerPhone ?? booking.customerPhone, - ownerName: truckEntrance?.ownerName ?? booking.customer, - bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference, - grnNumber, - direction: dto.direction, - warehouseId: dto.warehouseId, + // Queued, not sent here: an SMS/email round-trip inside the transaction + // holds capacity/location locks open for the whole gateway latency. + pendingNotifications.push({ + owner: { + phone: truckEntrance?.customerPhone ?? booking.customerPhone, + ownerName: truckEntrance?.ownerName ?? booking.customer, + bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference, + grnNumber, + direction: dto.direction, + warehouseId: dto.warehouseId, + }, + booking, + bookingId, }); result.receivedCount += 1; result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); - void this.notifyTruckAssignmentNeeded(booking, bookingId); } }); - return result; - } - - /** Bulk-load all EXPORT inventory that passed inspection and is READY_FOR_LOADING. */ - async loadPassedExport(performedBy?: string): Promise { - const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } }); - const result: LoadPassedExportResult = { loadedCount: 0, skippedCount: 0, results: [] }; - - for (const item of ready) { - const skip = (reason: string) => { - result.skippedCount += 1; - result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason }); - }; - - if (item.inspectionStatus !== 'PASSED') { skip('Inspection not PASSED'); continue; } - const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; - if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; } - const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null; - if (bookingStatus !== 'PAID') { skip('Booking not PAID'); continue; } - - await this.dataSource.transaction(async (manager) => { - await manager.getRepository(WarehouseInventory).update(item.id, { - status: 'LOADED', - loadedAt: new Date(), - }); - await this.activityLog.record( - { - activityType: 'INVENTORY_LOADED', - inventoryId: item.id, - warehouseId: item.warehouseId, - description: 'Bulk loaded (passed export)', - performedBy, - }, - manager, - ); - }); - - result.loadedCount += 1; - result.results.push({ inventoryId: item.id, status: 'LOADED' }); + // Fan out after commit, un-awaited: the receive response must not wait on the + // SMS gateway. Both notifiers swallow their own errors. + for (const pending of pendingNotifications) { + void this.notifyOwnerInventoryReceived(pending.owner); + void this.notifyTruckAssignmentNeeded(pending.booking, pending.bookingId); } return result; } + /** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */ private async exportInventoryByStatus( status: WarehouseInventoryStatus, @@ -1271,6 +1274,29 @@ export class WarehouseInventoryService { performedBy?: string, ): Promise { const result: TrainLoadResult = { loadedCount: 0, skippedCount: 0, results: [] }; + const [schedule]: Array<{ + trainNumber: string | null; + origin: string | null; + destination: string | null; + departure: string | null; + }> = await this.dataSource.query( + `SELECT ts.train_number AS "trainNumber", + COALESCE(oy.label, oy.code) AS "origin", + COALESCE(dy.label, dy.code) AS "destination", + ts.scheduled_departure_date AS "departure" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.id = $1 AND ts.deleted_at IS NULL`, + [scheduleId], + ); + const trainNote = schedule + ? `Loaded onto train ${schedule.trainNumber ?? scheduleId.slice(0, 8)}` + + (schedule.origin || schedule.destination + ? ` (${schedule.origin ?? '?'} -> ${schedule.destination ?? '?'})` + : '') + + (schedule.departure ? `, departure ${new Date(schedule.departure).toISOString()}` : '') + : undefined; const items = await this.trainLoadableItems(scheduleId); const byId = new Map(items.map((i) => [i.id, i])); const affectedBookingIds = new Set(); @@ -1287,7 +1313,12 @@ export class WarehouseInventoryService { if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; } try { - await this.load(inventoryId, { wagonId: item.wagonId, loadedBy: performedBy }); + await this.load(inventoryId, { + wagonId: item.wagonId, + loadedBy: performedBy, + trainScheduleId: scheduleId, + notes: trainNote, + }); result.loadedCount += 1; result.results.push({ inventoryId, status: 'LOADED' }); if (item.bookingId) affectedBookingIds.add(item.bookingId); @@ -1566,6 +1597,8 @@ export class WarehouseInventoryService { status: 'UNLOADED', unloadedAt: now, arrivedAt: existing.arrivedAt ?? now, + // Import GRN is issued automatically at train unload. + ...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now) }), }); await this.activityLog.record({ activityType: 'INVENTORY_UNLOADED', @@ -1599,6 +1632,7 @@ export class WarehouseInventoryService { quantity: 1, weight: Number(booking.weight) || 0, status: 'UNLOADED', + grnNumber: this.generateGrnNumber('IMPORT', booking.id, now), arrivedAt: now, unloadedAt: now, notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train', @@ -2326,6 +2360,27 @@ export class WarehouseInventoryService { }); } + /** + * Self-haul = the customer's own truck collects the goods: either a truck + * assigned via the portal (customer_truck_assigned_at), or a walk-in truck + * registered at the gate on a booking with no EDR last-mile leg. EDR + * last-mile bookings are never self-haul. + */ + private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise { + const runner = manager ?? this.dataSource; + const [row]: Array<{ ok: number }> = await runner.query( + `SELECT 1 AS ok + FROM freight.bookings b + LEFT JOIN freight.service_types st ON st.id = b.service_type_id + WHERE b.id = $1 AND b.deleted_at IS NULL + AND (b.customer_truck_assigned_at IS NOT NULL + OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL + AND COALESCE(st.includes_last_mile, false) = false))`, + [bookingId], + ); + return Boolean(row); + } + /** Record a DO / release order sent to the customer. Item stays READY_FOR_PICKUP. */ async release(id: string, dto: ReleaseOrderDto): Promise { const item = await this.findById(id); @@ -2335,19 +2390,17 @@ export class WarehouseInventoryService { ); } - const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime); + // Leaving = gate-out captured, with either a weighed gross or an explicit + // container weighing skip (bulk always weighs). + const isTruckLeaving = + Boolean(dto.gateOutTime) && (dto.grossWeight !== undefined || dto.weighingSkipped === true); if (isTruckLeaving) { await this.invoices.assertClearanceAllowed(id); if (item.bookingId) { - const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> = - await this.dataSource.query( - `SELECT customer_truck_assigned_at AS "customerTruckAssignedAt" - FROM freight.bookings - WHERE id = $1 AND deleted_at IS NULL`, - [item.bookingId], - ); - const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt); + // Self-haul = customer collects: a truck assigned via the portal, OR a + // walk-in truck registered at the gate on a booking with no EDR last mile. + const usesCustomerTruck = await this.isSelfHaulBooking(item.bookingId); // Self-haul: the handover must be signed before the exit paper is issued. // Prefer the structured handover record; fall back to the legacy note. const handoverSigned = @@ -2361,7 +2414,8 @@ export class WarehouseInventoryService { // Authoritative weight match: the truck's net (gross − tare) must equal the // total VGM cargo weight of the containers selected as loaded on it. - if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) { + // Skipped when the operator chose not to weigh (containers only). + if (!dto.weighingSkipped && dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) { const selected = dto.containerNumber .split(/[,;\n]+/) .map((n) => n.trim()) @@ -2431,13 +2485,10 @@ export class WarehouseInventoryService { [item.bookingId], ); // Self-haul: generate the per-booking handover on first truck arrival - // (idempotent). It must be signed before the truck leaves. - const [selfHaul]: Array<{ ok: number }> = await manager.query( - `SELECT 1 AS ok FROM freight.bookings - WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL AND deleted_at IS NULL`, - [item.bookingId], - ); - if (selfHaul) { + // (idempotent) and notify the customer to sign it. Covers BOTH portal- + // assigned trucks and walk-in trucks registered manually at the gate + // (no portal assignment, no EDR last mile). Must be signed before leaving. + if (await this.isSelfHaulBooking(item.bookingId, manager)) { await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager); } } @@ -2603,12 +2654,13 @@ export class WarehouseInventoryService { Array<{ containerNumber: string; goods: string | null; - stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED'; + stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED'; grnNumber: string | null; truckAssignmentId: string | null; truckPlate: string | null; truckArrived: boolean; truckLeft: boolean; + loaded: boolean; bookingReference: string | null; contractId: string | null; hasLastMile: boolean; @@ -2624,6 +2676,7 @@ export class WarehouseInventoryService { truckPlate: string | null; truckArrived: boolean; truckLeft: boolean; + loaded: boolean; bookingReference: string | null; contractId: string | null; hasLastMile: boolean; @@ -2637,6 +2690,7 @@ export class WarehouseInventoryService { a.plate_number AS "truckPlate", (a.arrived_at IS NOT NULL) AS "truckArrived", (a.departed_at IS NOT NULL) AS "truckLeft", + (ctc.loaded_at IS NOT NULL) AS loaded, b.reference AS "bookingReference", b.contract_id AS "contractId", (b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile", @@ -2666,22 +2720,28 @@ export class WarehouseInventoryService { return rows.map((r) => ({ containerNumber: r.containerNumber, goods: r.goods, + // A container the customer assigned to a truck is ASSIGNED (planned); it + // only becomes LOADED once the operator loads it (loaded_at) on truck + // leaving. Departed → LEFT, delivered → DELIVERED. stage: r.delivered ? 'DELIVERED' : r.truckLeft ? 'LEFT' - : r.truckAssignmentId + : r.loaded ? 'LOADED' - : r.grnNumber - ? 'GRN' - : r.received - ? 'RECEIVED' - : 'PENDING', + : r.truckAssignmentId + ? 'ASSIGNED' + : r.grnNumber + ? 'GRN' + : r.received + ? 'RECEIVED' + : 'PENDING', grnNumber: r.grnNumber, truckAssignmentId: r.truckAssignmentId, truckPlate: r.truckPlate, truckArrived: r.truckArrived, truckLeft: r.truckLeft, + loaded: r.loaded, bookingReference: r.bookingReference, contractId: r.contractId, hasLastMile: r.hasLastMile, @@ -2700,11 +2760,12 @@ export class WarehouseInventoryService { const rows: Array<{ containerNumber: string; weightTons: string }> = await this.dataSource.query( `SELECT bcu.container_number AS "containerNumber", - COALESCE(bcu.vgm_tons, 0) AS "weightTons" + MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons" FROM freight.booking_container_units bcu JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL + GROUP BY bcu.container_number ORDER BY bcu.container_number`, [bookingId], ); @@ -3016,6 +3077,21 @@ export class WarehouseInventoryService { }; } + /** Handover PDF resolved by booking (for the portal, which only has bookingId). */ + async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { + const [inv]: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL + ORDER BY updated_at DESC NULLS LAST, created_at DESC + LIMIT 1`, + [bookingId], + ); + if (!inv) { + throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`); + } + return this.handoverDocument(inv.id); + } + async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( `SELECT inv.id, @@ -3242,7 +3318,22 @@ export class WarehouseInventoryService { [item.bookingId], ); } else { - await this.handover.ensureAtDelivery(item.bookingId, {}, manager); + // EDR last-mile: the handover is per delivering truck. Resolve the + // vehicle that carried this item's container so each truck gets its own + // handover (falls back to a booking-level one when unresolvable). + let truckPlate: string | null = null; + if (item.containerId) { + const [veh]: Array<{ plate: string | null }> = await manager.query( + `SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate + FROM freight.last_mile_container_allocations lca + JOIN freight.vehicles v ON v.id = lca.vehicle_id + WHERE lca.container_id = $1 AND lca.vehicle_id IS NOT NULL + LIMIT 1`, + [item.containerId], + ); + truckPlate = veh?.plate ?? null; + } + await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager); } } }); @@ -3299,6 +3390,8 @@ export class WarehouseInventoryService { warehouseInventoryId: id, bookingId: item.bookingId ?? null, wagonId: dto.wagonId, + // Which train this load belongs to — durable even if wagons reshuffle. + trainScheduleId: dto.trainScheduleId ?? null, loadedAt: now, loadedBy: dto.loadedBy ?? null, loadedWeight, @@ -3337,7 +3430,7 @@ export class WarehouseInventoryService { }); // Enrich with wagon numbers (read-only lookup into the scheduling domain). - const wagonIds = [...new Set(loadings.map((l) => l.wagonId))]; + const wagonIds = [...new Set(loadings.map((l) => l.wagonId).filter((id): id is string => Boolean(id)))]; const wagonNumbers = new Map(); if (wagonIds.length > 0) { const rows: Array<{ id: string; wagon_number: string }> = await this.dataSource.query( @@ -3348,7 +3441,7 @@ export class WarehouseInventoryService { } return loadings.map((loading) => - Object.assign(loading, { wagonNumber: wagonNumbers.get(loading.wagonId) ?? null }), + Object.assign(loading, { wagonNumber: (loading.wagonId && wagonNumbers.get(loading.wagonId)) ?? null }), ); } @@ -4383,14 +4476,17 @@ export class WarehouseInventoryService { if (!dto.driverName?.trim()) { throw new BadRequestException('Driver name is required for exit inspection'); } - if (dto.tareWeight === undefined) { + // Container bookings may skip the weighbridge entirely (weighingSkipped); + // bulk always weighs. + const weighingSkipped = dto.weighingSkipped === true; + if (dto.tareWeight === undefined && !weighingSkipped) { throw new BadRequestException('Tare weight is required for truck arrival'); } - const tareWeight = Number(dto.tareWeight); + const tareWeight = dto.tareWeight === undefined ? null : Number(dto.tareWeight); const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight); const computedNetWeight = - grossWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3)); + grossWeight == null || tareWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3)); const submittedNetWeight = dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight); @@ -4402,7 +4498,11 @@ export class WarehouseInventoryService { throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.'); } } - if ((dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && grossWeight == null) { + if ( + !weighingSkipped && + (dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && + grossWeight == null + ) { throw new BadRequestException('Gross weight is required for truck exit'); } @@ -4418,7 +4518,8 @@ export class WarehouseInventoryService { dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null, dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null, dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null, - `Tare Weight: ${tareWeight} t`, + weighingSkipped ? 'Weighing: SKIPPED' : null, + tareWeight == null ? null : `Tare Weight: ${tareWeight} t`, grossWeight == null ? null : `Gross Weight: ${grossWeight} t`, computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`, dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null, @@ -4442,6 +4543,8 @@ export class WarehouseInventoryService { containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber, gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime, tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight, + // The weigh/skip decision is made at arrival and sticks for the exit. + weighingSkipped: dto.weighingSkipped || /^Weighing:\s*SKIPPED/im.test(inspection) || undefined, }; } diff --git a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts index 9aa9e169c..a8abee67b 100644 --- a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts +++ b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts @@ -227,7 +227,6 @@ async function ensureReferences(manager: any) { name: 'Gate Pass Demo Flat Wagon', capacityTons: 70, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['CONTAINER'], isActive: true, equatedLengthM: 14, @@ -420,8 +419,6 @@ async function ensureWagon(manager: any, scenario: ScenarioTrain, sequenceNo: nu wagonTypeId, trainId: null, sequenceNumber: sequenceNo, - tareWeight: 20, - maxPayloadWeight: 70, status: WagonStatus.Assigned, currentYardId: yardId, currentTrainScheduleId: scheduleId, diff --git a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts index 3ebcca6ab..4f801330a 100644 --- a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -102,7 +102,6 @@ async function main() { name: 'Negad Demo Flat Wagon', capacityTons: 70, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ['CONTAINER'], isActive: true, equatedLengthM: 14, @@ -307,8 +306,6 @@ async function main() { wagonTypeId: wagonType.id, trainId: null, sequenceNumber: sequenceNo, - tareWeight: 20, - maxPayloadWeight: 70, status: WagonStatus.Assigned, currentYardId: indode.id, notes: 'Demo wagon for Negad to Indode marshalling', diff --git a/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts b/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts index c5a49e629..ebee2a547 100644 --- a/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { Booking } from '../modules/bookings/entities/booking.entity'; +import { CompanyProfile } from '../modules/companies/entities/company-profile.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; @@ -91,12 +92,26 @@ export class Batch5TestDataSeeder { return; } + // bookings.company_id AND bookings.company_profile_id are both NOT NULL, so a + // seed booking needs an owning company profile. Resolve the profile and take + // its company from it, so the two columns can never disagree. Without this the + // seeder aborted on its first insert. + const companyProfile = await this.dataSource + .getRepository(CompanyProfile) + .findOne({ where: {} }); + if (!companyProfile) { + this.logger.warn('No company profile found; skipping Batch 5 seed'); + return; + } + const now = new Date(); for (const seed of SEEDS) { const booking = await bookingRepo.save( bookingRepo.create({ reference: seed.ref, + companyId: companyProfile.companyId, + companyProfileId: companyProfile.id, originYardId: originYard.id, destinationYardId: destYard.id, serviceTypeId: serviceType.id, diff --git a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts new file mode 100644 index 000000000..6fba64010 --- /dev/null +++ b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts @@ -0,0 +1,873 @@ +import type { + ContractTemplateArticle, + ContractTemplateCode, +} from "../../modules/contract-templates/entities/contract-template.entity"; + +/** + * Default article packs for the six contract templates, transcribed from the + * signed EDR contract documents (test/contrat_docs). Article bodies use the + * dynamic-article text format: one clause per line, "- " prefix for bullets + * nested under the previous clause, single-line body = plain paragraph. + * Handlebars placeholders ({{client.companyName}}, {{contractDate}}, + * {{contractYear}}, {{reference}}) interpolate at render time. + */ +export interface ContractTemplateSeed { + code: ContractTemplateCode; + name: string; + description: string; + documentTitle: string; + whereasClauses: string[]; + articles: Array>; +} + +const a = (id: string, title: string, body: string): Omit => ({ + id, + title, + body: body.trim(), +}); + +/* ────────────────────────────── IMPORT / BULK ────────────────────────────── */ + +const IMPORT_BULK: ContractTemplateSeed = { + code: "IMPORT_BULK", + name: "Bulk Import Contract", + description: + "Import of bulk cargo (e.g. steel billets) from Djibouti (DMP/Nagad) to Galaan Multipurpose Port with customs clearance and optional last-mile delivery.", + documentTitle: "Bulk Cargo Transportation and Customs Clearance Services", + whereasClauses: [ + "The Client has agreed to engage the Service Provider for transportation and customs clearance services for bulk cargo, including first-mile transport to the railway station at Djibouti, loading at either DMP or Nagad Railway Station (Djibouti), port/rail terminal handling, loading onto the train, railway transport to Galaan Multipurpose Port (GMP) in Ethiopia, unloading at the destination port from train to load directly on truck, onward transportation to the Client's site (excluding truck loading at Djibouti and truck unloading at the Client destination where last-mile service is undertaken by the Service Provider), and all related documentation.", + "The Service Provider has agreed to provide the requested services in accordance with the terms and conditions of this Agreement.", + ], + articles: [ + a( + "objective", + "Objective of the Services", + `The objective of this contract is to provide the Client with integrated logistics services for the transportation of bulk cargo, including: +- First-mile transportation in Djibouti from the Client's designated cargo location to the selected railway station (DMP or Nagad). +- Port handling and loading onto railway wagons. +- Railway transport from DMP and/or Nagad Railway freight station (Djibouti) to Galaan Multipurpose Port. +- Customs clearance in Djibouti and Ethiopia. +- Unloading from train at the destination port to load directly on truck. +- Last-mile delivery by truck to the Client's delivery site where the last-mile service is undertaken by the Service Provider. +The truck loading at Djibouti and the truck unloading at the Client's delivery site shall be the responsibility of the Client.`, + ), + a( + "client-obligations", + "Obligations of the Client", + `Provide written/email/electronic instructions specifying the cargo volume and the selected loading station (DMP or Nagad) for each shipment. +Prepare and submit all necessary documents and permits to enable smooth service execution. +Ensure cargo readiness in compliance with specifications (including weight, size, and contour restrictions). +Handle truck loading at Djibouti Free Zone/Old Port/DMP and any other designated cargo location at Djibouti, and truck unloading at the delivery site. +Ensure safety and proper securing of cargo during truck handling. +Submit all required documents necessary for customs clearance and cargo release within one (1) calendar day from the date of request or notification by the Service Provider. +Upon receipt of the wagon allocation list and train schedule from the Service Provider, ensure that the cargo is transferred to the designated loading freight station and made ready for loading within two (2) days prior to wagon arrival. Any delay beyond this period resulting from Client-related issues shall be subject to a charge of USD 56 per wagon per day, or part thereof, until the cargo is made available for loading. +Upon arrival of the train at Galaan Multipurpose Port (GMP), offload cargo from wagons within twenty-four (24) hours of train arrival. Where the Client undertakes last-mile transportation, the Client may arrange sufficient trucks at the time of train arrival to enable direct loading of cargo from wagons to trucks. +In the event the Client is unable to provide trucks for the collection of cargo within twenty-four (24) hours of train arrival, the Service Provider shall have the right to handle and reposition the cargo to any location it deems appropriate, and shall not be held responsible for any loss, shortage, or damage arising from such repositioning. +Any additional handling, re-handling, or repeated loading operations performed by the Service Provider shall be charged as double handling fees at a rate of USD 4 per ton, payable by the Client. +If stored, the full cargo must be collected from the Galaan Multipurpose Port compound within three (3) days from the time of train arrival at the port. +If the Client fails to collect the cargo within the specified period, the Client shall be liable to pay demurrage charges of USD 2 per day per ton, calculated based on the Commercial Bank of Ethiopia's daily selling exchange rate on the date of payment. +Where the last-mile service is provided by the Service Provider, unload the cargo from the truck at the delivery site within the agreed time frame. +Designate authorized representatives (with valid power of attorney) for handover at origin and destination. +Settle demurrage payments within ten (10) calendar days from the date the Service Provider issues a claim. +Pay the Service Provider one hundred percent (100%) of the contract price in advance for each train set in accordance with the pricing article of this Agreement. +Contact the Service Provider to obtain confirmation prior to booking and proceeding with payment.`, + ), + a( + "provider-obligations", + "Obligations of the Service Provider", + `Provide first-mile transportation in Djibouti from the Client's designated location to the selected railway station (DMP or Nagad). +Carry out port handling and loading onto railway wagons. +Provide railway transportation from DMP/Nagad (Djibouti) railway freight station to Galaan Multipurpose Port. +Perform unloading at Galaan Multipurpose Port (GMP) to load directly on truck. +Perform customs clearance in Djibouti and Ethiopia, including border station procedures. +Prepare and submit all required transport documentation. +Provide cargo insurance coverage for each supplied wagon. +Notify the Client of train schedules, wagon numbers, and expected arrival times in advance.`, + ), + a( + "force-majeure", + "Force Majeure", + `Neither party shall be liable for delays or non-performance caused by force majeure events beyond their reasonable control. +Force majeure shall be interpreted in accordance with the Ethiopian Civil Code.`, + ), + a( + "liability", + "Liabilities Related to Damages and Losses", + `The Service Provider shall be fully responsible for any loss, shortage, or damage to cargo that occurs after it has been taken over until delivery to the Client's delivery site. +Compensation shall be based on the market value of the cargo, in accordance with applicable laws.`, + ), + a( + "pricing", + "Contract Price and Payment Terms", + `Rail transport to Galaan Multipurpose Port: USD 59.4 per metric ton. +Djibouti handling (first-mile, port handling and loading, and documentation): USD 18 (eighteen) per metric ton for cargo from the Free Zone; USD 20 (twenty) per metric ton for cargo from the Old Port or DMP. +Lashing materials shall be charged at USD 150 (one hundred fifty) per wagon and wood at USD 50 (fifty) per wagon when provided by the Service Provider; the provision continues until the cargo reaches and is fully unloaded at the designated destination station. +Each wagon shall be loaded up to a maximum of seventy (70) metric tons; for billing purposes one full wagon shall be deemed equivalent to this volume. +The price for last-mile delivery shall be determined once the cargo departs from the loading point and shall be communicated to the Client by official email upon the Client's request. +Payments shall be made 100% in advance in USD.`, + ), + a( + "contract-documents", + "Contract Documents", + `The following documents form part of this contract: +- This Contract Agreement. +- Any amendments made to this Agreement. +- Minutes of negotiation (if any).`, + ), + a( + "documentation", + "Documentation Requirements", + `The Service Provider shall deliver the following to the Client: +- Freight Carriage Acceptance Sheet of the Addis Ababa–Djibouti Railway. +- Notice of transportation and miscellaneous charges. +- Summary of payment request as per the agreed tariff, if required.`, + ), + a( + "consignment-notes", + "Consignment Notes", + `The Service Provider shall certify the taking over of goods in the Freight Carriage Acceptance Sheet. +This document shall serve as prima facie evidence of receipt of the cargo. +Upon delivery of the cargo to the Client, the Cargo Handover Out Voucher signed by both parties shall serve as evidence of cargo receipt.`, + ), + a( + "termination", + "Termination of Contract", + `This contract may be terminated: +- By mutual consent. +- Upon completion of the agreed contract period or cargo volume. +- For breach of fundamental provisions, with one-week prior written notice.`, + ), + a( + "effectiveness", + "Contract Effectiveness", + `This Agreement becomes effective on the date it is signed by both parties.`, + ), + a( + "duration", + "Duration", + `The contract is valid until August 31, {{contractYear}} from the date of effectiveness, extendable by mutual agreement.`, + ), + a( + "disputes", + "Settlement of Disputes", + `Disputes shall first be settled amicably. +If unresolved, disputes shall be referred to the competent Federal Court of Ethiopia in Addis Ababa. +The governing law shall be the laws of the Federal Democratic Republic of Ethiopia.`, + ), + ], +}; + +/* ────────────────────────────── EXPORT / BULK ────────────────────────────── */ + +const EXPORT_BULK: ContractTemplateSeed = { + code: "EXPORT_BULK", + name: "Bulk Export Contract", + description: + "Export of bulk cargo (e.g. livestock) by railway from Ethiopian loading stations to Nagad railway freight yard, Djibouti.", + documentTitle: "Bulk Cargo Transportation Service by Railway", + whereasClauses: [ + "The Client has agreed to deliver bulk cargo to the Service Provider for transport from the agreed Ethiopian loading station to Nagad railway freight yard using the Addis Ababa–Djibouti railway line.", + "The Service Provider has agreed to provide the service to transport the bulk cargo from the agreed loading station to Nagad railway freight yard.", + ], + articles: [ + a( + "objective", + "Objective of the Service", + `To undertake the railway transportation of bulk cargo from the agreed Ethiopian loading station to Nagad railway freight yard.`, + ), + a( + "client-obligations", + "Obligations of the Client", + `Give written instruction to the Service Provider to transport a minimum of one wagon of cargo; the wagon request shall be made at least five (5) days in advance for each wagon. +Maintain detailed information incorporating type, weight, and destination of the cargo ready for shipment, and notify the Service Provider or its nominated agent by notice, email, or fax. +Prepare the necessary documents and facilities to make the cargo ready for transport. +Note the allowable transport period of the cargo: the maximum time range during which the goods maintain their condition without any problem. The allowable transport period must be at least two (2) days longer than the delivery period. +Ensure cargo is properly loaded and fastened in the wagons, provide the necessary lashing and barriers for loading as per the instruction of the departure station, and bear responsibility for the condition of the cargo during the transport period. +Supply the necessary provisions for the cargo for each wagon and assign a responsible person to travel with the train to check the status of the cargo during transport, where the nature of the cargo so requires. +Supply the minimum amount of cargo available for at least one wagon. +Execute loading, lashing, and preparing barriers on wagons at the loading station and provide the complete documents/bill to the Service Provider within one (1) calendar day. +For each extra calendar day used for loading cargo and completing documents at the loading station, pay the wagon-occupied fee per the pricing article; the fee shall be paid within ten (10) calendar days from the date the Service Provider claims it, failing which compensation is payable calculated on the basis of the Commercial Bank of Ethiopia interest rate for the delay period. +Be responsible for safety matters, and indemnify and hold the Service Provider harmless against all consequences resulting from accidents arising from or associated with the loading and unloading process. +Execute and cover the cost of loading and unloading of cargo at both the loading station and Nagad railway freight yard. +Follow up that the cargo is loaded and unloaded on time. +Delegate representatives at both ends to consign and receive cargo with signature and stamp. Representatives shall hold a duly signed and stamped power of attorney and shall produce their ID or passport when consigning or receiving the cargo. +Prepare the necessary facilities to take over the transported cargo at Nagad freight yard upon arrival by issuing handover documents. +Take the transported cargo out of the wagons at Nagad freight yard within one (1) calendar day starting from the day following the notice of arrival. +Pay the wagon-occupied fee per the pricing article for delays of more than one (1) calendar day at Nagad railway freight yard due to the fault of the Client in resolving customs or third-party claims or any other causes. +After the wagon list is submitted to the Client, if a wagon is not loaded due to the fault of the Client, pay 100% of the transportation price per wagon for each unloaded wagon. +Pay the Service Provider 100% of the contract price in advance for each wagon.`, + ), + a( + "provider-obligations", + "Obligations of the Service Provider", + `Provide the list and identification numbers of wagons with sequence and locomotives at least twenty-four (24) hours in advance to the Client, with any correction at least twelve (12) hours before arrival at destination. +Transport the cargo from the loading station to Nagad railway freight yard. +Provide safe transportation of the cargo throughout the transit. +Present customs clearance documents and mobilize the rolling stock as needed. +Provide the wagons assigned for the freight at the agreed place and time, and follow up that the cargo is loaded on time. +Transport and deliver the cargo taken over, in the condition received, within two (2) calendar days to Nagad railway freight yard. +Where a wagon carrying cargo stops due to accident or mechanical problem, promptly notify the nearby customs station, police office, and the Client. A wagon stopped in Ethiopia due to mechanical defect shall be maintained within four (4) calendar days; within Nagad (Djibouti) territory within twelve (12) calendar days. In case of accident where the problem cannot be solved within one (1) calendar day and the wagon is not operational, the Service Provider shall have the cargo carried and delivered by another wagon, and shall provide an accident or defect report issued by the local police office regarding the sustained damage. +Buy a cargo liability insurance policy for each supplied wagon. +Provide wagon cleaning service and charge the cleaning fee based on actual expenditure. +If the Client fails or refuses to receive the cargo beyond the allowable transport period, the Service Provider has the right to handle the cargo. +Neither party shall be liable for any indirect or consequential loss sustained by the other in connection with this Agreement.`, + ), + a( + "force-majeure", + "Force Majeure", + `The parties have no obligation to pay demurrage or any other compensation if they have failed to discharge their obligations due to force majeure. +Force majeure shall be deemed to exist when the contract is not performed due to any event beyond the reasonable control of a party which prevents that party from complying with its obligations under this Agreement, including but not limited to: +- Acts of God (such as, but not limited to, fires, explosions, earthquakes, drought, tidal waves, and floods). +- War, hostilities (whether war is declared or not), invasion, acts of foreign enemies, mobilization, requisition, or embargo. +- Rebellion, revolution, insurrection, military or usurped power, or civil war. +- Contamination by radioactivity from any nuclear fuel or nuclear waste. +- Riot, commotion, strikes, go-slows, lockouts, or disorder. +- Acts of terrorism. +A party wishing to claim protection in respect of a force majeure event shall, as soon as possible following the occurrence or commencement of the event, notify the other party of its nature and expected duration, and shall thereafter keep the other party informed until it is able to perform its obligations under this Agreement.`, + ), + a( + "pricing", + "Contract Price and Terms of Payment", + `The price of bulk cargo transportation from the loading station to Nagad shall be USD 696 (six hundred ninety-six) per wagon. +Payment for transport services shall be made in Birr based on the selling price of USD to Birr on the date of payment set by the Commercial Bank of Ethiopia. +If there is an increment or decrement of the USD exchange rate to Birr between the date of payment and the date the wagon/train number is provided to the Client, either the Client shall make the additional payment to the Service Provider or the Service Provider shall refund the difference from the initial payment to the Client. +The cost of loading at the loading station and unloading at Nagad shall be covered by the Client and is not part of this contract agreement. +The Client shall pay 100% of the contract price in advance. +The Client shall pay a demurrage fee for occupied wagons as follows: +- Wagons occupied between 1 and 3 days: USD 193 per wagon per day. +- Wagons occupied between 4 and 7 days: USD 290 per wagon per day. +- Wagons occupied 8 days and above: USD 590 per wagon per day. +Demurrage payment shall be made in Birr based on the selling price of USD to Birr set by the Commercial Bank of Ethiopia on the date of the demurrage occurrence.`, + ), + a( + "contract-documents", + "Contract Documents", + `The following documents shall constitute the contract between the Client and the Service Provider: +- Amendments made to this contract (if any). +- This Contract Agreement. +- Final minutes of negotiation (if any). +If there is any discrepancy between the documents, they shall be interpreted with priority in the order listed above.`, + ), + a( + "documentation", + "Documentation Requirements", + `The following documents shall be delivered to the Client upon request for settlement: +- Consignment Note (cargo handover document to the Client). +- Summary of payment request of the Service Provider prepared as per the agreed tariff.`, + ), + a( + "consignment-notes", + "Consignment Notes", + `The Service Provider must certify the taking over of the goods on the duplicates of the consignment note in an appropriate manner and return the duplicate to the Client. +A consignment note shall be prima facie evidence of the receipt of the goods by the Service Provider and of the kind, number, and weight of the goods.`, + ), + a( + "termination", + "Termination of Contract", + `The contract may be terminated: +- Upon mutual consent of the parties. +- Upon completion of the contract period. +- If either or both parties breach a fundamental provision of the contract, upon prior legal notice delivered by either party.`, + ), + a( + "effectiveness", + "Contract Effectiveness", + `The contract shall come into full force and effect on the date when all of the following are accomplished: +- The contract is signed by the Client and the Service Provider. +- The Service Provider has received the advance payment of 100% of the contract price for each train set of cargo.`, + ), + a( + "cargo-amount", + "Cargo Amount", + `The minimum cargo to be transported shall be one wagon.`, + ), + a( + "duration", + "Duration of Contract", + `The contract shall last for three (3) months starting from the date of contract signing, with possible extension upon mutual agreement.`, + ), + a( + "disputes", + "Settlement of Disputes", + `If a dispute arises between the parties, they shall exert efforts to settle their differences amicably. +If the parties fail to settle their disputes amicably, the case shall be taken to the competent Federal Court of law presiding in Addis Ababa. +The governing law shall be the laws of the Federal Democratic Republic of Ethiopia.`, + ), + ], +}; + +/* ──────────────────────────── INTERCITY / BULK ───────────────────────────── */ + +const INTERCITY_BULK: ContractTemplateSeed = { + code: "INTERCITY_BULK", + name: "Bulk Intercity Contract", + description: + "Domestic (intercity) bulk cargo transportation by railway between Ethiopian freight yards, e.g. Dire Dawa to Sebeta.", + documentTitle: "Bulk Cargo Transportation Service by Railway (Intercity)", + whereasClauses: [ + "The Client has requested the Service Provider to transport bulk cargo between the agreed Ethiopian railway freight yards using the Ethio–Djibouti Railway.", + "The Service Provider has accepted the Client's request to render the said transportation service.", + ], + articles: [ + a( + "objective", + "Objective of the Contract", + `The Service Provider shall undertake the railway transportation of bulk cargo from the agreed origin railway freight yard to the agreed destination railway freight yard.`, + ), + a( + "client-obligations", + "Obligations of the Client", + `Provide written instructions to the Service Provider to transport a minimum of sixteen (16) wagons of cargo per consignment. +Prepare all necessary documents, including laboratory tests from the pertinent organ and off-taking contract where applicable, and the facilities required to sign the contract and make the cargo ready for transport. +Assign representatives at the origin yard and other stations, as required, to hand over the cargo to the Service Provider and handle transit clearance if required. +Transport the cargo to the designated loading points at the origin yard. +Be responsible for cargo handling: loading at the origin yard and unloading at the destination yard, in accordance with the standards set by the EDR operations and technical terms. +Make advance payment to the Service Provider for services in accordance with the payment terms and conditions of this contract. +Follow up to ensure that the cargo is loaded and unloaded on time. +Delegate representatives at the cargo destination to immediately receive the transported cargo. +Ensure representatives are duly authorized with a power of attorney, signed and stamped by the Client, and present valid identification (ID or passport) when consigning or receiving cargo. +Maintain detailed information including item, weight, and destination of the cargo, and communicate the same to the Service Provider or its nominated agent via written notice, email, or fax. +Prepare the necessary facilities to immediately take over the transported cargo at the destination upon arrival and provide sufficient trucks at the destination freight yard for unloading from railway wagons. +Upon arrival of the train/wagon at the unloading site, sign the train arrival confirmation sheet to acknowledge the arrival time. +Inspect the loaded wagons jointly with the Service Provider and EDR at the loading yard, and again with the customs agent (if required) and the Service Provider at the destination yard. +After receiving the cargo, sign the Freight Carriage Acceptance Sheet (copies II, III, and IV) immediately to confirm delivery. +Compensate the Service Provider or any third party for actual loss or damage caused to persons, property, or wagons during unloading where such damage is attributable to the Client's fault. +Each consignment (train) shall be granted three (3) hours of free time at the loading station and one (1) day at the unloading station. For each additional 3 hours of loading or parking the Client shall pay ETB 5,000 (five thousand) per wagon, and for each additional day of unloading ETB 5,000 (five thousand) per wagon per day. +Bear demurrage charges of ETB 5,000 (five thousand) per wagon per 3 hours for delays exceeding three (3) hours at any station resulting from the Client's failure to resolve customs or third-party claims. +Pay 100% of the transport price in advance. Any additional charges or fees shall be paid within ten (10) calendar days after submission of the Service Provider's payment request.`, + ), + a( + "provider-obligations", + "Obligations of the Service Provider", + `Provide the necessary train(s) to execute the transportation service under this contract, and furnish the Client with the list and identification numbers of wagons and locomotives at least 24 hours in advance, with corrections (if any) communicated at least 12 hours before the expected time of arrival at destination. +Provide pre-arrival notification including the train number to the discharging terminal and customs at least 24/12 hours before train arrival. +Transport the cargo from origin to destination within two (2) days from completion of loading (time counting starts upon completion of documentation and loading). +Provide safe transportation of the cargo throughout transit. +Deliver the cargo to the Client at the destination railway freight yard in the same condition as received. +Purchase a cargo liability insurance policy for each wagon transported.`, + ), + a( + "force-majeure", + "Force Majeure", + `Neither party shall be liable due to a force majeure event. +For the purposes of this contract, force majeure shall mean any unforeseeable event or circumstance beyond the reasonable control of the affected party which absolutely prevents the performance of the contract, including but not limited to natural disasters, war, civil commotion, strikes, government actions, epidemics, or interruption of railway operations due to accidents or infrastructure failure. +The affected party shall notify the other party in writing within a reasonable period not exceeding two (2) hours after the occurrence of the force majeure event, providing evidence and details of the impact on performance and the mitigating steps taken.`, + ), + a( + "liability", + "Liabilities Related to Damages and Losses", + `The Service Provider will be responsible for any loss, shortage, or damage occurring to the cargo it has received.`, + ), + a( + "pricing", + "Contract Price", + `The price for transporting cargo from the origin freight yard to the destination freight yard shall be USD 400 (four hundred) per wagon. +Each wagon shall be loaded with a maximum of 70 (seventy) metric tons. +Payment for transport services may be made in Ethiopian Birr, based on the Commercial Bank of Ethiopia's official selling exchange rate of USD to Birr on the date of payment. +If the exchange rate changes between the payment and the wagon assignment date, payment adjustments will be made accordingly. +The contract price shall include the cost of railway transportation from the origin freight yard to the destination freight yard. +Excluded cost: cargo handling (loading and unloading) is not included in the contract price and shall remain the sole responsibility of the Client.`, + ), + a( + "contract-documents", + "Contract Documents", + `The following documents shall constitute the contract between the Client and the Service Provider: +- Amendments made to this contract (if any). +- This Contract Agreement. +- Final minutes of negotiation (if any). +If there is any discrepancy between the documents, they shall be interpreted with priority in the order listed above.`, + ), + a( + "documentation", + "Documentation Requirements", + `The following documents shall be delivered to the Client by the Service Provider to collect and settle payment: +- Freight Carriage Acceptance Sheet of the Ethio-Djibouti Railway. +- Notice of collecting transportation and miscellaneous charges of the Ethio-Djibouti Railway (if any). +- Summary of payment request of the Service Provider prepared as per the agreed tariff. +- Railway Waybill.`, + ), + a( + "consignment-notes", + "Consignment Notes", + `The Service Provider must certify the taking over of the goods on copy III (kept by the consignee for future reference) of the Freight Carriage Acceptance Sheet of the Ethio-Djibouti Railway in an appropriate manner and provide it to the Client. +The Freight Carriage Acceptance Sheet shall be prima facie evidence of the receipt of the goods by the Service Provider and of the kind, number, and weight of the goods.`, + ), + a( + "termination", + "Termination of Contract", + `The contract may be terminated: +- Upon mutual consent of the parties. +- Upon completion of the contract period or amount of cargo, whichever comes first. +- If either or both parties breach a fundamental provision of the contract, upon one-week prior legal notice delivered by either party.`, + ), + a( + "duration", + "Duration of Contract", + `The contract duration shall be three (3) months from the date of effectiveness of the contract, with possible extension upon mutual agreement of the parties.`, + ), + a( + "disputes", + "Settlement of Disputes", + `If a dispute arises between the parties, they shall exert efforts to settle their differences amicably. +If the parties fail to settle their dispute amicably, the case shall be taken to the competent Federal Court of law presiding in Addis Ababa. +The governing law shall be the laws of the Federal Democratic Republic of Ethiopia.`, + ), + a( + "effectiveness", + "Contract Effectiveness", + `The contract shall come into full force and effect on the date when the contract is signed by the parties and witnesses.`, + ), + ], +}; + +/* ──────────────────────────── IMPORT / CONTAINER ─────────────────────────── */ + +const IMPORT_CONTAINER: ContractTemplateSeed = { + code: "IMPORT_CONTAINER", + name: "Container Import Contract", + description: + "Import container transport by railway from SGTD (Djibouti) to Dire Dawa, Modjo dry port, or Galaan Multipurpose Port, with empty-container return.", + documentTitle: "Import Container Transport Service by Railway", + whereasClauses: [ + "The Client has requested and agreed to the transportation of container cargo from SGTD railway freight station at Djibouti to Dire Dawa, Modjo dry port, and Galaan Multipurpose Port (GMP), and the return of empty containers from Dire Dawa, Modjo dry port, and Galaan Multipurpose Port (GMP) to SGTD railway freight station using the Addis Ababa–Djibouti railway line.", + "The Service Provider has agreed to transport the container cargo as per the terms of this contract.", + ], + articles: [ + a( + "objective", + "Objective and Scope of the Services", + `To provide railway transportation services for 40ft and/or 20ft full containers from SGTD to Dire Dawa, Modjo dry port, and/or Galaan Multipurpose Port (GMP), and empty container return from Dire Dawa, Modjo dry port, and Galaan Multipurpose Port (GMP) to SGTD. +The scope of the services comprises: +- Railway transport service. +- Cargo handling at Galaan Multipurpose Port (GMP).`, + ), + a( + "client-obligations", + "Obligations of the Client", + `Give written/email/electronic shipment instructions to the Service Provider for transportation of container cargo from SGTD to Dire Dawa, Modjo dry port, and/or Galaan Multipurpose Port (GMP). +Prepare all necessary documents and facilities for shipment. +Ensure the following minimum supply of containers per shipment based on the loading terminal and destination: +- Minimum of twenty-five (25) 40ft containers or fifty (50) 20ft containers to Modjo dry port. +- Minimum of ten (10) 40ft containers or twenty (20) 20ft containers to Dire Dawa dry port. +- Minimum of one (1) 40ft container or two (2) TEU to Galaan Multipurpose Port (GMP). +One flat wagon must carry either one 40ft container or two 20ft containers. +If two 20ft containers are loaded on one flat wagon, their weight difference must not exceed 10 tons. +Ensure timely loading and unloading of cargo. +Assign representatives at both ends to oversee container handover and ensure the necessary arrangements for cargo reception at the destination upon arrival. +Maintain and provide detailed cargo information (type, weight, destination, etc.). +Be responsible for cargo handling, loading, and unloading of both empty and full containers at Modjo, Dire Dawa dry port, and SGTD. +Book wagons at least five (5) days in advance. +Ensure containers are ready one day before the planned loading date. +Submit all required documents to the Djibouti Nagad station at least 24 hours in advance before starting to load. Failure to submit the documents within the stipulated time shall result in the following demurrage charges, calculated as a percentage of the booked wagon price: +- Delay of up to twelve (12) hours: 20% of the booked wagon price. +- Delay exceeding twelve (12) hours but not more than one (1) day: 50% of the booked wagon price. +- Delay of more than one (1) day: 100% of the booked wagon price. +Collect the full container from Galaan Multipurpose Port (GMP) within three (3) calendar days from the day following the arrival notice. +If the Client fails to collect the container within the specified period, the Service Provider shall have the right to reposition the container to any location it deems appropriate; in such case, the Service Provider shall not be held responsible for any damage or loss arising from such repositioning. +If the Client fails to collect the container from Galaan Multipurpose Port within the specified period, the Client shall be liable to pay demurrage charges of 15 USD per day per 20ft container and 27 USD per day per 40ft container, calculated based on the Commercial Bank of Ethiopia's daily selling exchange rate on the date of payment. +In the event the Client fails to collect the container(s) within the specified period, the Client shall be liable to pay double handling charges of 27 USD per 20ft container per handling or 40 USD per 40ft container per handling, calculated based on the Commercial Bank of Ethiopia's daily selling exchange rate on the date of payment. +If empty containers cannot be offloaded from the train upon arrival at SGTD due to any Client-related issue, the Client shall be liable for the applicable penalty charges. +Penalty charges for delay at SGTD/Nagad upon train arrival: 20 USD per day per 20ft container; 33 USD per day per 40ft container. +Collect the containers within one day at Dire Dawa and Modjo dry port, or make the necessary payment to the dry port as per the standard of the dry port. +For returning empty containers, deliver to Dire Dawa dry port, Modjo dry port, or Galaan Multipurpose Port. +Once the empty containers are returned from the Client's premises and stored at Dire Dawa/Modjo dry port while awaiting train allocation for return to SGTD, any demurrage and/or storage charges incurred from the dry port thereafter shall not be the responsibility or liability of the Service Provider; the Client shall be solely responsible for settling such charges. +Provide clean empty containers that meet SGTD standards. If the port refuses to take over an empty container because of inside cleanliness problems, additional cleaning costs incurred due to non-compliance will be borne by the Client. +Ensure containers are structurally intact and meet weight distribution requirements. +Prohibited cargo: cargo covered with tarpaulin is not allowed due to safety risks. +Notify the Service Provider forty-eight (48) hours in advance before wagon booking if transporting hazardous or valuable goods. +If a booked wagon is not loaded due to Client-related issues, including but not limited to a damaged container, missing lock, unpaid demurrage, port system errors, or incomplete documentation and submission, the Client shall be charged 100% of the total price of the reserved wagon. +Refund requests before wagon booking require an official request, with a 3% administrative fee deducted. If the refund is due to the Service Provider, the Client shall receive a full refund. Refunds not requested and completed within six months shall be considered waived. +Pay 100% of the transportation fee in advance for each train set. +Settle additional penalties due to non-compliance within ten (10) days of invoice issuance. +Late payment incurs a penalty of an additional 10%.`, + ), + a( + "provider-obligations", + "Obligations of the Service Provider", + `Assign the necessary voyage based on the operational schedule and cargo demand, and notify the train schedule 48 hours in advance. +Provide a list of wagons/voyage or train number 24 hours in advance and update corrections 12 hours before arrival. +Provide safe transportation of the containers. +Deliver the cargo within two (2) days after train departure, provided that all required documents are submitted on time and no unforeseen circumstances or events occur. +Return empty containers from Dire Dawa, Modjo, and Galaan Multipurpose Port to SGTD within seven (7) calendar days of receipt. +In the event of export cargo operations, the Service Provider may prioritize the loading of export containers during the loading of empty containers and the unloading of import containers from the train at Galaan Multipurpose Port. +The Service Provider reserves the right to refuse the transportation of any cargo classified as dangerous goods. +If any operational, technical, or mechanical problem occurs throughout the transit, notify customs and arrange cargo transfer within 4 days if the incident occurs in Ethiopia, or within 6 days if it occurs in Djibouti. +Provide accident or defect reports if needed. +Buy cargo liability insurance for each wagon.`, + ), + a( + "force-majeure", + "Force Majeure", + `Neither party shall be liable for delays or non-performance caused by force majeure events beyond their reasonable control. +Force majeure shall be interpreted in accordance with the Ethiopian Civil Code.`, + ), + a( + "pricing", + "Contract Price and Terms of Payment", + `From SGTD to Dire Dawa dry port, the rate is USD 919 per one 40ft or USD 942 per two 20ft containers with empty return; USD 762 per one 40ft or USD 780 per two 20ft containers without empty return. +From SGTD to Modjo, the rate is USD 1,781 per one 40ft or USD 1,808 per two 20ft containers with empty return, and USD 1,507 per one 40ft or two 20ft containers without empty return. +From SGTD to Galaan Multipurpose Port, the rate is USD 1,916 per one 40ft or USD 1,944 per two 20ft containers with empty return, and USD 1,676 per one 40ft or USD 1,690 per two 20ft containers without empty return. +If cargo exceeds 40 tons per two 20ft containers of gross weight, additional charges apply proportionally. +Gross weight shall be the total sum of cargo, packing, and container tare weight. +Payment for any additional tonnage shall be made in advance before the container is loaded onto the wagon. +The price of loading and unloading and container handling at Modjo, Dire Dawa dry port, and SGTD container railway freight yard is not part of this contract; it is the Client's responsibility. +Additional costs (if applicable): +- Last-mile delivery service by truck from Galaan Multipurpose Port or Modjo to Addis Ababa or Modjo and surrounding areas shall incur an additional cost, fully covered by the Client. +- For clients utilizing EDR's last-mile logistics services, the applicable charges shall vary based on the cargo movement route. +- The charge for last-mile delivery from Galaan Multipurpose Port and Modjo dry port shall be communicated to the Client by the Service Provider after receiving the necessary details regarding the cargo destination, type, and weight. +All payments shall be made one hundred percent (100%) in advance in United States Dollars (USD).`, + ), + a( + "contract-documents", + "Contract Documents", + `The following documents constitute this contract: +- Amendments (if any). +- This Contract Agreement. +- Final minutes of negotiation (if any). +If there is any discrepancy between the documents, they shall be interpreted with priority in the order listed above.`, + ), + a( + "documentation", + "Documentation Requirements", + `Equipment Interchange Receipt of SGTD, Railway Waybill, Container Carriage Acceptance Sheet, and incidental charges (if any). +Payment summary as per the agreed contract price (if required).`, + ), + a( + "consignment-notes", + "Consignment Notes", + `The Service Provider must certify the taking over and handing over of the container cargo on the Freight Carriage Acceptance Sheet of EDR in an appropriate manner and provide it to the Client. +The Freight Carriage Acceptance Sheet shall be prima facie evidence of the receipt of the container cargo by the Service Provider and of the kind, number, and weight of the goods. +Upon delivery of the cargo to the Client, the Cargo Handover Out Voucher signed by both parties shall serve as evidence of cargo receipt.`, + ), + a( + "amendment", + "Amendment", + `This contract can be amended by mutual agreement. +Notwithstanding the above, the Service Provider may revise transport tariffs due to operational and regulatory changes by providing at least five (5) working days' prior written notice to the Client.`, + ), + a( + "termination", + "Termination of Contract", + `The contract may be terminated: +- By mutual agreement. +- Upon completion of the contract period or agreed cargo shipments. +- If either party breaches fundamental terms.`, + ), + a( + "effectiveness", + "Contract Effectiveness", + `The contract is valid once signed by both parties and witnesses.`, + ), + a( + "duration", + "Contract Period", + `Valid until August 31, {{contractYear}}, with a possible extension upon mutual agreement.`, + ), + a( + "disputes", + "Settlement of Disputes", + `Disputes shall be settled amicably. +If unresolved, disputes shall be taken to the Federal Court in Addis Ababa.`, + ), + ], +}; + +/* ──────────────────────────── EXPORT / CONTAINER ─────────────────────────── */ + +const EXPORT_CONTAINER: ContractTemplateSeed = { + code: "EXPORT_CONTAINER", + name: "Container Export Contract", + description: + "Export container transport, freight forwarding, and customs clearing from Galaan Multipurpose Port or Modjo dry port to SGTD container freight station (Djibouti).", + documentTitle: "Export Container Transport, Freight Forwarding and Customs Clearing Service", + whereasClauses: [ + "The parties have agreed on the following services: rail transport, customs clearance, transit work, freight forwarding, and handling of container cargo.", + ], + articles: [ + a( + "objective", + "Objective and Scope of the Services", + `Customs clearance (Ethiopia side): +- Processing of export declarations. +- Coordination with the Ethiopian Customs Authority for clearance. +- Ensuring compliance with all export regulations. +Rail transport: +- Transportation of containers from Galaan Multipurpose Port (GMP) or Modjo dry port to SGTD container freight station. +Djibouti transit and handling: +- Customs clearance in Djibouti. +- Coordination with Djibouti port and transit authorities. +- Freight forwarding and last-mile facilitation as required. +Excluded costs: +- Shore handling. +- Shifting of containers from SGTD to DMP or DMP to SGTD port.`, + ), + a( + "client-obligations", + "Obligations of the Client", + `Give written/email instructions to the Service Provider for transportation of containers from Galaan Multipurpose Port (GMP) and/or Modjo to Djibouti. +Supply a minimum of two (2) 20ft containers (or an equivalent load to fill one flat wagon). +Submit all forwarding service booking requests at least seventy-two (72) hours prior to the scheduled train departure and no later than 7 days before the vessel cut-off time, whichever is applicable. +For clients requiring first-mile service, submit a first-mile service request notice no less than seventy-two (72) hours in advance. +Complete and submit accurate export documents as per the request of the Service Provider; payment must be submitted at least 36 hours before train departure. +Deliver all cargo to the designated loading port or freight station at least three (3) hours prior to the scheduled train loading time. +Failure to meet the stated deadlines may result in cancellation of the booking and transfer arrangements; any resulting delays, penalties, or additional costs shall be the sole responsibility of the Client. +If the Client fails to deliver the container, fails to provide the requested documents for completing export documents as instructed above, or cancels after wagon reservation, the Client shall pay USD 150.00 per wagon as a penalty, after notification. +Containers must have four (4) undamaged corners. +One flat wagon must carry either one 40ft container or two 20ft containers. +If two 20ft containers are loaded on one flat wagon, their weight difference must not exceed 10 tons. +The gross weight of the container must not exceed the standard loading capacity indicated on the container; the Client is responsible for ensuring full compliance with the maximum allowable load. +Prohibited cargo: cargo covered with tarpaulin is not allowed due to safety risks. +If the cargo to be transported is dangerous and/or valuable goods, notify the Service Provider 48 (forty-eight) hours before the wagon booking for further discussion and decision. +Refund requests before wagon booking require an official request, with a 3% administrative fee deducted. If the refund is due to the Service Provider, the Client shall receive a full refund. Refunds not requested and completed within six months shall be considered waived. +Any delay caused by missing or incorrect documents shall be the Client's responsibility. +100% of the transportation and customs clearance fee must be paid in advance.`, + ), + a( + "provider-obligations", + "Obligations of the Service Provider", + `Complete gate pass processing and submit to Nagad Station for each shipment within eighteen (18) hours after train departure. +The Service Provider reserves the right to refuse the transportation of any cargo classified as dangerous goods. +Maintain cargo liability insurance for railway transport; additional insurance for port handling or last-mile transport shall be the Client's responsibility. +The Service Provider is not liable for customs penalties or demurrage due to delays beyond its control. +Notify the Client immediately, in writing, of any delays, port issues, or customs holds. +The Service Provider shall not be liable for: +- Inherent defects of the cargo. +- Improper packing or loading conducted by the Client. +- Customs-related delays. +- Delays caused by force majeure events.`, + ), + a( + "force-majeure", + "Force Majeure", + `Neither party shall be liable for delays or non-performance caused by force majeure events beyond their reasonable control. +Force majeure shall be interpreted in accordance with the Ethiopian Civil Code.`, + ), + a( + "pricing", + "Pricing and Payment Terms", + `Railway transportation charges from GMP to SGTD: USD 819 (eight hundred nineteen) per 40ft container; USD 834 (eight hundred thirty-four) per two (2) 20ft containers. +Railway transportation charges from Modjo to SGTD: USD 725 (seven hundred twenty-five) per 40ft container; USD 725 (seven hundred twenty-five) per two (2) 20ft containers. +Where the total cargo weight exceeds fifty (50) metric tons per two (2) 20ft containers, an additional charge of USD 10 (ten) shall apply for each excess metric ton. +Freight forwarding and customs clearance charges from GMP to SGTD: USD 540 (five hundred forty) per 40ft container; USD 349 (three hundred forty-nine) per 20ft container. +Freight forwarding and customs clearance charges from Modjo to SGTD: USD 569 (five hundred sixty-nine) per 40ft container; USD 389 (three hundred eighty-nine) per 20ft container. +For consolidated containers containing more than one (1) shipping document, the first document shall be included under the agreed contract rate; any additional document within the same container shall be subject to an extra charge of USD 50 per document. +Payment must be supported by an official receipt before cargo departs from Galaan Multipurpose Port/Modjo. +If the Client uses PIL Shipping Line, any local charge incurred will be covered by the Client as per the invoice issued by the shipping line. +If storage or demurrage occurs due to Client-related issues (delay in document submission, payment delay, or any other Client-related reason), the Client shall pay the corresponding charges; charges apply per day after the free storage period, based on the invoice and SGTD tariff. +During export season, EDR may provide seasonal export support through the facilitation of empty containers. +Additional costs (if applicable): +- First-mile delivery service by truck within Addis Ababa or Modjo and surrounding areas, originating from warehouses or any other places designated by the Client, shall incur an additional cost fully covered by the Client. +- For clients utilizing EDR's first- or last-mile logistics services, the applicable charges shall vary based on the cargo movement route. +- The charge for first-mile delivery to Galaan Multipurpose Port and Modjo dry port shall be communicated to the Client by the Service Provider after receiving the necessary details regarding the cargo origin, type, and weight. +- For any vessel outbound charges, IMO charges, or other fees not included in the port handling payment, the Service Provider shall request the Client to settle the required amount based on the official receipt issued by the port or the shipping line. +Payment terms: +- All charges, including rail transport and customs clearance charges, remain 100% payable in advance. +- Payments shall be calculated based on the Commercial Bank of Ethiopia's daily selling exchange rate on the date the wagon or train number is provided. +- If the exchange rate changes between the payment and the wagon assignment date, adjustments will be made accordingly. +- Any additional costs incurred due to customs issues or port delays shall be borne by the Client and paid based on actual costs, supported by official receipts, within 10 days. +- Late payment incurs a penalty of 10%.`, + ), + a( + "contract-documents", + "Contract Documents", + `The following documents shall constitute the contract between the Client and the Service Provider: +- Any amendments made to this contract (if applicable). +- This Contract Agreement. +- Final minutes of negotiation (if applicable). +In the event of any discrepancy between these documents, they shall be interpreted with priority in the order listed above.`, + ), + a( + "documentation", + "Documentation Requirements", + `Consignment Note (cargo handover document). +Payment summary prepared as per the agreed tariff, if required.`, + ), + a( + "consignment-notes", + "Consignment Notes", + `The Service Provider must certify the taking over and handing over of the container cargo on the Freight Carriage Acceptance Sheet of EDR in an appropriate manner and provide it to the Client. +The Freight Carriage Acceptance Sheet shall be prima facie evidence of the receipt of the container cargo by the Service Provider and of the kind, number, and weight of the goods. +Upon delivery of the cargo to the Client, the Cargo Handover Out Voucher signed by both parties shall serve as evidence of cargo receipt.`, + ), + a( + "amendment", + "Amendment", + `This contract can be amended by mutual agreement. +Notwithstanding the above, the Service Provider may revise transport tariffs due to operational and regulatory changes by providing at least five (5) working days' prior written notice to the Client.`, + ), + a( + "termination", + "Termination of Contract", + `The contract may be terminated: +- By mutual agreement. +- Upon completion of the contract period or agreed cargo shipments. +- If either party breaches fundamental terms. +If terminated for cause, the terminating party must issue a 15-day written notice specifying the breach and allow an opportunity to cure, if applicable.`, + ), + a( + "effectiveness", + "Contract Effectiveness", + `The contract is valid once signed by both parties and witnesses.`, + ), + a( + "duration", + "Contract Period", + `Valid until August 31, {{contractYear}}, with a possible extension upon mutual agreement.`, + ), + a( + "disputes", + "Settlement of Disputes", + `Disputes shall be settled amicably. +If amicable settlement fails, disputes shall be submitted to the Federal Court located in Addis Ababa. +The signatories confirm that they are fully authorized to sign and execute this Contract Agreement; the power of attorney of the signatories for the parties is enclosed with this contract agreement.`, + ), + ], +}; + +/* ─────────────────────────── INTERCITY / CONTAINER ───────────────────────── */ + +const INTERCITY_CONTAINER: ContractTemplateSeed = { + code: "INTERCITY_CONTAINER", + name: "Container Intercity Contract", + description: + "Domestic (intercity) container transport by railway between Ethiopian terminals — Galaan Multipurpose Port, Modjo dry port, and Dire Dawa — including empty repositioning.", + documentTitle: "Intercity Container Transport Service by Railway", + whereasClauses: [ + "The Client has requested and agreed to the transportation of container cargo between the agreed Ethiopian railway terminals (Galaan Multipurpose Port (GMP), Modjo dry port, and Dire Dawa), including the repositioning of empty containers between those terminals, using the Addis Ababa–Djibouti railway line within Ethiopia.", + "The Service Provider has agreed to transport the container cargo as per the terms of this contract.", + ], + articles: [ + a( + "objective", + "Objective and Scope of the Services", + `To provide domestic railway transportation services for 40ft and/or 20ft full containers between the agreed Ethiopian terminals (Galaan Multipurpose Port (GMP), Modjo dry port, and Dire Dawa), and the repositioning of empty containers between those terminals. +The scope of the services comprises: +- Railway transport service between the agreed origin and destination terminals. +- Cargo handling at Galaan Multipurpose Port (GMP).`, + ), + a( + "client-obligations", + "Obligations of the Client", + `Give written/email/electronic shipment instructions to the Service Provider for transportation of container cargo between the agreed terminals. +Prepare all necessary documents and facilities for shipment. +Ensure the minimum supply of containers per shipment agreed with the Service Provider for the selected loading terminal and destination. +One flat wagon must carry either one 40ft container or two 20ft containers. +If two 20ft containers are loaded on one flat wagon, their weight difference must not exceed 10 tons. +Ensure timely loading and unloading of cargo. +Assign representatives at both ends to oversee container handover and ensure the necessary arrangements for cargo reception at the destination upon arrival. +Maintain and provide detailed cargo information (type, weight, destination, etc.). +Be responsible for cargo handling, loading, and unloading of both empty and full containers at Modjo and Dire Dawa dry port. +Book wagons at least five (5) days in advance. +Ensure containers are ready one day before the planned loading date. +Collect the full container from Galaan Multipurpose Port (GMP) within three (3) calendar days from the day following the arrival notice. +If the Client fails to collect the container within the specified period, the Service Provider shall have the right to reposition the container to any location it deems appropriate; in such case, the Service Provider shall not be held responsible for any damage or loss arising from such repositioning. +If the Client fails to collect the container from Galaan Multipurpose Port within the specified period, the Client shall be liable to pay demurrage charges of 15 USD per day per 20ft container and 27 USD per day per 40ft container, calculated based on the Commercial Bank of Ethiopia's daily selling exchange rate on the date of payment. +In the event the Client fails to collect the container(s) within the specified period, the Client shall be liable to pay double handling charges of 27 USD per 20ft container per handling or 40 USD per 40ft container per handling. +Collect the containers within one day at Dire Dawa and Modjo dry port, or make the necessary payment to the dry port as per the standard of the dry port. +Once empty containers are returned from the Client's premises and stored at a dry port while awaiting train allocation, any demurrage and/or storage charges incurred from the dry port thereafter shall be the sole responsibility of the Client. +Provide clean empty containers that meet the receiving terminal's standards; additional cleaning costs incurred due to non-compliance will be borne by the Client. +Ensure containers are structurally intact and meet weight distribution requirements. +Prohibited cargo: cargo covered with tarpaulin is not allowed due to safety risks. +Notify the Service Provider forty-eight (48) hours in advance before wagon booking if transporting hazardous or valuable goods. +If a booked wagon is not loaded due to Client-related issues, including but not limited to a damaged container, missing lock, unpaid demurrage, port system errors, or incomplete documentation and submission, the Client shall be charged 100% of the total price of the reserved wagon. +Refund requests before wagon booking require an official request, with a 3% administrative fee deducted. If the refund is due to the Service Provider, the Client shall receive a full refund. Refunds not requested and completed within six months shall be considered waived. +Pay 100% of the transportation fee in advance for each train set. +Settle additional penalties due to non-compliance within ten (10) days of invoice issuance. +Late payment incurs a penalty of an additional 10%.`, + ), + a( + "provider-obligations", + "Obligations of the Service Provider", + `Assign the necessary voyage based on the operational schedule and cargo demand, and notify the train schedule 48 hours in advance. +Provide a list of wagons/voyage or train number 24 hours in advance and update corrections 12 hours before arrival. +Provide safe transportation of the containers. +Deliver the cargo within two (2) days after train departure, provided that all required documents are submitted on time and no unforeseen circumstances or events occur. +In the event of export cargo operations, the Service Provider may prioritize the loading of export containers during the loading of empty containers and the unloading of containers from the train at Galaan Multipurpose Port. +The Service Provider reserves the right to refuse the transportation of any cargo classified as dangerous goods. +If any operational, technical, or mechanical problem occurs throughout the transit, notify the Client and the relevant authorities and arrange cargo transfer within four (4) days. +Provide accident or defect reports if needed. +Buy cargo liability insurance for each wagon.`, + ), + a( + "force-majeure", + "Force Majeure", + `Neither party shall be liable for delays or non-performance caused by force majeure events beyond their reasonable control. +Force majeure shall be interpreted in accordance with the Ethiopian Civil Code.`, + ), + a( + "pricing", + "Contract Price and Terms of Payment", + `The applicable rate per 40ft container or per two (2) 20ft containers for the agreed route shall be as per the prevailing EDR domestic container tariff, as set out in the commercial schedule of this contract. +If cargo exceeds 40 tons per two 20ft containers of gross weight, additional charges apply proportionally. +Gross weight shall be the total sum of cargo, packing, and container tare weight. +Payment for any additional tonnage shall be made in advance before the container is loaded onto the wagon. +The price of loading and unloading and container handling at Modjo and Dire Dawa dry port is not part of this contract; it is the Client's responsibility. +Additional costs (if applicable): +- Last-mile delivery service by truck from the destination terminal to the Client's premises shall incur an additional cost, fully covered by the Client. +- For clients utilizing EDR's last-mile logistics services, the applicable charges shall vary based on the cargo movement route and shall be communicated to the Client by the Service Provider after receiving the necessary details regarding the cargo destination, type, and weight. +All payments shall be made one hundred percent (100%) in advance. +Payment may be made in Ethiopian Birr based on the Commercial Bank of Ethiopia's daily selling exchange rate on the date the wagon or train number is provided; if the exchange rate changes between the payment and the wagon assignment date, adjustments will be made accordingly.`, + ), + a( + "contract-documents", + "Contract Documents", + `The following documents constitute this contract: +- Amendments (if any). +- This Contract Agreement. +- Final minutes of negotiation (if any). +If there is any discrepancy between the documents, they shall be interpreted with priority in the order listed above.`, + ), + a( + "documentation", + "Documentation Requirements", + `Equipment Interchange Receipt, Railway Waybill, Container Carriage Acceptance Sheet, and incidental charges (if any). +Payment summary as per the agreed contract price (if required).`, + ), + a( + "consignment-notes", + "Consignment Notes", + `The Service Provider must certify the taking over and handing over of the container cargo on the Freight Carriage Acceptance Sheet of EDR in an appropriate manner and provide it to the Client. +The Freight Carriage Acceptance Sheet shall be prima facie evidence of the receipt of the container cargo by the Service Provider and of the kind, number, and weight of the goods. +Upon delivery of the cargo to the Client, the Cargo Handover Out Voucher signed by both parties shall serve as evidence of cargo receipt.`, + ), + a( + "amendment", + "Amendment", + `This contract can be amended by mutual agreement. +Notwithstanding the above, the Service Provider may revise transport tariffs due to operational and regulatory changes by providing at least five (5) working days' prior written notice to the Client.`, + ), + a( + "termination", + "Termination of Contract", + `The contract may be terminated: +- By mutual agreement. +- Upon completion of the contract period or agreed cargo shipments. +- If either party breaches fundamental terms.`, + ), + a( + "effectiveness", + "Contract Effectiveness", + `The contract is valid once signed by both parties and witnesses.`, + ), + a( + "duration", + "Contract Period", + `Valid until August 31, {{contractYear}}, with a possible extension upon mutual agreement.`, + ), + a( + "disputes", + "Settlement of Disputes", + `Disputes shall be settled amicably. +If unresolved, disputes shall be taken to the Federal Court in Addis Ababa.`, + ), + ], +}; + +export const CONTRACT_TEMPLATE_DEFAULTS: ContractTemplateSeed[] = [ + IMPORT_BULK, + EXPORT_BULK, + INTERCITY_BULK, + IMPORT_CONTAINER, + EXPORT_CONTAINER, + INTERCITY_CONTAINER, +]; diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index c42d831bc..3720c0e2a 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -211,7 +211,6 @@ export class DemoBookingsSeeder { name: "Flat Wagon", capacityTons: 70, lengthMeters: 14, - maxWagonsPerTrain: 53, supportedLoadTypes: ["CONTAINER"], isActive: true, equatedLengthM: 14, @@ -224,7 +223,6 @@ export class DemoBookingsSeeder { name: "Covered Hopper", capacityTons: 60, lengthMeters: 12, - maxWagonsPerTrain: 55, supportedLoadTypes: ["BULK"], isActive: true, equatedLengthM: 12, @@ -236,7 +234,6 @@ export class DemoBookingsSeeder { name: "Powder Wagon", capacityTons: 55, lengthMeters: 12, - maxWagonsPerTrain: 55, supportedLoadTypes: ["BULK"], isActive: true, equatedLengthM: 12, @@ -248,7 +245,6 @@ export class DemoBookingsSeeder { name: "Open Wagon", capacityTons: 65, lengthMeters: 13, - maxWagonsPerTrain: 53, supportedLoadTypes: ["BULK"], isActive: true, equatedLengthM: 13, @@ -508,8 +504,6 @@ export class DemoBookingsSeeder { wagonTypeId: nw5.id, trainId: null, sequenceNumber: null, - tareWeight: 20, - maxPayloadWeight: 70, status: WagonStatus.Available, currentYardId: index % 2 === 0 ? djibouti.id : addis.id, notes: "Demo wagon for train scheduling", diff --git a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts index da2da818f..45c374332 100644 --- a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts @@ -76,15 +76,11 @@ export class DemoFreightDataSeeder { } const toCreate = MIN_WAGONS_PER_TYPE - existing; - const tare = Number(type.tareWeightTons ?? 20); - const maxPayload = Number(type.capacityTons ?? 60); const rows = Array.from({ length: toCreate }, (_, i) => { const seq = existing + i + 1; return wagonRepo.create({ wagonNumber: `${type.code}-${String(seq).padStart(4, '0')}`, wagonTypeId: type.id, - tareWeight: tare, - maxPayloadWeight: maxPayload, status: WagonStatus.Available, }); }); diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 1aef33801..13759ddb3 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -18,6 +18,28 @@ interface OnboardingField { const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"]; +/** fileKey of the delegation letter attached to the Power of Attorney step. */ +export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; + +/** + * Seeded as optional: the delegation letter is only mandatory once a PoA has + * been entered, or when the company operates as a freight forwarder. That rule + * spans form fields as well as files, so it lives in the onboarding gate + * (companies.service.getOnboardingRequirements) rather than in `isRequired`. + */ +const poaDelegationField = (displayOrder: number): OnboardingField => ({ + fileKey: POA_DELEGATION_FILE_KEY, + fileLabel: "PoA Delegation Letter", + helpText: + "Signed letter in which the General Manager delegates the representative named above.", + isRequired: false, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 10, + displayOrder, +}); + /** Documents required from an Ethiopian company at onboarding. */ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ { @@ -33,8 +55,9 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ }, { fileKey: "commercial_license", - fileLabel: "Commercial License", - helpText: "Verified against the government trade system during registration.", + fileLabel: "Commercial Registration", + helpText: + "Verified against the government trade system during registration.", isRequired: true, isMultiple: false, maxFiles: 1, @@ -53,6 +76,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 3, }, + poaDelegationField(4), ]; /** Documents required from a Foreign company at onboarding. */ @@ -101,6 +125,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 4, }, + poaDelegationField(5), ]; /** Legacy combined set, kept for the older per-company-type codes. */ @@ -108,7 +133,8 @@ const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [ { fileKey: "business_license", fileLabel: "Business License / Trade License", - helpText: "Verified against the government trade system during registration.", + helpText: + "Verified against the government trade system during registration.", isRequired: true, isMultiple: false, maxFiles: 1, @@ -489,9 +515,14 @@ const SELF_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [ const CONTRACT_INTAKE_ENTITY = "contract_intake"; const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [ - clearanceField("commercial_framework", "Commercial Framework / Agreement", 1, { - required: false, - }), + clearanceField( + "commercial_framework", + "Commercial Framework / Agreement", + 1, + { + required: false, + }, + ), clearanceField("onboarding_attachment", "Onboarding Attachment", 2, { required: false, }), @@ -542,7 +573,7 @@ const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ export class FileUploadSettingsSeeder { private readonly logger = new Logger(FileUploadSettingsSeeder.name); - constructor(private readonly dataSource: DataSource) {} + constructor(private readonly dataSource: DataSource) { } async run() { await this.dataSource.transaction(async (manager) => { @@ -552,35 +583,35 @@ export class FileUploadSettingsSeeder { const allSettings: Array< OnboardingDocumentSetting & { description: string } > = [ - ...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({ - ...s, - description: COMPANY_ONBOARDING_DESCRIPTION, - })), - ...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({ - ...s, - description: CLEARANCE_DESCRIPTION, - })), - ...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({ - ...s, - description: - "Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.", - })), - ...SELF_CLEARANCE_SETTINGS.map((s) => ({ - ...s, - description: - "Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.", - })), - ...CONTRACT_INTAKE_SETTINGS.map((s) => ({ - ...s, - description: - "Commercial/framework documents attached at contract submission.", - })), - ...DRIVER_DOCUMENT_SETTINGS.map((s) => ({ - ...s, - description: - "Documents uploaded against a driver profile (license, ID, contracts, etc.).", - })), - ]; + ...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({ + ...s, + description: COMPANY_ONBOARDING_DESCRIPTION, + })), + ...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: CLEARANCE_DESCRIPTION, + })), + ...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({ + ...s, + description: + "Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.", + })), + ...SELF_CLEARANCE_SETTINGS.map((s) => ({ + ...s, + description: + "Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.", + })), + ...CONTRACT_INTAKE_SETTINGS.map((s) => ({ + ...s, + description: + "Commercial/framework documents attached at contract submission.", + })), + ...DRIVER_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: + "Documents uploaded against a driver profile (license, ID, contracts, etc.).", + })), + ]; for (const documentSetting of allSettings) { await settingRepository.upsert( @@ -601,7 +632,9 @@ export class FileUploadSettingsSeeder { }); if (!setting) { - throw new Error(`file_upload_setting_seed_failed:${documentSetting.code}`); + throw new Error( + `file_upload_setting_seed_failed:${documentSetting.code}`, + ); } await fieldRepository.delete({ settingId: setting.id }); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index d4d85e84c..e281e6464 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -131,6 +131,7 @@ export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [ perm('d1a00001-0001-4000-8000-000000000003', 'edr_freight_app:customers:update', 'Update customer'), perm('d1a00001-0001-4000-8000-000000000004', 'edr_freight_app:customers:deactivate', 'Deactivate customer'), perm('d1a00001-0001-4000-8000-000000000005', 'edr_freight_app:customers:verify', 'Verify customer (KYC/Fayda)'), + perm('d1a00001-0001-4000-8000-000000000006', 'edr_freight_app:customers:reset-password', 'Trigger customer password reset'), ]; // D. Finance — payments + invoices @@ -204,6 +205,7 @@ export const FLEET_ROAD_PERMISSIONS: FreightPermissionSeed[] = [ perm('e2b00001-0001-4000-8000-000000000003', 'edr_freight_app:drivers:update', 'Update driver'), perm('e2b00001-0001-4000-8000-000000000004', 'edr_freight_app:drivers:delete', 'Delete driver'), perm('e2c00001-0001-4000-8000-000000000001', 'edr_freight_app:tracking:view', 'Track vehicles'), + perm('e2c00001-0001-4000-8000-000000000002', 'edr_freight_app:tracking:manage', 'Manage GPS trackers'), perm('e2d00001-0001-4000-8000-000000000001', 'edr_freight_app:fuel:view', 'View fuel purchases'), perm('e2d00001-0001-4000-8000-000000000002', 'edr_freight_app:fuel:create', 'Create fuel purchase'), perm('e2d00001-0001-4000-8000-000000000003', 'edr_freight_app:fuel:update', 'Update fuel purchase'), @@ -394,6 +396,7 @@ export const FREIGHT_PERMS = { update: 'edr_freight_app:customers:update', deactivate: 'edr_freight_app:customers:deactivate', verify: 'edr_freight_app:customers:verify', + resetPassword: 'edr_freight_app:customers:reset-password', }, payments: { view: 'edr_freight_app:payments:view', @@ -477,6 +480,7 @@ export const FREIGHT_PERMS = { }, tracking: { view: 'edr_freight_app:tracking:view', + manage: 'edr_freight_app:tracking:manage', }, fuel: { view: 'edr_freight_app:fuel:view', @@ -660,9 +664,13 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.fleet.manage, // Path A (no customs): Operations reviews the customer's self-clearance docs - // on the contract before the customer may create a shipment booking. + // — on the contract for ONE_TIME contracts, and PER BOOKING for GENERAL + // contracts (booking-level document review → finalize → CLEARANCE_READY). FREIGHT_PERMS.contracts.view, FREIGHT_PERMS.contracts.opsClearanceReview, + FREIGHT_PERMS.bookings.clearanceView, + FREIGHT_PERMS.bookings.reviewDocuments, + FREIGHT_PERMS.bookings.finalizeClearance, ...allRuleEngineViewKeys(), ], director: [ diff --git a/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts index 53d6c9eec..eec158856 100644 --- a/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts +++ b/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts @@ -203,7 +203,6 @@ export class MarshallingDemoTrainsSeeder { const totalWeight = bookingWeights.reduce((sum, weight) => sum + weight, 0); const wagonCapacity = Number(refs.wagonType.capacityTons) || 70; const wagonLength = Number(refs.wagonType.lengthMeters) || 14; - const tareWeight = Number(refs.wagonType.tareWeightTons) || 14; const trainSet = await trainSetRepo.save( trainSetRepo.create({ @@ -275,8 +274,6 @@ export class MarshallingDemoTrainsSeeder { wagonTypeId: refs.wagonType.id, yardId: originYard.id, trainScheduleId: schedule.id, - tareWeight, - capacityTons: wagonCapacity, dispatched: hasDeparted, }); @@ -420,8 +417,6 @@ export class MarshallingDemoTrainsSeeder { wagonTypeId: string; yardId: string; trainScheduleId: string; - tareWeight: number; - capacityTons: number; dispatched: boolean; }): Promise { const repo = this.dataSource.getRepository(Wagon); @@ -433,8 +428,6 @@ export class MarshallingDemoTrainsSeeder { wagonTypeId: input.wagonTypeId, currentYardId: input.yardId, currentTrainScheduleId: input.trainScheduleId, - tareWeight: input.tareWeight, - maxPayloadWeight: input.capacityTons, status: WagonStatus.Assigned, notes: 'Marshalling demo seed wagon', }), diff --git a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts index fbc19100a..d8e585a35 100644 --- a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts @@ -3,6 +3,7 @@ import { DataSource } from 'typeorm'; import { Booking } from '../modules/bookings/entities/booking.entity'; import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; +import { CompanyProfile } from '../modules/companies/entities/company-profile.entity'; import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -60,10 +61,16 @@ export class WarehouseDemoSeeder { (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? (await serviceTypeRepo.findOne({ where: { isActive: true } })); const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } }); + // bookings.company_id AND company_profile_id are both NOT NULL — a demo booking + // still needs an owner. Take the company from the profile so they always agree. + const companyProfile = await this.dataSource + .getRepository(CompanyProfile) + .findOne({ where: {} }); - if (!djibYard || !ethYard || !serviceType) { + if (!djibYard || !ethYard || !serviceType || !companyProfile) { this.logger.warn( - `Missing yards/service type (djib=${djibYard?.code}, eth=${ethYard?.code}, svc=${serviceType?.code}); skipping`, + `Missing yards/service type/company profile (djib=${djibYard?.code}, eth=${ethYard?.code}, ` + + `svc=${serviceType?.code}, companyProfile=${companyProfile?.id ?? 'none'}); skipping`, ); return; } @@ -89,7 +96,7 @@ export class WarehouseDemoSeeder { ): Promise => bookingRepo.save( bookingRepo.create({ - ...this.demoBookingDefaults(), + ...this.demoBookingDefaults(companyProfile), reference, originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id, destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.id, @@ -182,7 +189,15 @@ export class WarehouseDemoSeeder { } // 6) Import Arrive Queue — an ARRIVED import train with IN_TRANSIT bookings, no inventory yet. - await this.seedArrivedImportTrain(djibYard, ethYard, serviceType, cargoType, ago(60), ago(360)); + await this.seedArrivedImportTrain( + djibYard, + ethYard, + serviceType, + cargoType, + companyProfile, + ago(60), + ago(360), + ); created += 1; this.logger.log(`✅ Warehouse demo seeded: ${created} buckets populated across every queue`); @@ -199,6 +214,7 @@ export class WarehouseDemoSeeder { ethYard: Yard, serviceType: ServiceType, cargoType: CargoType | null, + owner: CompanyProfile, arrival: Date, departure: Date, ): Promise { @@ -238,7 +254,7 @@ export class WarehouseDemoSeeder { for (let i = 1; i <= 3; i++) { const b = await bookingRepo.save( bookingRepo.create({ - ...this.demoBookingDefaults(), + ...this.demoBookingDefaults(owner), reference: `WH-DEMO-ARR-${i}`, originYardId: djibYard.id, destinationYardId: ethYard.id, @@ -258,8 +274,10 @@ export class WarehouseDemoSeeder { } } - private demoBookingDefaults(): Partial { + private demoBookingDefaults(owner: CompanyProfile): Partial { return { + companyId: owner.companyId, + companyProfileId: owner.id, scheduledDate: new Date(), contractType: 'SPOT', equipmentReturn: 'TERMINAL', diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index b89807b72..2f2e0d7be 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -16,29 +16,93 @@ "@edr/types": "workspace:*", "@edr/ui-common": "workspace:*", "@hello-pangea/dnd": "^18.0.1", + "@hookform/resolvers": "^5.4.0", "@mantine/core": "^9.3.0", "@mantine/dates": "^9.3.0", "@mantine/hooks": "^9.3.0", + "@radix-ui/react-accordion": "^1.2.13", + "@radix-ui/react-alert-dialog": "^1.1.16", + "@radix-ui/react-avatar": "^1.1.12", + "@radix-ui/react-checkbox": "^1.3.4", + "@radix-ui/react-collapsible": "^1.1.13", + "@radix-ui/react-context-menu": "^2.3.0", + "@radix-ui/react-dialog": "^1.1.16", + "@radix-ui/react-dropdown-menu": "^2.1.17", + "@radix-ui/react-hover-card": "^1.1.16", + "@radix-ui/react-label": "^2.1.9", + "@radix-ui/react-navigation-menu": "^1.2.15", + "@radix-ui/react-popover": "^1.1.16", + "@radix-ui/react-progress": "^1.1.9", + "@radix-ui/react-radio-group": "^1.4.0", + "@radix-ui/react-scroll-area": "^1.2.11", + "@radix-ui/react-select": "^2.3.0", + "@radix-ui/react-separator": "^1.1.9", + "@radix-ui/react-slider": "^1.4.0", + "@radix-ui/react-slot": "^1.2.5", + "@radix-ui/react-switch": "^1.3.0", + "@radix-ui/react-tabs": "^1.1.14", + "@radix-ui/react-toast": "^1.2.16", + "@radix-ui/react-toggle-group": "^1.1.12", + "@radix-ui/react-tooltip": "^1.2.9", + "@react-pdf-viewer/core": "^3.12.0", + "@react-pdf-viewer/default-layout": "^3.12.0", + "@react-pdf-viewer/zoom": "^3.12.0", + "@react-pdf/renderer": "^4.5.1", + "@reduxjs/toolkit": "^2.12.0", "@tabler/icons-react": "^3.44.0", "@tanstack/react-query": "^5.100.11", + "@tanstack/react-table": "^8.21.3", + "@tinymce/tinymce-react": "^6.3.0", "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz", "@vis.gl/react-google-maps": "^1.8.3", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^3.6.0", + "dayjs": "^1.11.21", + "dompurify": "^3.4.8", + "ethiopian-calendar-date-converter": "^2.1.6", + "file-type": "^18.7.0", + "framer-motion": "^12.40.0", + "html2canvas": "^1.4.1", + "i18next": "^26.3.5", + "i18next-browser-languagedetector": "^8.2.1", + "jquery": "^3.7.1", + "js-cookie": "^3.0.8", + "jspdf": "^3.0.4", "libphonenumber-js": "^1.12.24", + "lodash": "^4.18.1", "lucide-react": "^1.14.0", + "next-themes": "^0.4.6", + "pdf-lib": "^1.17.1", + "prop-types": "^15.8.1", + "qs": "^6.15.2", "radix-ui": "^1.4.3", "react": "19.2.6", + "react-css-nocode-editor": "^1.0.13", + "react-day-picker": "^9.14.0", "react-dom": "19.2.6", + "react-dropzone": "^14.4.1", + "react-hook-form": "^7.77.0", "react-hot-toast": "^2.6.0", + "react-i18next": "^17.0.8", + "react-icons": "^5.6.0", + "react-image-crop": "^11.0.10", + "react-intersection-observer": "^9.16.0", + "react-pdf": "^10.4.1", + "react-pdf-html": "^2.1.5", + "react-resizable-panels": "^3.0.6", "react-router-dom": "^6.27.0", + "react-signature-canvas": "1.1.0-alpha.2", "recharts": "^3.8.1", "socket.io-client": "^4.8.3", "sonner": "^2.0.7", "stream-browserify": "^3.0.0", "tailwind-merge": "^3.6.0", "tinymce": "^8.6.0", + "xlsx": "^0.18.5", + "zod": "^3.25.76", "zustand": "^5.0.0" }, "devDependencies": { @@ -46,6 +110,9 @@ "@edr/tsconfig": "workspace:*", "@tailwindcss/vite": "^4.3.0", "@types/google.maps": "^3.65.2", + "@types/jquery": "^3.5.34", + "@types/js-cookie": "^3.0.6", + "@types/lodash": "^4.17.24", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.2", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index df8db49d4..56480bad5 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -13,6 +13,7 @@ import { PackageOpen, Paperclip, Receipt, + ScrollText, Send, Settings, ShieldCheck, @@ -53,11 +54,11 @@ import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPa import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage"; import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage"; import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage"; -// Hidden for now — Shipment Requests pages disabled (imports kept commented). -// import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage"; -// import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage"; +import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage"; +import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage"; import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm"; import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; +import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage"; @@ -84,6 +85,8 @@ import UserManagementPage from "./pages/dashboard/user-management/UserManagement import UsersPage from "./pages/dashboard/user-management/UsersPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; +import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; +import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; import VehicleDetailPage from "./pages/fleet/VehicleDetailPage"; import DriverDetailPage from "./pages/fleet/DriverDetailPage"; @@ -129,6 +132,7 @@ import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; import { HealthCheck } from "./features/health/HealthCheck"; import FaydaCallbackPage from "./pages/FaydaCallbackPage"; +import { UserManagementRoutes } from "./user-management/route"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { @@ -141,7 +145,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Staff", - href: "/um", + href: "/user-management", icon: , }, { @@ -187,13 +191,20 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ FREIGHT_PERMS.contracts.clearanceEtActions, ], }, - // Hidden for now — Shipment Requests nav item disabled. - // { - // label: "Shipment Requests", - // href: "/dashboard/shipment-requests", - // icon: , - // permission: FREIGHT_PERMS.contracts.createBooking, - // }, + { + label: "Shipment Requests", + href: "/dashboard/shipment-requests", + icon: , + permission: FREIGHT_PERMS.contracts.createBooking, + }, + // Operations Path A queue: per-booking self-clearance review for + // GENERAL non-customs booking instances (and legacy self-clear bookings). + { + label: "Self-Clearance Review", + href: "/dashboard/contracts/ops-clearance", + icon: , + permission: FREIGHT_PERMS.contracts.opsClearanceReview, + }, { label: "GL Djibouti Clearance", href: "/dashboard/gl-djibouti/clearance", @@ -456,6 +467,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.admin, }, + { + label: "Contract templates", + href: "/dashboard/contract-templates", + icon: , + permission: FREIGHT_PERMS.admin, + }, ], }, { @@ -653,6 +670,7 @@ const App = () => { return ( + {UserManagementRoutes()} } /> } /> } /> @@ -752,7 +770,6 @@ const App = () => { } /> - {/* Hidden for now — Shipment Requests pages disabled. { } /> - */} {/* GL (Path B) contract clearance review hub */} { } /> - {/* Path A ops queue out of scope for now → fold into the GL hub. */} + {/* Path A — Operations reviews per-booking self-clearance documents + (GENERAL contracts without customs). */} } + element={ + + + + } /> { /> {/* Legacy embedded user management routes */} - } /> + {/* } /> } /> } - /> + /> */} {/* } /> */} - } /> - } /> + } /> */} { } /> + + + + } + /> + + + + } + /> { + return []; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function getMyShares(..._args: any[]): Promise { + return []; +} diff --git a/apps/edr-freight-web/backoffice/src/DMS/api/dms.http.ts b/apps/edr-freight-web/backoffice/src/DMS/api/dms.http.ts new file mode 100644 index 000000000..d1d3a0f8f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/DMS/api/dms.http.ts @@ -0,0 +1,24 @@ +// STUB — see DMS/pages/_shared/utils.ts. DMS HTTP client, not migrated. +// Returns empty payloads so any DMS call degrades to "no results" rather than +// crashing. Wire the real @/DMS/api/dms.http if DMS is brought over. + +function warn(method: string, url: string) { + // eslint-disable-next-line no-console + console.warn(`[DMS stub] ${method} ${url} — DMS is not migrated; returning empty.`); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function empty(): Promise<{ data: T }> { + return { data: {} as T }; +} + +export const dmsHttp = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get: (url: string): Promise<{ data: T }> => (warn("GET", url), empty()), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + post: (url: string): Promise<{ data: T }> => (warn("POST", url), empty()), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + put: (url: string): Promise<{ data: T }> => (warn("PUT", url), empty()), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete: (url: string): Promise<{ data: T }> => (warn("DELETE", url), empty()), +}; diff --git a/apps/edr-freight-web/backoffice/src/DMS/api/folders.api.ts b/apps/edr-freight-web/backoffice/src/DMS/api/folders.api.ts new file mode 100644 index 000000000..895182597 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/DMS/api/folders.api.ts @@ -0,0 +1,12 @@ +// STUB — see DMS/pages/_shared/utils.ts. DMS folders API, not migrated. +// Returns empty lists so folder pickers render empty instead of crashing. + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function getMySubFolders(..._args: any[]): Promise { + return []; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function getMyCollaborationsSubFolders(..._args: any[]): Promise { + return []; +} diff --git a/apps/edr-freight-web/backoffice/src/DMS/pages/_shared/file-kind.utils.ts b/apps/edr-freight-web/backoffice/src/DMS/pages/_shared/file-kind.utils.ts new file mode 100644 index 000000000..50e2878c0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/DMS/pages/_shared/file-kind.utils.ts @@ -0,0 +1,21 @@ +// STUB — see DMS/pages/_shared/utils.ts. DMS is not migrated; inert placeholder. + +export type FileKind = + | "word" + | "excel" + | "ppt" + | "pdf" + | "image" + | "video" + | "file"; + +export function detectFileKind(nameOrMime?: string | null): FileKind { + const s = (nameOrMime ?? "").toLowerCase(); + if (/\.(docx?|word)/.test(s)) return "word"; + if (/\.(xlsx?|csv)/.test(s)) return "excel"; + if (/\.(pptx?)/.test(s)) return "ppt"; + if (/\.pdf/.test(s)) return "pdf"; + if (/\.(png|jpe?g|gif|webp|svg|image)/.test(s)) return "image"; + if (/\.(mp4|mov|avi|video)/.test(s)) return "video"; + return "file"; +} diff --git a/apps/edr-freight-web/backoffice/src/DMS/pages/_shared/od-icons.tsx b/apps/edr-freight-web/backoffice/src/DMS/pages/_shared/od-icons.tsx new file mode 100644 index 000000000..cb9c26f65 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/DMS/pages/_shared/od-icons.tsx @@ -0,0 +1,14 @@ +// STUB — see DMS/pages/_shared/utils.ts. DMS file-type icons, not migrated. +// Each renders nothing so document components resolve without the real assets. +import type { SVGProps } from "react"; + +const Empty = (_props: SVGProps) => null; + +export const ODWinFolderSvg = Empty; +export const ODIconWord = Empty; +export const ODIconExcel = Empty; +export const ODIconPpt = Empty; +export const ODIconPdf = Empty; +export const ODIconImage = Empty; +export const ODIconVideo = Empty; +export const ODIconFile = Empty; diff --git a/apps/edr-freight-web/backoffice/src/DMS/pages/_shared/utils.ts b/apps/edr-freight-web/backoffice/src/DMS/pages/_shared/utils.ts new file mode 100644 index 000000000..e8ec80618 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/DMS/pages/_shared/utils.ts @@ -0,0 +1,20 @@ +// STUB — @/DMS is not migrated into this backoffice. These placeholders exist +// only so record-management's document components resolve; the DMS feature is +// inert here. Replace with the real @/DMS module if DMS is ever brought over. + +export function formatBytes(bytes?: number | null): string { + if (!bytes || bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i] ?? "B"}`; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function resolveOwnerName(owner?: any): string { + return owner?.name ?? owner?.username ?? owner?.email ?? ""; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function pickName(item?: any): string { + return item?.name ?? item?.title ?? item?.fileName ?? ""; +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/components/ComplaintPageLayout.tsx b/apps/edr-freight-web/backoffice/src/complaints/components/ComplaintPageLayout.tsx new file mode 100644 index 000000000..9aad0a009 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/components/ComplaintPageLayout.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; +import Header from "@/layout/components/Header"; +import Footer from "@/layout/components/Footer"; + +interface ComplaintPageLayoutProps { + children: ReactNode; + heroTitle: string; + heroSubtitle?: string; +} + +export function ComplaintPageLayout({ + children, + heroTitle, + heroSubtitle, +}: ComplaintPageLayoutProps) { + return ( +
+
+
+
+
+

+ {heroTitle} +

+ {heroSubtitle && ( +

+ {heroSubtitle} +

+ )} +
+
{children}
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/components/ComplaintVerificationGuard.tsx b/apps/edr-freight-web/backoffice/src/complaints/components/ComplaintVerificationGuard.tsx new file mode 100644 index 000000000..3b8baddbb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/components/ComplaintVerificationGuard.tsx @@ -0,0 +1,12 @@ +import { Navigate, Outlet } from "react-router-dom"; +import { getComplaintVerification } from "../utils/complaintVerificationStorage"; + +export function ComplaintVerificationGuard() { + const session = getComplaintVerification(); + + if (!session) { + return ; + } + + return ; +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/components/ComplaintVerifiedInfoPanel.tsx b/apps/edr-freight-web/backoffice/src/complaints/components/ComplaintVerifiedInfoPanel.tsx new file mode 100644 index 000000000..0eb48e1ab --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/components/ComplaintVerifiedInfoPanel.tsx @@ -0,0 +1,69 @@ +import { useTranslation } from "react-i18next"; +import { BadgeCheck, Building2, IdCard, User } from "lucide-react"; +import { + PortalFieldLabel, + PortalReadOnlyField, +} from "@/external-portal/components/shared/PortalFormPrimitives"; +import type { ComplaintVerificationSession } from "../types/complaint.types"; + +interface ComplaintVerifiedInfoPanelProps { + session: ComplaintVerificationSession; +} + +export function ComplaintVerifiedInfoPanel({ + session, +}: ComplaintVerifiedInfoPanelProps) { + const { t } = useTranslation(); + const isTinComplaint = session.method === "tin"; + + return ( +
+

+ + {isTinComplaint + ? t("complaint.tin.verifiedInfo") + : t("complaint.fayda.verifiedInfo")} +

+
+ {isTinComplaint ? ( + <> + + + {session.organization?.tradeName ? ( + + ) : null} + {session.organization?.licenseNumber ? ( + + ) : null} + + ) : ( + <> + + + )} +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintCallbackPage.tsx b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintCallbackPage.tsx new file mode 100644 index 000000000..02f1dc36d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintCallbackPage.tsx @@ -0,0 +1,160 @@ +import { useEffect, useRef, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/shared/common/ui/button"; +import { useAuth } from "@/shared/context/AuthContext"; +import { + isComplaintFaydaState, +} from "@/shared/utils/faydaOidc"; +import { + mapRegisterWithFaydaCitizen, + persistFaydaRegistrationAuth, +} from "@/shared/utils/faydaAuthSession"; +import { + FaydaOidcError, + verifyComplaintUser, +} from "../services/complaintVerificationService"; +import { + clearComplaintVerification, + storeComplaintVerification, +} from "../utils/complaintVerificationStorage"; +import { COMPLAINT_RECORDS_PATH } from "../utils/complaintRoutes"; + +export default function ComplaintCallbackPage() { + const { t, i18n } = useTranslation(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { setUser, setSelectedPositionId } = useAuth(); + const [error, setError] = useState(null); + const hasRun = useRef(false); + + useEffect(() => { + if (hasRun.current) return; + hasRun.current = true; + + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const oidcError = searchParams.get("error"); + const oidcErrorDescription = searchParams.get("error_description"); + + if (oidcError) { + clearComplaintVerification(); + const message = + oidcErrorDescription || + t("complaint.fayda.verificationFailed"); + setError(message); + return; + } + + if (!code) { + clearComplaintVerification(); + setError(t("complaint.fayda.missingCode")); + return; + } + + if (state && !isComplaintFaydaState(state)) { + clearComplaintVerification(); + setError(t("complaint.fayda.verificationFailed")); + return; + } + + const processCallback = async () => { + try { + const result = await verifyComplaintUser( + { code, state }, + i18n.language, + ); + + if (!result.verified || !result.registration) { + throw new Error(t("complaint.fayda.verificationFailed")); + } + + const profile = await persistFaydaRegistrationAuth(result.registration); + if (profile) { + setUser(profile); + const firstPositionId = + profile.employee?.[0]?.positions?.[0]?.employeePositionId; + if (firstPositionId) { + setSelectedPositionId(firstPositionId); + } + } + + const citizen = mapRegisterWithFaydaCitizen( + result.registration, + i18n.language, + profile, + ); + + if (!citizen.fullName && !profile) { + throw new FaydaOidcError("FAYDA_PROFILE_INCOMPLETE"); + } + + storeComplaintVerification({ + verified: true, + method: "fayda", + citizen, + verifiedAt: result.verifiedAt, + }); + + navigate(COMPLAINT_RECORDS_PATH, { + replace: true, + state: { fromComplaintVerification: true }, + }); + } catch (err: unknown) { + clearComplaintVerification(); + if (err instanceof FaydaOidcError) { + const translationKey = `complaint.fayda.errors.${err.code}`; + const translated = t(translationKey); + setError( + translated !== translationKey + ? translated + : t("complaint.fayda.verificationFailed"), + ); + return; + } + + const message = + err instanceof Error + ? err.message + : t("complaint.fayda.verificationFailed"); + setError(message); + } + }; + + processCallback(); + }, [ + searchParams, + navigate, + t, + i18n.language, + setUser, + setSelectedPositionId, + ]); + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + return ( +
+ +

+ {t("complaint.fayda.verifying")} +

+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintFormPage.tsx b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintFormPage.tsx new file mode 100644 index 000000000..ff552866e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintFormPage.tsx @@ -0,0 +1,13 @@ +import { Navigate } from "react-router-dom"; +import { getComplaintVerification } from "../utils/complaintVerificationStorage"; +import { COMPLAINT_RECORDS_PATH } from "../utils/complaintRoutes"; + +export default function ComplaintFormPage() { + const session = getComplaintVerification(); + + if (!session) { + return ; + } + + return ; +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintMethodChoicePage.tsx b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintMethodChoicePage.tsx new file mode 100644 index 000000000..d4f791d5d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintMethodChoicePage.tsx @@ -0,0 +1,94 @@ +import { useEffect } from "react"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { AlertCircle, Building2, Search, UserRound } from "lucide-react"; +import { toast } from "sonner"; +import { startComplaintFaydaAuth } from "@/shared/utils/faydaOidc"; +import { ComplaintPageLayout } from "../components/ComplaintPageLayout"; +import { clearComplaintVerification } from "../utils/complaintVerificationStorage"; + +export default function ComplaintMethodChoicePage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const error = searchParams.get("error"); + + useEffect(() => { + clearComplaintVerification(); + }, []); + + useEffect(() => { + if (error) { + toast.error(error); + setSearchParams({}, { replace: true }); + } + }, [error, setSearchParams]); + + const authOptions = [ + { + provider: "fayda" as const, + title: t("registration.auth.continueWithFayda"), + description: t("registration.auth.faydaDescription"), + icon: UserRound, + onClick: () => startComplaintFaydaAuth(), + }, + { + provider: "etrade" as const, + title: t("registration.auth.continueWithEtrade"), + description: t("registration.auth.etradeDescription"), + icon: Building2, + onClick: () => navigate("/complaints/tin"), + }, + ]; + + return ( + +
+ {error && ( +
+ +

{error}

+
+ )} + +

+ {t("complaint.choice.prompt")} +

+ +
+ {authOptions.map((option) => { + const Icon = option.icon; + return ( + + ); + })} +
+ +
+ + + {t("complaint.followTitle")} + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintSuccessPage.tsx b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintSuccessPage.tsx new file mode 100644 index 000000000..ea294f8f1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintSuccessPage.tsx @@ -0,0 +1,44 @@ +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { CheckCircle, Search } from "lucide-react"; +import { Button } from "@/shared/common/ui/button"; +import { ComplaintPageLayout } from "../components/ComplaintPageLayout"; +import { clearComplaintVerification } from "../utils/complaintVerificationStorage"; + +export default function ComplaintSuccessPage() { + const { t } = useTranslation(); + + const handleDone = () => { + clearComplaintVerification(); + }; + + return ( + +
+ +

+ {t("complaint.fayda.successMessage")} +

+

+ {t("complaint.fayda.successNote")} +

+ +
+ + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintTinVerificationPage.tsx b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintTinVerificationPage.tsx new file mode 100644 index 000000000..fa5ea062c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/pages/ComplaintTinVerificationPage.tsx @@ -0,0 +1,308 @@ +import { FormEvent, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { AlertCircle, ArrowLeft, Building2, Loader2 } from "lucide-react"; +import { Button } from "@/shared/common/ui/button"; +import { Input } from "@/shared/common/ui/input"; +import { Label } from "@/shared/common/ui/label"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; +import { useAuth } from "@/shared/context/AuthContext"; +import { registerWithEtrade } from "@/shared/services/authService"; +import { persistFaydaRegistrationAuth } from "@/shared/utils/faydaAuthSession"; +import { ComplaintPageLayout } from "../components/ComplaintPageLayout"; +import { TinRegistrationNotFoundError } from "../services/complaintVerificationService"; +import { storeComplaintVerification } from "../utils/complaintVerificationStorage"; +import { COMPLAINT_RECORDS_PATH } from "../utils/complaintRoutes"; +import type { VerifiedOrganization } from "../types/complaint.types"; +import { + EtradeBusinessLicenseOption, + fetchRegistrationByTin, + mapEtradeBusinessLicenses, + mapEtradeRegistration, + resolveEtradeLanguage, + resolveEtradePhoneForSignup, +} from "../services/etradeTinService"; +import { EtradeLicensePicker } from "@/shared/components/etrade/EtradeLicensePicker"; + +type VerificationStep = "tin" | "select_license"; + +export default function ComplaintTinVerificationPage() { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const { setUser, setSelectedPositionId } = useAuth(); + const { handleError, getErrorMessage } = useErrorHandler(t); + const [step, setStep] = useState("tin"); + const [tin, setTin] = useState(""); + const [organization, setOrganization] = useState( + null, + ); + const [organizationName, setOrganizationName] = useState(""); + const [licenseOptions, setLicenseOptions] = useState< + EtradeBusinessLicenseOption[] + >([]); + const [selectedLicenseNumber, setSelectedLicenseNumber] = useState(""); + const [error, setError] = useState(null); + const [isVerifying, setIsVerifying] = useState(false); + const [isContinuing, setIsContinuing] = useState(false); + + const handleVerify = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + + const normalizedTin = tin.trim(); + if (!normalizedTin) { + setError(t("complaint.tin.tinRequired")); + return; + } + + if (!/^\d{10}$/.test(normalizedTin)) { + setError(t("complaint.tin.tinInvalid")); + return; + } + + try { + setIsVerifying(true); + const registration = await fetchRegistrationByTin( + normalizedTin, + resolveEtradeLanguage(i18n.language), + ); + const verifiedOrganization = mapEtradeRegistration( + registration, + normalizedTin, + ); + + if (!verifiedOrganization.organizationName && !verifiedOrganization.tin) { + setError(t("complaint.tin.notFound")); + return; + } + + const businesses = mapEtradeBusinessLicenses( + registration, + resolveEtradeLanguage(i18n.language), + ); + + if (businesses.length === 0) { + setError(t("registration.etrade.noLicensesFound")); + return; + } + + setOrganization(verifiedOrganization); + setOrganizationName(verifiedOrganization.organizationName); + setLicenseOptions(businesses); + setSelectedLicenseNumber(businesses[0].licenseNumber); + setStep("select_license"); + } catch (verifyError) { + if (verifyError instanceof TinRegistrationNotFoundError) { + setError(t("complaint.tin.notFound")); + return; + } + if ( + verifyError instanceof Error && + verifyError.message === "ETRADE_REFERER_REJECTED" + ) { + setError(t("complaint.tin.proxyError")); + return; + } + setError(await getErrorMessage(verifyError)); + } finally { + setIsVerifying(false); + } + }; + + const handleLicenseContinue = async () => { + if (!organization || !selectedLicenseNumber) return; + + const selectedLicense = licenseOptions.find( + (option) => option.licenseNumber === selectedLicenseNumber, + ); + if (!selectedLicense) return; + + try { + setIsContinuing(true); + setError(null); + + let phoneNumber: string | undefined; + try { + phoneNumber = + (await resolveEtradePhoneForSignup( + selectedLicense.licenseNumber, + organization.tin, + resolveEtradeLanguage(i18n.language), + )) ?? undefined; + } catch (phoneError) { + console.warn( + "[complaint:eTrade] Failed to resolve phone from GetBusinessByLicenseNo", + phoneError, + ); + } + + if (!phoneNumber) { + setError(t("registration.etrade.phoneLookupFailed")); + return; + } + + const registrationResponse = await registerWithEtrade({ + tin: organization.tin, + licenseNumber: selectedLicense.licenseNumber, + phoneNumber, + }); + const registration = registrationResponse.data; + + if (!registration?.token?.trim()) { + setError(t("complaint.tin.registrationFailed")); + return; + } + + const profile = await persistFaydaRegistrationAuth(registration); + if (profile) { + setUser(profile); + const firstPositionId = + profile.employee?.[0]?.positions?.[0]?.employeePositionId; + if (firstPositionId) { + setSelectedPositionId(firstPositionId); + } + } + + storeComplaintVerification({ + verified: true, + method: "tin", + organization: { + ...organization, + licenseNumber: selectedLicense.licenseNumber, + tradeName: selectedLicense.tradeName, + mainGuid: selectedLicense.mainGuid, + }, + verifiedAt: new Date().toISOString(), + registration, + }); + + navigate(COMPLAINT_RECORDS_PATH, { + replace: true, + state: { fromComplaintVerification: true }, + }); + } catch (continueError) { + handleError(continueError); + } finally { + setIsContinuing(false); + } + }; + + return ( + +
+ + + {t("complaint.back")} + + +
+
+ +
+
+

+ {step === "tin" + ? t("complaint.tin.formTitle") + : t("registration.etrade.selectLicenseTitle")} +

+

+ {step === "tin" + ? t("complaint.tin.formDescription") + : t("registration.etrade.selectLicenseDescription")} +

+
+
+ + {error && ( +
+ +

{error}

+
+ )} + + {step === "tin" ? ( +
+
+ + + setTin(event.target.value.replace(/\D/g, "").slice(0, 10)) + } + placeholder={t("complaint.tin.tinPlaceholder")} + className="font-mono text-lg tracking-wide" + autoComplete="off" + disabled={isVerifying} + required + /> +

+ {t("complaint.tin.tinHint")} +

+
+ + +
+ ) : ( +
+ + +
+ + +
+
+ )} +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/services/complaintVerificationService.ts b/apps/edr-freight-web/backoffice/src/complaints/services/complaintVerificationService.ts new file mode 100644 index 000000000..6ba06a049 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/services/complaintVerificationService.ts @@ -0,0 +1,72 @@ +import type { + ComplaintVerificationResult, + FaydaCallbackData, +} from "../types/complaint.types"; +import { + FaydaOidcError, + registerFaydaCitizenFromCode, +} from "./faydaOidcService"; +import { + fetchRegistrationByTin, + mapEtradeRegistration, + resolveEtradeLanguage, + TinRegistrationNotFoundError, +} from "./etradeTinService"; + +/** + * Authenticates or registers a citizen via FAYDA OIDC using a single flow. + */ +export async function verifyComplaintUser( + callbackData: FaydaCallbackData, + language = "en", +): Promise { + if (!callbackData.code?.trim()) { + throw new Error("Missing authorization code from FAYDA"); + } + + const { registration } = await registerFaydaCitizenFromCode( + callbackData.code, + language, + ); + + return { + verified: true, + method: "fayda", + verifiedAt: new Date().toISOString(), + registration, + }; +} + +/** + * Verifies an organization via the eTrade TIN registration API. + */ +export async function verifyComplaintTin( + tin: string, + language: string, +): Promise { + const normalizedTin = tin.trim(); + if (!/^\d{10}$/.test(normalizedTin)) { + throw new Error("INVALID_TIN"); + } + + const registration = await fetchRegistrationByTin( + normalizedTin, + resolveEtradeLanguage(language), + ); + + const organization = mapEtradeRegistration(registration, normalizedTin); + + if (!organization.organizationName && !organization.tin) { + throw new TinRegistrationNotFoundError(); + } + + return { + verified: true, + method: "tin", + organization, + verifiedAt: new Date().toISOString(), + }; +} + +export { TinRegistrationNotFoundError }; +export { FaydaOidcError }; diff --git a/apps/edr-freight-web/backoffice/src/complaints/services/etradeTinService.ts b/apps/edr-freight-web/backoffice/src/complaints/services/etradeTinService.ts new file mode 100644 index 000000000..3bdc653c4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/services/etradeTinService.ts @@ -0,0 +1,365 @@ +export type EtradeLanguage = "en" | "am"; + +export interface EtradeBusinessSubGroup { + Code?: number; + Description?: string; +} + +export interface EtradeBusiness { + MainGuid?: string; + OwnerTIN?: string; + DateRegistered?: string; + TradeNameAmh?: string; + TradesName?: string; + LicenceNumber?: string; + LicenseNumber?: string; + RenewalDate?: string; + RenewedFrom?: string; + RenewedTo?: string; + SubGroups?: EtradeBusinessSubGroup[]; +} + +export interface EtradeBusinessLicenseOption { + mainGuid: string; + licenseNumber: string; + tradeName: string; + activities: string[]; + renewedTo?: string; +} + +export interface EtradeRegistrationInfo { + Tin?: string; + BusinessName?: string; + BusinessNameAmh?: string; + RegNo?: string; + Businesses?: EtradeBusiness[]; + tin?: string; + businessName?: string; + businessNameAmh?: string; + regNo?: string; + businesses?: EtradeBusiness[]; + [key: string]: unknown; +} + +export class TinRegistrationNotFoundError extends Error { + readonly code = "TIN_NOT_FOUND" as const; + + constructor() { + super("TIN_NOT_FOUND"); + this.name = "TinRegistrationNotFoundError"; + } +} + +const ETRADE_API_BASE = + import.meta.env.VITE_ETRADE_API_BASE?.trim() || "/api/etrade"; + +export function resolveEtradeLanguage(language: string): EtradeLanguage { + return language.toLowerCase().startsWith("am") ? "am" : "en"; +} + +function hasRegistrationData(data: unknown): data is EtradeRegistrationInfo { + if (data == null) return false; + if (typeof data !== "object") return false; + if (Array.isArray(data)) return data.length > 0; + + const record = data as Record; + const keys = Object.keys(record); + if (keys.length === 0) return false; + + const tin = String(record.Tin ?? record.tin ?? "").trim(); + const businessName = String( + record.BusinessName ?? record.businessName ?? "", + ).trim(); + + return Boolean(tin || businessName); +} + +function normalizeEtradeBusinesses( + data: EtradeRegistrationInfo, +): EtradeBusiness[] { + const businesses = data.Businesses ?? data.businesses; + return Array.isArray(businesses) ? businesses : []; +} + +export function mapEtradeBusinessLicenses( + data: EtradeRegistrationInfo, + language: EtradeLanguage, +): EtradeBusinessLicenseOption[] { + const companyName = String( + data.BusinessName ?? data.businessName ?? "", + ).trim(); + const companyNameAmh = String( + data.BusinessNameAmh ?? data.businessNameAmh ?? "", + ).trim(); + + return normalizeEtradeBusinesses(data).flatMap((business) => { + const licenseNumber = String( + business.LicenceNumber ?? business.LicenseNumber ?? "", + ).trim(); + if (!licenseNumber) return []; + + const tradeName = String(business.TradesName ?? "").trim(); + const tradeNameAmh = String(business.TradeNameAmh ?? "").trim(); + const activities = (business.SubGroups ?? []) + .map((group) => String(group.Description ?? "").trim()) + .filter(Boolean); + + const displayTradeName = + language === "am" + ? tradeNameAmh || tradeName || companyNameAmh || companyName + : tradeName || tradeNameAmh || companyName || companyNameAmh; + + const option: EtradeBusinessLicenseOption = { + mainGuid: String(business.MainGuid ?? licenseNumber).trim(), + licenseNumber, + tradeName: displayTradeName || licenseNumber, + activities, + }; + + const renewedTo = String(business.RenewedTo ?? "").trim(); + if (renewedTo) { + option.renewedTo = renewedTo; + } + + return [option]; + }); +} + +export function mapEtradeRegistration( + data: EtradeRegistrationInfo, + fallbackTin: string, +) { + const tin = String(data.Tin ?? data.tin ?? fallbackTin).trim(); + const organizationName = String( + data.BusinessName ?? data.businessName ?? "", + ).trim(); + const regNo = String(data.RegNo ?? data.regNo ?? "").trim(); + + return { + organizationName: organizationName || tin, + tin, + ...(regNo ? { regNo } : {}), + }; +} + +export interface EtradeBusinessByLicense { + MainGuid?: string; + OwnerTIN?: string; + TradeName?: string; + LicenceNumber?: string; + LicenseNumber?: string; + AssociateShortInfos?: Array<{ + Position?: string; + ManagerName?: string; + ManagerNameEng?: string; + MobilePhone?: string; + RegularPhone?: string; + }>; + AddressInfo?: { + Region?: string; + Zone?: string; + Woreda?: string; + Kebele?: string; + HouseNo?: string; + MobilePhone?: string; + RegularPhone?: string; + }; + [key: string]: unknown; +} + +/** + * Normalize Ethiopian phone numbers for IAM auth. + * Accepts 09/07 mobiles and common 0X landline forms; returns +251... + */ +export function normalizeEtradePhoneNumber( + raw: string | null | undefined, +): string | null { + if (!raw?.trim()) return null; + + let digits = raw.trim().replace(/[^\d+]/g, ""); + if (digits.startsWith("+")) { + digits = digits.slice(1); + } + + // Already international without + + if (digits.startsWith("251") && digits.length >= 12) { + return `+${digits}`; + } + + // Local with leading 0 (mobile or landline): 09xxxxxxxx / 07xxxxxxxx / 0Xxxxxxxx + if (digits.startsWith("0") && digits.length >= 9) { + return `+251${digits.slice(1)}`; + } + + // Mobile without leading 0: 9xxxxxxxx / 7xxxxxxxx + if (/^[97]\d{8}$/.test(digits)) { + return `+251${digits}`; + } + + // Bare landline-ish digits (e.g. 52543000) — prefix country code only if 8–9 digits + if (/^\d{8,9}$/.test(digits)) { + return `+251${digits}`; + } + + return null; +} + +function pickRawPhoneFromBusiness(data: EtradeBusinessByLicense): string | null { + const associates = Array.isArray(data.AssociateShortInfos) + ? data.AssociateShortInfos + : []; + + // Prefer RegularPhone — eTrade MobilePhone is often a landline/invalid value + // (e.g. "52543000", "222400000") while RegularPhone holds the mobile (09...). + for (const associate of associates) { + const regular = String(associate.RegularPhone ?? "").trim(); + if (regular) return regular; + } + + const addressRegular = String(data.AddressInfo?.RegularPhone ?? "").trim(); + if (addressRegular) return addressRegular; + + for (const associate of associates) { + const mobile = String(associate.MobilePhone ?? "").trim(); + if (mobile) return mobile; + } + + const addressMobile = String(data.AddressInfo?.MobilePhone ?? "").trim(); + if (addressMobile) return addressMobile; + + return null; +} + +export function extractEtradePhoneNumber( + data: EtradeBusinessByLicense, +): string | null { + return normalizeEtradePhoneNumber(pickRawPhoneFromBusiness(data)); +} + +export async function fetchBusinessByLicenseNo( + licenseNo: string, + tin: string, + language: EtradeLanguage, +): Promise { + const params = new URLSearchParams({ + LicenseNo: licenseNo.trim(), + Tin: tin.trim(), + Lang: language, + }); + const url = `${ETRADE_API_BASE}/BusinessMain/GetBusinessByLicenseNo?${params.toString()}`; + + let response: Response; + try { + response = await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + } catch (error) { + console.error("[eTrade:License] Network request failed", error); + throw error; + } + + const responseText = (await response.text()).trim(); + + if (response.status === 417) { + console.error( + "[eTrade:License] Referer rejected by eTrade. Ensure /api/etrade proxy is configured.", + responseText, + ); + throw new Error("ETRADE_REFERER_REJECTED"); + } + + if (!response.ok && response.status !== 404) { + throw new Error(`ETRADE_HTTP_${response.status}`); + } + + if (!responseText) { + throw new Error("ETRADE_BUSINESS_NOT_FOUND"); + } + + let data: unknown; + try { + data = JSON.parse(responseText); + } catch (parseError) { + console.error( + "[eTrade:License] Invalid JSON response", + parseError, + responseText, + ); + throw new Error("INVALID_ETRADE_RESPONSE"); + } + + if (data == null || typeof data !== "object") { + throw new Error("ETRADE_BUSINESS_NOT_FOUND"); + } + + return data as EtradeBusinessByLicense; +} + +/** + * Looks up business details by license + TIN and returns a normalized phone + * suitable for IAM signup (`+251...`). + */ +export async function resolveEtradePhoneForSignup( + licenseNo: string, + tin: string, + language: EtradeLanguage = "en", +): Promise { + const business = await fetchBusinessByLicenseNo(licenseNo, tin, language); + return extractEtradePhoneNumber(business); +} + +export async function fetchRegistrationByTin( + tin: string, + language: EtradeLanguage, +): Promise { + const normalizedTin = tin.trim(); + const url = `${ETRADE_API_BASE}/Registration/GetRegistrationInfoByTin/${encodeURIComponent(normalizedTin)}/${language}`; + + let response: Response; + try { + response = await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + } catch (error) { + console.error("[eTrade:TIN] Network request failed", error); + throw error; + } + + const responseText = (await response.text()).trim(); + + if (response.status === 417) { + console.error( + "[eTrade:TIN] Referer rejected by eTrade. Ensure /api/etrade proxy is configured.", + responseText, + ); + throw new Error("ETRADE_REFERER_REJECTED"); + } + + if (!response.ok && response.status !== 404) { + throw new Error(`ETRADE_HTTP_${response.status}`); + } + + if (!responseText) { + throw new TinRegistrationNotFoundError(); + } + + let data: unknown; + try { + data = JSON.parse(responseText); + } catch (parseError) { + console.error("[eTrade:TIN] Invalid JSON response", parseError, responseText); + throw new Error("INVALID_ETRADE_RESPONSE"); + } + + if (data == null || !hasRegistrationData(data)) { + throw new TinRegistrationNotFoundError(); + } + + return data; +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/services/faydaOidcService.ts b/apps/edr-freight-web/backoffice/src/complaints/services/faydaOidcService.ts new file mode 100644 index 000000000..5c88d1fd1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/services/faydaOidcService.ts @@ -0,0 +1,75 @@ +import axios from "axios"; +import { + registerWithFayda, + type RegisterWithFaydaResponse, +} from "@/shared/services/authService"; +import { getComplaintFaydaRedirectUri } from "@/shared/utils/faydaOidc"; +import type { VerifiedCitizen } from "../types/complaint.types"; + +export class FaydaOidcError extends Error { + constructor( + readonly code: string, + message?: string, + ) { + super(message ?? code); + this.name = "FaydaOidcError"; + } +} + +export interface FaydaRegistrationResult { + registration: RegisterWithFaydaResponse; + citizen?: VerifiedCitizen; +} + +function mapRegisterWithFaydaError(error: unknown): never { + if (axios.isAxiosError(error)) { + const payload = error.response?.data; + const serverCode = + payload && typeof payload === "object" && "code" in payload + ? String((payload as { code?: string }).code ?? "") + : ""; + + if (serverCode.startsWith("FAYDA_")) { + throw new FaydaOidcError(serverCode); + } + + if (!error.response) { + throw new FaydaOidcError("FAYDA_TOKEN_NETWORK_ERROR"); + } + + const status = error.response.status; + if (status === 400) { + throw new FaydaOidcError("FAYDA_TOKEN_HTTP_400"); + } + if (status === 401) { + throw new FaydaOidcError("FAYDA_TOKEN_HTTP_401"); + } + if (status >= 500) { + throw new FaydaOidcError("FAYDA_TOKEN_HTTP_500"); + } + } + + if (error instanceof FaydaOidcError) { + throw error; + } + + throw new FaydaOidcError("FAYDA_EXCHANGE_FAILED"); +} + +export async function registerFaydaCitizenFromCode( + code: string, + _preferredLocale = "en", +): Promise { + try { + const response = await registerWithFayda({ code }); + const registration = response.data; + + if (!registration?.token?.trim()) { + throw new FaydaOidcError("FAYDA_EXCHANGE_FAILED"); + } + + return { registration }; + } catch (error) { + mapRegisterWithFaydaError(error); + } +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/types/complaint.types.ts b/apps/edr-freight-web/backoffice/src/complaints/types/complaint.types.ts new file mode 100644 index 000000000..e5d1c9701 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/types/complaint.types.ts @@ -0,0 +1,42 @@ +export type ComplaintVerificationMethod = "fayda" | "tin"; + +export interface VerifiedCitizen { + fullName: string; + faydaId: string; +} + +export interface VerifiedOrganization { + organizationName: string; + tin: string; + regNo?: string; + licenseNumber?: string; + tradeName?: string; + mainGuid?: string; +} + +import type { RegisterWithFaydaResponse } from "@/shared/services/authService"; + +export interface ComplaintVerificationResult { + verified: boolean; + method: ComplaintVerificationMethod; + citizen?: VerifiedCitizen; + organization?: VerifiedOrganization; + verifiedAt: string; + registration?: RegisterWithFaydaResponse; +} + +export interface ComplaintVerificationSession extends ComplaintVerificationResult { + verified: true; + method: ComplaintVerificationMethod; +} + +export interface FaydaCallbackData { + code: string; + state?: string | null; +} + +export interface ComplaintFormDraft { + recipient: string; + subject: string; + description: string; +} diff --git a/apps/edr-freight-web/backoffice/src/complaints/utils/complaintRoutes.ts b/apps/edr-freight-web/backoffice/src/complaints/utils/complaintRoutes.ts new file mode 100644 index 000000000..2d6a0fa21 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/utils/complaintRoutes.ts @@ -0,0 +1,4 @@ +export const COMPLAINT_SUBMIT_PATH = + "/external-portal/portal-outgoing/submit-letter"; + +export const COMPLAINT_RECORDS_PATH = "/external-portal/portal-outgoing"; diff --git a/apps/edr-freight-web/backoffice/src/complaints/utils/complaintVerificationStorage.ts b/apps/edr-freight-web/backoffice/src/complaints/utils/complaintVerificationStorage.ts new file mode 100644 index 000000000..f7a500cfc --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/complaints/utils/complaintVerificationStorage.ts @@ -0,0 +1,109 @@ +import type { ComplaintVerificationSession } from "../types/complaint.types"; + +const STORAGE_KEY = "complaint-verification"; +const COMPLAINT_VERIFICATION_IDLE_TIMEOUT_MS = 10 * 60 * 1000; + +export function storeComplaintVerification( + session: ComplaintVerificationSession, +): void { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session)); +} + +export function getComplaintVerification(): ComplaintVerificationSession | null { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return null; + + try { + const session = JSON.parse(raw) as ComplaintVerificationSession; + if (!session.verified) { + return null; + } + + const method = session.method ?? "fayda"; + if (method === "fayda" && !session.citizen) { + return null; + } + if (method === "tin" && !session.organization?.tin) { + return null; + } + + return { ...session, method }; + } catch { + clearComplaintVerification(); + return null; + } +} + +export function clearComplaintVerification(): void { + sessionStorage.removeItem(STORAGE_KEY); +} + +export function hasComplaintVerification(): boolean { + return getComplaintVerification() !== null; +} + +export function isComplaintAuthContext(pathname = ""): boolean { + return ( + pathname.startsWith("/complaints") || + pathname === "/complaint-form" || + pathname === "/follow-complaint" || + pathname === "/callback" + ); +} + +export function setupComplaintVerificationIdleCleanup( + timeoutMs = COMPLAINT_VERIFICATION_IDLE_TIMEOUT_MS, +): () => void { + let timeoutId: number | null = null; + + const scheduleCleanup = () => { + if (timeoutId) { + window.clearTimeout(timeoutId); + } + + timeoutId = window.setTimeout(() => { + clearComplaintVerification(); + }, timeoutMs); + }; + + const handleActivity = () => { + if (!sessionStorage.getItem(STORAGE_KEY)) { + if (timeoutId) { + window.clearTimeout(timeoutId); + timeoutId = null; + } + return; + } + + scheduleCleanup(); + }; + + const events: (keyof WindowEventMap)[] = [ + "mousemove", + "mousedown", + "keydown", + "scroll", + "touchstart", + "click", + "focus", + ]; + + events.forEach((eventName) => { + window.addEventListener(eventName, handleActivity, { passive: true }); + }); + + document.addEventListener("visibilitychange", handleActivity); + handleActivity(); + + return () => { + if (timeoutId) { + window.clearTimeout(timeoutId); + timeoutId = null; + } + + events.forEach((eventName) => { + window.removeEventListener(eventName, handleActivity); + }); + document.removeEventListener("visibilitychange", handleActivity); + }; +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx index 78cc5efd6..1b2c46bac 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -47,6 +47,12 @@ export interface ClearanceReviewSectionProps { queriesLocked?: boolean; /** Read-only audit view — no approve/query actions. */ readOnly?: boolean; + /** + * GENERAL customs bookings use the phased milestone workflow (same as + * ONE_TIME contracts): hide the legacy output-documents upload block and the + * finalize button — declaration/duty/transit run in the phased action panel. + */ + phasedCustoms?: boolean; } const STATUS_META: Record< @@ -73,6 +79,7 @@ export function ClearanceReviewSection({ approvalsLocked = false, queriesLocked = false, readOnly = false, + phasedCustoms = false, }: ClearanceReviewSectionProps) { const qc = useQueryClient(); const [queryNotes, setQueryNotes] = useState>({}); @@ -240,7 +247,7 @@ export function ClearanceReviewSection({ - {clearance.outputCode && ( + {clearance.outputCode && !phasedCustoms && ( )} - {finalizeMutation.isError && ( + {!phasedCustoms && finalizeMutation.isError && ( }> {finalizeMutation.error instanceof Error ? finalizeMutation.error.message @@ -349,8 +356,10 @@ export function ClearanceReviewSection({ )} - - + {phasedCustoms ? ( + // Phased (GENERAL customs) — no legacy finalize; the milestone steps in + // the action panel drive the workflow, same as ONE_TIME contracts. + - + {clearance.allApproved ? ( + + ) : ( + + )} {clearance.allApproved - ? "All required documents are approved — you can finalize." - : "Approve every required document to unlock finalization."} + ? "All required documents are approved. Continue declaration, duty, and transit in the action panel." + : "Approve every required document to unlock the customs milestone steps."} - - - + + ) : clearance.status !== "DOCUMENTS_UNDER_REVIEW" ? ( + // Finalize is only valid from DOCUMENTS_UNDER_REVIEW (the API rejects + // any other status with a 409) — once the booking moved on, show the + // finalized state instead of a button that can only fail. + + + + {clearance.allApproved ? ( + + ) : ( + + )} + + + {clearance.allApproved + ? "Clearance has been finalized. The customer can now pick a shipment day and proceed to operation." + : "Finalization unlocks once the customer submits their documents and every required document is approved."} + + + + ) : ( + + + + + + + + {clearance.allApproved + ? "All required documents are approved — you can finalize." + : "Approve every required document to unlock finalization."} + + + + + + )} {viewer} ); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx index dc59c4dfc..c94e005ae 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -50,6 +50,13 @@ import { import { contractsService } from "@/services/contracts.service"; import { bookingsService } from "@/services/bookings.service"; +/** Today as `yyyy-MM-dd` in the browser's local zone (a DateInput `minDate`). */ +function todayISODate(): string { + const now = new Date(); + const tz = now.getTimezoneOffset() * 60000; + return new Date(now.getTime() - tz).toISOString().slice(0, 10); +} + /** * Export customs flow, ordered per the stakeholder process: * customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET) @@ -937,6 +944,7 @@ export function ReleaseOrderCard({ ); const [loading, setLoading] = useState(false); const [amendLoading, setAmendLoading] = useState(false); + const minVesselDate = useMemo(todayISODate, []); return ( @@ -949,6 +957,7 @@ export function ReleaseOrderCard({ label="Vessel departure date" value={vesselDate} onChange={(v) => setVesselDate(v ? new Date(v) : null)} + minDate={minVesselDate} size="sm" /> diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx index 87a8993ee..1953796dd 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Button, Group, Modal, Stack, Text } from "@mantine/core"; import { DateInput } from "@mantine/dates"; import { Ship, Upload } from "lucide-react"; @@ -40,6 +40,13 @@ export function GlClearanceUploadModal({ vesselDepartureDate ? new Date(vesselDepartureDate) : null, ); const [loading, setLoading] = useState(false); + // Earliest selectable vessel date (today, local) — refreshed on each open. + const todayISODate = useMemo(() => { + if (!opened) return undefined; + const now = new Date(); + const tz = now.getTimezoneOffset() * 60000; + return new Date(now.getTime() - tz).toISOString().slice(0, 10); + }, [opened]); const isDo = kind === "do"; const isRo = kind === "ro"; @@ -115,6 +122,7 @@ export function GlClearanceUploadModal({ label="Vessel departure date" value={vesselDate} onChange={(v) => setVesselDate(v ? new Date(v) : null)} + minDate={todayISODate} size="sm" required /> @@ -123,6 +131,7 @@ export function GlClearanceUploadModal({ label="Vessel arrival date (optional)" value={vesselDate} onChange={(v) => setVesselDate(v ? new Date(v) : null)} + minDate={todayISODate} size="sm" clearable /> diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 547e9ce9f..32ec2c511 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -12,6 +12,7 @@ import { Button, Center, Divider, + FileButton, Group, Loader, Modal, @@ -31,10 +32,13 @@ import { CalendarDays, CheckCircle2, ChevronLeft, + FileDown, FileText, + FileUp, MapPin, Package, Receipt, + Repeat, X, } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -54,6 +58,10 @@ import { type GlShipmentQuantities, } from "./gl-booking-form/total"; import { ContractCapacityNotice } from "./gl-booking-form/ContractCapacityNotice"; +import { + downloadContainerImportTemplate, + parseContainerExcel, +} from "./gl-booking-form/container-excel"; import { fieldStyles, StepCard, @@ -64,6 +72,21 @@ import { /** All booking-window times are communicated in East Africa Time. */ const EAT_TZ = "Africa/Addis_Ababa"; +// ISO 6346: 4-letter owner/category code + 6-digit serial + check digit. +// Same rule the customer portal shipment form enforces. +const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/; + +interface UnitErrors { + containerNumber?: string; + vgmTons?: string; +} + +interface BulkErrors { + quantity?: string; + hazardous?: string; + reefer?: string; +} + function fmtWindowOpensAt(iso: string): string { const date = new Date(iso).toLocaleDateString("en-GB", { weekday: "short", @@ -170,9 +193,19 @@ export default function GlCreateBookingForm() { const [notes, setNotes] = useState(""); const [containerLines, setContainerLines] = useState([]); const [bulkLines, setBulkLines] = useState([]); + const [withReturn, setWithReturn] = useState(false); const [prefilled, setPrefilled] = useState(false); const [priceOpen, setPriceOpen] = useState(false); const seededRef = useRef(false); + const returnSeededRef = useRef(false); + + // Seed the equipment-return toggle from the contract exactly once (also when + // the form is prefilled from a shipment request); GL can flip it per shipment. + useEffect(() => { + if (!contract || returnSeededRef.current) return; + returnSeededRef.current = true; + setWithReturn(contract.equipmentReturn === "WITH_RETURN"); + }, [contract]); const isContainer = contract?.freightType === "CONTAINER"; const routes = useMemo( @@ -372,6 +405,123 @@ export default function GlCreateBookingForm() { prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), ); + // Same client-side validation as the customer portal shipment form: ISO + // container numbers (unique within the shipment) and a positive VGM per unit; + // bulk needs a positive quantity with hazardous/reefer portions bounded by it. + const [showErrors, setShowErrors] = useState(false); + + // Excel import: one row per container. All-or-nothing — a file with any bad + // row is rejected with row-numbered errors so nothing is silently dropped. + const [importErrors, setImportErrors] = useState([]); + const [importSummary, setImportSummary] = useState(null); + const importResetRef = useRef<(() => void) | null>(null); + const excelOpts = { + allowedSizes: containerSizes, + includeHazardous: contract?.isHazardous ?? false, + includeReefer: contract?.isReefer ?? false, + }; + + const handleImportFile = async (file: File | null) => { + // Reset the hidden input so re-picking the same (fixed) file re-fires. + importResetRef.current?.(); + if (!file) return; + const { rows, errors } = await parseContainerExcel(file, excelOpts); + if (errors.length > 0) { + setImportSummary(null); + setImportErrors(errors); + return; + } + // Replace only the lines for sizes present in the file; a contracted size + // the file omits keeps whatever was already entered for it. + setContainerLines((prev) => + containerSizes.map((size) => { + const imported = rows.filter((r) => r.containerSize === size); + if (imported.length === 0) { + return ( + prev.find((l) => l.containerSize === size) ?? { + containerSize: size, + units: [emptyUnit()], + } + ); + } + return { + containerSize: size, + units: imported.map((r) => ({ + containerNumber: r.containerNumber, + sealNumber: r.sealNumber, + vgmTons: r.vgmTons, + hazardous: r.hazardous, + reefer: r.reefer, + })), + }; + }), + ); + setImportErrors([]); + setShowErrors(false); + setImportSummary(`Imported ${rows.length} container(s) from ${file.name}.`); + }; + + const unitErrors = useMemo(() => { + if (!isContainer) return []; + const numberCounts = new Map(); + containerLines.forEach((line) => + line.units.forEach((u) => { + const key = u.containerNumber.trim().toUpperCase(); + if (!key) return; + numberCounts.set(key, (numberCounts.get(key) ?? 0) + 1); + }), + ); + return containerLines.map((line) => + line.units.map((u) => { + const errs: UnitErrors = {}; + const key = u.containerNumber.trim().toUpperCase(); + if (!key) { + errs.containerNumber = "Container number is required."; + } else if (!ISO_CONTAINER_NUMBER_REGEX.test(key)) { + errs.containerNumber = + "Enter a valid ISO container number (e.g. ABCD1234567)."; + } else if ((numberCounts.get(key) ?? 0) > 1) { + errs.containerNumber = "Duplicate container number in this shipment."; + } + const vgm = Number(u.vgmTons); + if (String(u.vgmTons).trim() === "" || Number.isNaN(vgm) || vgm <= 0) { + errs.vgmTons = "Enter a valid VGM."; + } + return errs; + }), + ); + }, [isContainer, containerLines]); + + const bulkErrors = useMemo(() => { + if (isContainer) return []; + return bulkLines.map((line) => { + const errs: BulkErrors = {}; + const qty = Number(line.cargoWeightTons || line.itemCount || 0); + if (Number.isNaN(qty) || qty <= 0) { + errs.quantity = "Enter a quantity greater than 0."; + } + const h = Number(line.hazardousQuantity || 0); + if (Number.isNaN(h) || h < 0) { + errs.hazardous = "Enter a valid hazardous quantity."; + } else if (qty > 0 && h > qty) { + errs.hazardous = `Can't exceed the cargo quantity (${qty}).`; + } + const r = Number(line.reeferQuantity || 0); + if (Number.isNaN(r) || r < 0) { + errs.reefer = "Enter a valid refrigerated quantity."; + } else if (qty > 0 && r > qty) { + errs.reefer = `Can't exceed the cargo quantity (${qty}).`; + } + return errs; + }); + }, [isContainer, bulkLines]); + + const cargoValid = isContainer + ? unitErrors.every((line) => + line.every((e) => !e.containerNumber && !e.vgmTons), + ) + : bulkErrors.every((e) => !e.quantity && !e.hazardous && !e.reefer); + const canSubmit = windowOpen && Boolean(scheduledDate) && @@ -388,6 +538,10 @@ export default function GlCreateBookingForm() { scheduledDate, ...(contractRouteId ? { contractRouteId } : {}), ...(notes.trim() ? { notes: notes.trim() } : {}), + // Equipment return is a container concern — bulk keeps the contract default. + ...(isContainer + ? { equipmentReturn: withReturn ? "WITH_RETURN" : "WITHOUT_RETURN" } + : {}), }; if (isContainer) { @@ -401,7 +555,7 @@ export default function GlCreateBookingForm() { hazardousQuantity: l.units.filter((u) => u.hazardous).length, reeferQuantity: l.units.filter((u) => u.reefer).length, units: l.units.map((u) => ({ - containerNumber: u.containerNumber, + containerNumber: u.containerNumber.trim().toUpperCase(), ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), vgmTons: Number(u.vgmTons) || 0, })), @@ -458,6 +612,13 @@ export default function GlCreateBookingForm() { const overweightLines = validation?.overweightLines ?? []; const openPriceModal = () => { + // Surface the per-field errors (portal-parity validation) instead of + // sending an invalid payload to the price preview. + if (!cargoValid) { + setShowErrors(true); + return; + } + setShowErrors(false); setPriceOpen(true); const payload = buildPayload(); if (payload) { @@ -467,7 +628,7 @@ export default function GlCreateBookingForm() { }; const handleSubmit = () => { - if (!contract || !windowOpen) return; + if (!contract || !windowOpen || !cargoValid) return; // Never book past unresolved 20ft pairing hard-blocks. if (pairingErrors.length > 0) return; // A line above the container type's max capacity can never book. @@ -483,8 +644,13 @@ export default function GlCreateBookingForm() { } catch { // Non-fatal } - navigate(`/dashboard/bookings/${booking.id}/clearance`); + } + if (contract.contractKind === "GENERAL") { + // GENERAL per-booking clearance: land on the booking's clearance + // detail — the same page the Shipments tab on the hub opens. + navigate(`/dashboard/clearance/${booking.id}`); } else { + // ONE_TIME customs keeps its clearance on the contract. navigate(`/dashboard/contracts/clearance/${contract.id}`); } }, @@ -647,6 +813,83 @@ export default function GlCreateBookingForm() { description="Enter the quantity and per-container details for each size in the contract scope." /> + {containerSizes.length > 0 && ( + + + + + Import containers from Excel + + + One row per container. Importing fills the lines below + for the sizes in the file. + + + + + + {(props) => ( + + )} + + + + {importErrors.length > 0 && ( + } + title="Import failed — fix the file and try again" + mt="sm" + > + + {importErrors.slice(0, 8).map((msg, i) => ( + + {msg} + + ))} + {importErrors.length > 8 && ( + + …and {importErrors.length - 8} more. + + )} + + + )} + {importSummary && ( + } + mt="sm" + > + {importSummary} + + )} + + )} {containerLines.length === 0 ? ( @@ -692,6 +935,11 @@ export default function GlCreateBookingForm() { label={unitIdx === 0 ? "Container number *" : undefined} placeholder="e.g. MSCU1234567" value={unit.containerNumber} + error={ + showErrors + ? unitErrors[lineIdx]?.[unitIdx]?.containerNumber + : undefined + } onChange={(e) => patchUnit(lineIdx, unitIdx, { containerNumber: e.currentTarget.value, @@ -720,6 +968,11 @@ export default function GlCreateBookingForm() { min={0} decimalScale={2} value={unit.vgmTons} + error={ + showErrors + ? unitErrors[lineIdx]?.[unitIdx]?.vgmTons + : undefined + } onChange={(v) => patchUnit(lineIdx, unitIdx, { vgmTons: v }) } @@ -808,6 +1061,7 @@ export default function GlCreateBookingForm() { min={0} decimalScale={2} value={line.cargoWeightTons} + error={showErrors ? bulkErrors[idx]?.quantity : undefined} onChange={(v) => patchBulk(idx, { cargoWeightTons: v })} radius={10} styles={fieldStyles} @@ -818,6 +1072,7 @@ export default function GlCreateBookingForm() { placeholder="e.g. 500" min={0} value={line.itemCount} + error={showErrors ? bulkErrors[idx]?.quantity : undefined} onChange={(v) => patchBulk(idx, { itemCount: v })} radius={10} styles={fieldStyles} @@ -828,6 +1083,7 @@ export default function GlCreateBookingForm() { label="Hazardous quantity" min={0} value={line.hazardousQuantity} + error={showErrors ? bulkErrors[idx]?.hazardous : undefined} onChange={(v) => patchBulk(idx, { hazardousQuantity: v })} radius={10} styles={fieldStyles} @@ -838,6 +1094,7 @@ export default function GlCreateBookingForm() { label="Refrigerated quantity" min={0} value={line.reeferQuantity} + error={showErrors ? bulkErrors[idx]?.reefer : undefined} onChange={(v) => patchBulk(idx, { reeferQuantity: v })} radius={10} styles={fieldStyles} @@ -849,6 +1106,67 @@ export default function GlCreateBookingForm() { )} + {isContainer ? ( + + } + title="Equipment Return" + description="Choose whether the empty container(s) come back to EDR after unloading." + /> + setWithReturn((v) => !v)} + > + + + + + + + + With return + + + {withReturn + ? "Container(s) returned to EDR after unloading." + : "Container(s) retained by the customer after delivery."} + + + + setWithReturn(e.currentTarget.checked)} + onClick={(e) => e.stopPropagation()} + style={{ flexShrink: 0 }} + /> + + + + ) : null} + } @@ -905,26 +1223,39 @@ export default function GlCreateBookingForm() { marginTop: 24, }} > - - - - + + {showErrors && !cargoValid ? ( + } + mb="sm" + > + Fix the highlighted cargo fields before reviewing the price. + + ) : null} + + + + + isImport @@ -251,9 +262,17 @@ export function PhasedClearanceActionPanel({ effectiveBookingCreated, bookingMilestones, t1Uploaded, + freightPaid, ) : 0, - [clearance, isImport, effectiveBookingCreated, bookingMilestones, t1Uploaded], + [ + clearance, + isImport, + effectiveBookingCreated, + bookingMilestones, + t1Uploaded, + freightPaid, + ], ); if (isImport) { @@ -554,14 +573,26 @@ export function PhasedClearanceActionPanel({ )} + : } + > + + + : } > - + + ); + } + const wagonAllocated = Boolean(clearance.train?.wagonAllocated); return ( @@ -1015,6 +1064,18 @@ function RiskStep({ ); } + // Customs cannot rate cargo still under transit — the server rejects the + // assignment until the T1 is closed, so do not offer the control yet. + if (!clearance.t1?.closed) { + return ( + + ); + } + if (!canAct || !bookingId) { return ( - {!done ? children : null} + {!done || keepChildrenWhenDone ? children : null} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/AssignRiskCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/AssignRiskCard.tsx index 05fd97084..0f655edc5 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/AssignRiskCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/AssignRiskCard.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Badge, Box, Button, Group, SegmentedControl, Text } from "@mantine/core"; import { ShieldAlert } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -15,22 +15,42 @@ const RISK_COLOR: Record = { export function AssignRiskCard({ bookingId, milestone, + locked = false, }: { bookingId: string; milestone: Freight.IClearanceMilestone; + /** + * Duty has already been advised off this risk level, so the decision is now + * final. Until then a mis-assigned level must stay correctable — the server + * accepts reassignment and overwrites the milestone metadata. + */ + locked?: boolean; }) { const assign = useAssignRisk(bookingId); - const [level, setLevel] = useState("GREEN"); const assigned = milestone.status === "COMPLETED"; const current = milestone.metadata?.riskLevel; + const [level, setLevel] = useState( + current ?? "GREEN", + ); + + // The milestone loads (and refetches after a reassignment) after first render, + // so mirror the persisted level onto the control whenever it changes. + useEffect(() => { + if (current) setLevel(current); + }, [current]); return ( @@ -60,9 +80,10 @@ export function AssignRiskCard({ size="compact-sm" color="edr-green" loading={assign.isPending} + disabled={assigned && level === current} onClick={() => assign.mutate({ riskLevel: level })} > - Assign risk + {assigned ? "Reassign risk" : "Assign risk"} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlActionsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlActionsPanel.tsx index 57133cb0c..f11619b82 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlActionsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlActionsPanel.tsx @@ -70,7 +70,11 @@ export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) { {showTransport ? : null} {riskMs ? ( - + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts new file mode 100644 index 000000000..bbd3acf90 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts @@ -0,0 +1,200 @@ +import * as XLSX from "xlsx"; + +// Excel import for container shipments: one spreadsheet row per physical +// container, mirroring the manual per-unit fields (number, seal, VGM) plus the +// hazardous/reefer flags when the contract allows them. The parser is +// all-or-nothing — any bad row rejects the file with row-numbered errors so a +// partial import can never silently drop containers. + +// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit. +const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/; + +export interface ContainerExcelOptions { + /** Container sizes the contract scope allows (e.g. ["20ft", "40ft"]). */ + allowedSizes: string[]; + includeHazardous: boolean; + includeReefer: boolean; +} + +export interface ImportedContainerRow { + containerSize: string; + containerNumber: string; + sealNumber: string; + vgmTons: string; + hazardous: boolean; + reefer: boolean; +} + +export interface ContainerExcelResult { + rows: ImportedContainerRow[]; + errors: string[]; +} + +type ColumnKey = + | "containerSize" + | "containerNumber" + | "sealNumber" + | "vgmTons" + | "hazardous" + | "reefer"; + +/** Match a header cell to a known column, tolerant of casing/spacing/units. */ +function headerKey(raw: string): ColumnKey | null { + const h = raw.toLowerCase().replace(/[^a-z]/g, ""); + if (!h) return null; + if (h.includes("size")) return "containerSize"; + if (h.includes("seal")) return "sealNumber"; + if (h.includes("vgm") || h.includes("weight")) return "vgmTons"; + if (h.includes("hazard")) return "hazardous"; + if (h.includes("reefer") || h.includes("refrigerat")) return "reefer"; + // After the more specific matches: "Container Number", "Container No", … + if (h.includes("container") || h.includes("number")) return "containerNumber"; + return null; +} + +/** "20", "20ft", "20 FT" … → the matching contracted size, or null. */ +function normalizeSize(raw: string, allowed: string[]): string | null { + const digits = raw.replace(/[^0-9]/g, ""); + if (!digits) return null; + return allowed.find((s) => s.replace(/[^0-9]/g, "") === digits) ?? null; +} + +function parseFlag(raw: string): boolean { + const v = raw.trim().toLowerCase(); + return v === "yes" || v === "y" || v === "true" || v === "1" || v === "x"; +} + +/** + * Parse an uploaded workbook into one row per container. Returns either the + * full row set or the list of row-numbered problems (never both). + */ +export async function parseContainerExcel( + file: File, + opts: ContainerExcelOptions, +): Promise { + let sheet: XLSX.WorkSheet | undefined; + try { + const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" }); + sheet = workbook.Sheets[workbook.SheetNames[0]]; + } catch { + return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] }; + } + if (!sheet) { + return { rows: [], errors: ["The file has no sheets."] }; + } + + const grid = XLSX.utils.sheet_to_json(sheet, { + header: 1, + raw: false, + defval: "", + }); + + // First row with a recognizable column is the header; everything above + // (titles, blank rows) is ignored. + let headerRowIdx = -1; + let columns: Array = []; + for (let i = 0; i < grid.length; i++) { + const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? ""))); + if (mapped.includes("containerNumber") && mapped.includes("containerSize")) { + headerRowIdx = i; + columns = mapped; + break; + } + } + if (headerRowIdx < 0) { + return { + rows: [], + errors: [ + 'Could not find the expected columns. The sheet needs at least "Container Size" and "Container Number" headers — download the template to see the format.', + ], + }; + } + if (!columns.includes("vgmTons")) { + return { + rows: [], + errors: ['Missing a "VGM (Tons)" column — download the template to see the format.'], + }; + } + + const rows: ImportedContainerRow[] = []; + const errors: string[] = []; + const numberCounts = new Map(); + + for (let i = headerRowIdx + 1; i < grid.length; i++) { + const cells = grid[i] ?? []; + if (cells.every((c) => String(c ?? "").trim() === "")) continue; + const rowNo = i + 1; // 1-based, as shown in Excel + + const cell = (key: ColumnKey) => { + const idx = columns.indexOf(key); + return idx >= 0 ? String(cells[idx] ?? "").trim() : ""; + }; + + const size = normalizeSize(cell("containerSize"), opts.allowedSizes); + if (!size) { + errors.push( + `Row ${rowNo}: container size "${cell("containerSize") || "—"}" is not in this contract's scope (allowed: ${opts.allowedSizes.join(", ")}).`, + ); + } + + const containerNumber = cell("containerNumber").toUpperCase(); + if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) { + errors.push( + `Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. MSCU1234567).`, + ); + } else { + numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1); + } + + const vgmRaw = cell("vgmTons"); + const vgm = Number(vgmRaw); + if (!vgmRaw || Number.isNaN(vgm) || vgm <= 0) { + errors.push(`Row ${rowNo}: VGM "${vgmRaw || "—"}" must be a number greater than 0.`); + } + + rows.push({ + containerSize: size ?? "", + containerNumber, + sealNumber: cell("sealNumber"), + vgmTons: vgmRaw, + hazardous: opts.includeHazardous && parseFlag(cell("hazardous")), + reefer: opts.includeReefer && parseFlag(cell("reefer")), + }); + } + + numberCounts.forEach((count, num) => { + if (count > 1) errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`); + }); + + if (rows.length === 0 && errors.length === 0) { + errors.push("The sheet has no container rows below the header."); + } + + return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] }; +} + +/** Generate and download the simple import template with one sample row per size. */ +export function downloadContainerImportTemplate(opts: ContainerExcelOptions) { + const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"]; + if (opts.includeHazardous) headers.push("Hazardous (YES/NO)"); + if (opts.includeReefer) headers.push("Reefer (YES/NO)"); + + const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"]; + const sampleRows = sizes.map((size, i) => { + const row: Array = [ + size, + `MSCU${String(1234567 + i).padStart(7, "0")}`, + `SL${String(482910 + i)}`, + size.startsWith("40") ? 28 : 24.5, + ]; + if (opts.includeHazardous) row.push("NO"); + if (opts.includeReefer) row.push("NO"); + return row; + }); + + const sheet = XLSX.utils.aoa_to_sheet([headers, ...sampleRows]); + sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 16) })); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, sheet, "Containers"); + XLSX.writeFile(workbook, "container-import-template.xlsx"); +} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index 924d62436..371a83ccf 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -153,6 +153,7 @@ export function ChangeRequestReview({ company }: { company: Company }) { : ([] as string[]); const docCount = pending?.documentFileIds?.length ?? 0; const licenseChanges = pending?.licenseChanges ?? []; + const documentChanges = pending?.documentChanges ?? []; const confirmReject = () => { if (!rejectId) return; @@ -210,11 +211,75 @@ export function ChangeRequestReview({ company }: { company: Company }) { )} + {documentChanges.length > 0 && ( + + + Document changes + + {documentChanges.map((c, i) => ( + + {c.op === "add" ? ( + + ) : ( + + )} + + {c.op === "add" ? "Add" : "Remove"} + + + view({ + name: c.fileName ?? humanize(c.code), + url: fileViewUrl(c.fileId), + }) + } + style={{ + textDecoration: + c.op === "remove" ? "line-through" : undefined, + }} + > + {c.fileName ?? humanize(c.code)} + + + {humanize(c.code)} + + + ))} + + )} + {docCount > 0 && ( - - {docCount} document{docCount === 1 ? "" : "s"} uploaded with this - request — review them in the Documents tab. - + + + Documents uploaded with this request + + {pending!.documentFileIds.map((fileId, i) => ( + + + + view({ + name: `Document ${i + 1}`, + url: fileViewUrl(fileId), + }) + } + > + Document {i + 1} + + + ))} + )} {licenseChanges.length > 0 && ( diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx new file mode 100644 index 000000000..bc505a21f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx @@ -0,0 +1,105 @@ +import { Button, Modal, Radio, Stack, Text } from "@mantine/core"; +import { useMutation } from "@tanstack/react-query"; +import { KeyRound } from "lucide-react"; +import { useState } from "react"; + +import { useAuth } from "@/auth/useAuth"; +import { useToast } from "@/hooks/use-toast"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { api } from "@/services/api"; +import type { Company, ResetChannel } from "@/types/customer"; + +export interface ResetPasswordActionProps { + company: Pick; +} + +/** + * Staff-triggered password reset. Sends a one-time code to the customer's + * primary contact; the customer picks their own new password. No credential is + * ever shown to or handled by staff. + */ +export default function ResetPasswordAction({ company }: ResetPasswordActionProps) { + const { user } = useAuth(); + const { toast } = useToast(); + const [opened, setOpened] = useState(false); + const [channel, setChannel] = useState("phone"); + + const { mutate, isPending } = useMutation( + api.customers.resetPassword.mutationOptions({ + onSuccess: (result) => { + setOpened(false); + toast({ + title: "Reset code sent", + description: `The customer can now reset their password using the code sent to ${result.maskedTarget}.`, + }); + }, + onError: (error) => { + toast({ + title: "Could not send reset code", + description: error.message, + variant: "destructive", + }); + }, + }), + ); + + if (!hasPermission(user, FREIGHT_PERMS.customers.resetPassword)) return null; + + return ( + <> + + + setOpened(false)} + title="Send a password-reset code" + centered + > + + + We'll send a one-time code to this customer's primary contact. + They choose their own new password — you will not see it. + + + setChannel(v as ResetChannel)} + label="Send the code via" + > + + + + + + + + The code goes to the primary contact's own email or phone, which + may differ from the company contact details shown above. + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 2a87e3b0c..d42673d2b 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -13,5 +13,9 @@ export { ChangeRequestReview, ChangeRequestPendingBadge, } from "./ChangeRequestReview"; +export { + default as ResetPasswordAction, + type ResetPasswordActionProps, +} from "./ResetPasswordAction"; export { formatBytes, formatDate, formatMoney, humanize } from "./format"; export { TableCard, type TableCardProps } from "./TableCard"; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index 391a8157d..698b0fc2c 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -250,7 +250,10 @@ const FreightSidebar = ({ diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 8553bcf0b..8c1aed302 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -307,13 +307,24 @@ const RuleEngineFormDialog = ({ ); } + const isNumber = field.type === "number"; + return ( setField(field.name, e.currentTarget.value)} + onChange={(e) => { + const next = e.currentTarget.value; + if (isNumber && next.trim().startsWith("-")) return; + setField(field.name, next); + }} placeholder={field.placeholder} required={field.required} size="md" diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx index ec3774ce6..1bfa392bc 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx @@ -392,6 +392,7 @@ export default function BookingWindowSettingsModal({ } min={1} clampBehavior="none" + allowNegative={false} allowDecimal={false} /> ) : ( @@ -410,6 +411,7 @@ export default function BookingWindowSettingsModal({ } min={0} clampBehavior="none" + allowNegative={false} allowDecimal={false} /> )} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx index 06c9013d4..9c3c54f3c 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/DurationField.tsx @@ -98,6 +98,7 @@ export default function DurationField({ emitNative(v === "" ? "" : Number(v), unit) } clampBehavior="none" + allowNegative={false} allowDecimal min={min != null ? convert(min, nativeUnit, unit) : 0} disabled={disabled} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx index 6ea7dfc5b..c173614dc 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EditScheduleDateModal.tsx @@ -67,9 +67,13 @@ export default function EditScheduleDateModal({ ); const [value, setValue] = useState(""); + // Earliest selectable departure, refreshed each time the modal opens. + const [minValue, setMinValue] = useState(""); useEffect(() => { - if (opened) setValue(toLocalInputValue(currentDate)); + if (!opened) return; + setValue(toLocalInputValue(currentDate)); + setMinValue(toLocalInputValue(new Date().toISOString())); }, [opened, currentDate]); const handleSave = async () => { @@ -77,6 +81,13 @@ export default function EditScheduleDateModal({ toast({ title: "Pick a departure date", variant: "destructive" }); return; } + if (new Date(value).getTime() < Date.now()) { + toast({ + title: "Departure date must be in the future", + variant: "destructive", + }); + return; + } try { await save.mutateAsync({ id: scheduleId, @@ -124,6 +135,7 @@ export default function EditScheduleDateModal({ setValue(e.currentTarget.value)} /> diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx index 84367fad7..b02ebbc8f 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx @@ -295,9 +295,9 @@ export function PriorityTrackingTab({ data, bookings }: Props) { }, [bookings]); const scoreMax = useMemo(() => maxScore(ranked), [ranked]); - // maxWagons is not on the board DTO (capacity is length/weight-based), so the - // capacity line shows the wagons currently committed rather than a hard cap. - const maxWagons: number | null = null; + // Wagon-slot cap from the board DTO (derived from train length and the + // shortest wagon type); null on legacy rows without a computable cap. + const maxWagons: number | null = data.capacity.maxWagons ?? null; // Split the ranking at the capacity line: cumulative wagons of slot-occupying // bookings (allocated + selected + paid-waiting) up to the train's wagon cap. @@ -431,23 +431,31 @@ export function PriorityTrackingTab({ data, bookings }: Props) { {data.capacity.allocatedWagons} allocated ·{" "} {capUsed} in batch + {maxWagons != null ? ` · ${maxWagons} max` : ""} + {/* Scale against the real wagon cap when the DTO carries one; fall back + to the in-batch total on legacy rows without a computable cap. */} 0 - ? Math.min(100, (data.capacity.allocatedWagons / capUsed) * 100) + (maxWagons ?? capUsed) > 0 + ? Math.min( + 100, + (data.capacity.allocatedWagons / (maxWagons ?? capUsed)) * 100, + ) : 0 } color="edr-green" /> 0 + (maxWagons ?? capUsed) > 0 ? Math.min( 100, - ((capUsed - data.capacity.allocatedWagons) / capUsed) * 100, + ((capUsed - data.capacity.allocatedWagons) / + (maxWagons ?? capUsed)) * + 100, ) : 0 } diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/RescheduleTrainDialog.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/RescheduleTrainDialog.tsx index d0f670e51..271603bca 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/RescheduleTrainDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/RescheduleTrainDialog.tsx @@ -1,9 +1,19 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Button, Group, Modal, Stack, Text, TextInput, Textarea } from "@mantine/core"; import toast from "react-hot-toast"; import { trainSchedulingService } from "@/services/trainScheduling.service"; +/** `min` for a `datetime-local` input: now, in the browser's local zone. */ +function nowLocalDateTime(): string { + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` + + `T${pad(now.getHours())}:${pad(now.getMinutes())}` + ); +} + export function RescheduleTrainDialog({ scheduleId, currentBookingIds, @@ -20,12 +30,21 @@ export function RescheduleTrainDialog({ const [newDepartureDate, setNewDepartureDate] = useState(""); const [reason, setReason] = useState(""); const [loading, setLoading] = useState(false); + // Earliest selectable departure, refreshed each time the dialog opens. + const minDepartureDate = useMemo( + () => (opened ? nowLocalDateTime() : ""), + [opened], + ); const handleSubmit = async () => { if (!newDepartureDate) { toast.error("Select a new departure date"); return; } + if (new Date(newDepartureDate).getTime() < Date.now()) { + toast.error("New departure must be in the future"); + return; + } setLoading(true); try { await trainSchedulingService.maintenanceReschedule(scheduleId, { @@ -53,6 +72,7 @@ export function RescheduleTrainDialog({ setNewDepartureDate(e.target.value)} /> diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx index c7530209b..6ba1a0e29 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx @@ -16,6 +16,7 @@ type DiagramWagonInput = { sequenceNo: number; capacityTons: number; assignedWeightTons: number; + tareWeightTons?: number | null; slotLoadType?: string | null; wagonType?: { code?: string | null } | null; wagonTypeCode?: string | null; @@ -33,6 +34,7 @@ type NormalizedWagon = { sequenceNo: number; capacityTons: number; assignedWeightTons: number; + tareWeightTons: number; wagonTypeCode: string | null; physicalWagonNumber: string | null; isEmpty: boolean; @@ -69,6 +71,7 @@ function normalizeWagon(w: DiagramWagonInput, freightType?: string | null): Norm sequenceNo: w.sequenceNo, capacityTons: Number(w.capacityTons) || 0, assignedWeightTons: Number(w.assignedWeightTons) || 0, + tareWeightTons: Number(w.tareWeightTons) || 0, wagonTypeCode: w.wagonType?.code ?? w.wagonTypeCode ?? null, physicalWagonNumber: w.physicalWagonNumber ?? null, isEmpty: allocations.length === 0, @@ -278,7 +281,9 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) { wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : "" }${ wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : "" - }${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)`; + }${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)${ + wagon.tareWeightTons ? `\nTare: ${wagon.tareWeightTons}T` : "" + }`; // container blocks: one per container number (cap visual at 2 = TEU per wagon) const blocks = wagon.containerNumbers.slice(0, 2); @@ -523,15 +528,22 @@ export function TrainCompositionDiagram({ const assigned = normalized.filter((w) => !w.isEmpty).length; const totalWeight = normalized.reduce((s, w) => s + w.assignedWeightTons, 0); const totalCapacity = normalized.reduce((s, w) => s + w.capacityTons, 0); + // Every coupled wagon's tare is hauled — empty ones included — so the + // locomotive pull limit is measured against gross (tare + cargo), the same + // ceiling the allocation engine spends from. + const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0); + const grossWeight = totalWeight + totalTare; return { total: normalized.length, assigned, empty: normalized.length - assigned, totalWeight: Math.round(totalWeight * 100) / 100, + totalTare: Math.round(totalTare * 100) / 100, + grossWeight: Math.round(grossWeight * 100) / 100, totalCapacity, pullUtil: locomotive?.maxPullWeightTons && locomotive.maxPullWeightTons > 0 - ? Math.min(100, Math.round((totalWeight / locomotive.maxPullWeightTons) * 100)) + ? Math.min(100, Math.round((grossWeight / locomotive.maxPullWeightTons) * 100)) : null, }; }, [normalized, locomotive]); @@ -598,12 +610,30 @@ export function TrainCompositionDiagram({ }} > - {stats.totalWeight}T + {stats.totalWeight}T cargo of {stats.totalCapacity}T capacity + {stats.totalTare > 0 ? ( + + + {stats.grossWeight}T gross + + + incl. {stats.totalTare}T tare + + + ) : null} @@ -626,7 +656,10 @@ export function TrainCompositionDiagram({ - Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T + Locomotive load ·{" "} + {stats.totalTare > 0 + ? `${stats.grossWeight}T of ${locomotive?.maxPullWeightTons}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)` + : `${stats.totalWeight}T of ${locomotive?.maxPullWeightTons}T`} 95 ? "red.7" : "edr-green.7"}> diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx index bb769df46..57be67d68 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx @@ -37,6 +37,7 @@ const STAGE_TABS: Array<{ value: string; label: string }> = [ { value: 'ALL', label: 'All' }, { value: 'RECEIVED', label: 'Received' }, { value: 'GRN', label: "GRN'd" }, + { value: 'ASSIGNED', label: 'Assigned' }, { value: 'LOADED', label: 'Loaded' }, { value: 'LEFT', label: 'Left' }, { value: 'DELIVERED', label: 'Delivered' }, @@ -46,13 +47,15 @@ const STAGE_COLOR: Record = { PENDING: 'gray', RECEIVED: 'blue', GRN: 'teal', + ASSIGNED: 'indigo', LOADED: 'grape', LEFT: 'orange', DELIVERED: 'green', }; -/** Loadable = not yet on a truck (before LOADED). */ -const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN'; +/** Loadable = not yet loaded (PENDING/RECEIVED/GRN, or customer-ASSIGNED awaiting load). */ +const isLoadable = (i: ContainerItem) => + i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN' || i.stage === 'ASSIGNED'; export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) { const { toast } = useToast(); @@ -77,8 +80,13 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen () => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)), [items, tab], ); + // Only arrived, not-yet-departed trucks can be loaded. const truckOptions = trucks - .filter((t) => !(t as { departedAt?: string }).departedAt) + .filter( + (t) => + Boolean((t as { arrivedAt?: string }).arrivedAt) && + !(t as { departedAt?: string }).departedAt, + ) .map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` })); const loadMutation = useMutation({ @@ -181,7 +189,7 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen {i.contractId ? Contract : '—'} {i.hasLastMile ? EDR : Self-haul} - {i.truckAssignmentId && ( + {i.loaded && i.truckAssignmentId && ( ({ assignedEquipmentNumber: '', customsSealNumber: '', declarationNumber: '', - incoterms: '', - hsCodes: '', - itemCode: '', itemDescription: '', packagingType: '', unitCount: '', @@ -217,7 +212,6 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({ volumeDimensions: '', conditionAtReceipt: '', damagedRejectedQuantity: '', - warehouseCodeLocation: '', driverName: '', driverPhone: '', driverLicenseNumber: '', @@ -239,9 +233,6 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined, customsSealNumber: form.customsSealNumber.trim() || undefined, declarationNumber: form.declarationNumber.trim() || undefined, - incoterms: form.incoterms.trim() || undefined, - hsCodes: form.hsCodes.trim() || undefined, - itemCode: form.itemCode.trim() || undefined, itemDescription: form.itemDescription.trim() || undefined, packagingType: form.packagingType.trim() || undefined, unitCount: form.unitCount === '' ? undefined : Number(form.unitCount), @@ -251,7 +242,6 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl volumeDimensions: form.volumeDimensions.trim() || undefined, conditionAtReceipt: form.conditionAtReceipt.trim() || undefined, damagedRejectedQuantity: form.damagedRejectedQuantity === '' ? undefined : Number(form.damagedRejectedQuantity), - warehouseCodeLocation: form.warehouseCodeLocation.trim() || undefined, driverName: form.driverName.trim(), driverPhone: form.driverPhone.trim(), driverLicenseNumber: form.driverLicenseNumber.trim() || undefined, @@ -265,6 +255,127 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl warehouseManagerName: form.warehouseManagerName.trim() || undefined, }); + + + +const SUB_STAGE_COLOR: Record = { + PENDING: 'gray', + RECEIVED: 'blue', + GRN: 'teal', + ASSIGNED: 'indigo', + LOADED: 'grape', + LEFT: 'orange', + DELIVERED: 'green', +}; + +/** + * Expanded booking row: the booking's containers / bulk items with their + * lifecycle stage. Shares the ['container-items', bookingId] cache with + * ContainerItemsModal, so expanding after using the modal is instant. + */ +function BookingItemsExpansion({ + bookingId, + colSpan, + bulkFallback, +}: { + bookingId: string | null; + colSpan: number; + bulkFallback?: string; +}) { + const { data: items = [], isLoading } = useQuery({ + queryKey: ['container-items', bookingId], + queryFn: () => warehouseService.getContainerItems(bookingId as string), + enabled: Boolean(bookingId), + }); + + return ( + + + {isLoading ? ( + + + + ) : items.length === 0 ? ( + + {bulkFallback ?? 'No container units recorded on this booking.'} + + ) : ( + + + + Container # + Goods + Stage + Truck + GRN + + + + {items.map((i) => ( + + + {i.containerNumber} + + {i.goods ?? '—'} + + + {i.stage} + + + {i.truckPlate ?? '—'} + {i.grnNumber ?? '—'} + + ))} + +
+ )} +
+
+ ); +} + +type ConfirmAction = { title: string; message: string; confirmLabel: string; run: () => void }; + +/** One-click bulk actions are irreversible — make the click deliberate. */ +function ConfirmActionModal({ + action, + onClose, +}: { + action: ConfirmAction | null; + onClose: () => void; +}) { + return ( + + + {action?.message} + + + + + + + ); +} + +/** "3 skipped — Booking not PAID" instead of a bare count. */ +const skippedSummary = ( + skippedCount: number, + results: Array<{ reason?: string; message?: string }>, +): string | undefined => { + if (!skippedCount) return undefined; + const reason = results.find((x) => x.reason || x.message); + return `${skippedCount} skipped${reason ? ` — ${reason.reason ?? reason.message}` : ''}`; +}; + const commonNonEmptyValue = (values: Array) => { const unique = [...new Set(values.map((value) => value?.trim()).filter(Boolean))] as string[]; return unique.length === 1 ? unique[0] : ''; @@ -296,6 +407,10 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): { const truckType = commonNonEmptyValue( bookings.map((booking) => booking.firstMileTruckType || booking.customerTruckType), ); + const customsSealNumber = commonNonEmptyValue(bookings.map((booking) => booking.sealNumbers)); + // Booking's declared cargo weight (tonnes) — the receive-time net until re-weighed. + const bookingWeight = bookings.length === 1 ? Number(bookings[0]?.weight ?? '') : NaN; + const netWeightKg: number | '' = Number.isFinite(bookingWeight) && bookingWeight > 0 ? bookingWeight : ''; const edrDigitalBookingId = bookings.length === 1 ? bookings[0]?.reference ?? bookings[0]?.id ?? '' @@ -321,9 +436,11 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): { customerPhone, edrDigitalBookingId, assignedEquipmentNumber, + customsSealNumber, itemDescription, packagingType, unitCount, + netWeightKg, grossWeightKg: '', truckPlateNumber, trailerPlateNumber, @@ -331,6 +448,7 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): { driverPhone, driverLicenseNumber, truckType, + driverSignatoryName: driverName, }, lockedFields: { ownerName: Boolean(ownerName), @@ -550,38 +668,19 @@ function TruckEntranceFields({ )} Customs and compliance - - onChange({ ...value, declarationNumber: e.currentTarget.value })} - /> - onChange({ ...value, incoterms: e.currentTarget.value })} - /> - onChange({ ...value, hsCodes: e.currentTarget.value })} + label="Declaration / Bill of Entry number" + value={value.declarationNumber} + onChange={(e) => onChange({ ...value, declarationNumber: e.currentTarget.value })} /> Physical cargo specifications - - onChange({ ...value, itemCode: e.currentTarget.value })} - /> - onChange({ ...value, itemDescription: e.currentTarget.value })} - /> - + onChange({ ...value, itemDescription: e.currentTarget.value })} + /> ({ + value: t.scheduleId, + label: `${t.trainNumber ?? t.scheduleId.slice(0, 8)} · ${t.origin ?? '?'} → ${t.destination ?? '?'} · dep ${t.departureTime ? formatDate(t.departureTime) : '—'} · ${t.readyCount} ready`, + }))} + value={targetScheduleId} + onChange={setTargetScheduleId} + searchable + /> + )} + + + + + + + {isLoading ? ( @@ -1498,22 +1666,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: No EXPORT items with inspection PASSED waiting to be loaded.
) : ( - + - - - + Booking Ref GRN - Booking ID - Customer ID Customer Name Container # Cargo Type @@ -1525,29 +1684,24 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: {rows.map((r: ReadyToLoadRow) => ( - + + - toggleOne(r.id)} - /> + setExpandedRow(expandedRow === r.id ? null : r.id)} + > + {expandedRow === r.id ? : } + - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {r.containerNumber ?? '—'} {r.cargoType ?? '—'} @@ -1561,11 +1715,17 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: - - {r.status} - + + {expandedRow === r.id && ( + + )} + ))}
@@ -1592,6 +1752,8 @@ function LoadedExportTab({ const { data: rows = [], isLoading } = useQuery( api.warehouses.loadedExport.queryOptions({ enabled }), ); + const [confirmAction, setConfirmAction] = useState(null); + const [expandedRow, setExpandedRow] = useState(null); const bulkDispatch = useMutation( api.warehouses.bulkDispatchExport.mutationOptions(), ); @@ -1616,7 +1778,7 @@ function LoadedExportTab({ const r = await bulkDispatch.mutateAsync(inventoryIds); toast({ title: `${r.dispatchedCount} dispatched`, - description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + description: skippedSummary(r.skippedCount, r.results), }); setSelected(new Set()); onChanged?.(); @@ -1646,7 +1808,14 @@ function LoadedExportTab({ variant="default" disabled={rows.length === 0} loading={bulkDispatch.isPending} - onClick={() => dispatch(rows.map((r) => r.id))} + onClick={() => + setConfirmAction({ + title: 'Dispatch all', + message: `Dispatch all ${rows.length} loaded item(s)? They leave warehouse inventory for the train.`, + confirmLabel: `Dispatch ${rows.length}`, + run: () => dispatch(rows.map((r) => r.id)), + }) + } > Dispatch All @@ -1656,7 +1825,14 @@ function LoadedExportTab({ leftSection={} disabled={selected.size === 0} loading={bulkDispatch.isPending} - onClick={() => dispatch([...selected])} + onClick={() => + setConfirmAction({ + title: 'Dispatch selected', + message: `Dispatch ${selected.size} selected item(s)? They leave warehouse inventory for the train.`, + confirmLabel: `Dispatch ${selected.size}`, + run: () => dispatch([...selected]), + }) + } > Dispatch Selected @@ -1673,7 +1849,7 @@ function LoadedExportTab({ No LOADED export items {dispatchable ? 'waiting to dispatch' : 'yet'}. ) : ( - + @@ -1687,22 +1863,21 @@ function LoadedExportTab({ /> )} + Booking Ref GRN - Booking ID - Customer ID Customer Name Container # Cargo Type Weight Route Status - {dispatchable && Actions} {rows.map((r: ReadyToLoadRow) => ( - + + {dispatchable && ( )} - - {r.bookingReference ?? '—'} - - + setExpandedRow(expandedRow === r.id ? null : r.id)} + > + {expandedRow === r.id ? : } + + + + {r.bookingReference ?? '—'} - - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {r.containerNumber ?? '—'} {r.cargoType ?? '—'} @@ -1735,29 +1911,23 @@ function LoadedExportTab({ {r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'} - - {r.status} - + - {dispatchable && ( - - - - )} + {expandedRow === r.id && ( + + )} + ))}
)} + setConfirmAction(null)} /> ); } @@ -1859,9 +2029,7 @@ function ImportTrainDetailTable({ Wagon - Booking ID Booking Ref - Customer ID Customer Name Container # Cargo Type @@ -1895,15 +2063,9 @@ function ImportTrainDetailTable({ {it.sequenceNo ? `#${it.sequenceNo}` : '-'} {it.wagonNumber ?? ''} - - {it.bookingId.slice(0, 8)}… - {it.bookingReference ?? '—'} - - {it.customerId ? `${it.customerId.slice(0, 8)}…` : '—'} - {it.customerName ?? '—'} {it.containerNumber ?? '—'} {it.cargoType ?? '—'} @@ -1993,6 +2155,7 @@ function ImportArriveQueueTab({ api.warehouses.autoUnloadArrivedBookings.mutationOptions(), ); const [openId, setOpenId] = useState(null); + const [confirmAction, setConfirmAction] = useState(null); const [busyId, setBusyId] = useState(null); const [assignmentsBySchedule, setAssignmentsBySchedule] = useState< Record> @@ -2034,7 +2197,7 @@ function ImportArriveQueueTab({ const alreadyUnloaded = r.unloadedCount === 0 && r.skippedCount > 0 && r.failedCount === 0; const firstReason = r.results.find((item) => item.reason)?.reason; const extra = [ - r.skippedCount ? `${r.skippedCount} skipped` : '', + skippedSummary(r.skippedCount, r.results) ?? '', r.failedCount ? `${r.failedCount} failed` : '', ] .filter(Boolean) @@ -2130,7 +2293,14 @@ function ImportArriveQueueTab({ leftSection={} loading={busyId === t.scheduleId} disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading} - onClick={() => autoUnload(t)} + onClick={() => + setConfirmAction({ + title: 'Auto unload train', + message: `Unload all arrived bookings from train ${t.trainNumber ?? t.scheduleId.slice(0, 8)} into their assigned warehouse locations?`, + confirmLabel: 'Unload train', + run: () => autoUnload(t), + }) + } > {fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'} @@ -2169,6 +2339,7 @@ function ImportArriveQueueTab({
)} + setConfirmAction(null)} /> ); } @@ -2189,6 +2360,8 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { ); const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions()); const [selected, setSelected] = useState>(new Set()); + const [confirmAction, setConfirmAction] = useState(null); + const [expandedRow, setExpandedRow] = useState(null); const [inspectId, setInspectId] = useState(null); const [busyId, setBusyId] = useState(null); const [viewItem, setViewItem] = useState(null); @@ -2220,7 +2393,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] }); toast({ title: `${r.inspectedCount} marked inspected`, - description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + description: skippedSummary(r.skippedCount, r.results), }); setSelected(new Set()); void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); @@ -2245,6 +2418,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { handoverDocumentReference: row.handoverDocumentReference, handoverDocumentDate: row.handoverDocumentDate, deliveredAt: row.deliveredAt, + // Carries the saved [Exit Inspection] block so Truck Leaving opens with the + // arrival details (plate, driver, tare, gate-in) read-only instead of blank. + notes: row.notes, booking: row.bookingId ? { id: row.bookingId, @@ -2282,7 +2458,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); } catch (error) { pdfWindow?.close(); - toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) }); + toast({ variant: 'destructive', title: 'Handover document failed', description: await extractDownloadErrorMessage(error) }); } finally { setBusyId(null); } @@ -2296,7 +2472,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow); } catch (error) { pdfWindow?.close(); - toast({ variant: 'destructive', title: 'Exit paper failed', description: extractErrorMessage(error) }); + toast({ variant: 'destructive', title: 'Exit paper failed', description: await extractDownloadErrorMessage(error) }); } finally { setBusyId(null); } @@ -2321,7 +2497,14 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { leftSection={} disabled={selected.size === 0} loading={inspectMutation.isPending} - onClick={markInspected} + onClick={() => + setConfirmAction({ + title: 'Mark inspected', + message: `Mark ${selected.size} selected item(s) as inspection PASSED? Passed import items become ready for pickup.`, + confirmLabel: `Mark ${selected.size} inspected`, + run: markInspected, + }) + } > Mark Selected as Inspected @@ -2337,10 +2520,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { No unloaded import items. Items appear here after Auto Unload on an arrived train. ) : ( - + + (allSelected ? unselectAll() : selectAll())} /> - Booking ID Booking Ref GRN - Customer ID Customer Name Arrival Time Container # @@ -2368,7 +2550,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { {rows.map((r: ImportUnloadedItem) => ( - + + + + setExpandedRow(expandedRow === r.id ? null : r.id)} + > + {expandedRow === r.id ? : } + + - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {formatDate(r.arrivalTime)} {r.containerNumber ?? '—'} @@ -2409,7 +2593,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { - {r.currentStatus} + @@ -2503,6 +2687,14 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { + {expandedRow === r.id && ( + + )} + ))}
@@ -2531,6 +2723,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { bookingId={containerItemsItem?.booking?.id ?? null} bookingReference={containerItemsItem?.booking?.reference ?? null} /> + setConfirmAction(null)} /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index 4268b8732..6833167f7 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; +import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; import { Info, Scale } from 'lucide-react'; import { useMutation, useQuery } from '@tanstack/react-query'; @@ -105,13 +105,16 @@ const parseInspectionNote = (notes: string | null | undefined) => { grossWeight: lineNumber(note, 'Gross Weight'), netWeight: lineNumber(note, 'Net Weight'), gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')), + weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''), }; }; export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) { const { toast } = useToast(); const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); - const bookingId = item?.booking?.id; + // Some openers (inventory workbench) supply bookingId without the booking + // relation — fall back to it, or the truck/container-weight queries never run. + const bookingId = item?.booking?.id ?? item?.bookingId ?? undefined; // Customer self-haul trucks assigned to this booking via the portal. const { data: customerTrucks = [] } = useQuery({ queryKey: ['release-customer-trucks', bookingId], @@ -135,6 +138,8 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const [containerNumbers, setContainerNumbers] = useState(['']); const [gateInTime, setGateInTime] = useState(''); const [tareWeight, setTareWeight] = useState(''); + // Containers may skip the weighbridge (decided at arrival, sticks for exit). Bulk always weighs. + const [weighTruck, setWeighTruck] = useState<'yes' | 'no'>('yes'); const [grossWeight, setGrossWeight] = useState(''); const [netWeight, setNetWeight] = useState(''); const [gateOutTime, setGateOutTime] = useState(''); @@ -158,6 +163,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber)); setGateInTime(inspection.gateInTime); setTareWeight(inspection.tareWeight); + setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes'); setGrossWeight(inspection.grossWeight); setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight)); setGateOutTime(inspection.gateOutTime); @@ -165,7 +171,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea }, [opened, item, truckPrefill]); const savedInspection = parseInspectionNote(item?.notes); - const isExitStep = savedInspection.tareWeight !== ''; + const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped; const isEntranceLocked = isExitStep; const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt); const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber); @@ -196,8 +202,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea ]; // Only trucks actually assigned to THIS booking (last-mile prefill or customer // portal) are selectable. No global fleet list — if nothing is assigned, the - // operator types the plate manually in the field below. - const truckSelectOptions = assignedTruckOptions; + // operator types the plate manually in the field below. Deduped by plate: + // duplicate option values crash Mantine's Select. + const truckSelectOptions = [ + ...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(), + ]; // Neither a last-mile truck nor a customer truck has been assigned yet. const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck; const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill; @@ -210,17 +219,28 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const containerWeightByNumber = new Map( containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]), ); - const containerSelectData = containerWeights.map((c) => ({ - value: c.containerNumber, - label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`, - })); + // Mantine Selects throw on duplicate option values — legacy bookings can carry + // the same container number on two lines, so dedupe defensively. + const containerSelectData = [ + ...new Map( + containerWeights.map((c) => [ + c.containerNumber, + { + value: c.containerNumber, + label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`, + }, + ]), + ).values(), + ]; const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean); const selectedCargoWeight = Number( selectedContainerNumbers .reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0) .toFixed(3), ); - const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0; + // Skip is only offered for container bookings; bulk always weighs. + const skipWeighing = hasContainerWeights && weighTruck === 'no'; + const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing; const systemNetWeight = useContainerNet ? selectedCargoWeight @@ -230,6 +250,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const computedNetWeight = tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null; const weightMismatch = + !skipWeighing && computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001; const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing'; @@ -239,19 +260,25 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea toast({ variant: 'destructive', title: 'Truck plate and driver name are required' }); return; } - if (!gateInTime || tareWeight === '') { - toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' }); + if (!gateInTime || (!skipWeighing && tareWeight === '')) { + toast({ + variant: 'destructive', + title: skipWeighing ? 'Gate in time is required' : 'Gate in time and tare weight are required', + }); return; } - if (isExitStep && (!gateOutTime || grossWeight === '')) { - toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' }); + if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) { + toast({ + variant: 'destructive', + title: skipWeighing ? 'Gate out time is required' : 'Gate out time and gross weight are required', + }); return; } if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) { toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' }); return; } - if (isExitStep && systemNetWeight === '') { + if (isExitStep && !skipWeighing && systemNetWeight === '') { toast({ variant: 'destructive', title: 'System recorded net weight is missing' }); return; } @@ -279,9 +306,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea truckType: truckType.trim() || undefined, containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined, gateInTime: toIsoDateTime(gateInTime), - tareWeight: Number(tareWeight), - grossWeight: grossWeight === '' ? undefined : Number(grossWeight), - netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined, + weighingSkipped: skipWeighing || undefined, + tareWeight: skipWeighing ? undefined : Number(tareWeight), + grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight), + netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined, gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined, }, }); @@ -347,8 +375,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea placeholder="Select the assigned truck" searchable clearable + // Enabled at arrival so the operator picks which assigned truck came; + // only locked on the exit (leaving) step once identity is captured. + disabled={isEntranceLocked} data={truckSelectOptions} - disabled={isTruckIdentityLocked} value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null} onChange={(value) => { const truck = truckSelectOptions.find((row) => row.value === value); @@ -419,9 +449,24 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea )} setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
+ {hasContainerWeights && ( + + Weigh truck? + setWeighTruck((v as 'yes' | 'no') ?? 'yes')} + disabled={isEntranceLocked} + /> + {skipWeighing && ( + Weighbridge skipped — container passes without tare/gross. + )} + + )} - setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} /> - setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} /> + setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} disabled={skipWeighing} /> + setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing} /> { const kind = itemKind(item); const busy = busyId === item.id; - const nextAction = getNextInventoryAction(item); + // Per-booking Load and Dispatch are retired: wagon loading happens in + // the train flow and dispatch at the train level (which already + // advances inventory). Only the remaining lifecycle actions render. + const rawNextAction = getNextInventoryAction(item); + const nextAction = + rawNextAction === 'load' || rawNextAction === 'dispatch' ? null : rawNextAction; const canGenerateHandover = item.inspectionStatus === 'PASSED' && Boolean(item.bookingId) && @@ -232,26 +237,15 @@ export function WarehouseInventoryTable({ )} {item.status === 'READY_FOR_PICKUP' && ( - <> - - - + )} {item.status !== 'DISPATCHED' && ( diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 2ceaae870..dbac021f3 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -103,7 +103,8 @@ export const QUERY_KEYS = { schedules: () => ["train-scheduling", "schedules"] as const, scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const, track: (id: string) => ["train-scheduling", "track", id] as const, - batchBoard: () => ["train-scheduling", "batch-board"] as const, + batchBoard: (filters?: unknown) => + ["train-scheduling", "batch-board", "list", filters ?? {}] as const, batchBoardDetail: (scheduleId: string) => ["train-scheduling", "batch-board", scheduleId] as const, unassignedBookings: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 77a3bc95e..c6f98c8a2 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -86,6 +86,8 @@ export const URL_CONSTANTS = { `/bookings/by-company/${id}/customer-view`, PAYMENTS_CUSTOMER_VIEW: (id: string) => `/payments/by-company/${id}/customer-view`, + RESET_PASSWORD: (companyId: string) => + `/backoffice/customers/${companyId}/reset-password`, }, BILLING: { diff --git a/apps/edr-freight-web/backoffice/src/external-portal/components/External-Portal-Navigation/PortalHeader.tsx b/apps/edr-freight-web/backoffice/src/external-portal/components/External-Portal-Navigation/PortalHeader.tsx new file mode 100644 index 000000000..5259dcb7a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/external-portal/components/External-Portal-Navigation/PortalHeader.tsx @@ -0,0 +1,250 @@ +import NotificationList from "@/record-management/components/NotificationList"; +import { useAuthUser } from "@/shared/hooks/useAuthUser"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import { ChevronDown, User, Key, LogOut } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/shared/common/ui/button"; +import { useTranslation } from "react-i18next"; +import { FiBell, FiChevronDown } from "react-icons/fi"; +import { useNavigate } from "react-router-dom"; +import { useNotifications } from "@/shared/hooks/useNotification"; +import { cn } from "@/shared/common/ui/fileUploader/utils"; +import { useUser } from "@/shared/context/UserContext"; +import { + UI_LANGUAGE_OPTIONS, + getUiLanguageLabel, + getUiLanguageShortLabel, + resolveUiLanguage, +} from "@/shared/i18n/uiLanguages"; + +export const ExternalPortal = ({ + mobileView = false, + onItemClick, +}: { + mobileView?: boolean; + onItemClick?: () => void; +}) => { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const userDetails = useUser(); + const { logout } = useAuthUser(); + const fullName = userDetails?.name?.en || t("header.user"); + const splittedName = fullName.trim().split(" "); + const initials = + splittedName.length === 1 + ? splittedName[0][0] + : `${splittedName[0][0]}${splittedName[1][0]}`; + + const currentLanguage = resolveUiLanguage(i18n.language); + const changeLanguage = (lng: string) => i18n.changeLanguage(lng); + const handleLogout = () => { + logout("/external-portal/signin"); + }; + + const { unseenCount } = useNotifications({ + take: 10, + skip: 0, + orderBy: "updatedAt:DESC", + }); + const [openNotifications, setOpenNotifications] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setOpenNotifications(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + // Handle navigation with optional callback for mobile + const handleNavigation = (path: string) => { + navigate(path); + if (onItemClick) onItemClick(); + }; + + return ( + <> +
+ {/* Notifications */} + +
+ + {openNotifications && ( +
+ +
+ )} +
+ {/* Language Switcher */} + + + + + + {UI_LANGUAGE_OPTIONS.map((lang) => ( + { + changeLanguage(lang.value); + if (onItemClick) onItemClick(); + }} + className={cn( + "flex items-center justify-between text-sm text-gray-700 hover:bg-primary-100 rounded-md px-3 py-2 cursor-pointer", + currentLanguage === lang.value && "bg-primary-50", + )} + > + {getUiLanguageLabel(lang.value, t)} + {currentLanguage === lang.value && ( + + {getUiLanguageShortLabel(lang.value, t)} + + )} + + ))} + + + {/* User Menu */} + + + + + + handleNavigation("/profile")}> + + {t("header.viewProfile")} + + + handleNavigation("/record-management/change-password") + }> + + {t("header.changePassword")} + + + + + {t("header.signOut")} + + + +
+ + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/external-portal/components/External-Portal-Navigation/VerificationPending.tsx b/apps/edr-freight-web/backoffice/src/external-portal/components/External-Portal-Navigation/VerificationPending.tsx new file mode 100644 index 000000000..52de124c3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/external-portal/components/External-Portal-Navigation/VerificationPending.tsx @@ -0,0 +1,49 @@ +"use client"; +import Header from "../../../layout/components/Header"; +import React from "react"; +import { AlertCircle, Mail, PhoneCall } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +interface VerificationPendingProps { + hasCompletedRegistration: boolean; + contactNumber?: string; + email?:string; + +} + +const VerificationPending: React.FC = ({ + hasCompletedRegistration, + contactNumber, + email, +}) => { + const { t } = useTranslation(); + + if (hasCompletedRegistration) { + return null; // ✅ Nothing to show if user has finished registration + } + + return ( +
+
+ +
+
+ +

+ {t("verification.pending")} +

+
+ +

+ {t("verification.message")}{" "} + + {email} or {contactNumber} . +

+ + +
+
+ ); +}; + +export default VerificationPending; diff --git a/apps/edr-freight-web/backoffice/src/external-portal/components/Registration/ExternalAuthPage.tsx b/apps/edr-freight-web/backoffice/src/external-portal/components/Registration/ExternalAuthPage.tsx new file mode 100644 index 000000000..46c4ff381 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/external-portal/components/Registration/ExternalAuthPage.tsx @@ -0,0 +1,5 @@ +import UserTypeSelection from "./UserTypeSelection"; + +export default function ExternalAuthPage() { + return ; +} diff --git a/apps/edr-freight-web/backoffice/src/external-portal/components/Registration/ExternalPortalCallBack.tsx b/apps/edr-freight-web/backoffice/src/external-portal/components/Registration/ExternalPortalCallBack.tsx new file mode 100644 index 000000000..e811c5b59 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/external-portal/components/Registration/ExternalPortalCallBack.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useRegisterExternalPortalUser } from "@/external-portal/hooks/useRegisterExternalPortalUser"; +import { Button } from "@/shared/common/ui/button"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; +import { getDefaultFaydaRedirectUri } from "@/shared/utils/faydaOidc"; +import { persistFaydaRegistrationAuth } from "@/shared/utils/faydaAuthSession"; +import { Loader2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { toast } from "sonner"; + +export default function ExternalPortalCallback() { + const { t } = useTranslation(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { handleError } = useErrorHandler(t); + const { isFayidaRegistering, registerExternalFayidaUser } = + useRegisterExternalPortalUser(); + const hasRun = useRef(false); + + useEffect(() => { + if (hasRun.current) return; + hasRun.current = true; + + const code = searchParams.get("code"); + const state = searchParams.get("state"); + + if (!code) { + toast.error("Missing authorization code"); + setError("Missing authorization code"); + setLoading(false); + navigate("/external-portal/signin", { replace: true }); + return; + } + + const loginWithFayda = async () => { + try { + const res = await registerExternalFayidaUser({ + code, + redirectUri: getDefaultFaydaRedirectUri(), + }); + + if (!res || !res.response?.data) { + throw new Error("Invalid response from Fayda login"); + } + + const data = res.response.data; + const userId = data?.userId ?? data?.user?.id ?? data?.user?.userId; + const token = data?.token; + const refreshToken = data?.refreshToken; + + if (!userId) { + throw new Error("Failed to get user ID from server."); + } + + await persistFaydaRegistrationAuth({ + ...data, + token, + refreshToken, + }); + + navigate(`/verify-otp?userId=${userId}&isExternalOrg=false`, { + replace: true, + }); + } catch (err: unknown) { + console.error("LoginWithFayda error:", err); + handleError(err); + setError( + err instanceof Error + ? err.message + : t("registration.auth.faydaSigninFailed"), + ); + } finally { + setLoading(false); + } + }; + + loginWithFayda(); + }, [searchParams, navigate, handleError, registerExternalFayidaUser, t]); + + if (loading || isFayidaRegistering) { + return ( +
+ +

+ {t("registration.auth.faydaProcessing")} +

+
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + return null; +} diff --git a/apps/edr-freight-web/backoffice/src/external-portal/components/Registration/FilePreview.tsx b/apps/edr-freight-web/backoffice/src/external-portal/components/Registration/FilePreview.tsx new file mode 100644 index 000000000..a0049dcb1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/external-portal/components/Registration/FilePreview.tsx @@ -0,0 +1,249 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shared/common/ui/dialog"; +import { Button } from "@/shared/common/ui/button"; +import { + FileText, + ImageIcon, + FileIcon, + VideoIcon, + DownloadIcon, + XIcon, + Loader2, +} from "lucide-react"; +import { cn } from "@/shared/lib/utils"; +import { useToast } from "@/shared/common/ui/use-toast"; + +type FileType = "image" | "pdf" | "video" | "other"; + +interface FilePreviewProps { + file: File | string; // Can accept File object or URL string + type?: FileType; // Optional type hint + className?: string; + onRemove?: () => void; + showDownload?: boolean; +} + +export const FilePreview = ({ + file, + type, + className, + onRemove, + showDownload = true, +}: FilePreviewProps) => { + const [previewUrl, setPreviewUrl] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [detectedType, setDetectedType] = useState("other"); + const { toast } = useToast(); + + useEffect(() => { + const determineFileType = (): FileType => { + if (type) return type; + + if (typeof file === "string") { + const extension = file.split(".").pop()?.toLowerCase(); + if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension || "")) { + return "image"; + } + if (extension === "pdf") return "pdf"; + if (["mp4", "webm", "ogg"].includes(extension || "")) return "video"; + return "other"; + } + + if (file.type.startsWith("image/")) return "image"; + if (file.type === "application/pdf") return "pdf"; + if (file.type.startsWith("video/")) return "video"; + return "other"; + }; + + const generatePreview = async () => { + setIsLoading(true); + setDetectedType(determineFileType()); + + try { + if (typeof file === "string") { + setPreviewUrl(file); + } else { + const url = URL.createObjectURL(file); + setPreviewUrl(url); + } + } catch (error) { + console.error("Error generating preview:", error); + toast({ + title: "Error", + description: "Could not generate file preview", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + generatePreview(); + + return () => { + if (previewUrl && typeof file !== "string") { + URL.revokeObjectURL(previewUrl); + } + }; + }, [file, type]); + + const handleDownload = () => { + if (!previewUrl) return; + + const link = document.createElement("a"); + link.href = previewUrl; + link.download = typeof file === "string" ? "download" : file.name; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const renderPreview = () => { + if (isLoading) { + return ( +
+ +
+ ); + } + + switch (detectedType) { + case "image": + return ( + Preview setIsLoading(false)} + /> + ); + case "pdf": + return ( +
+ + + {typeof file === "string" ? "PDF Document" : file.name} + +
+ ); + case "video": + return ( + + ); + default: + return ( +
+ + + {typeof file === "string" ? "File" : file.name} + +
+ ); + } + }; + + return ( + <> +
+ {renderPreview()} + +
+ {onRemove && ( + + )} + {showDownload && previewUrl && ( + + )} +
+ + +
+ + + + + + {typeof file === "string" ? "File Preview" : file.name} + + +
+ {detectedType === "image" && ( + Fullscreen preview + )} + {detectedType === "pdf" && ( +