diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b1c797dc6..b6a7fdb69 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,6 +43,8 @@ jobs: "passenger-portal" "passenger-backoffice" "payment-api" + "synapse" + "element-web" ) if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then @@ -84,6 +86,10 @@ jobs: echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal") echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice") echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api") + # synapse / element-web have no per-service filter line: their only + # source is infrastructure/matrix/, already caught by GLOBAL_PATTERN + # above (which redeploys every service), so a dedicated line here + # would never fire. SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u)) @@ -119,7 +125,7 @@ jobs: - name: Resolve project and build env file run: | case "${{ matrix.service }}" in - freight-api|freight-portal|freight-backoffice|gps-tracker) + freight-api|freight-portal|freight-backoffice|gps-tracker|synapse|element-web) echo "PROJECT=edr-freight" >> "$GITHUB_ENV" echo "BUILD_ENV_FILE=freight-web.build.env" >> "$GITHUB_ENV" ;; diff --git a/CLAUDE.md b/CLAUDE.md index b90d3b1dd..a20fbaee7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,102 +1,362 @@ # 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. + +**Looking for where something lives? Read [`docs/MAP.md`](docs/MAP.md) first.** It routes +you to the right module or page without a repo-wide grep. + ## Overview -Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight Management and Passenger Management applications, plus shared types, NestJS utilities, and React component libraries. +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 | 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 | +The two domains are **not built the same way**. Check which stack you are in before +copying a pattern across: -`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. Each holds a `portal/` and `backoffice/` sub-app, both of which are independent pnpm workspace packages (declared in `pnpm-workspace.yaml`). The existing `pnpm dev:freight` / `pnpm dev:passenger` turbo filters (`@edr/freight-*` / `@edr/passenger-*`) cover all four web apps + their APIs. +| App | Package name | Stack | Default port | +| ------------------------------ | --------------------------- | ---------------------- | ------------ | +| `edr-freight-api` | `@edr/freight-api` | NestJS + **TypeORM** | 3001 | +| `edr-freight-web/portal` | `@edr/freight-portal` | React + **Vite** | 5273 | +| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React + **Vite** | 5283 | +| `edr-passenger-api` | `@edr/passenger-api` | NestJS + **Prisma** | 4000 | +| `edr-passenger-web/portal` | `@edr/passenger-portal` | **Next.js** | 5174 | +| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | **Next.js** | 5184 | +| `edr-payment-api` | `@edr/payment-api` | NestJS + **TypeORM** | 3003 | + +Those are the **fallbacks compiled into the code**, not what you will be running. Every +port is overridden by `PORT` in the app's `.env` / `.env.development`; the freight vite +apps read it in `vite.config.ts` (`Number(env.PORT) || 5273`). This machine is shared by +the whole team and the low ports are contested — see the workspace root `CLAUDE.md` and +`./wt ports` for who currently holds what. + +`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. + +`apps/edr-gps-tracker/` is a separate service with its own `.env.example`. ## Packages -| Package | Purpose | -| ---------------------- | ---------------------------------------------------------------------------------- | -| `@edr/types` | Shared TypeScript interfaces and enums | -| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | -| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) | -| `@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 | +| Package | Location | Purpose | +| ----------------------- | ----------------------------- | ------------------------------------------------------------- | +| `@edr/types` | `packages/types` | Shared TypeScript interfaces and enums | +| `@edr/api-common` | `packages/api-common` | NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | +| `@edr/ui-common` | `packages/ui-common` | Shared React components and theme | +| `@edr/iam-seed` | `packages/iam-seed` | IAM baseline seeder for apps sharing the `iam` schema | +| `@edr/payment-providers`| `packages/payment-providers` | Payment gateway integrations | +| `@edr/eslint-config` | `packages/config/eslint-config` | Shared ESLint configs (base/nestjs/react) | +| `@edr/tsconfig` | `packages/config/tsconfig` | Shared TypeScript configs | +| `@edr/prettier-config` | `packages/config/prettier-config` | Shared Prettier config | + +The three `config/*` packages are nested one level deeper than the rest — `packages/config` +itself is not a package. + +**`@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` | Run only freight API + web | -| `pnpm dev:passenger` | Run only passenger API + web | -| `pnpm build` | Build every package and app | -| `pnpm test` | Run all tests | -| `pnpm lint` | Lint everything | -| `pnpm type-check` | Type-check every package | -| `pnpm format` | Format all files with Prettier | +| 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 type-check` | Type-check every package | +| `pnpm format` | Format all files with Prettier | +| `pnpm lint` | **Does not work** — see below | -## Standards +**`pnpm lint` fails.** `eslint` is not installed anywhere in the workspace, so +`turbo run lint` dies with `eslint: not found` even though every package declares a +`lint` script and `@edr/eslint-config` exists. Until someone adds the dependency, +tsc's `noUnusedLocals` is the only working unused-code check. Do not claim a change is +"lint clean". -- **TypeScript strict mode** is enabled in every package and app. -- **pnpm** is the only supported package manager — never run `npm install` or `yarn`. -- **Conventional commits** are enforced via commitlint on every commit. -- **NestJS modules** follow the 4-layer pattern: `module → controller → service → repository` (entities and DTOs live alongside). +`pnpm format` uses bare `prettier`, which ignores `@edr/prettier-config` — it is wired to +nothing. On the single-quoted passenger apps it will re-quote the whole file. Pass +`--config` explicitly there. + +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. +- IAM tables live in their own `iam` schema (`iam.users`, `iam.user_credentials`), + freight tables in `freight`. +- `psql` is not installed on the dev machine. To query the database, use the `edr-db` + skill (below) or write a short Node script using `pg` 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 migrations. - **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`). -- **All entities** have `createdAt`, `updatedAt`, `deletedAt` (soft delete) via `@edr/api-common`'s `BaseEntity`. -- **All columns** use `snake_case` in the database (`@Column({ name: 'snake_case' })`); TypeScript properties use `camelCase`. -- **Never use `synchronize: true`** in production database config. All schema changes go through TypeORM migrations. -- **ESLint + Prettier** run on pre-commit via Husky + lint-staged. -- **Services** never inject TypeORM `Repository` directly — they inject the custom repository class. -- **Controllers** never contain business logic. +- **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. -## Auth +## Architecture -Authentication is handled by an external package (`@edr/iamui-common` or equivalent) that will be integrated later. **Do not** implement any auth, login, logout, JWT verification, password hashing, or user management code in this repo. +### NestJS module shape -When auth integration is needed, use placeholder TODO comments: +`module → controller → service → repository`, with `entities/` and `dto/` alongside. +`docs/MAP.md` lists the ~60 freight modules grouped by domain. -- `// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth` -- `// TODO: integrate @edr/auth — replace stub @CurrentUser with real one` +### Data access — the real model -The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are bare metadata setters with no guard wiring — they exist so controllers can be annotated correctly without depending on auth infrastructure yet. +There are two sanctioned ways to read and write, and you must pick the right one: -## Port Assignments +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. -- `edr-freight-api`: 3001 -- `edr-freight-web/portal`: 5173 -- `edr-freight-web/backoffice`: 5183 -- `edr-passenger-api`: 3002 -- `edr-payment-api`: 3003 -- `edr-passenger-web/portal`: 5174 -- `edr-passenger-web/backoffice`: 5184 +Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it. +It carries one obligation: -## Database Layout +> **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*). -- `postgres-freight` (port 5433): database `edr_freight` — freight API only. -- `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only. -- `edr_payment` schema — lives in the same Postgres database as the domain system (whatever the passenger `DATABASE_URL` points at) but is owned exclusively by `apps/edr-payment-api`. Dedicated DB user, no cross-schema FKs, domain apps have no grants on it (see `docs/payment-service/`). -- Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues. +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. **Freight and payment use TypeORM migrations; passenger uses Prisma** +(`apps/edr-passenger-api/prisma/migrations`) — the rules below are about the TypeORM side. + +- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate + one-shot step, via the Dockerfile's `migration` build target (`docker build --target + migration`), with `migrationsTransactionMode: 'each'`. + - CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it + (`docker run --rm --env-file ...`) *before* building/deploying the app image. + - e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and + `freight-api-e2e` depends on it (`condition: service_completed_successfully`). + - Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run + migrations yourself before `docker compose up freight-api`, e.g. + `docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .` + then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't + use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled + output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`. + It silently applies zero freight migrations while exiting 0. +- Consequences you must design for: + - 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. + - `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a + hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never + notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own + `forFeature()` registrations), but the standalone migration `DataSource` + (`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing + entity throws `Entity metadata for X#y was not found` at `initialize()`, before a + single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate + for this to break again** — diff the package's entity classes against `iamEntities` + when bumping it. +- **Give every migration a unique timestamp.** `apps/edr-freight-api/src/migrations` holds + 39 files, and 8 timestamps are shared by two or more of them. TypeORM orders by timestamp + and breaks ties non-deterministically. Check before adding one: + + ```bash + ls apps/edr-freight-api/src/migrations | grep -oE '^[0-9]+' | sort | uniq -d + ``` + + The prefix must be unused *and* higher than the newest recorded row. Note the + `freight.migrations` table has far more rows (~309) than this folder has files — most + come from `@tria-plc/iamapi-common`'s own migrations, which run from the same data source. +- **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. +- Login is freight-api's own `POST /api/auth/login` (SharedAuthModule from + `@tria-plc/api-common`). Every login call needs an **`x-client-app` header** — + `backoffice` for employees, `portal` for customers. Without it the API 403s with + "Missing or unrecognized x-client-app header". Browsers send it; curl must add 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 **freight** web apps use **Mantine v9** (`^9.3.0`). 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. +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. +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). +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` has been red on `dev`, so a + fully green suite is not the bar — but confirm that for yourself rather than assuming it, + then run the specs covering what you touched and confirm you introduced no new failure. +5. **Formatting is clean** for the files you touched. Git hooks do **not** run automatically + (see below), and `pnpm lint` does not work at all, so `noUnusedLocals` from the + type-check is your only unused-code signal. +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 | +| Login 403 from curl | "Missing or unrecognized x-client-app header" | Send `x-client-app: backoffice` or `portal` | +| Copying a passenger pattern into freight | Passenger is Prisma + Next.js, freight is TypeORM + Vite | Check which stack you are in first | + +## 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/CLAUDE_NEW.md b/CLAUDE_NEW.md deleted file mode 100644 index a99f64d0a..000000000 --- a/CLAUDE_NEW.md +++ /dev/null @@ -1,313 +0,0 @@ -# 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: false` — **migrations do NOT run on API boot.** They run as a separate - one-shot step, via the Dockerfile's `migration` build target (`docker build --target - migration`), with `migrationsTransactionMode: 'each'`. - - CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it - (`docker run --rm --env-file ...`) *before* building/deploying the app image. - - e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and - `freight-api-e2e` depends on it (`condition: service_completed_successfully`). - - Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run - migrations yourself before `docker compose up freight-api`, e.g. - `docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .` - then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't - use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled - output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`. - It silently applies zero freight migrations while exiting 0. -- Consequences you must design for: - - 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. - - `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a - hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never - notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own - `forFeature()` registrations), but the standalone migration `DataSource` - (`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing - entity throws `Entity metadata for X#y was not found` at `initialize()`, before a - single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate - for this to break again** — diff the package's entity classes against `iamEntities` - when bumping it. -- **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/.env.example b/apps/edr-freight-api/.env.example index 2c636eee4..930c8f1ca 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -1,4 +1,10 @@ # Copy to .env for local/docker compose (not committed). + +# Set to "dev" or "staging" to bypass OTP (fixed code 000000 also accepted), +# payment (invoice auto-marked paid on initiate, no gateway call) and Fayda +# (canned verified profile, no eSignet call). Leave unset in production. +ENV= + PORT=3001 # @tria-plc/auditlog's client interceptor stamps every AuditLog row's # `application` from this env var directly, bypassing MezgebModule.forRoot's @@ -172,14 +178,23 @@ EIMS_SELLER_LOCALITY= # Tax treatment — REQUIRES FINANCE SIGN-OFF. The application models no tax at all # (invoice.taxAmount is always 0), so nothing here is defaulted: registration fails # locally, naming the missing variables, until these are set. -# Required, and deliberately unset: the choice is a tax position, not a default. +# Required, and deliberately unset here: the choice is a tax position, not a default. # MoR's enum (from its own 400): TOT10 TOT2 VAT15 VWHT TWHT VATEX VATWH WHOP2 WTHOI VAT0 VWTH -# Pending finance confirmation of VAT0 (zero-rated) vs VATEX (exempt). +# Finance confirmed 2026-08-12: VATEX (exempt) for EDR's freight business — set in .env. EIMS_TAX_CODE= EIMS_TAX_RATE_PERCENT=0 EIMS_EXCISE_TAX_VALUE=0 EIMS_INCOME_WITHHOLD_VALUE=0 EIMS_TRANSACTION_WITHHOLD_VALUE=0 +# Per-chargeType override, for an invoice whose lines need different MoR tax treatment (e.g. a +# zero-rated freight line next to a taxed accessorial) — IRC-P01 compliance-test material. +# A charge type not listed here falls back to EIMS_TAX_CODE / EIMS_TAX_RATE_PERCENT above. +# EIMS_TAX_CODE_BY_CHARGE_TYPE and EIMS_TAX_RATE_BY_CHARGE_TYPE must list the same charge types. +EIMS_TAX_CODE_BY_CHARGE_TYPE= +EIMS_TAX_RATE_BY_CHARGE_TYPE= +# Same mechanism; charge types not listed fall back to EIMS_EXCISE_TAX_VALUE / 0 respectively. +EIMS_EXCISE_BY_CHARGE_TYPE= +EIMS_DISCOUNT_BY_CHARGE_TYPE= # Document classification and payment presentation. EIMS_TRANSACTION_TYPE=B2B # Lowercase constant: MoR's oneOf branches require exactly 'goods' or 'service'. @@ -204,3 +219,22 @@ EIMS_AUTO_SUBMIT=false EIMS_AUTO_SUBMIT_CRON=0 */5 * * * * # MoR rejects documents older than 3 days; the sweep will not attempt those. EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3 +# ── Internal chat (Matrix/Element) ────────────────────────────────────────── +# Disabled by default; /chat/sso and the nightly room/membership reconcile are +# no-ops until enabled. See infrastructure/matrix/. +MATRIX_ENABLED=false +# Synapse URL reachable from this container (docker-compose service DNS in +# prod, e.g. http://synapse:8008 — NOT the public https://matrix.edr.et). +MATRIX_BASE_URL=http://localhost:8008 +# Synapse's own public_baseurl — what Element itself is configured to call. +# Only used to seed the sso.html handoff page's localStorage. +MATRIX_PUBLIC_BASE_URL=https://matrix.edr.et +MATRIX_CHAT_WEB_URL=https://chat.edr.et +MATRIX_SERVER_NAME=matrix.edr.et +# Must exactly match infrastructure/matrix/synapse/.env's MATRIX_JWT_SECRET — +# this is the whole trust boundary for the SSO handoff. +MATRIX_JWT_SECRET= +# access_token of a Synapse server-admin account. Bootstrap it once via +# infrastructure/matrix/synapse's MATRIX_REGISTRATION_SHARED_SECRET (see that +# file's comments) — this app never touches the shared secret itself. +MATRIX_ADMIN_TOKEN= diff --git a/apps/edr-freight-api/.q.mjs b/apps/edr-freight-api/.q.mjs new file mode 100644 index 000000000..b2b454075 --- /dev/null +++ b/apps/edr-freight-api/.q.mjs @@ -0,0 +1,9 @@ +import pg from 'pg'; +import fs from 'fs'; +const env = Object.fromEntries(fs.readFileSync('.env','utf8').split('\n').filter(l=>/^[A-Z_]+=/.test(l)).map(l=>{const i=l.indexOf('=');return [l.slice(0,i),l.slice(i+1).replace(/^"|"$/g,'')]})); +const c = new pg.Client({host:env.DB_HOST,port:+env.DB_PORT,database:env.DB_NAME,user:env.DB_USER,password:env.DB_PASSWORD}); +await c.connect(); +const sql = process.argv[2]; +const r = await c.query(sql); +console.log(JSON.stringify(r.rows,null,1)); +await c.end(); diff --git a/apps/edr-freight-api/data/audit-endpoints.js b/apps/edr-freight-api/data/audit-endpoints.js new file mode 100644 index 000000000..2c11ea95b --- /dev/null +++ b/apps/edr-freight-api/data/audit-endpoints.js @@ -0,0 +1,639 @@ +/** + * Freight API — every state-changing endpoint (POST / PUT / PATCH / DELETE). + * + * Shape: " ": [title, method, entity] + * + * Keyed by method + path rather than path alone: 50 paths serve more than one + * method (PATCH and DELETE on /api/contracts/:id, for example), so a path-only + * key would collide and drop those endpoints. + * + * Paths include the global prefix `api` (see app.setGlobalPrefix in src/main.ts). + * Titles come from each route's @ApiOperation summary, falling back to a + * humanized handler name where a route has none. + * + * Excludes the AI Assist and Account entities. + * Generated from the controllers under src/ — 488 endpoints. + */ +const AUDIT_ENDPOINTS = { + // Approval Rule + "POST /api/approval-rules": ["Create an approval rule step", "POST", "Approval Rule"], + "PATCH /api/approval-rules/:id": ["Update an approval rule", "PATCH", "Approval Rule"], + "DELETE /api/approval-rules/:id": ["Soft-delete an approval rule", "DELETE", "Approval Rule"], + "POST /api/approval-rules/:id/move-order": ["Move an approval step up or down within its chain", "POST", "Approval Rule"], + "POST /api/approval-rules/reorder": ["Bulk reorder approval steps within a chain", "POST", "Approval Rule"], + + // Booking + "POST /api/bookings": ["Create a new freight booking (DRAFT)", "POST", "Booking"], + "POST /api/bookings/:bookingId/allocate-containers": ["Allocate containers to vehicles", "POST", "Booking"], + "PATCH /api/bookings/:id": ["Update booking", "PATCH", "Booking"], + "DELETE /api/bookings/:id": ["Soft-delete DRAFT booking", "DELETE", "Booking"], + "POST /api/bookings/:id/cancel": ["Cancel booking", "POST", "Booking"], + "POST /api/bookings/:id/cancel-hold": ["Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED);", "POST", "Booking"], + "POST /api/bookings/:id/clearance/declaration": ["GL ET uploads customs declaration on booking (GENERAL customs)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/delivery-order": ["Upload Booking Delivery Order", "POST", "Booking"], + "POST /api/bookings/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration": ["GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration/accept": ["Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration/change": ["Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/duty": ["GL ET sets duty/tax on booking with notice attachment", "POST", "Booking"], + "POST /api/bookings/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on booking", "POST", "Booking"], + "POST /api/bookings/:id/clearance/export-release": ["Confirm Booking Export Release", "POST", "Booking"], + "POST /api/bookings/:id/clearance/finalize": ["GL finalizes clearance (requires 100% approved) → CLEARANCE_READY", "POST", "Booking"], + "POST /api/bookings/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance on booking", "POST", "Booking"], + "POST /api/bookings/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"], + "POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"], + "POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"], + "POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"], + "POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"], + "POST /api/bookings/:id/clearance/transit-permit": ["Upload Booking Transit Permit", "POST", "Booking"], + "POST /api/bookings/:id/confirm-submit": ["Confirm submit after price change", "POST", "Booking"], + "POST /api/bookings/:id/consolidation": ["Request freight consolidation", "POST", "Booking"], + "DELETE /api/bookings/:id/consolidation": ["Remove consolidation pairing", "DELETE", "Booking"], + "POST /api/bookings/:id/contract/generate": ["Generate contract PDF from template", "POST", "Booking"], + "POST /api/bookings/:id/contract/sign": ["Apply digital signature (customer or staff)", "POST", "Booking"], + "POST /api/bookings/:id/customer-cancel": ["Customer cancels their own booking before payment — no cancellation fee", "POST", "Booking"], + "POST /api/bookings/:id/customer-truck-assignment": ["Customer assigns external truck and driver for terminal pickup", "POST", "Booking"], + "POST /api/bookings/:id/customer-trucks": ["Add a customer self-haul truck carrying 1–2 of the booking containers", "POST", "Booking"], + "PATCH /api/bookings/:id/customer-trucks/:assignmentId": ["Edit a not-yet-arrived customer truck (plate/driver/type + containers)", "PATCH", "Booking"], + "DELETE /api/bookings/:id/customer-trucks/:assignmentId": ["Remove a not-yet-arrived customer truck from a booking", "DELETE", "Booking"], + "POST /api/bookings/:id/customer-trucks/:assignmentId/depart": ["Register an import truck leaving: containers loaded + weighed gross (staff)", "POST", "Booking"], + "POST /api/bookings/:id/customer-trucks/:assignmentId/load": ["Truck_dispatch: load selected containers onto a truck (staff)", "POST", "Booking"], + "POST /api/bookings/:id/customer-trucks/bulk": ["Bulk add customer trucks from array payload (Excel parsed)", "POST", "Booking"], + "POST /api/bookings/:id/customer/sign": ["Customer digital signature (deprecated — use POST contract/sign)", "POST", "Booking"], + "POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "POST", "Booking"], + "PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"], + "POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"], + "POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"], + "POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"], + "POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"], + "POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"], + "POST /api/bookings/:id/operations/complete": ["Mark completed", "POST", "Booking"], + "POST /api/bookings/:id/operations/start-transit": ["Mark in transit", "POST", "Booking"], + "POST /api/bookings/:id/reject": ["Customer reject price estimate", "POST", "Booking"], + "POST /api/bookings/:id/staff/accept": ["Staff accept intake → set contract validity window + start approval chain", "POST", "Booking"], + "POST /api/bookings/:id/staff/reject": ["Staff final reject", "POST", "Booking"], + "POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "POST", "Booking"], + "POST /api/bookings/:id/submit": ["Customer submit booking", "POST", "Booking"], + "POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"], + "POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"], + "POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"], + "POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"], + + // Cargo + "POST /api/cargoes": ["Create a new cargo", "POST", "Cargo"], + "PATCH /api/cargoes/:id": ["Update a cargo", "PATCH", "Cargo"], + "DELETE /api/cargoes/:id": ["Delete a cargo", "DELETE", "Cargo"], + "POST /api/cargoes/:id/deliver": ["Mark cargo as delivered", "POST", "Cargo"], + "POST /api/cargoes/:id/load": ["Load cargo into a container", "POST", "Cargo"], + "POST /api/cargoes/:id/unload": ["Unload cargo from container", "POST", "Cargo"], + + // Cargo Type + "POST /api/cargo-types": ["Create a cargo type", "POST", "Cargo Type"], + "PATCH /api/cargo-types/:id": ["Update a cargo type", "PATCH", "Cargo Type"], + "DELETE /api/cargo-types/:id": ["Soft-delete a cargo type", "DELETE", "Cargo Type"], + "POST /api/cargo-types/:id/move-order": ["Move a cargo type up or down in display order", "POST", "Cargo Type"], + "POST /api/cargo-types/reorder": ["Bulk reorder cargo types by ID list", "POST", "Cargo Type"], + + // Company + "POST /api/companies": ["Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", "POST", "Company"], + "POST /api/companies/:companyId/documents": ["Upload documents for a company (onboarding)", "POST", "Company"], + "POST /api/companies/:companyId/profiles": ["Add a profile (employee) to a company", "POST", "Company"], + "PATCH /api/companies/:id": ["Update a company", "PATCH", "Company"], + "DELETE /api/companies/:id": ["Soft-delete a company", "DELETE", "Company"], + "POST /api/companies/change-requests/:id/approve": ["Approve a pending profile change request (applies the changes)", "POST", "Company"], + "POST /api/companies/change-requests/:id/reject": ["Reject a pending profile change request with a note", "POST", "Company"], + "POST /api/companies/change-requests/:id/request-changes": ["Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)", "POST", "Company"], + "POST /api/companies/company-profile": ["Create a single operational profile for the current user's company. The role starts pending and does not become the active mode", "POST", "Company"], + "POST /api/companies/company-profiles": ["Add operational profile(s) (importer/exporter/forwarder) to the current user's company", "POST", "Company"], + "POST /api/companies/company-profiles/:profileId/license": ["Add business-license document(s) to a profile. For an approved company", "POST", "Company"], + "DELETE /api/companies/company-profiles/:profileId/license/:fileId": ["Remove a business-license file (staged for review on an approved company)", "DELETE", "Company"], + "POST /api/companies/company-profiles/:profileId/license/:fileId/replace": ["Replace a business-license file with a newly uploaded one (staged for", "POST", "Company"], + "POST /api/companies/company-profiles/:profileId/reapply": ["Resubmit a rejected operational role for approval (→ pending)", "POST", "Company"], + "PATCH /api/companies/company-profiles/:profileId/status": ["Update a company profile's approval status", "PATCH", "Company"], + "POST /api/companies/create": ["Create a company with its associated external profile (onboarding)", "POST", "Company"], + "POST /api/companies/documents/:fileId/request-change": ["Ask the customer to correct one uploaded document", "POST", "Company"], + "POST /api/companies/fetch-etrade-info": ["Fetch company info from eTrade by TIN", "POST", "Company"], + "POST /api/companies/identity/fayda/complete": ["Bind a completed Fayda verification to the company's owner or Power of Attorney", "POST", "Company"], + "DELETE /api/companies/identity/fayda/poa": ["Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together", "DELETE", "Company"], + "DELETE /api/companies/identity/gm": ["Clear the General Manager's identity — the \\\"same as owner\\\" declaration or a verification, and the details either wrote", "DELETE", "Company"], + "POST /api/companies/identity/gm/same-as-owner": ["Declare the General Manager is the company's owner, copying the owner's verified identity across", "POST", "Company"], + "POST /api/companies/identity/poa/same-as-owner": ["Declare the Power of Attorney is the company's owner, copying the owner's identity across", "POST", "Company"], + "DELETE /api/companies/identity/poa/same-as-owner": ["Undo the Power of Attorney \\\"same as owner\\\" declaration and the identity it copied, leaving the representative open to be verified in their own right", "DELETE", "Company"], + "PATCH /api/companies/onboarding-step": ["Persist the user's current onboarding wizard step", "PATCH", "Company"], + "POST /api/companies/onboarding/complete": ["Mark the current user's onboarding as complete", "POST", "Company"], + "POST /api/companies/onboarding/start": ["Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", "POST", "Company"], + "POST /api/companies/poa-delegation": ["Upload the Power of Attorney delegation letter, replacing any existing one", "POST", "Company"], + "DELETE /api/companies/poa-delegation/:fileId": ["Remove the Power of Attorney delegation letter (staged for review on an approved company)", "DELETE", "Company"], + "PATCH /api/companies/profile": ["Update profile (flattened settings page)", "PATCH", "Company"], + + // Compliance + "POST /api/compliance": ["Create a compliance record", "POST", "Compliance"], + "PATCH /api/compliance/:id": ["Update a compliance record", "PATCH", "Compliance"], + "DELETE /api/compliance/:id": ["Soft-delete a compliance record", "DELETE", "Compliance"], + + // Consignment + "POST /api/consignments": ["Create a new consignment", "POST", "Consignment"], + + // Container + "POST /api/containers": ["Create a new container", "POST", "Container"], + "PATCH /api/containers/:id": ["Update a container", "PATCH", "Container"], + "DELETE /api/containers/:id": ["Delete a container", "DELETE", "Container"], + "POST /api/containers/:id/assign-wagon": ["Assign container to a wagon", "POST", "Container"], + "POST /api/containers/:id/unassign-wagon": ["Unassign container from wagon", "POST", "Container"], + + // Container Type + "POST /api/container-types": ["Create a container type", "POST", "Container Type"], + "PATCH /api/container-types/:id": ["Update a container type", "PATCH", "Container Type"], + "DELETE /api/container-types/:id": ["Soft-delete a container type", "DELETE", "Container Type"], + "POST /api/container-types/:id/move-order": ["Move a container type up or down in display order", "POST", "Container Type"], + "POST /api/container-types/reorder": ["Bulk reorder container types by ID list", "POST", "Container Type"], + + // Contract + "POST /api/contracts": ["Create a new contract (DRAFT) with routes + cargo scope", "POST", "Contract"], + "PATCH /api/contracts/:id": ["Update contract", "PATCH", "Contract"], + "DELETE /api/contracts/:id": ["Soft-delete DRAFT contract", "DELETE", "Contract"], + "POST /api/contracts/:id/approval-steps/:stepId/approve": ["Approve one approval step in sequence", "POST", "Contract"], + "POST /api/contracts/:id/approval-steps/:stepId/reject": ["Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)", "POST", "Contract"], + "POST /api/contracts/:id/booking-requests": ["Customer submits a shipment request on a GENERAL customs contract", "POST", "Contract"], + "POST /api/contracts/:id/bookings": ["Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia)", "POST", "Contract"], + "POST /api/contracts/:id/bookings/:bookingId/complete": ["Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing", "POST", "Contract"], + "POST /api/contracts/:id/bookings/initiate": ["Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request", "POST", "Contract"], + "POST /api/contracts/:id/cancel": ["Customer cancels their own contract (blocked while a booking is live)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/declaration": ["GL ET uploads customs declaration documents (multi-file)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/delivery-order": ["GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates", "POST", "Contract"], + "POST /api/contracts/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/documents/:fileKey/replace": ["GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving", "POST", "Contract"], + "POST /api/contracts/:id/clearance/duty": ["GL ET sets duty/tax requirement and advises amount with notice attachment", "POST", "Contract"], + "POST /api/contracts/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on contract", "POST", "Contract"], + "POST /api/contracts/:id/clearance/duty/dispute": ["Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/export-release": ["GL ET confirms export release after declaration", "POST", "Contract"], + "POST /api/contracts/:id/clearance/finalize": ["GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/finalize-export-clearance": ["GL ET finalizes export clearance after post-booking transit permit upload", "POST", "Contract"], + "POST /api/contracts/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance — unlocks Djibouti DO upload", "POST", "Contract"], + "POST /api/contracts/:id/clearance/ops-finalize": ["Operations finalizes self-clearance → customer may create the booking", "POST", "Contract"], + "POST /api/contracts/:id/clearance/ops-review": ["Operations reviews a customer self-clearance document (Approve | Query)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…) pre-booking", "POST", "Contract"], + "POST /api/contracts/:id/clearance/release-order": ["GL DJ uploads Release Order + vessel departure date (export)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/review": ["GL ET reviews a clearance document (Approve | Query)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/ro-amendment": ["GL DJ requests port amendment when RO vessel window is too short", "POST", "Contract"], + "POST /api/contracts/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Contract"], + "POST /api/contracts/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the customs declaration", "POST", "Contract"], + "POST /api/contracts/:id/clearance/transit-permit": ["GL ET uploads import transit permit documents (multi-file)", "POST", "Contract"], + "POST /api/contracts/:id/confirm-submit": ["Confirm submit after a price change", "POST", "Contract"], + "POST /api/contracts/:id/contract/generate": ["Generate contract document → CONTRACT_READY", "POST", "Contract"], + "POST /api/contracts/:id/contract/send-signing-otp": ["Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)", "POST", "Contract"], + "POST /api/contracts/:id/contract/sign": ["Apply digital signature (customer or staff/director/ceo)", "POST", "Contract"], + "PUT /api/contracts/:id/document/articles": ["Edit this contract\\'s document articles only (per-contract; never touches the six shared templates)", "PUT", "Contract"], + "POST /api/contracts/:id/documents": ["Upload intake documents for a contract (DRAFT only)", "POST", "Contract"], + "POST /api/contracts/:id/generate-price": ["Generate unit-rate breakdown (no totals at contract phase)", "POST", "Contract"], + "POST /api/contracts/:id/milestones/:code/complete": ["GL marks a pre-booking (contract) milestone complete", "POST", "Contract"], + "POST /api/contracts/:id/renew": ["Create a renewal draft linked via renewalOfId", "POST", "Contract"], + "POST /api/contracts/:id/resume": ["Staff lift a suspension — contract returns to its prior status", "POST", "Contract"], + "POST /api/contracts/:id/staff/accept": ["Staff accept → set validity window + start approval chain", "POST", "Contract"], + "POST /api/contracts/:id/staff/reject": ["Staff reject contract", "POST", "Contract"], + "POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "POST", "Contract"], + "POST /api/contracts/:id/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"], + "POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"], + "POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"], + "POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"], + "POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"], + "POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/documents": ["GL uploads post-booking operational documents (DO/RO/T1/…)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/duty": ["GL ET advises duty & tax amount + declaration serial", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/duty-slip": ["Customer uploads the duty/tax payment slip", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice": ["GL DJ raises the post-offload final invoice (amount + invoice document)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice-slip": ["Customer attaches the payment slip for the final invoice", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice/approve": ["Customer approves the drafted final invoice — unlocks the payment slip", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice/confirm": ["GL (ET or DJ) confirms the payment slip — settles the final invoice", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/incidents": ["GL DJ logs a cargo exception with photo evidence", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/milestones/:code/complete": ["GL / Ops / Terminal marks a post-booking milestone complete", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/risk": ["GL ET assigns a customs risk level (GREEN/YELLOW/RED)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/second-duty": ["GL ET advises (or skips) the post-arrival additional duty/tax round (import)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/second-duty-slip": ["Customer attaches the additional duty/tax payment slip", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/station-assign": ["GL station manager routes the shipment + binds staff", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/t1-close": ["Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/t1-documents": ["GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/transport-document": ["GL ET uploads export transit permit documents (multi-file)", "POST", "Contract"], + "POST /api/gl-exchange/:entityId": ["Share a document with the other GL desk", "POST", "Contract"], + "PATCH /api/gl-exchange/documents/:documentId": ["Uploader edits a shared document (title, visibility, file)", "PATCH", "Contract"], + "DELETE /api/gl-exchange/documents/:documentId": ["Uploader removes a shared document", "DELETE", "Contract"], + + // Contract Template + "POST /api/contract-templates": ["Create a bulk contract template for a (cargo type, customs option) pair", "POST", "Contract Template"], + "PATCH /api/contract-templates/:code": ["Update template metadata (name, title, recitals, active flag)", "PATCH", "Contract Template"], + "DELETE /api/contract-templates/:code": ["Delete a staff-created bulk template (system templates refuse)", "DELETE", "Contract Template"], + "POST /api/contract-templates/:code/articles": ["Add an article to the template", "POST", "Contract Template"], + "PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "Contract Template"], + "PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"], + "DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"], + "POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"], + + // Driver + "POST /api/drivers": ["Create a new driver", "POST", "Driver"], + "PATCH /api/drivers/:id": ["Update a driver", "PATCH", "Driver"], + "DELETE /api/drivers/:id": ["Delete a driver", "DELETE", "Driver"], + "POST /api/drivers/:id/documents": ["Upload driver documents (code driver_docs)", "POST", "Driver"], + "DELETE /api/drivers/:id/documents/:fileId": ["Delete a driver document", "DELETE", "Driver"], + + // Dropdown Setting + "POST /api/dropdown-settings": ["Create a new dropdown setting", "POST", "Dropdown Setting"], + "PATCH /api/dropdown-settings/:id": ["Update a dropdown setting's metadata", "PATCH", "Dropdown Setting"], + "DELETE /api/dropdown-settings/:id": ["Soft-delete a dropdown setting", "DELETE", "Dropdown Setting"], + "POST /api/dropdown-settings/:id/options": ["Append a single option to a setting", "POST", "Dropdown Setting"], + "PUT /api/dropdown-settings/:id/options": ["Replace the full option list for a setting", "PUT", "Dropdown Setting"], + "PATCH /api/dropdown-settings/options/:optionId": ["Update a single option", "PATCH", "Dropdown Setting"], + "DELETE /api/dropdown-settings/options/:optionId": ["Soft-delete a single option", "DELETE", "Dropdown Setting"], + + // EIMS Invoice + "POST /api/invoices/:id/eims/register": ["Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged", "POST", "EIMS Invoice"], + "POST /api/invoices/:id/eims/resolve": ["Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block", "POST", "EIMS Invoice"], + "POST /api/invoices/:id/eims/verify": ["Verify the invoice's stored IRN against EIMS", "POST", "EIMS Invoice"], + + // Exchange Setting + "PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"], + + // Facility + "POST /api/facilities": ["Create a new facility", "POST", "Facility"], + "PATCH /api/facilities/:id": ["Update a facility", "PATCH", "Facility"], + "DELETE /api/facilities/:id": ["Delete a facility (soft delete)", "DELETE", "Facility"], + + // Fayda Verification + "POST /api/fayda/verification/start": ["Start a VeriFayda 2.0 verification session", "POST", "Fayda Verification"], + + // File Upload Setting + "POST /api/file-upload-settings": ["Create a new file upload setting", "POST", "File Upload Setting"], + "PATCH /api/file-upload-settings/:id": ["Update a file upload setting's metadata", "PATCH", "File Upload Setting"], + "DELETE /api/file-upload-settings/:id": ["Soft-delete a file upload setting", "DELETE", "File Upload Setting"], + "POST /api/file-upload-settings/:id/fields": ["Append a single field to a setting", "POST", "File Upload Setting"], + "PUT /api/file-upload-settings/:id/fields": ["Replace the full field list for a setting", "PUT", "File Upload Setting"], + "PATCH /api/file-upload-settings/fields/:fieldId": ["Update a single field", "PATCH", "File Upload Setting"], + "DELETE /api/file-upload-settings/fields/:fieldId": ["Soft-delete a single field", "DELETE", "File Upload Setting"], + + // First Mile + "POST /api/first-mile": ["Create a first-mile leg", "POST", "First Mile"], + "PATCH /api/first-mile/:id": ["Update a first-mile leg", "PATCH", "First Mile"], + "DELETE /api/first-mile/:id": ["Soft-delete a first-mile leg", "DELETE", "First Mile"], + "POST /api/first-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "First Mile"], + "POST /api/first-mile/:id/invoice": ["Generate the first-mile delivery-fee invoice", "POST", "First Mile"], + "POST /api/first-mile/:id/vehicles": ["Set the vehicles assigned to a first-mile pickup (multi-truck)", "POST", "First Mile"], + "POST /api/first-mile/accept/:reference": ["Accept a paid booking and create a first-mile leg", "POST", "First Mile"], + + // Fuel + "POST /api/fuel/purchases": ["Record fuel purchase", "POST", "Fuel"], + + // GPS Tracking + "POST /api/gps/devices": ["Register a GPS tracker", "POST", "GPS Tracking"], + "PATCH /api/gps/devices/:id": ["Update a GPS tracker (name / assigned vehicle)", "PATCH", "GPS Tracking"], + "DELETE /api/gps/devices/:id": ["Delete a GPS tracker", "DELETE", "GPS Tracking"], + + // Import Operation + "POST /api/import-operations/customs/:bookingId/declaration": ["Batch 12: record declaration serial number", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/documents": ["Batch 12: upload IM4/IM5/T1/permit/payment-slip documents", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/duties-taxes-paid": ["Batch 12: mark duties and taxes paid", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/notify-duties-taxes": ["Batch 12: notify duties and taxes", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/release-permitted": ["Batch 12: mark import release permitted", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/risk": ["Batch 12: assign customs risk", "POST", "Import Operation"], + "POST /api/import-operations/djibouti-incidents": ["Batch 8: report a Djibouti import incident / exception", "POST", "Import Operation"], + "POST /api/import-operations/empty-container-returns": ["Batch 16: create an empty container return record", "POST", "Import Operation"], + "POST /api/import-operations/empty-container-returns/:id/status": ["Batch 16: advance empty container return workflow", "POST", "Import Operation"], + + // Incident + "POST /api/incidents": ["Report an incident", "POST", "Incident"], + "PATCH /api/incidents/:id": ["Update an incident", "PATCH", "Incident"], + "DELETE /api/incidents/:id": ["Delete an incident", "DELETE", "Incident"], + + // Interchange Document + "PATCH /api/interchange-documents/:id/acknowledge": ["Acknowledge an interchange document", "PATCH", "Interchange Document"], + "PATCH /api/interchange-documents/:id/dispute": ["Dispute an interchange document", "PATCH", "Interchange Document"], + "POST /api/interchange-documents/generate-from-schedule": ["Generate interchange document from a train schedule handover", "POST", "Interchange Document"], + + // Last Mile + "POST /api/last-mile": ["Create a last-mile leg", "POST", "Last Mile"], + "PATCH /api/last-mile/:id": ["Update a last-mile leg", "PATCH", "Last Mile"], + "DELETE /api/last-mile/:id": ["Soft-delete a last-mile leg", "DELETE", "Last Mile"], + "POST /api/last-mile/:id/detention-times": ["Set each truck\\'s own detention window (arrived at destination / returned)", "POST", "Last Mile"], + "POST /api/last-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "Last Mile"], + "POST /api/last-mile/:id/invoice": ["Generate the delivery-fee invoice for a last-mile leg", "POST", "Last Mile"], + "POST /api/last-mile/:id/proof-of-delivery": ["Record proof of delivery (signature + photos) and complete the leg", "POST", "Last Mile"], + "POST /api/last-mile/:id/vehicles": ["Set the vehicles assigned to a last-mile delivery (multi-truck)", "POST", "Last Mile"], + "POST /api/last-mile/:id/warehouse-gate-times": ["Set each truck\\'s warehouse gate arrival/departure times", "POST", "Last Mile"], + "POST /api/last-mile/accept/:reference": ["Accept a paid booking and create a last-mile leg", "POST", "Last Mile"], + + // Last Mile Request + "POST /api/last-mile-requests/:id/approve": ["Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature", "POST", "Last Mile Request"], + "POST /api/last-mile-requests/:id/contract/sign": ["Customer agrees and signs the LM contract — then the advance invoice is issued", "POST", "Last Mile Request"], + "POST /api/last-mile-requests/:id/reject": ["Truck & Machinery chief rejects the request with a reason", "POST", "Last Mile Request"], + "POST /api/last-mile-requests/:id/submit": ["Customer confirms which containers go via EDR last-mile", "POST", "Last Mile Request"], + + // Locomotive + "POST /api/locomotives": ["Create a locomotive", "POST", "Locomotive"], + "PATCH /api/locomotives/:id": ["Update a locomotive", "PATCH", "Locomotive"], + "POST /api/locomotives/:id/decommission": ["Decommission a locomotive", "POST", "Locomotive"], + "DELETE /api/locomotives/:id/permanent": ["Permanently delete a locomotive (irreversible; refused if any train references it)", "DELETE", "Locomotive"], + + // Maintenance + "POST /api/maintenance/costs": ["Record maintenance cost", "POST", "Maintenance"], + "POST /api/maintenance/intervals": ["Define/adjust a service interval (e.g. oil change every 10,000 km)", "POST", "Maintenance"], + "DELETE /api/maintenance/intervals/:id": ["Deactivate a service interval (stops auto-scheduling)", "DELETE", "Maintenance"], + "POST /api/maintenance/parts": ["Create part", "POST", "Maintenance"], + "PATCH /api/maintenance/parts/:id": ["Update part", "PATCH", "Maintenance"], + "DELETE /api/maintenance/parts/:id": ["Delete part", "DELETE", "Maintenance"], + "POST /api/maintenance/schedules": ["Schedule maintenance", "POST", "Maintenance"], + "PATCH /api/maintenance/schedules/:id": ["Update maintenance schedule", "PATCH", "Maintenance"], + "POST /api/maintenance/warranties": ["Create warranty", "POST", "Maintenance"], + "DELETE /api/maintenance/warranties/:id": ["Delete warranty", "DELETE", "Maintenance"], + "POST /api/maintenance/work-orders": ["Create work order", "POST", "Maintenance"], + "PATCH /api/maintenance/work-orders/:id": ["Update work order", "PATCH", "Maintenance"], + "DELETE /api/maintenance/work-orders/:id": ["Delete work order", "DELETE", "Maintenance"], + + // Notification Inbox + "PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "Notification Inbox"], + "POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"], + + // Organization User + "PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "Organization User"], + "POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"], + + // OTP + "POST /api/otp/send": ["Send OTP", "POST", "OTP"], + "POST /api/otp/verify": ["Verify OTP", "POST", "OTP"], + + // Password Reset + "POST /api/auth/forgot-password/request": ["Send a password-reset code to the account's email AND phone", "POST", "Password Reset"], + "POST /api/auth/forgot-password/resolve-link": ["Validate a staff-issued reset link and return its set-password ticket", "POST", "Password Reset"], + "POST /api/auth/forgot-password/verify": ["Exchange a valid reset code for a single-use set-password ticket", "POST", "Password Reset"], + "POST /api/backoffice/customers/:companyId/reset-password": ["Send a password-reset link to a customer's primary contact", "POST", "Password Reset"], + + // Payment + "POST /api/billing/invoices/:id/confirm-offline": ["Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance", "POST", "Payment"], + "POST /api/billing/my-invoices/:id/confirm": ["Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices", "POST", "Payment"], + "POST /api/billing/my-invoices/:id/pay": ["Initiate payment for one of the customer's invoices", "POST", "Payment"], + "POST /api/internal/payments/bill-query": ["Live still-payable check + payer name for a CBE bill (called while CBE is on the line)", "POST", "Payment"], + "POST /api/internal/payments/mark-paid": ["Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", "POST", "Payment"], + "POST /api/payments/initiate": ["Initiate payment for an invoice", "POST", "Payment"], + "POST /api/payments/redirect-success/:bookingId": ["Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)", "POST", "Payment"], + + // Priority Config + "POST /api/priority-configs": ["Create a priority config", "POST", "Priority Config"], + "PATCH /api/priority-configs/:id": ["Update a priority config", "PATCH", "Priority Config"], + "DELETE /api/priority-configs/:id": ["Soft-delete a priority config", "DELETE", "Priority Config"], + "POST /api/priority-configs/:id/move-order": ["Move a priority config up or down in display order", "POST", "Priority Config"], + "POST /api/priority-configs/reorder": ["Bulk reorder priority configs by ID list", "POST", "Priority Config"], + + // Priority Rule Change Request + "POST /api/priority-rule-change-requests": ["Submit a priority-rule change for approval", "POST", "Priority Rule Change Request"], + "POST /api/priority-rule-change-requests/:id/approve": ["Approve and apply a pending change", "POST", "Priority Rule Change Request"], + "POST /api/priority-rule-change-requests/:id/reject": ["Reject a pending change", "POST", "Priority Rule Change Request"], + + // Procurement + "POST /api/procurement/acquisitions": ["Create an asset acquisition", "POST", "Procurement"], + "PATCH /api/procurement/acquisitions/:id": ["Update an asset acquisition", "PATCH", "Procurement"], + "DELETE /api/procurement/acquisitions/:id": ["Delete an asset acquisition", "DELETE", "Procurement"], + "POST /api/procurement/disposals": ["Create an asset disposal", "POST", "Procurement"], + "DELETE /api/procurement/disposals/:id": ["Delete an asset disposal", "DELETE", "Procurement"], + "POST /api/procurement/vendors": ["Create a vendor", "POST", "Procurement"], + "PATCH /api/procurement/vendors/:id": ["Update a vendor", "PATCH", "Procurement"], + "DELETE /api/procurement/vendors/:id": ["Delete a vendor", "DELETE", "Procurement"], + + // Rate + "POST /api/rates": ["Create a rate (DRAFT)", "POST", "Rate"], + "PATCH /api/rates/:id": ["Update a DRAFT rate", "PATCH", "Rate"], + "DELETE /api/rates/:id": ["Soft-delete a rate", "DELETE", "Rate"], + "POST /api/rates/:id/approve": ["CEO approves a rate", "POST", "Rate"], + "POST /api/rates/:id/submit": ["Submit rate for CEO approval", "POST", "Rate"], + + // Rate Change Request + "POST /api/rate-change-requests": ["Propose a change to a LIVE rate", "POST", "Rate Change Request"], + "POST /api/rate-change-requests/:id/approve": ["Approve a rate change and put it into effect", "POST", "Rate Change Request"], + "POST /api/rate-change-requests/:id/reject": ["Reject a rate change — the rate keeps its current value", "POST", "Rate Change Request"], + + // Route + "POST /api/routes": ["Create route", "POST", "Route"], + "PATCH /api/routes/:id": ["Update route", "PATCH", "Route"], + "DELETE /api/routes/:id": ["Deactivate route", "DELETE", "Route"], + "DELETE /api/routes/:id/permanent": ["Permanently delete a route (irreversible; refused while any train schedule references it)", "DELETE", "Route"], + + // Schedule + // NOTE: duplicate route — also declared in modules/scheduling-reschedule/scheduling-reschedule.controller.ts:52. + // Two controllers register this same path; Nest serves whichever module loads first. + "POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"], + "POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"], + "POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"], + + // Service Type + "POST /api/service-types": ["Create a service type", "POST", "Service Type"], + "PATCH /api/service-types/:id": ["Update a service type", "PATCH", "Service Type"], + "DELETE /api/service-types/:id": ["Soft-delete a service type", "DELETE", "Service Type"], + "POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"], + "POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"], + + // Shipping Line + "POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"], + "PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"], + "DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"], + + // Signature + "PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"], + + // Support Chat + "POST /api/support/agent/conversations": ["Start chatting with a company (returns the thread if one exists)", "POST", "Support Chat"], + "POST /api/support/agent/conversations/:id/messages": ["Reply as an agent, optionally with attachments", "POST", "Support Chat"], + "POST /api/support/agent/conversations/:id/read": ["Mark a thread read (agent side)", "POST", "Support Chat"], + "POST /api/support/conversation/messages": ["Send a message as the customer (optionally with attachments), opening the thread if needed", "POST", "Support Chat"], + "POST /api/support/conversation/read": ["Mark my company's thread read (customer side)", "POST", "Support Chat"], + + // Support Content + "PATCH /api/support-content/documents/:slug": ["Replace a document's payload, recording a new version", "PATCH", "Support Content"], + "POST /api/support-content/documents/:slug/versions/:version/restore": ["Restore a version — re-saves it as a new version, never destructive", "POST", "Support Content"], + "POST /api/support-content/media": ["Upload an image or video for a help section", "POST", "Support Content"], + + // Train + "POST /api/trains": ["Register a new train", "POST", "Train"], + "PATCH /api/trains/:id": ["Update a train", "PATCH", "Train"], + "DELETE /api/trains/:id": ["Delete a train", "DELETE", "Train"], + + // Train Build + "POST /api/train-builder": ["Build a train: code + yard + 2+ locomotives (+ optional wagons)", "POST", "Train Build"], + "DELETE /api/train-builder/:id": ["Disband the train (release wagons and locomotives)", "DELETE", "Train Build"], + "POST /api/train-builder/:id/activate": ["Reactivate a deactivated train back to AVAILABLE", "POST", "Train Build"], + "POST /api/train-builder/:id/deactivate": ["Deactivate the train (park it) — only allowed with no active schedule", "POST", "Train Build"], + "PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "Train Build"], + "PUT /api/train-builder/:id/locomotives": ["Replace the locomotive set (minimum 1, same yard)", "PUT", "Train Build"], + "POST /api/train-builder/:id/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"], + "POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the consist", "POST", "Train Build"], + "DELETE /api/train-builder/:id/wagons/:wagonId": ["Detach one wagon from the consist", "DELETE", "Train Build"], + "POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"], + "PATCH /api/train-builder/:id/yard": ["Relocate the train — its locomotives and wagons move to the new yard with it", "PATCH", "Train Build"], + + // Train Schedule + "POST /api/train-scheduling/bookings/:bookingId/allocate": ["Staff: place a paid booking onto a fitting train (notifies customer on date change)", "POST", "Train Schedule"], + "POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "POST", "Train Schedule"], + "POST /api/train-scheduling/bookings/:bookingId/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"], + "POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/assign-unassigned-booking": ["Assign one linked unallocated booking to wagons (preserves existing assignments)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/booking-window": ["Open or close a schedule booking window", "PATCH", "Train Schedule"], + "DELETE /api/train-scheduling/schedules/:id/bookings/:bookingId": ["Unassign a booking from a train schedule", "DELETE", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/bookings/:bookingId/load": ["Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/bookings/:bookingId/unload": ["Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/checkpoints": ["Log the train passing a station (final station triggers arrival)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/confirm-loading": ["Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/container-items/:itemId": ["Update a container number on a wagon slot", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/dispatch": ["Dispatch a scheduled train", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/doc-review-complete": ["Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/finalize": ["Finalize a draft train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/depart": ["Depart loaded import train from Djibouti", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/documents": ["Upload/check an import Djibouti-side document", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/gatepass-granted": ["Mark import Djibouti gatepass permission granted", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/load-list": ["Generate import load list / marshalling document summary", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/loaded-on-train": ["Confirm import cargo loaded on train at Djibouti", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/ready-for-loading": ["Mark import train ready for loading at Djibouti", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/import-loading-status": ["Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/intercity/:bookingId/load": ["Confirm intercity cargo loaded (train must be at the booking's origin yard)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/intercity/:bookingId/unload": ["Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/intercity/accept": ["Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/loading-status": ["Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)", "PATCH", "Train Schedule"], + // NOTE: duplicate route — also declared in modules/train-scheduling/controllers/train-scheduling.controller.ts:798. + // Two controllers register this same path; Nest serves whichever module loads first. + "POST /api/train-scheduling/schedules/:id/maintenance [modules/train-scheduling/controllers/train-scheduling.controller.ts]": ["Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/pin-wagons": ["Pin physical wagons to train set slots", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/run-allocation": ["Run wagon-level allocation for all eligible linked bookings", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/run-batch": ["Manually run the batch fill for a schedule", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/schedule-date": ["Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/switch-government-booking": ["Switch out commercial bookings to allocate a government booking in their place", "POST", "Train Schedule"], + "DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"], + + // Transit Agent + "POST /api/transit-agents": ["Create a transit agent", "POST", "Transit Agent"], + "PATCH /api/transit-agents/:id": ["Update a transit agent", "PATCH", "Transit Agent"], + "DELETE /api/transit-agents/:id": ["Soft-delete a transit agent", "DELETE", "Transit Agent"], + + // Truck Type + "POST /api/truck-types": ["Create a truck type", "POST", "Truck Type"], + "PATCH /api/truck-types/:id": ["Update a truck type", "PATCH", "Truck Type"], + "DELETE /api/truck-types/:id": ["Soft-delete a truck type", "DELETE", "Truck Type"], + + // User Trade Access + "PUT /api/user-trade-access/:userId": ["Set the trade directions a backoffice user may see", "PUT", "User Trade Access"], + + // Vehicle + "POST /api/vehicles": ["Create a new vehicle", "POST", "Vehicle"], + "PATCH /api/vehicles/:id": ["Update a vehicle", "PATCH", "Vehicle"], + "DELETE /api/vehicles/:id": ["Delete a vehicle", "DELETE", "Vehicle"], + + // Wagon + "POST /api/wagons": ["Create a new wagon", "POST", "Wagon"], + "PATCH /api/wagons/:id": ["Update a wagon", "PATCH", "Wagon"], + "DELETE /api/wagons/:id": ["Delete a wagon", "DELETE", "Wagon"], + "POST /api/wagons/:id/assign-train": ["Assign wagon to a train", "POST", "Wagon"], + "DELETE /api/wagons/:id/permanent": ["Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)", "DELETE", "Wagon"], + "POST /api/wagons/:id/unassign-train": ["Unassign wagon from train", "POST", "Wagon"], + "POST /api/wagons/bulk-status": ["Set the status of multiple wagons (audited in wagon_status_logs)", "POST", "Wagon"], + "POST /api/wagons/bulk-transfer": ["Transfer multiple wagons to a destination yard", "POST", "Wagon"], + + // Wagon Transfer Request + "POST /api/wagon-transfer-requests": ["File a count-only wagon-transfer request", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/:id/cancel": ["Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/:id/close-short": ["OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/:id/fulfill": ["OCC: pick wagons and execute the transfer", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/bulk-fulfill": ["OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING)", "POST", "Wagon Transfer Request"], + + // Wagon Type + "POST /api/wagon-types": ["Create a wagon type", "POST", "Wagon Type"], + "PATCH /api/wagon-types/:id": ["Update a wagon type", "PATCH", "Wagon Type"], + "DELETE /api/wagon-types/:id": ["Soft-delete a wagon type", "DELETE", "Wagon Type"], + + // Warehouse + "POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "Warehouse"], + "PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"], + "DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"], + "POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"], + "POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"], + "PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"], + "DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"], + "POST /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Acknowledge / snooze an item fee-accrual alert", "POST", "Warehouse"], + "DELETE /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Remove an accrual acknowledgement (re-surface for alerts)", "DELETE", "Warehouse"], + "POST /api/warehouses": ["Create warehouse", "POST", "Warehouse"], + "PATCH /api/warehouses/:id": ["Update warehouse", "PATCH", "Warehouse"], + "POST /api/warehouses/:warehouseId/yards": ["Create a yard within a warehouse", "POST", "Warehouse"], + + // Warehouse Fee Invoice + "POST /api/last-mile/:id/generate-truck-detention-invoice": ["Generate a truck-detention invoice for a last-mile leg (per truck per day)", "POST", "Warehouse Fee Invoice"], + "PATCH /api/warehouse-fee-invoices/:id/cancel": ["Cancel a warehouse fee invoice", "PATCH", "Warehouse Fee Invoice"], + "POST /api/warehouse-fee-invoices/:id/pay": ["Record a payment against a warehouse fee invoice", "POST", "Warehouse Fee Invoice"], + "POST /api/warehouse-fee-invoices/:id/pay-online": ["Initiate Telebirr/Waafi payment for a warehouse fee invoice", "POST", "Warehouse Fee Invoice"], + "POST /api/warehouse-inventory/:id/generate-fee-invoice": ["Generate a warehouse fee invoice from Batch 5 fee calculation", "POST", "Warehouse Fee Invoice"], + + // Warehouse Inspection Report + "PATCH /api/warehouse-inspection-reports/:id": ["Update an inspection report", "PATCH", "Warehouse Inspection Report"], + "POST /api/warehouse-inspection-reports/:id/attachments": ["Upload inspection images / documents", "POST", "Warehouse Inspection Report"], + "POST /api/warehouse-inventory/:inventoryId/inspection-reports": ["Create an inspection / damage report for an inventory item", "POST", "Warehouse Inspection Report"], + + // Warehouse Inventory + "POST /api/warehouse-inventory/:id/deliver": ["Deliver import goods to the customer + capture proof of delivery", "POST", "Warehouse Inventory"], + "PATCH /api/warehouse-inventory/:id/dispatch": ["Mark loaded inventory DISPATCHED (left the terminal)", "PATCH", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/gate-clearance": ["Final terminal release / gate clearance (blocked while fees unpaid)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/load": ["Load READY_FOR_LOADING inventory onto a wagon", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/move": ["Move inventory to another warehouse/yard/zone", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/ready-for-loading": ["Mark reserved inventory READY_FOR_LOADING", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/ready-for-pickup": ["Mark inspected IMPORT inventory READY_FOR_PICKUP", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/release": ["Issue a DO / release order for ready-for-pickup inventory", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/store": ["Mark received inventory as STORED (optional explicit warehouse/yard/zone)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/auto-load-ready": ["Auto-load READY_FOR_LOADING inventory with PAID bookings", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/auto-unload-arrived": ["Bulk auto-unload all arrived bookings into the warehouse", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bookings/:bookingId/approve-delivery": ["Approve delivery — customer records their full name (signature optional)", "POST", "Warehouse Inventory"], + "PATCH /api/warehouse-inventory/bookings/:bookingId/double-handling": ["Record Yes/No double handling after unloading (Yes applies the double-handling fee rule)", "PATCH", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bookings/:bookingId/request-handover-signature": ["Ask the customer to sign the handover (creates one if none, then notifies)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bookings/:bookingId/unload": ["Unload a single arrived booking into a location", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bulk-dispatch-export": ["Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bulk-mark-inspected": ["Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/export/auto-unload-at-djibouti": ["Unload all eligible export items assigned to an arrived Djibouti-side train", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/handovers/:handoverId/sign": ["Customer signs one handover (EDR last-mile: one signature per truck)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/import/auto-unload-arrived-bookings": ["Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/receive": ["Receive inventory at a warehouse location", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/receive-bulk": ["Bulk-receive selected eligible PAID bookings into a location", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/reserve": ["Reserve stored inventory for a PAID booking", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/train/:scheduleId/load": ["Load selected inventory items onto their allocated wagons for a train", "POST", "Warehouse Inventory"], + + // Warehouse Yard + "PATCH /api/warehouse-yards/:id": ["Update warehouse yard", "PATCH", "Warehouse Yard"], + "POST /api/warehouse-yards/:yardId/zones": ["Create a zone within a yard", "POST", "Warehouse Yard"], + + // Warehouse Zone + "PATCH /api/warehouse-zones/:id": ["Update warehouse zone", "PATCH", "Warehouse Zone"], + + // Weight Limit Rule + "POST /api/weight-limit-rules": ["Create a weight limit rule", "POST", "Weight Limit Rule"], + "PATCH /api/weight-limit-rules/:id": ["Update a weight limit rule", "PATCH", "Weight Limit Rule"], + "DELETE /api/weight-limit-rules/:id": ["Soft-delete a weight limit rule", "DELETE", "Weight Limit Rule"], + + // Yard + "POST /api/yards": ["Create a yard", "POST", "Yard"], + "PATCH /api/yards/:id": ["Update a yard", "PATCH", "Yard"], + "DELETE /api/yards/:id": ["Soft-delete a yard", "DELETE", "Yard"], + "POST /api/yards/:id/move-order": ["Move a yard up or down in display order", "POST", "Yard"], + "POST /api/yards/reorder": ["Bulk reorder yards by ID list", "POST", "Yard"], + + // Yard Distance + "POST /api/yard-distances": ["Create a yard distance", "POST", "Yard Distance"], + "PATCH /api/yard-distances/:id": ["Update a yard distance", "PATCH", "Yard Distance"], + "DELETE /api/yard-distances/:id": ["Soft-delete a yard distance", "DELETE", "Yard Distance"], +}; + +module.exports = AUDIT_ENDPOINTS; diff --git a/apps/edr-freight-api/docs/audit-endpoints.md b/apps/edr-freight-api/docs/audit-endpoints.md new file mode 100644 index 000000000..38e89e367 --- /dev/null +++ b/apps/edr-freight-api/docs/audit-endpoints.md @@ -0,0 +1,922 @@ +# Freight API — Mutating Endpoints (Audit Surface) + +Every state-changing route in `apps/edr-freight-api` — `POST`, `PUT`, `PATCH`, `DELETE`. +This is the candidate surface for audit logging: each row is an action a user can take +that changes persisted state and therefore needs a who / what / when trail. + +All paths include the global prefix `api` (`app.setGlobalPrefix("api")` in `src/main.ts`). +Titles come from each route's `@ApiOperation({ summary })`; where a route has none, +the title is derived from its handler name. + +> Generated by reading the `@Post` / `@Put` / `@Patch` / `@Delete` decorators in +> `src/**/*.controller.ts`. Re-generate after adding routes so this stays complete. + +## Summary + +| Method | Count | +| --- | ---: | +| `POST` | 351 | +| `PUT` | 8 | +| `PATCH` | 72 | +| `DELETE` | 61 | +| **Total** | **492** | + +Across **66** entities. + +## Entity index + +| Entity | Endpoints | +| --- | ---: | +| [Account](#account) | 3 | +| [AI Assist](#ai-assist) | 1 | +| [Approval Rule](#approval-rule) | 5 | +| [Booking](#booking) | 57 | +| [Cargo](#cargo) | 6 | +| [Cargo Type](#cargo-type) | 5 | +| [Company](#company) | 30 | +| [Compliance](#compliance) | 3 | +| [Consignment](#consignment) | 1 | +| [Container](#container) | 5 | +| [Container Type](#container-type) | 5 | +| [Contract](#contract) | 68 | +| [Contract Template](#contract-template) | 8 | +| [Driver](#driver) | 5 | +| [Dropdown Setting](#dropdown-setting) | 7 | +| [EIMS Invoice](#eims-invoice) | 3 | +| [Exchange Setting](#exchange-setting) | 1 | +| [Facility](#facility) | 3 | +| [Fayda Verification](#fayda-verification) | 1 | +| [File Upload Setting](#file-upload-setting) | 7 | +| [First Mile](#first-mile) | 7 | +| [Fuel](#fuel) | 1 | +| [GPS Tracking](#gps-tracking) | 3 | +| [Import Operation](#import-operation) | 9 | +| [Incident](#incident) | 3 | +| [Interchange Document](#interchange-document) | 3 | +| [Last Mile](#last-mile) | 10 | +| [Last Mile Request](#last-mile-request) | 4 | +| [Locomotive](#locomotive) | 4 | +| [Maintenance](#maintenance) | 13 | +| [Notification Inbox](#notification-inbox) | 2 | +| [Organization User](#organization-user) | 2 | +| [OTP](#otp) | 2 | +| [Password Reset](#password-reset) | 4 | +| [Payment](#payment) | 7 | +| [Priority Config](#priority-config) | 5 | +| [Priority Rule Change Request](#priority-rule-change-request) | 3 | +| [Procurement](#procurement) | 8 | +| [Rate](#rate) | 5 | +| [Rate Change Request](#rate-change-request) | 3 | +| [Route](#route) | 4 | +| [Schedule](#schedule) | 3 | +| [Service Type](#service-type) | 5 | +| [Shipping Line](#shipping-line) | 3 | +| [Signature](#signature) | 1 | +| [Support Chat](#support-chat) | 5 | +| [Support Content](#support-content) | 3 | +| [Train](#train) | 3 | +| [Train Build](#train-build) | 11 | +| [Train Schedule](#train-schedule) | 48 | +| [Transit Agent](#transit-agent) | 3 | +| [Truck Type](#truck-type) | 3 | +| [User Trade Access](#user-trade-access) | 1 | +| [Vehicle](#vehicle) | 3 | +| [Wagon](#wagon) | 8 | +| [Wagon Transfer Request](#wagon-transfer-request) | 5 | +| [Wagon Type](#wagon-type) | 3 | +| [Warehouse](#warehouse) | 12 | +| [Warehouse Fee Invoice](#warehouse-fee-invoice) | 5 | +| [Warehouse Inspection Report](#warehouse-inspection-report) | 3 | +| [Warehouse Inventory](#warehouse-inventory) | 24 | +| [Warehouse Yard](#warehouse-yard) | 2 | +| [Warehouse Zone](#warehouse-zone) | 1 | +| [Weight Limit Rule](#weight-limit-rule) | 3 | +| [Yard](#yard) | 5 | +| [Yard Distance](#yard-distance) | 3 | + +--- + +## Endpoints by entity + +### Account + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Send a verification code to a new email/phone before changing it | `POST` | `/api/me/contact/otp` | `modules/auth/account.controller.ts:26` | +| Change the account's email or phone, gated by a verification code | `PATCH` | `/api/me/contact` | `modules/auth/account.controller.ts:40` | +| Change the account's display name | `PATCH` | `/api/me/name` | `modules/auth/account.controller.ts:54` | + +### AI Assist + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Mock AI: extract structured booking fields from free-text request | `POST` | `/api/ai/booking/extract` | `modules/ai/ai.controller.ts:16` | + +### Approval Rule + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create an approval rule step | `POST` | `/api/approval-rules` | `modules/rule-engine/controllers/approval-rules.controller.ts:66` | +| Move an approval step up or down within its chain | `POST` | `/api/approval-rules/:id/move-order` | `modules/rule-engine/controllers/approval-rules.controller.ts:51` | +| Bulk reorder approval steps within a chain | `POST` | `/api/approval-rules/reorder` | `modules/rule-engine/controllers/approval-rules.controller.ts:43` | +| Update an approval rule | `PATCH` | `/api/approval-rules/:id` | `modules/rule-engine/controllers/approval-rules.controller.ts:73` | +| Soft-delete an approval rule | `DELETE` | `/api/approval-rules/:id` | `modules/rule-engine/controllers/approval-rules.controller.ts:80` | + +### Booking + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new freight booking (DRAFT) | `POST` | `/api/bookings` | `modules/bookings/bookings.controller.ts:170` | +| Allocate containers to vehicles | `POST` | `/api/bookings/:bookingId/allocate-containers` | `modules/bookings/booking-allocation.controller.ts:13` | +| Cancel booking | `POST` | `/api/bookings/:id/cancel` | `modules/bookings/bookings.controller.ts:1544` | +| Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); | `POST` | `/api/bookings/:id/cancel-hold` | `modules/bookings/bookings.controller.ts:1568` | +| GL ET uploads customs declaration on booking (GENERAL customs) | `POST` | `/api/bookings/:id/clearance/declaration` | `modules/bookings/bookings.controller.ts:1126` | +| Upload Booking Delivery Order | `POST` | `/api/bookings/:id/clearance/delivery-order` | `modules/bookings/bookings.controller.ts:1268` | +| Customer uploads clearance documents (fieldname = document key) | `POST` | `/api/bookings/:id/clearance/documents` | `modules/bookings/bookings.controller.ts:959` | +| GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review | `POST` | `/api/bookings/:id/clearance/draft-declaration` | `modules/bookings/bookings.controller.ts:1175` | +| Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia | `POST` | `/api/bookings/:id/clearance/draft-declaration/accept` | `modules/bookings/bookings.controller.ts:1200` | +| Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable) | `POST` | `/api/bookings/:id/clearance/draft-declaration/change` | `modules/bookings/bookings.controller.ts:1211` | +| GL ET sets duty/tax on booking with notice attachment | `POST` | `/api/bookings/:id/clearance/duty` | `modules/bookings/bookings.controller.ts:1144` | +| Customer uploads duty/tax payment slip on booking | `POST` | `/api/bookings/:id/clearance/duty-slip` | `modules/bookings/bookings.controller.ts:1238` | +| Confirm Booking Export Release | `POST` | `/api/bookings/:id/clearance/export-release` | `modules/bookings/bookings.controller.ts:1326` | +| GL finalizes clearance (requires 100% approved) → CLEARANCE_READY | `POST` | `/api/bookings/:id/clearance/finalize` | `modules/bookings/bookings.controller.ts:1087` | +| GL ET finalizes import pre-clearance on booking | `POST` | `/api/bookings/:id/clearance/finalize-pre-clearance` | `modules/bookings/bookings.controller.ts:1230` | +| GL uploads customs output documents (IM4/EX3/…) | `POST` | `/api/bookings/:id/clearance/output-documents` | `modules/bookings/bookings.controller.ts:1071` | +| Customer requests operation with a schedule day | `POST` | `/api/bookings/:id/clearance/proceed` | `modules/bookings/bookings.controller.ts:979` | +| Upload Booking Release Order | `POST` | `/api/bookings/:id/clearance/release-order` | `modules/bookings/bookings.controller.ts:1288` | +| GL reviews a clearance document (Approve | Query) | `POST` | `/api/bookings/:id/clearance/review` | `modules/bookings/bookings.controller.ts:1051` | +| Request Booking RO Amendment | `POST` | `/api/bookings/:id/clearance/ro-amendment` | `modules/bookings/bookings.controller.ts:1311` | +| GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns | `POST` | `/api/bookings/:id/clearance/transit-assignee/assign` | `modules/bookings/bookings.controller.ts:1112` | +| GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration | `POST` | `/api/bookings/:id/clearance/transit-assignee/request` | `modules/bookings/bookings.controller.ts:1098` | +| Upload Booking Transit Permit | `POST` | `/api/bookings/:id/clearance/transit-permit` | `modules/bookings/bookings.controller.ts:1251` | +| Confirm submit after price change | `POST` | `/api/bookings/:id/confirm-submit` | `modules/bookings/bookings.controller.ts:906` | +| Request freight consolidation | `POST` | `/api/bookings/:id/consolidation` | `modules/bookings/bookings.controller.ts:1583` | +| Generate contract PDF from template | `POST` | `/api/bookings/:id/contract/generate` | `modules/bookings/bookings.controller.ts:1406` | +| Apply digital signature (customer or staff) | `POST` | `/api/bookings/:id/contract/sign` | `modules/bookings/bookings.controller.ts:1452` | +| Customer cancels their own booking before payment — no cancellation fee | `POST` | `/api/bookings/:id/customer-cancel` | `modules/bookings/bookings.controller.ts:1555` | +| Customer assigns external truck and driver for terminal pickup | `POST` | `/api/bookings/:id/customer-truck-assignment` | `modules/bookings/bookings.controller.ts:460` | +| Add a customer self-haul truck carrying 1–2 of the booking containers | `POST` | `/api/bookings/:id/customer-trucks` | `modules/bookings/bookings.controller.ts:686` | +| Register an import truck leaving: containers loaded + weighed gross (staff) | `POST` | `/api/bookings/:id/customer-trucks/:assignmentId/depart` | `modules/bookings/bookings.controller.ts:788` | +| Truck_dispatch: load selected containers onto a truck (staff) | `POST` | `/api/bookings/:id/customer-trucks/:assignmentId/load` | `modules/bookings/bookings.controller.ts:773` | +| Bulk add customer trucks from array payload (Excel parsed) | `POST` | `/api/bookings/:id/customer-trucks/bulk` | `modules/bookings/bookings.controller.ts:701` | +| Customer digital signature (deprecated — use POST contract/sign) | `POST` | `/api/bookings/:id/customer/sign` | `modules/bookings/bookings.controller.ts:1487` | +| Upload documents for a booking (DRAFT only) | `POST` | `/api/bookings/:id/documents` | `modules/bookings/bookings.controller.ts:869` | +| Generate a GRN over the received containers (all received, or a subset) — one GRN per batch | `POST` | `/api/bookings/:id/generate-grn` | `modules/bookings/bookings.controller.ts:820` | +| Generate price preview (DRAFT or CHANGES_REQUESTED) | `POST` | `/api/bookings/:id/generate-price` | `modules/bookings/bookings.controller.ts:882` | +| Expedite government booking to PAID / ELIGIBLE for scheduling | `POST` | `/api/bookings/:id/government-expedite` | `modules/bookings/bookings.controller.ts:1390` | +| Staff contract signature and fully execute (use contract/sign STAFF preferred) | `POST` | `/api/bookings/:id/marketing/approve` | `modules/bookings/bookings.controller.ts:1505` | +| Operations reviews an operation request: ACCEPT (→ batch pool), | `POST` | `/api/bookings/:id/operation/review` | `modules/bookings/bookings.controller.ts:1030` | +| Mark completed | `POST` | `/api/bookings/:id/operations/complete` | `modules/bookings/bookings.controller.ts:1536` | +| Mark in transit | `POST` | `/api/bookings/:id/operations/start-transit` | `modules/bookings/bookings.controller.ts:1528` | +| Customer reject price estimate | `POST` | `/api/bookings/:id/reject` | `modules/bookings/bookings.controller.ts:918` | +| Staff accept intake → set contract validity window + start approval chain | `POST` | `/api/bookings/:id/staff/accept` | `modules/bookings/bookings.controller.ts:1355` | +| Staff final reject | `POST` | `/api/bookings/:id/staff/reject` | `modules/bookings/bookings.controller.ts:1374` | +| Staff return booking for customer updates | `POST` | `/api/bookings/:id/staff/request-changes` | `modules/bookings/bookings.controller.ts:1339` | +| Customer submit booking | `POST` | `/api/bookings/:id/submit` | `modules/bookings/bookings.controller.ts:894` | +| Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles | `POST` | `/api/bookings/:id/wagon-cancellations` | `modules/bookings/bookings.controller.ts:558` | +| Preview the fee/credit of a partial wagon cancellation (no writes) | `POST` | `/api/bookings/:id/wagon-cancellations/preview` | `modules/bookings/bookings.controller.ts:544` | +| Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid) | `POST` | `/api/bookings/wagon-cancellations/:cancellationId/rebook` | `modules/bookings/bookings.controller.ts:638` | +| Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission) | `POST` | `/api/bookings/wagon-cancellations/:cancellationId/withdraw` | `modules/bookings/bookings.controller.ts:624` | +| Update booking | `PATCH` | `/api/bookings/:id` | `modules/bookings/bookings.controller.ts:211` | +| Edit a not-yet-arrived customer truck (plate/driver/type + containers) | `PATCH` | `/api/bookings/:id/customer-trucks/:assignmentId` | `modules/bookings/bookings.controller.ts:716` | +| Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first | `PATCH` | `/api/bookings/:id/export-handover-mode` | `modules/bookings/bookings.controller.ts:761` | +| Soft-delete DRAFT booking | `DELETE` | `/api/bookings/:id` | `modules/bookings/bookings.controller.ts:861` | +| Remove consolidation pairing | `DELETE` | `/api/bookings/:id/consolidation` | `modules/bookings/bookings.controller.ts:1590` | +| Remove a not-yet-arrived customer truck from a booking | `DELETE` | `/api/bookings/:id/customer-trucks/:assignmentId` | `modules/bookings/bookings.controller.ts:732` | + +### Cargo + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new cargo | `POST` | `/api/cargoes` | `modules/cargoes/cargoes.controller.ts:34` | +| Mark cargo as delivered | `POST` | `/api/cargoes/:id/deliver` | `modules/cargoes/cargoes.controller.ts:81` | +| Load cargo into a container | `POST` | `/api/cargoes/:id/load` | `modules/cargoes/cargoes.controller.ts:67` | +| Unload cargo from container | `POST` | `/api/cargoes/:id/unload` | `modules/cargoes/cargoes.controller.ts:74` | +| Update a cargo | `PATCH` | `/api/cargoes/:id` | `modules/cargoes/cargoes.controller.ts:53` | +| Delete a cargo | `DELETE` | `/api/cargoes/:id` | `modules/cargoes/cargoes.controller.ts:60` | + +### Cargo Type + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a cargo type | `POST` | `/api/cargo-types` | `modules/rule-engine/controllers/cargo-types.controller.ts:51` | +| Move a cargo type up or down in display order | `POST` | `/api/cargo-types/:id/move-order` | `modules/rule-engine/controllers/cargo-types.controller.ts:36` | +| Bulk reorder cargo types by ID list | `POST` | `/api/cargo-types/reorder` | `modules/rule-engine/controllers/cargo-types.controller.ts:28` | +| Update a cargo type | `PATCH` | `/api/cargo-types/:id` | `modules/rule-engine/controllers/cargo-types.controller.ts:58` | +| Soft-delete a cargo type | `DELETE` | `/api/cargo-types/:id` | `modules/rule-engine/controllers/cargo-types.controller.ts:65` | + +### Company + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter) | `POST` | `/api/companies` | `modules/companies/companies.controller.ts:565` | +| Upload documents for a company (onboarding) | `POST` | `/api/companies/:companyId/documents` | `modules/companies/companies.controller.ts:728` | +| Add a profile (employee) to a company | `POST` | `/api/companies/:companyId/profiles` | `modules/companies/companies.controller.ts:843` | +| Approve a pending profile change request (applies the changes) | `POST` | `/api/companies/change-requests/:id/approve` | `modules/companies/companies.controller.ts:790` | +| Reject a pending profile change request with a note | `POST` | `/api/companies/change-requests/:id/reject` | `modules/companies/companies.controller.ts:806` | +| Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it) | `POST` | `/api/companies/change-requests/:id/request-changes` | `modules/companies/companies.controller.ts:824` | +| Create a single operational profile for the current user's company. The role starts pending and does not become the active mode | `POST` | `/api/companies/company-profile` | `modules/companies/companies.controller.ts:272` | +| Add operational profile(s) (importer/exporter/forwarder) to the current user's company | `POST` | `/api/companies/company-profiles` | `modules/companies/companies.controller.ts:229` | +| Add business-license document(s) to a profile. For an approved company | `POST` | `/api/companies/company-profiles/:profileId/license` | `modules/companies/companies.controller.ts:290` | +| Replace a business-license file with a newly uploaded one (staged for | `POST` | `/api/companies/company-profiles/:profileId/license/:fileId/replace` | `modules/companies/companies.controller.ts:311` | +| Resubmit a rejected operational role for approval (→ pending) | `POST` | `/api/companies/company-profiles/:profileId/reapply` | `modules/companies/companies.controller.ts:165` | +| Create a company with its associated external profile (onboarding) | `POST` | `/api/companies/create` | `modules/companies/companies.controller.ts:539` | +| Ask the customer to correct one uploaded document | `POST` | `/api/companies/documents/:fileId/request-change` | `modules/companies/companies.controller.ts:699` | +| Fetch company info from eTrade by TIN | `POST` | `/api/companies/fetch-etrade-info` | `modules/companies/companies.controller.ts:197` | +| Bind a completed Fayda verification to the company's owner or Power of Attorney | `POST` | `/api/companies/identity/fayda/complete` | `modules/companies/companies.controller.ts:414` | +| Declare the General Manager is the company's owner, copying the owner's verified identity across | `POST` | `/api/companies/identity/gm/same-as-owner` | `modules/companies/companies.controller.ts:432` | +| Declare the Power of Attorney is the company's owner, copying the owner's identity across | `POST` | `/api/companies/identity/poa/same-as-owner` | `modules/companies/companies.controller.ts:461` | +| Mark the current user's onboarding as complete | `POST` | `/api/companies/onboarding/complete` | `modules/companies/companies.controller.ts:527` | +| Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally | `POST` | `/api/companies/onboarding/start` | `modules/companies/companies.controller.ts:246` | +| Upload the Power of Attorney delegation letter, replacing any existing one | `POST` | `/api/companies/poa-delegation` | `modules/companies/companies.controller.ts:380` | +| Update a company | `PATCH` | `/api/companies/:id` | `modules/companies/companies.controller.ts:613` | +| Update a company profile's approval status | `PATCH` | `/api/companies/company-profiles/:profileId/status` | `modules/companies/companies.controller.ts:748` | +| Persist the user's current onboarding wizard step | `PATCH` | `/api/companies/onboarding-step` | `modules/companies/companies.controller.ts:504` | +| Update profile (flattened settings page) | `PATCH` | `/api/companies/profile` | `modules/companies/companies.controller.ts:219` | +| Soft-delete a company | `DELETE` | `/api/companies/:id` | `modules/companies/companies.controller.ts:634` | +| Remove a business-license file (staged for review on an approved company) | `DELETE` | `/api/companies/company-profiles/:profileId/license/:fileId` | `modules/companies/companies.controller.ts:338` | +| Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together | `DELETE` | `/api/companies/identity/fayda/poa` | `modules/companies/companies.controller.ts:491` | +| Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote | `DELETE` | `/api/companies/identity/gm` | `modules/companies/companies.controller.ts:448` | +| Undo the Power of Attorney \"same as owner\" declaration and the identity it copied, leaving the representative open to be verified in their own right | `DELETE` | `/api/companies/identity/poa/same-as-owner` | `modules/companies/companies.controller.ts:478` | +| Remove the Power of Attorney delegation letter (staged for review on an approved company) | `DELETE` | `/api/companies/poa-delegation/:fileId` | `modules/companies/companies.controller.ts:401` | + +### Compliance + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a compliance record | `POST` | `/api/compliance` | `modules/compliance/compliance.controller.ts:23` | +| Update a compliance record | `PATCH` | `/api/compliance/:id` | `modules/compliance/compliance.controller.ts:51` | +| Soft-delete a compliance record | `DELETE` | `/api/compliance/:id` | `modules/compliance/compliance.controller.ts:58` | + +### Consignment + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new consignment | `POST` | `/api/consignments` | `modules/consignments/consignments.controller.ts:29` | + +### Container + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new container | `POST` | `/api/containers` | `modules/container-management/containers.controller.ts:33` | +| Assign container to a wagon | `POST` | `/api/containers/:id/assign-wagon` | `modules/container-management/containers.controller.ts:66` | +| Unassign container from wagon | `POST` | `/api/containers/:id/unassign-wagon` | `modules/container-management/containers.controller.ts:73` | +| Update a container | `PATCH` | `/api/containers/:id` | `modules/container-management/containers.controller.ts:52` | +| Delete a container | `DELETE` | `/api/containers/:id` | `modules/container-management/containers.controller.ts:59` | + +### Container Type + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a container type | `POST` | `/api/container-types` | `modules/rule-engine/controllers/container-types.controller.ts:51` | +| Move a container type up or down in display order | `POST` | `/api/container-types/:id/move-order` | `modules/rule-engine/controllers/container-types.controller.ts:36` | +| Bulk reorder container types by ID list | `POST` | `/api/container-types/reorder` | `modules/rule-engine/controllers/container-types.controller.ts:28` | +| Update a container type | `PATCH` | `/api/container-types/:id` | `modules/rule-engine/controllers/container-types.controller.ts:58` | +| Soft-delete a container type | `DELETE` | `/api/container-types/:id` | `modules/rule-engine/controllers/container-types.controller.ts:65` | + +### Contract + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new contract (DRAFT) with routes + cargo scope | `POST` | `/api/contracts` | `modules/contracts/contracts.controller.ts:188` | +| Approve one approval step in sequence | `POST` | `/api/contracts/:id/approval-steps/:stepId/approve` | `modules/contracts/contracts.controller.ts:552` | +| Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there) | `POST` | `/api/contracts/:id/approval-steps/:stepId/reject` | `modules/contracts/contracts.controller.ts:571` | +| Customer submits a shipment request on a GENERAL customs contract | `POST` | `/api/contracts/:id/booking-requests` | `modules/contracts/contracts.controller.ts:170` | +| Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia) | `POST` | `/api/contracts/:id/bookings` | `modules/contracts/contracts.controller.ts:1076` | +| Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing | `POST` | `/api/contracts/:id/bookings/:bookingId/complete` | `modules/contracts/contracts.controller.ts:1133` | +| Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request | `POST` | `/api/contracts/:id/bookings/initiate` | `modules/contracts/contracts.controller.ts:1105` | +| Customer cancels their own contract (blocked while a booking is live) | `POST` | `/api/contracts/:id/cancel` | `modules/contracts/contracts.controller.ts:526` | +| GL ET uploads customs declaration documents (multi-file) | `POST` | `/api/contracts/:id/clearance/declaration` | `modules/contracts/contracts.controller.ts:795` | +| GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates | `POST` | `/api/contracts/:id/clearance/delivery-order` | `modules/contracts/contracts.controller.ts:957` | +| Customer uploads clearance documents (fieldname = document key) | `POST` | `/api/contracts/:id/clearance/documents` | `modules/contracts/contracts.controller.ts:734` | +| GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving | `POST` | `/api/contracts/:id/clearance/documents/:fileKey/replace` | `modules/contracts/contracts.controller.ts:894` | +| GL ET sets duty/tax requirement and advises amount with notice attachment | `POST` | `/api/contracts/:id/clearance/duty` | `modules/contracts/contracts.controller.ts:808` | +| Customer uploads duty/tax payment slip on contract | `POST` | `/api/contracts/:id/clearance/duty-slip` | `modules/contracts/contracts.controller.ts:932` | +| Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable) | `POST` | `/api/contracts/:id/clearance/duty/dispute` | `modules/contracts/contracts.controller.ts:918` | +| GL ET confirms export release after declaration | `POST` | `/api/contracts/:id/clearance/export-release` | `modules/contracts/contracts.controller.ts:1008` | +| GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy) | `POST` | `/api/contracts/:id/clearance/finalize` | `modules/contracts/contracts.controller.ts:788` | +| GL ET finalizes export clearance after post-booking transit permit upload | `POST` | `/api/contracts/:id/clearance/finalize-export-clearance` | `modules/contracts/contracts.controller.ts:1018` | +| GL ET finalizes import pre-clearance — unlocks Djibouti DO upload | `POST` | `/api/contracts/:id/clearance/finalize-pre-clearance` | `modules/contracts/contracts.controller.ts:838` | +| Operations finalizes self-clearance → customer may create the booking | `POST` | `/api/contracts/:id/clearance/ops-finalize` | `modules/contracts/contracts.controller.ts:1051` | +| Operations reviews a customer self-clearance document (Approve | Query) | `POST` | `/api/contracts/:id/clearance/ops-review` | `modules/contracts/contracts.controller.ts:1032` | +| GL uploads customs output documents (IM4/EX3/…) pre-booking | `POST` | `/api/contracts/:id/clearance/output-documents` | `modules/contracts/contracts.controller.ts:776` | +| GL DJ uploads Release Order + vessel departure date (export) | `POST` | `/api/contracts/:id/clearance/release-order` | `modules/contracts/contracts.controller.ts:978` | +| GL ET reviews a clearance document (Approve | Query) | `POST` | `/api/contracts/:id/clearance/review` | `modules/contracts/contracts.controller.ts:756` | +| GL DJ requests port amendment when RO vessel window is too short | `POST` | `/api/contracts/:id/clearance/ro-amendment` | `modules/contracts/contracts.controller.ts:997` | +| GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns | `POST` | `/api/contracts/:id/clearance/transit-assignee/assign` | `modules/contracts/contracts.controller.ts:863` | +| GL ET asks GL Djibouti to name the transit officer — required before the customs declaration | `POST` | `/api/contracts/:id/clearance/transit-assignee/request` | `modules/contracts/contracts.controller.ts:845` | +| GL ET uploads import transit permit documents (multi-file) | `POST` | `/api/contracts/:id/clearance/transit-permit` | `modules/contracts/contracts.controller.ts:944` | +| Confirm submit after a price change | `POST` | `/api/contracts/:id/confirm-submit` | `modules/contracts/contracts.controller.ts:380` | +| Generate contract document → CONTRACT_READY | `POST` | `/api/contracts/:id/contract/generate` | `modules/contracts/contracts.controller.ts:596` | +| Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number) | `POST` | `/api/contracts/:id/contract/send-signing-otp` | `modules/contracts/contracts.controller.ts:667` | +| Apply digital signature (customer or staff/director/ceo) | `POST` | `/api/contracts/:id/contract/sign` | `modules/contracts/contracts.controller.ts:680` | +| Upload intake documents for a contract (DRAFT only) | `POST` | `/api/contracts/:id/documents` | `modules/contracts/contracts.controller.ts:354` | +| Generate unit-rate breakdown (no totals at contract phase) | `POST` | `/api/contracts/:id/generate-price` | `modules/contracts/contracts.controller.ts:366` | +| GL marks a pre-booking (contract) milestone complete | `POST` | `/api/contracts/:id/milestones/:code/complete` | `modules/contracts/contracts.controller.ts:1222` | +| Create a renewal draft linked via renewalOfId | `POST` | `/api/contracts/:id/renew` | `modules/contracts/contracts.controller.ts:705` | +| Staff lift a suspension — contract returns to its prior status | `POST` | `/api/contracts/:id/resume` | `modules/contracts/contracts.controller.ts:510` | +| Staff accept → set validity window + start approval chain | `POST` | `/api/contracts/:id/staff/accept` | `modules/contracts/contracts.controller.ts:387` | +| Staff reject contract | `POST` | `/api/contracts/:id/staff/reject` | `modules/contracts/contracts.controller.ts:476` | +| Staff return contract for customer updates | `POST` | `/api/contracts/:id/staff/request-changes` | `modules/contracts/contracts.controller.ts:460` | +| Customer submit contract (freezes contract_rate_snapshots) | `POST` | `/api/contracts/:id/submit` | `modules/contracts/contracts.controller.ts:373` | +| Staff freeze a signed contract (reversible, any post-signature step) | `POST` | `/api/contracts/:id/suspend` | `modules/contracts/contracts.controller.ts:492` | +| Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created) | `POST` | `/api/contracts/:id/validate-shipment` | `modules/contracts/contracts.controller.ts:1162` | +| GL marks a shipment request accepted + links the created booking | `POST` | `/api/contracts/booking-requests/:reqId/accept` | `modules/contracts/contracts.controller.ts:134` | +| Customer cancels their own pending shipment request | `POST` | `/api/contracts/booking-requests/:reqId/cancel` | `modules/contracts/contracts.controller.ts:160` | +| GL rejects a shipment request | `POST` | `/api/contracts/booking-requests/:reqId/reject` | `modules/contracts/contracts.controller.ts:149` | +| GL uploads post-booking operational documents (DO/RO/T1/…) | `POST` | `/api/contracts/bookings/:bookingId/documents` | `modules/contracts/contracts.controller.ts:1441` | +| GL ET advises duty & tax amount + declaration serial | `POST` | `/api/contracts/bookings/:bookingId/duty` | `modules/contracts/contracts.controller.ts:1260` | +| Customer uploads the duty/tax payment slip | `POST` | `/api/contracts/bookings/:bookingId/duty-slip` | `modules/contracts/contracts.controller.ts:1455` | +| GL DJ raises the post-offload final invoice (amount + invoice document) | `POST` | `/api/contracts/bookings/:bookingId/final-invoice` | `modules/contracts/contracts.controller.ts:1332` | +| Customer attaches the payment slip for the final invoice | `POST` | `/api/contracts/bookings/:bookingId/final-invoice-slip` | `modules/contracts/contracts.controller.ts:1374` | +| Customer approves the drafted final invoice — unlocks the payment slip | `POST` | `/api/contracts/bookings/:bookingId/final-invoice/approve` | `modules/contracts/contracts.controller.ts:1359` | +| GL (ET or DJ) confirms the payment slip — settles the final invoice | `POST` | `/api/contracts/bookings/:bookingId/final-invoice/confirm` | `modules/contracts/contracts.controller.ts:1386` | +| GL DJ logs a cargo exception with photo evidence | `POST` | `/api/contracts/bookings/:bookingId/incidents` | `modules/contracts/contracts.controller.ts:1479` | +| GL / Ops / Terminal marks a post-booking milestone complete | `POST` | `/api/contracts/bookings/:bookingId/milestones/:code/complete` | `modules/contracts/contracts.controller.ts:1205` | +| GL ET assigns a customs risk level (GREEN/YELLOW/RED) | `POST` | `/api/contracts/bookings/:bookingId/risk` | `modules/contracts/contracts.controller.ts:1241` | +| GL ET advises (or skips) the post-arrival additional duty/tax round (import) | `POST` | `/api/contracts/bookings/:bookingId/second-duty` | `modules/contracts/contracts.controller.ts:1399` | +| Customer attaches the additional duty/tax payment slip | `POST` | `/api/contracts/bookings/:bookingId/second-duty-slip` | `modules/contracts/contracts.controller.ts:1429` | +| GL station manager routes the shipment + binds staff | `POST` | `/api/contracts/bookings/:bookingId/station-assign` | `modules/contracts/contracts.controller.ts:1276` | +| Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export) | `POST` | `/api/contracts/bookings/:bookingId/t1-close` | `modules/contracts/contracts.controller.ts:1316` | +| GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs | `POST` | `/api/contracts/bookings/:bookingId/t1-documents` | `modules/contracts/contracts.controller.ts:1301` | +| GL ET uploads export transit permit documents (multi-file) | `POST` | `/api/contracts/bookings/:bookingId/transport-document` | `modules/contracts/contracts.controller.ts:1289` | +| Share a document with the other GL desk | `POST` | `/api/gl-exchange/:entityId` | `modules/contracts/gl-exchange.controller.ts:59` | +| Edit this contract\'s document articles only (per-contract; never touches the six shared templates) | `PUT` | `/api/contracts/:id/document/articles` | `modules/contracts/contracts.controller.ts:441` | +| Update contract | `PATCH` | `/api/contracts/:id` | `modules/contracts/contracts.controller.ts:327` | +| Uploader edits a shared document (title, visibility, file) | `PATCH` | `/api/gl-exchange/documents/:documentId` | `modules/contracts/gl-exchange.controller.ts:79` | +| Soft-delete DRAFT contract | `DELETE` | `/api/contracts/:id` | `modules/contracts/contracts.controller.ts:346` | +| Uploader removes a shared document | `DELETE` | `/api/gl-exchange/documents/:documentId` | `modules/contracts/gl-exchange.controller.ts:105` | + +### Contract Template + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a bulk contract template for a (cargo type, customs option) pair | `POST` | `/api/contract-templates` | `modules/contract-templates/contract-templates.controller.ts:56` | +| Add an article to the template | `POST` | `/api/contract-templates/:code/articles` | `modules/contract-templates/contract-templates.controller.ts:118` | +| Render an HTML preview of the template against mock contract data | `POST` | `/api/contract-templates/:code/preview` | `modules/contract-templates/contract-templates.controller.ts:97` | +| Replace the full ordered article list (used for reorder) | `PUT` | `/api/contract-templates/:code/articles` | `modules/contract-templates/contract-templates.controller.ts:111` | +| Update template metadata (name, title, recitals, active flag) | `PATCH` | `/api/contract-templates/:code` | `modules/contract-templates/contract-templates.controller.ts:77` | +| Update an article's title or body | `PATCH` | `/api/contract-templates/:code/articles/:articleId` | `modules/contract-templates/contract-templates.controller.ts:125` | +| Delete a staff-created bulk template (system templates refuse) | `DELETE` | `/api/contract-templates/:code` | `modules/contract-templates/contract-templates.controller.ts:84` | +| Remove an article from the template | `DELETE` | `/api/contract-templates/:code/articles/:articleId` | `modules/contract-templates/contract-templates.controller.ts:136` | + +### Driver + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new driver | `POST` | `/api/drivers` | `modules/drivers/drivers.controller.ts:41` | +| Upload driver documents (code driver_docs) | `POST` | `/api/drivers/:id/documents` | `modules/drivers/drivers.controller.ts:80` | +| Update a driver | `PATCH` | `/api/drivers/:id` | `modules/drivers/drivers.controller.ts:128` | +| Delete a driver | `DELETE` | `/api/drivers/:id` | `modules/drivers/drivers.controller.ts:138` | +| Delete a driver document | `DELETE` | `/api/drivers/:id/documents/:fileId` | `modules/drivers/drivers.controller.ts:121` | + +### Dropdown Setting + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new dropdown setting | `POST` | `/api/dropdown-settings` | `modules/dropdown-settings/dropdown-settings.controller.ts:61` | +| Append a single option to a setting | `POST` | `/api/dropdown-settings/:id/options` | `modules/dropdown-settings/dropdown-settings.controller.ts:98` | +| Replace the full option list for a setting | `PUT` | `/api/dropdown-settings/:id/options` | `modules/dropdown-settings/dropdown-settings.controller.ts:88` | +| Update a dropdown setting's metadata | `PATCH` | `/api/dropdown-settings/:id` | `modules/dropdown-settings/dropdown-settings.controller.ts:68` | +| Update a single option | `PATCH` | `/api/dropdown-settings/options/:optionId` | `modules/dropdown-settings/dropdown-settings.controller.ts:108` | +| Soft-delete a dropdown setting | `DELETE` | `/api/dropdown-settings/:id` | `modules/dropdown-settings/dropdown-settings.controller.ts:78` | +| Soft-delete a single option | `DELETE` | `/api/dropdown-settings/options/:optionId` | `modules/dropdown-settings/dropdown-settings.controller.ts:118` | + +### EIMS Invoice + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged | `POST` | `/api/invoices/:id/eims/register` | `modules/eims/eims-invoice.controller.ts:32` | +| Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block | `POST` | `/api/invoices/:id/eims/resolve` | `modules/eims/eims-invoice.controller.ts:49` | +| Verify the invoice's stored IRN against EIMS | `POST` | `/api/invoices/:id/eims/verify` | `modules/eims/eims-invoice.controller.ts:42` | + +### Exchange Setting + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Set the USD→ETB fallback by hand (used only while CBE is unreachable) | `PATCH` | `/api/exchange-settings` | `modules/exchange-settings/exchange-settings.controller.ts:35` | + +### Facility + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new facility | `POST` | `/api/facilities` | `modules/facilities/facilities.controller.ts:22` | +| Update a facility | `PATCH` | `/api/facilities/:id` | `modules/facilities/facilities.controller.ts:41` | +| Delete a facility (soft delete) | `DELETE` | `/api/facilities/:id` | `modules/facilities/facilities.controller.ts:48` | + +### Fayda Verification + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Start a VeriFayda 2.0 verification session | `POST` | `/api/fayda/verification/start` | `modules/verifayda/verifayda.controller.ts:44` | + +### File Upload Setting + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new file upload setting | `POST` | `/api/file-upload-settings` | `modules/file-upload-settings/file-upload-settings.controller.ts:56` | +| Append a single field to a setting | `POST` | `/api/file-upload-settings/:id/fields` | `modules/file-upload-settings/file-upload-settings.controller.ts:93` | +| Replace the full field list for a setting | `PUT` | `/api/file-upload-settings/:id/fields` | `modules/file-upload-settings/file-upload-settings.controller.ts:83` | +| Update a file upload setting's metadata | `PATCH` | `/api/file-upload-settings/:id` | `modules/file-upload-settings/file-upload-settings.controller.ts:63` | +| Update a single field | `PATCH` | `/api/file-upload-settings/fields/:fieldId` | `modules/file-upload-settings/file-upload-settings.controller.ts:103` | +| Soft-delete a file upload setting | `DELETE` | `/api/file-upload-settings/:id` | `modules/file-upload-settings/file-upload-settings.controller.ts:73` | +| Soft-delete a single field | `DELETE` | `/api/file-upload-settings/fields/:fieldId` | `modules/file-upload-settings/file-upload-settings.controller.ts:113` | + +### First Mile + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a first-mile leg | `POST` | `/api/first-mile` | `modules/first-mile/first-mile.controller.ts:84` | +| Set per-vehicle actual distances (does not generate an invoice) | `POST` | `/api/first-mile/:id/distances` | `modules/first-mile/first-mile.controller.ts:124` | +| Generate the first-mile delivery-fee invoice | `POST` | `/api/first-mile/:id/invoice` | `modules/first-mile/first-mile.controller.ts:100` | +| Set the vehicles assigned to a first-mile pickup (multi-truck) | `POST` | `/api/first-mile/:id/vehicles` | `modules/first-mile/first-mile.controller.ts:114` | +| Accept a paid booking and create a first-mile leg | `POST` | `/api/first-mile/accept/:reference` | `modules/first-mile/first-mile.controller.ts:77` | +| Update a first-mile leg | `PATCH` | `/api/first-mile/:id` | `modules/first-mile/first-mile.controller.ts:91` | +| Soft-delete a first-mile leg | `DELETE` | `/api/first-mile/:id` | `modules/first-mile/first-mile.controller.ts:134` | + +### Fuel + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Record fuel purchase | `POST` | `/api/fuel/purchases` | `modules/fuel/fuel.controller.ts:22` | + +### GPS Tracking + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Register a GPS tracker | `POST` | `/api/gps/devices` | `modules/gps-tracking/gps-tracking.controller.ts:52` | +| Update a GPS tracker (name / assigned vehicle) | `PATCH` | `/api/gps/devices/:id` | `modules/gps-tracking/gps-tracking.controller.ts:59` | +| Delete a GPS tracker | `DELETE` | `/api/gps/devices/:id` | `modules/gps-tracking/gps-tracking.controller.ts:66` | + +### Import Operation + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Batch 12: record declaration serial number | `POST` | `/api/import-operations/customs/:bookingId/declaration` | `modules/import-operations/import-operations.controller.ts:53` | +| Batch 12: upload IM4/IM5/T1/permit/payment-slip documents | `POST` | `/api/import-operations/customs/:bookingId/documents` | `modules/import-operations/import-operations.controller.ts:44` | +| Batch 12: mark duties and taxes paid | `POST` | `/api/import-operations/customs/:bookingId/duties-taxes-paid` | `modules/import-operations/import-operations.controller.ts:71` | +| Batch 12: notify duties and taxes | `POST` | `/api/import-operations/customs/:bookingId/notify-duties-taxes` | `modules/import-operations/import-operations.controller.ts:62` | +| Batch 12: mark import release permitted | `POST` | `/api/import-operations/customs/:bookingId/release-permitted` | `modules/import-operations/import-operations.controller.ts:86` | +| Batch 12: assign customs risk | `POST` | `/api/import-operations/customs/:bookingId/risk` | `modules/import-operations/import-operations.controller.ts:80` | +| Batch 8: report a Djibouti import incident / exception | `POST` | `/api/import-operations/djibouti-incidents` | `modules/import-operations/import-operations.controller.ts:32` | +| Batch 16: create an empty container return record | `POST` | `/api/import-operations/empty-container-returns` | `modules/import-operations/import-operations.controller.ts:101` | +| Batch 16: advance empty container return workflow | `POST` | `/api/import-operations/empty-container-returns/:id/status` | `modules/import-operations/import-operations.controller.ts:107` | + +### Incident + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Report an incident | `POST` | `/api/incidents` | `modules/incidents/incidents.controller.ts:36` | +| Update an incident | `PATCH` | `/api/incidents/:id` | `modules/incidents/incidents.controller.ts:72` | +| Delete an incident | `DELETE` | `/api/incidents/:id` | `modules/incidents/incidents.controller.ts:79` | + +### Interchange Document + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Generate interchange document from a train schedule handover | `POST` | `/api/interchange-documents/generate-from-schedule` | `modules/interchange-documents/interchange-documents.controller.ts:41` | +| Acknowledge an interchange document | `PATCH` | `/api/interchange-documents/:id/acknowledge` | `modules/interchange-documents/interchange-documents.controller.ts:48` | +| Dispute an interchange document | `PATCH` | `/api/interchange-documents/:id/dispute` | `modules/interchange-documents/interchange-documents.controller.ts:58` | + +### Last Mile + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a last-mile leg | `POST` | `/api/last-mile` | `modules/last-mile/last-mile.controller.ts:97` | +| Set each truck\'s own detention window (arrived at destination / returned) | `POST` | `/api/last-mile/:id/detention-times` | `modules/last-mile/last-mile.controller.ts:142` | +| Set per-vehicle actual distances (does not generate an invoice) | `POST` | `/api/last-mile/:id/distances` | `modules/last-mile/last-mile.controller.ts:132` | +| Generate the delivery-fee invoice for a last-mile leg | `POST` | `/api/last-mile/:id/invoice` | `modules/last-mile/last-mile.controller.ts:179` | +| Record proof of delivery (signature + photos) and complete the leg | `POST` | `/api/last-mile/:id/proof-of-delivery` | `modules/last-mile/last-mile.controller.ts:166` | +| Set the vehicles assigned to a last-mile delivery (multi-truck) | `POST` | `/api/last-mile/:id/vehicles` | `modules/last-mile/last-mile.controller.ts:122` | +| Set each truck\'s warehouse gate arrival/departure times | `POST` | `/api/last-mile/:id/warehouse-gate-times` | `modules/last-mile/last-mile.controller.ts:154` | +| Accept a paid booking and create a last-mile leg | `POST` | `/api/last-mile/accept/:reference` | `modules/last-mile/last-mile.controller.ts:90` | +| Update a last-mile leg | `PATCH` | `/api/last-mile/:id` | `modules/last-mile/last-mile.controller.ts:104` | +| Soft-delete a last-mile leg | `DELETE` | `/api/last-mile/:id` | `modules/last-mile/last-mile.controller.ts:113` | + +### Last Mile Request + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature | `POST` | `/api/last-mile-requests/:id/approve` | `modules/last-mile-requests/last-mile-requests.controller.ts:119` | +| Customer agrees and signs the LM contract — then the advance invoice is issued | `POST` | `/api/last-mile-requests/:id/contract/sign` | `modules/last-mile-requests/last-mile-requests.controller.ts:83` | +| Truck & Machinery chief rejects the request with a reason | `POST` | `/api/last-mile-requests/:id/reject` | `modules/last-mile-requests/last-mile-requests.controller.ts:130` | +| Customer confirms which containers go via EDR last-mile | `POST` | `/api/last-mile-requests/:id/submit` | `modules/last-mile-requests/last-mile-requests.controller.ts:108` | + +### Locomotive + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a locomotive | `POST` | `/api/locomotives` | `modules/locomotives/locomotives.controller.ts:58` | +| Decommission a locomotive | `POST` | `/api/locomotives/:id/decommission` | `modules/locomotives/locomotives.controller.ts:72` | +| Update a locomotive | `PATCH` | `/api/locomotives/:id` | `modules/locomotives/locomotives.controller.ts:65` | +| Permanently delete a locomotive (irreversible; refused if any train references it) | `DELETE` | `/api/locomotives/:id/permanent` | `modules/locomotives/locomotives.controller.ts:82` | + +### Maintenance + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Record maintenance cost | `POST` | `/api/maintenance/costs` | `modules/maintenance/maintenance.controller.ts:38` | +| Define/adjust a service interval (e.g. oil change every 10,000 km) | `POST` | `/api/maintenance/intervals` | `modules/maintenance/maintenance.controller.ts:59` | +| Create part | `POST` | `/api/maintenance/parts` | `modules/maintenance/maintenance.controller.ts:150` | +| Schedule maintenance | `POST` | `/api/maintenance/schedules` | `modules/maintenance/maintenance.controller.ts:31` | +| Create warranty | `POST` | `/api/maintenance/warranties` | `modules/maintenance/maintenance.controller.ts:186` | +| Create work order | `POST` | `/api/maintenance/work-orders` | `modules/maintenance/maintenance.controller.ts:110` | +| Update part | `PATCH` | `/api/maintenance/parts/:id` | `modules/maintenance/maintenance.controller.ts:170` | +| Update maintenance schedule | `PATCH` | `/api/maintenance/schedules/:id` | `modules/maintenance/maintenance.controller.ts:45` | +| Update work order | `PATCH` | `/api/maintenance/work-orders/:id` | `modules/maintenance/maintenance.controller.ts:134` | +| Deactivate a service interval (stops auto-scheduling) | `DELETE` | `/api/maintenance/intervals/:id` | `modules/maintenance/maintenance.controller.ts:73` | +| Delete part | `DELETE` | `/api/maintenance/parts/:id` | `modules/maintenance/maintenance.controller.ts:177` | +| Delete warranty | `DELETE` | `/api/maintenance/warranties/:id` | `modules/maintenance/maintenance.controller.ts:200` | +| Delete work order | `DELETE` | `/api/maintenance/work-orders/:id` | `modules/maintenance/maintenance.controller.ts:141` | + +### Notification Inbox + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Mark all my notifications as read | `POST` | `/api/notifications/read-all` | `modules/notification-inbox/notification-inbox.controller.ts:53` | +| Mark one of my notifications as read | `PATCH` | `/api/notifications/:id/read` | `modules/notification-inbox/notification-inbox.controller.ts:44` | + +### Organization User + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create an organization user without assigning positions | `POST` | `/api/backoffice/organizations/:orgId/users` | `modules/backoffice/backoffice.controller.ts:24` | +| Replace org-scoped roles assigned to an employee user | `PUT` | `/api/backoffice/organizations/:orgId/employee-users/:userId/roles` | `modules/backoffice/backoffice.controller.ts:58` | + +### OTP + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Send OTP | `POST` | `/api/otp/send` | `modules/otp/otp.controller.ts:41` | +| Verify OTP | `POST` | `/api/otp/verify` | `modules/otp/otp.controller.ts:62` | + +### Password Reset + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Send a password-reset code to the account's email AND phone | `POST` | `/api/auth/forgot-password/request` | `modules/auth/forgot-password.controller.ts:30` | +| Validate a staff-issued reset link and return its set-password ticket | `POST` | `/api/auth/forgot-password/resolve-link` | `modules/auth/forgot-password.controller.ts:73` | +| Exchange a valid reset code for a single-use set-password ticket | `POST` | `/api/auth/forgot-password/verify` | `modules/auth/forgot-password.controller.ts:62` | +| Send a password-reset link to a customer's primary contact | `POST` | `/api/backoffice/customers/:companyId/reset-password` | `modules/auth/customer-reset.controller.ts:49` | + +### Payment + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance | `POST` | `/api/billing/invoices/:id/confirm-offline` | `modules/billing/billing.controller.ts:81` | +| Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices | `POST` | `/api/billing/my-invoices/:id/confirm` | `modules/billing/portal-billing.controller.ts:102` | +| Initiate payment for one of the customer's invoices | `POST` | `/api/billing/my-invoices/:id/pay` | `modules/billing/portal-billing.controller.ts:86` | +| Live still-payable check + payer name for a CBE bill (called while CBE is on the line) | `POST` | `/api/internal/payments/bill-query` | `modules/payment/internal-payment.controller.ts:56` | +| Apply a payment.succeeded / payment.failed event from the payment service (idempotent) | `POST` | `/api/internal/payments/mark-paid` | `modules/payment/internal-payment.controller.ts:45` | +| Initiate payment for an invoice | `POST` | `/api/payments/initiate` | `modules/billing/payment.controller.ts:39` | +| Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth) | `POST` | `/api/payments/redirect-success/:bookingId` | `modules/payment/payment.controller.ts:89` | + +### Priority Config + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a priority config | `POST` | `/api/priority-configs` | `modules/rule-engine/controllers/priority-configs.controller.ts:51` | +| Move a priority config up or down in display order | `POST` | `/api/priority-configs/:id/move-order` | `modules/rule-engine/controllers/priority-configs.controller.ts:66` | +| Bulk reorder priority configs by ID list | `POST` | `/api/priority-configs/reorder` | `modules/rule-engine/controllers/priority-configs.controller.ts:58` | +| Update a priority config | `PATCH` | `/api/priority-configs/:id` | `modules/rule-engine/controllers/priority-configs.controller.ts:74` | +| Soft-delete a priority config | `DELETE` | `/api/priority-configs/:id` | `modules/rule-engine/controllers/priority-configs.controller.ts:81` | + +### Priority Rule Change Request + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Submit a priority-rule change for approval | `POST` | `/api/priority-rule-change-requests` | `modules/rule-engine/controllers/priority-rule-change-requests.controller.ts:34` | +| Approve and apply a pending change | `POST` | `/api/priority-rule-change-requests/:id/approve` | `modules/rule-engine/controllers/priority-rule-change-requests.controller.ts:52` | +| Reject a pending change | `POST` | `/api/priority-rule-change-requests/:id/reject` | `modules/rule-engine/controllers/priority-rule-change-requests.controller.ts:65` | + +### Procurement + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create an asset acquisition | `POST` | `/api/procurement/acquisitions` | `modules/procurement/procurement.controller.ts:56` | +| Create an asset disposal | `POST` | `/api/procurement/disposals` | `modules/procurement/procurement.controller.ts:90` | +| Create a vendor | `POST` | `/api/procurement/vendors` | `modules/procurement/procurement.controller.ts:28` | +| Update an asset acquisition | `PATCH` | `/api/procurement/acquisitions/:id` | `modules/procurement/procurement.controller.ts:75` | +| Update a vendor | `PATCH` | `/api/procurement/vendors/:id` | `modules/procurement/procurement.controller.ts:41` | +| Delete an asset acquisition | `DELETE` | `/api/procurement/acquisitions/:id` | `modules/procurement/procurement.controller.ts:82` | +| Delete an asset disposal | `DELETE` | `/api/procurement/disposals/:id` | `modules/procurement/procurement.controller.ts:103` | +| Delete a vendor | `DELETE` | `/api/procurement/vendors/:id` | `modules/procurement/procurement.controller.ts:48` | + +### Rate + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a rate (DRAFT) | `POST` | `/api/rates` | `modules/rule-engine/controllers/rates.controller.ts:46` | +| CEO approves a rate | `POST` | `/api/rates/:id/approve` | `modules/rule-engine/controllers/rates.controller.ts:70` | +| Submit rate for CEO approval | `POST` | `/api/rates/:id/submit` | `modules/rule-engine/controllers/rates.controller.ts:63` | +| Update a DRAFT rate | `PATCH` | `/api/rates/:id` | `modules/rule-engine/controllers/rates.controller.ts:56` | +| Soft-delete a rate | `DELETE` | `/api/rates/:id` | `modules/rule-engine/controllers/rates.controller.ts:82` | + +### Rate Change Request + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Propose a change to a LIVE rate | `POST` | `/api/rate-change-requests` | `modules/rule-engine/controllers/rate-change-requests.controller.ts:23` | +| Approve a rate change and put it into effect | `POST` | `/api/rate-change-requests/:id/approve` | `modules/rule-engine/controllers/rate-change-requests.controller.ts:37` | +| Reject a rate change — the rate keeps its current value | `POST` | `/api/rate-change-requests/:id/reject` | `modules/rule-engine/controllers/rate-change-requests.controller.ts:48` | + +### Route + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create route | `POST` | `/api/routes` | `modules/routes/routes.controller.ts:61` | +| Update route | `PATCH` | `/api/routes/:id` | `modules/routes/routes.controller.ts:68` | +| Deactivate route | `DELETE` | `/api/routes/:id` | `modules/routes/routes.controller.ts:90` | +| Permanently delete a route (irreversible; refused while any train schedule references it) | `DELETE` | `/api/routes/:id/permanent` | `modules/routes/routes.controller.ts:79` | + +### Schedule + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Reschedule train for maintenance (new departure + rebalance) | `POST` | `/api/train-scheduling/schedules/:id/maintenance` | `modules/scheduling-reschedule/scheduling-reschedule.controller.ts:52` | +| Execute a confirmed reschedule plan | `POST` | `/api/train-scheduling/schedules/:id/reschedule/execute` | `modules/scheduling-reschedule/scheduling-reschedule.controller.ts:30` | +| Preview reschedule / government preempt plan | `POST` | `/api/train-scheduling/schedules/:id/reschedule/preview` | `modules/scheduling-reschedule/scheduling-reschedule.controller.ts:20` | + +### Service Type + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a service type | `POST` | `/api/service-types` | `modules/rule-engine/controllers/service-types.controller.ts:51` | +| Move a service type up or down in display order | `POST` | `/api/service-types/:id/move-order` | `modules/rule-engine/controllers/service-types.controller.ts:36` | +| Bulk reorder service types by ID list | `POST` | `/api/service-types/reorder` | `modules/rule-engine/controllers/service-types.controller.ts:28` | +| Update a service type | `PATCH` | `/api/service-types/:id` | `modules/rule-engine/controllers/service-types.controller.ts:58` | +| Soft-delete a service type | `DELETE` | `/api/service-types/:id` | `modules/rule-engine/controllers/service-types.controller.ts:65` | + +### Shipping Line + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a shipping line | `POST` | `/api/shipping-lines` | `modules/rule-engine/controllers/shipping-lines.controller.ts:33` | +| Update a shipping line | `PATCH` | `/api/shipping-lines/:id` | `modules/rule-engine/controllers/shipping-lines.controller.ts:40` | +| Soft-delete a shipping line | `DELETE` | `/api/shipping-lines/:id` | `modules/rule-engine/controllers/shipping-lines.controller.ts:47` | + +### Signature + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create or update the reusable saved signature | `PUT` | `/api/me/signature` | `modules/signatures/signatures.controller.ts:23` | + +### Support Chat + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Start chatting with a company (returns the thread if one exists) | `POST` | `/api/support/agent/conversations` | `modules/support-chat/support-chat-agent.controller.ts:49` | +| Reply as an agent, optionally with attachments | `POST` | `/api/support/agent/conversations/:id/messages` | `modules/support-chat/support-chat-agent.controller.ts:74` | +| Mark a thread read (agent side) | `POST` | `/api/support/agent/conversations/:id/read` | `modules/support-chat/support-chat-agent.controller.ts:114` | +| Send a message as the customer (optionally with attachments), opening the thread if needed | `POST` | `/api/support/conversation/messages` | `modules/support-chat/support-chat.controller.ts:65` | +| Mark my company's thread read (customer side) | `POST` | `/api/support/conversation/read` | `modules/support-chat/support-chat.controller.ts:102` | + +### Support Content + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Restore a version — re-saves it as a new version, never destructive | `POST` | `/api/support-content/documents/:slug/versions/:version/restore` | `modules/support-content/support-content.controller.ts:123` | +| Upload an image or video for a help section | `POST` | `/api/support-content/media` | `modules/support-content/support-content.controller.ts:55` | +| Replace a document's payload, recording a new version | `PATCH` | `/api/support-content/documents/:slug` | `modules/support-content/support-content.controller.ts:93` | + +### Train + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Register a new train | `POST` | `/api/trains` | `modules/trains/trains.controller.ts:33` | +| Update a train | `PATCH` | `/api/trains/:id` | `modules/trains/trains.controller.ts:52` | +| Delete a train | `DELETE` | `/api/trains/:id` | `modules/trains/trains.controller.ts:59` | + +### Train Build + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Build a train: code + yard + 2+ locomotives (+ optional wagons) | `POST` | `/api/train-builder` | `modules/trains/train-builder.controller.ts:50` | +| Reactivate a deactivated train back to AVAILABLE | `POST` | `/api/train-builder/:id/activate` | `modules/trains/train-builder.controller.ts:158` | +| Deactivate the train (park it) — only allowed with no active schedule | `POST` | `/api/train-builder/:id/deactivate` | `modules/trains/train-builder.controller.ts:149` | +| Persist a drag-reorder of the full consist | `POST` | `/api/train-builder/:id/reorder-wagons` | `modules/trains/train-builder.controller.ts:142` | +| Append AVAILABLE wagons from the train's yard to the consist | `POST` | `/api/train-builder/:id/wagons` | `modules/trains/train-builder.controller.ts:109` | +| Detach one wagon and move it to MAINTENANCE status | `POST` | `/api/train-builder/:id/wagons/:wagonId/maintenance` | `modules/trains/train-builder.controller.ts:131` | +| Replace the locomotive set (minimum 1, same yard) | `PUT` | `/api/train-builder/:id/locomotives` | `modules/trains/train-builder.controller.ts:78` | +| Edit the train's name and fixed import/export run numbers | `PATCH` | `/api/train-builder/:id/details` | `modules/trains/train-builder.controller.ts:88` | +| Relocate the train — its locomotives and wagons move to the new yard with it | `PATCH` | `/api/train-builder/:id/yard` | `modules/trains/train-builder.controller.ts:100` | +| Disband the train (release wagons and locomotives) | `DELETE` | `/api/train-builder/:id` | `modules/trains/train-builder.controller.ts:165` | +| Detach one wagon from the consist | `DELETE` | `/api/train-builder/:id/wagons/:wagonId` | `modules/trains/train-builder.controller.ts:120` | + +### Train Schedule + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Staff: place a paid booking onto a fitting train (notifies customer on date change) | `POST` | `/api/train-scheduling/bookings/:bookingId/allocate` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:880` | +| Staff: expire a reservation and free its capacity | `POST` | `/api/train-scheduling/bookings/:bookingId/expire` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:845` | +| Staff: mark a reserved booking paid and allocate it now | `POST` | `/api/train-scheduling/bookings/:bookingId/mark-paid` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:835` | +| Re-point a booking to another OPEN same-route schedule | `POST` | `/api/train-scheduling/bookings/:bookingId/move-schedule` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:855` | +| Preview a bulk train schedule | `POST` | `/api/train-scheduling/bulk/preview` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:302` | +| Create a bulk train schedule | `POST` | `/api/train-scheduling/bulk/schedules` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:316` | +| Assign bulk bookings to a train schedule | `POST` | `/api/train-scheduling/bulk/schedules/:id/assign-bookings` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:349` | +| Cancel bulk train schedule | `POST` | `/api/train-scheduling/bulk/schedules/:id/cancel` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:976` | +| Preview a container train schedule | `POST` | `/api/train-scheduling/container/preview` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:295` | +| Create a container train schedule | `POST` | `/api/train-scheduling/container/schedules` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:309` | +| Assign container bookings to a train schedule | `POST` | `/api/train-scheduling/container/schedules/:id/assign-bookings` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:335` | +| Cancel container train schedule | `POST` | `/api/train-scheduling/container/schedules/:id/cancel` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:969` | +| Preview a mixed-capable train schedule | `POST` | `/api/train-scheduling/preview` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:288` | +| Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged) | `POST` | `/api/train-scheduling/schedules/:id/adjust-consist` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:197` | +| Mark a dispatched train arrived (move assets to destination yard, free assets) | `POST` | `/api/train-scheduling/schedules/:id/arrive` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:915` | +| Assign bookings to a train schedule (mixed-capable) | `POST` | `/api/train-scheduling/schedules/:id/assign-bookings` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:323` | +| Assign one linked unallocated booking to wagons (preserves existing assignments) | `POST` | `/api/train-scheduling/schedules/:id/assign-unassigned-booking` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:423` | +| Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard) | `POST` | `/api/train-scheduling/schedules/:id/bookings/:bookingId/load` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:564` | +| Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival | `POST` | `/api/train-scheduling/schedules/:id/bookings/:bookingId/unload` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:577` | +| Log the train passing a station (final station triggers arrival) | `POST` | `/api/train-scheduling/schedules/:id/checkpoints` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:903` | +| Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch) | `POST` | `/api/train-scheduling/schedules/:id/confirm-loading` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:662` | +| Dispatch a scheduled train | `POST` | `/api/train-scheduling/schedules/:id/dispatch` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:514` | +| Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group) | `POST` | `/api/train-scheduling/schedules/:id/doc-review-complete` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:824` | +| Finalize a draft train schedule | `POST` | `/api/train-scheduling/schedules/:id/finalize` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:507` | +| Depart loaded import train from Djibouti | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/depart` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:675` | +| Upload/check an import Djibouti-side document | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/documents` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:622` | +| Mark import Djibouti gatepass permission granted | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/gatepass-granted` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:632` | +| Generate import load list / marshalling document summary | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/load-list` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:685` | +| Confirm import cargo loaded on train at Djibouti | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/loaded-on-train` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:652` | +| Mark import train ready for loading at Djibouti | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/ready-for-loading` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:642` | +| Confirm intercity cargo loaded (train must be at the booking's origin yard) | `POST` | `/api/train-scheduling/schedules/:id/intercity/:bookingId/load` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:590` | +| Confirm intercity cargo unloaded at the booking's destination yard (completes the booking) | `POST` | `/api/train-scheduling/schedules/:id/intercity/:bookingId/unload` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:602` | +| Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking) | `POST` | `/api/train-scheduling/schedules/:id/intercity/accept` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:541` | +| Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged | `POST` | `/api/train-scheduling/schedules/:id/maintenance` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:798` | +| Pin physical wagons to train set slots | `POST` | `/api/train-scheduling/schedules/:id/pin-wagons` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:500` | +| Run wagon-level allocation for all eligible linked bookings | `POST` | `/api/train-scheduling/schedules/:id/run-allocation` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:747` | +| Manually run the batch fill for a schedule | `POST` | `/api/train-scheduling/schedules/:id/run-batch` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:739` | +| Switch out commercial bookings to allocate a government booking in their place | `POST` | `/api/train-scheduling/schedules/:id/switch-government-booking` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:439` | +| Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads) | `POST` | `/api/train-scheduling/schedules/:id/wagons/:wagonId/move-load` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:402` | +| Update global train scheduling rules (singleton) | `PATCH` | `/api/train-scheduling/global-rules` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:124` | +| Open or close a schedule booking window | `PATCH` | `/api/train-scheduling/schedules/:id/booking-window` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:756` | +| Update a container number on a wagon slot | `PATCH` | `/api/train-scheduling/schedules/:id/container-items/:itemId` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:391` | +| Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch) | `PATCH` | `/api/train-scheduling/schedules/:id/import-loading-status` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:474` | +| Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only) | `PATCH` | `/api/train-scheduling/schedules/:id/loading-status` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:487` | +| Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window | `PATCH` | `/api/train-scheduling/schedules/:id/schedule-date` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:784` | +| Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens | `PATCH` | `/api/train-scheduling/schedules/:id/window-rule` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:770` | +| Unassign a booking from a train schedule | `DELETE` | `/api/train-scheduling/schedules/:id/bookings/:bookingId` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:363` | +| Remove an empty wagon slot from a train | `DELETE` | `/api/train-scheduling/schedules/:id/wagons/:trainSetWagonId` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:378` | + +### Transit Agent + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a transit agent | `POST` | `/api/transit-agents` | `modules/transit-agents/transit-agents.controller.ts:66` | +| Update a transit agent | `PATCH` | `/api/transit-agents/:id` | `modules/transit-agents/transit-agents.controller.ts:73` | +| Soft-delete a transit agent | `DELETE` | `/api/transit-agents/:id` | `modules/transit-agents/transit-agents.controller.ts:80` | + +### Truck Type + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a truck type | `POST` | `/api/truck-types` | `modules/truck-types/truck-types.controller.ts:58` | +| Update a truck type | `PATCH` | `/api/truck-types/:id` | `modules/truck-types/truck-types.controller.ts:65` | +| Soft-delete a truck type | `DELETE` | `/api/truck-types/:id` | `modules/truck-types/truck-types.controller.ts:72` | + +### User Trade Access + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Set the trade directions a backoffice user may see | `PUT` | `/api/user-trade-access/:userId` | `modules/user-trade-access/user-trade-access.controller.ts:45` | + +### Vehicle + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new vehicle | `POST` | `/api/vehicles` | `modules/vehicles/vehicles.controller.ts:37` | +| Update a vehicle | `PATCH` | `/api/vehicles/:id` | `modules/vehicles/vehicles.controller.ts:78` | +| Delete a vehicle | `DELETE` | `/api/vehicles/:id` | `modules/vehicles/vehicles.controller.ts:88` | + +### Wagon + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new wagon | `POST` | `/api/wagons` | `modules/wagons/wagons.controller.ts:39` | +| Assign wagon to a train | `POST` | `/api/wagons/:id/assign-train` | `modules/wagons/wagons.controller.ts:100` | +| Unassign wagon from train | `POST` | `/api/wagons/:id/unassign-train` | `modules/wagons/wagons.controller.ts:107` | +| Set the status of multiple wagons (audited in wagon_status_logs) | `POST` | `/api/wagons/bulk-status` | `modules/wagons/wagons.controller.ts:121` | +| Transfer multiple wagons to a destination yard | `POST` | `/api/wagons/bulk-transfer` | `modules/wagons/wagons.controller.ts:114` | +| Update a wagon | `PATCH` | `/api/wagons/:id` | `modules/wagons/wagons.controller.ts:71` | +| Delete a wagon | `DELETE` | `/api/wagons/:id` | `modules/wagons/wagons.controller.ts:93` | +| Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots) | `DELETE` | `/api/wagons/:id/permanent` | `modules/wagons/wagons.controller.ts:82` | + +### Wagon Transfer Request + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| File a count-only wagon-transfer request | `POST` | `/api/wagon-transfer-requests` | `modules/wagons/wagon-transfer-requests.controller.ts:50` | +| Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved) | `POST` | `/api/wagon-transfer-requests/:id/cancel` | `modules/wagons/wagon-transfer-requests.controller.ts:167` | +| OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall | `POST` | `/api/wagon-transfer-requests/:id/close-short` | `modules/wagons/wagon-transfer-requests.controller.ts:153` | +| OCC: pick wagons and execute the transfer | `POST` | `/api/wagon-transfer-requests/:id/fulfill` | `modules/wagons/wagon-transfer-requests.controller.ts:142` | +| OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING) | `POST` | `/api/wagon-transfer-requests/bulk-fulfill` | `modules/wagons/wagon-transfer-requests.controller.ts:73` | + +### Wagon Type + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a wagon type | `POST` | `/api/wagon-types` | `modules/wagon-types/wagon-types.controller.ts:53` | +| Update a wagon type | `PATCH` | `/api/wagon-types/:id` | `modules/wagon-types/wagon-types.controller.ts:60` | +| Soft-delete a wagon type | `DELETE` | `/api/wagon-types/:id` | `modules/wagon-types/wagon-types.controller.ts:67` | + +### Warehouse + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a warehouse allocation rule | `POST` | `/api/warehouse-allocation-rules` | `modules/warehouses/warehouse-rules.controller.ts:33` | +| Preview the yard/warehouse/zone a booking would be allocated to | `POST` | `/api/warehouse-allocation/preview` | `modules/warehouses/warehouse-rules.controller.ts:55` | +| Create a storage / demurrage fee rule | `POST` | `/api/warehouse-fee-rules` | `modules/warehouses/warehouse-rules.controller.ts:70` | +| Acknowledge / snooze an item fee-accrual alert | `POST` | `/api/warehouse-fees/accrual/:inventoryId/acknowledge` | `modules/warehouses/warehouse-rules.controller.ts:106` | +| Create warehouse | `POST` | `/api/warehouses` | `modules/warehouses/warehouses.controller.ts:51` | +| Create a yard within a warehouse | `POST` | `/api/warehouses/:warehouseId/yards` | `modules/warehouses/warehouses.controller.ts:78` | +| Update a warehouse allocation rule | `PATCH` | `/api/warehouse-allocation-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:40` | +| Update a fee rule | `PATCH` | `/api/warehouse-fee-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:77` | +| Update warehouse | `PATCH` | `/api/warehouses/:id` | `modules/warehouses/warehouses.controller.ts:64` | +| Delete a warehouse allocation rule | `DELETE` | `/api/warehouse-allocation-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:47` | +| Delete a fee rule | `DELETE` | `/api/warehouse-fee-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:84` | +| Remove an accrual acknowledgement (re-surface for alerts) | `DELETE` | `/api/warehouse-fees/accrual/:inventoryId/acknowledge` | `modules/warehouses/warehouse-rules.controller.ts:119` | + +### Warehouse Fee Invoice + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Generate a truck-detention invoice for a last-mile leg (per truck per day) | `POST` | `/api/last-mile/:id/generate-truck-detention-invoice` | `modules/warehouses/warehouse-invoice.controller.ts:28` | +| Record a payment against a warehouse fee invoice | `POST` | `/api/warehouse-fee-invoices/:id/pay` | `modules/warehouses/warehouse-invoice.controller.ts:109` | +| Initiate Telebirr/Waafi payment for a warehouse fee invoice | `POST` | `/api/warehouse-fee-invoices/:id/pay-online` | `modules/warehouses/warehouse-invoice.controller.ts:116` | +| Generate a warehouse fee invoice from Batch 5 fee calculation | `POST` | `/api/warehouse-inventory/:id/generate-fee-invoice` | `modules/warehouses/warehouse-invoice.controller.ts:20` | +| Cancel a warehouse fee invoice | `PATCH` | `/api/warehouse-fee-invoices/:id/cancel` | `modules/warehouses/warehouse-invoice.controller.ts:102` | + +### Warehouse Inspection Report + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Upload inspection images / documents | `POST` | `/api/warehouse-inspection-reports/:id/attachments` | `modules/warehouses/warehouse-inspection.controller.ts:68` | +| Create an inspection / damage report for an inventory item | `POST` | `/api/warehouse-inventory/:inventoryId/inspection-reports` | `modules/warehouses/warehouse-inspection.controller.ts:37` | +| Update an inspection report | `PATCH` | `/api/warehouse-inspection-reports/:id` | `modules/warehouses/warehouse-inspection.controller.ts:61` | + +### Warehouse Inventory + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Deliver import goods to the customer + capture proof of delivery | `POST` | `/api/warehouse-inventory/:id/deliver` | `modules/warehouses/warehouse-inventory.controller.ts:594` | +| Final terminal release / gate clearance (blocked while fees unpaid) | `POST` | `/api/warehouse-inventory/:id/gate-clearance` | `modules/warehouses/warehouse-inventory.controller.ts:219` | +| Load READY_FOR_LOADING inventory onto a wagon | `POST` | `/api/warehouse-inventory/:id/load` | `modules/warehouses/warehouse-inventory.controller.ts:384` | +| Move inventory to another warehouse/yard/zone | `POST` | `/api/warehouse-inventory/:id/move` | `modules/warehouses/warehouse-inventory.controller.ts:359` | +| Mark reserved inventory READY_FOR_LOADING | `POST` | `/api/warehouse-inventory/:id/ready-for-loading` | `modules/warehouses/warehouse-inventory.controller.ts:373` | +| Mark inspected IMPORT inventory READY_FOR_PICKUP | `POST` | `/api/warehouse-inventory/:id/ready-for-pickup` | `modules/warehouses/warehouse-inventory.controller.ts:391` | +| Issue a DO / release order for ready-for-pickup inventory | `POST` | `/api/warehouse-inventory/:id/release` | `modules/warehouses/warehouse-inventory.controller.ts:402` | +| Mark received inventory as STORED (optional explicit warehouse/yard/zone) | `POST` | `/api/warehouse-inventory/:id/store` | `modules/warehouses/warehouse-inventory.controller.ts:366` | +| Auto-load READY_FOR_LOADING inventory with PAID bookings | `POST` | `/api/warehouse-inventory/auto-load-ready` | `modules/warehouses/warehouse-inventory.controller.ts:125` | +| Bulk auto-unload all arrived bookings into the warehouse | `POST` | `/api/warehouse-inventory/auto-unload-arrived` | `modules/warehouses/warehouse-inventory.controller.ts:118` | +| Approve delivery — customer records their full name (signature optional) | `POST` | `/api/warehouse-inventory/bookings/:bookingId/approve-delivery` | `modules/warehouses/warehouse-inventory.controller.ts:470` | +| Ask the customer to sign the handover (creates one if none, then notifies) | `POST` | `/api/warehouse-inventory/bookings/:bookingId/request-handover-signature` | `modules/warehouses/warehouse-inventory.controller.ts:509` | +| Unload a single arrived booking into a location | `POST` | `/api/warehouse-inventory/bookings/:bookingId/unload` | `modules/warehouses/warehouse-inventory.controller.ts:209` | +| Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED) | `POST` | `/api/warehouse-inventory/bulk-dispatch-export` | `modules/warehouses/warehouse-inventory.controller.ts:195` | +| Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING) | `POST` | `/api/warehouse-inventory/bulk-mark-inspected` | `modules/warehouses/warehouse-inventory.controller.ts:202` | +| Unload all eligible export items assigned to an arrived Djibouti-side train | `POST` | `/api/warehouse-inventory/export/auto-unload-at-djibouti` | `modules/warehouses/warehouse-inventory.controller.ts:294` | +| Customer signs one handover (EDR last-mile: one signature per truck) | `POST` | `/api/warehouse-inventory/handovers/:handoverId/sign` | `modules/warehouses/warehouse-inventory.controller.ts:493` | +| Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED) | `POST` | `/api/warehouse-inventory/import/auto-unload-arrived-bookings` | `modules/warehouses/warehouse-inventory.controller.ts:244` | +| Receive inventory at a warehouse location | `POST` | `/api/warehouse-inventory/receive` | `modules/warehouses/warehouse-inventory.controller.ts:322` | +| Bulk-receive selected eligible PAID bookings into a location | `POST` | `/api/warehouse-inventory/receive-bulk` | `modules/warehouses/warehouse-inventory.controller.ts:140` | +| Reserve stored inventory for a PAID booking | `POST` | `/api/warehouse-inventory/reserve` | `modules/warehouses/warehouse-inventory.controller.ts:330` | +| Load selected inventory items onto their allocated wagons for a train | `POST` | `/api/warehouse-inventory/train/:scheduleId/load` | `modules/warehouses/warehouse-inventory.controller.ts:184` | +| Mark loaded inventory DISPATCHED (left the terminal) | `PATCH` | `/api/warehouse-inventory/:id/dispatch` | `modules/warehouses/warehouse-inventory.controller.ts:602` | +| Record Yes/No double handling after unloading (Yes applies the double-handling fee rule) | `PATCH` | `/api/warehouse-inventory/bookings/:bookingId/double-handling` | `modules/warehouses/warehouse-inventory.controller.ts:556` | + +### Warehouse Yard + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a zone within a yard | `POST` | `/api/warehouse-yards/:yardId/zones` | `modules/warehouses/warehouse-yards.controller.ts:50` | +| Update warehouse yard | `PATCH` | `/api/warehouse-yards/:id` | `modules/warehouses/warehouse-yards.controller.ts:36` | + +### Warehouse Zone + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Update warehouse zone | `PATCH` | `/api/warehouse-zones/:id` | `modules/warehouses/warehouse-zones.controller.ts:37` | + +### Weight Limit Rule + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a weight limit rule | `POST` | `/api/weight-limit-rules` | `modules/rule-engine/controllers/weight-limit-rules.controller.ts:32` | +| Update a weight limit rule | `PATCH` | `/api/weight-limit-rules/:id` | `modules/rule-engine/controllers/weight-limit-rules.controller.ts:39` | +| Soft-delete a weight limit rule | `DELETE` | `/api/weight-limit-rules/:id` | `modules/rule-engine/controllers/weight-limit-rules.controller.ts:46` | + +### Yard + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a yard | `POST` | `/api/yards` | `modules/rule-engine/controllers/yards.controller.ts:53` | +| Move a yard up or down in display order | `POST` | `/api/yards/:id/move-order` | `modules/rule-engine/controllers/yards.controller.ts:38` | +| Bulk reorder yards by ID list | `POST` | `/api/yards/reorder` | `modules/rule-engine/controllers/yards.controller.ts:30` | +| Update a yard | `PATCH` | `/api/yards/:id` | `modules/rule-engine/controllers/yards.controller.ts:60` | +| Soft-delete a yard | `DELETE` | `/api/yards/:id` | `modules/rule-engine/controllers/yards.controller.ts:67` | + +### Yard Distance + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a yard distance | `POST` | `/api/yard-distances` | `modules/rule-engine/controllers/yard-distances.controller.ts:42` | +| Update a yard distance | `PATCH` | `/api/yard-distances/:id` | `modules/rule-engine/controllers/yard-distances.controller.ts:49` | +| Soft-delete a yard distance | `DELETE` | `/api/yard-distances/:id` | `modules/rule-engine/controllers/yard-distances.controller.ts:56` | + diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index fb6a0bd31..e10e5dcb7 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -60,7 +60,6 @@ "@nestjs/typeorm": "^11.0.1", "@nestjs/websockets": "^11.1.27", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz", - "@tria-plc/auditlog": "file:../../local-packages/tria-plc-auditlog-1.1.2.tgz", "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", @@ -70,6 +69,7 @@ "cross-env": "^10.1.0", "dotenv": "^17.4.2", "dotenv-cli": "^11.0.0", + "exceljs": "^4.4.0", "handlebars": "^4.7.9", "jose": "^5.10.0", "libphonenumber-js": "^1.13.6", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index c1b890719..4ec11e082 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -16,7 +16,6 @@ import { import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed"; import { IamModule } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; -import { MezgebModule } from "@tria-plc/auditlog"; import appConfig from "./config/app.config"; import databaseConfig from "./config/database.config"; @@ -24,6 +23,7 @@ import telebirrConfig from "./config/telebirr.config"; import rabbitmqConfig from "./config/rabbitmq.config"; import faydaConfig from "./config/fayda.config"; import eimsConfig from "./config/eims.config"; +import chatConfig from "./config/chat.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; import { ContractsModule } from "./modules/contracts/contracts.module"; @@ -41,6 +41,8 @@ import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules. import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module"; import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module"; import { CompaniesModule } from "./modules/companies/companies.module"; +import { ShippingLineBookingCompletionModule } from "./modules/shipping-lines/shipping-line-booking-completion.module"; +import { ShippingLineCompaniesModule } from "./modules/shipping-lines/shipping-line-companies.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; @@ -49,6 +51,9 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; +import { PaymentSettingsModule } from "./modules/payment-settings/payment-settings.module"; +import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module"; +import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { SupportContentModule } from "./modules/support-content/support-content.module"; import { OtpModule } from "./modules/otp/otp.module"; @@ -113,7 +118,10 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; import { AiModule } from "./modules/ai/ai.module"; import { AuditModule } from "./modules/audit/audit.module"; -import { LoggerMiddleware } from "./logger.middleware"; +// dev replaced the local LoggerMiddleware with the shared RequestLogMiddleware +// and deleted ./logger.middleware, so the branch's import is dropped here. +import { RequestLogMiddleware } from "@edr/api-common"; +import { ChatModule } from "./modules/chat/chat.module"; import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache"; @@ -132,6 +140,7 @@ if (!process.env.APPLICATION_NAME) { rabbitmqConfig, faydaConfig, eimsConfig, + chatConfig, ], }), ScheduleModule.forRoot(), @@ -169,19 +178,6 @@ if (!process.env.APPLICATION_NAME) { return dataSource; }, }), - // Request + entity-level audit logging over RabbitMQ (@tria-plc/auditlog). - // Must come after TypeOrmModule above so it picks up this app's DataSource. - // rmqUrl falls back the same way notifications.module.ts's RABBITMQ_URL - // does: the dev broker only provisions the `edr` user on the `payment` - // vhost (docker-compose's RABBITMQ_DEFAULT_USER/VHOST), so an unset - // RABBITMQ_URL must land there too, not on guest@'/' (403 ACCESS_REFUSED). - MezgebModule.forRoot({ - applicationName: "freight-api", - rmqUrl: - process.env.RABBITMQ_URL ?? - process.env.PAYMENT_RABBITMQ_URL ?? - "amqp://localhost:5672", - }), SharedAuthModule, IamModule.forRoot({ applications: [EDR_FREIGHT_APPLICATION], @@ -213,6 +209,8 @@ if (!process.env.APPLICATION_NAME) { TrainSchedulingModule, SchedulingRescheduleModule, CompaniesModule, + ShippingLineCompaniesModule, + ShippingLineBookingCompletionModule, TrackingModule, BillingModule, NotificationsModule, @@ -221,6 +219,9 @@ if (!process.env.APPLICATION_NAME) { FileUploadSettingsModule, DropdownSettingsModule, ExchangeSettingsModule, + PaymentSettingsModule, + StampSettingsModule, + LogoSettingsModule, ContractTemplatesModule, SupportContentModule, OtpModule, @@ -258,6 +259,7 @@ if (!process.env.APPLICATION_NAME) { FleetHistoryModule, AiModule, AuditModule, + ChatModule, ], providers: [ EdrOrgSeeder, @@ -390,7 +392,9 @@ export class AppModule implements OnApplicationBootstrap { } configure(consumer: MiddlewareConsumer) { - consumer.apply(LoggerMiddleware).forRoutes("*"); + // FIRST: opens the request log context every later middleware/guard/service + // writes into via logCtx(). Anything applied above it logs into the void. + consumer.apply(RequestLogMiddleware).forRoutes("*"); consumer .apply(LoginAudienceMiddleware) .forRoutes( diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index d1b5364c3..f9eab4d39 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -49,6 +49,8 @@ export const MixedAudience = (permission: string | string[]) => export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); +export const ChatSync = () => BookingStaff(FREIGHT_PERMS.chat.sync); + /** * The document-review countdown in the backoffice header. Its own permission so * it can be granted to exactly the position types that decide operation @@ -90,6 +92,10 @@ export const TrainSchedulingReschedule = () => export const TrainSchedulingRulesManage = () => BookingStaff(FREIGHT_PERMS.trainScheduling.rulesManage); +/** Edit a schedule's operational run numbers (train + voyage) before dispatch. */ +export const TrainSchedulingEditTrainNumber = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.editTrainNumber); + /** * Fleet guards take an optional granular per-resource key (locomotives:create, * wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain diff --git a/apps/edr-freight-api/src/common/dev-bypass.util.ts b/apps/edr-freight-api/src/common/dev-bypass.util.ts new file mode 100644 index 000000000..5e6e39e8d --- /dev/null +++ b/apps/edr-freight-api/src/common/dev-bypass.util.ts @@ -0,0 +1,13 @@ +/** + * Dev/staging bypass gate for OTP, payment and Fayda verification. + * + * Gated purely on `ENV` (dev|staging) — never on NODE_ENV, so it can't be + * mistaken for a prod-vs-non-prod switch. `ENV` is simply left unset in + * production, so this is always false there. + */ +export function isBypassEnv(): boolean { + return ["dev", "staging"].includes(process.env.ENV ?? ""); +} + +/** Fixed code accepted in addition to the real one when isBypassEnv(). */ +export const DEV_BYPASS_OTP = "000000"; diff --git a/apps/edr-freight-api/src/common/document-upload.options.ts b/apps/edr-freight-api/src/common/document-upload.options.ts new file mode 100644 index 000000000..736d8099b --- /dev/null +++ b/apps/edr-freight-api/src/common/document-upload.options.ts @@ -0,0 +1,30 @@ +import { MulterOptions } from "@nestjs/platform-express/multer/interfaces/multer-options.interface"; + +/** + * Ceiling for a single uploaded document, in bytes. + * + * Mirrors the 50MB `max_size_mb` the file-upload settings hand the portal, so + * the client-side gate and the server-side cap agree. Raising this alone is not + * enough to accept a 50MB upload: the reverse proxy in front of the API applies + * its own `client_max_body_size`, and nginx's 1MB default rejects the request + * with a 413 before it ever reaches Nest (see docs/uploads.md). + */ +export const DOCUMENT_UPLOAD_MAX_BYTES = 50 * 1024 * 1024; + +/** Upper bound on parts in one multipart document post. */ +export const DOCUMENT_UPLOAD_MAX_FILES = 20; + +/** + * Multer caps for the document upload routes. + * + * Without an explicit `fileSize`, multer's default is unlimited and every byte + * is buffered in memory, so an oversized post is absorbed in full before + * anything can reject it. With the limit set, multer stops reading the socket + * at the ceiling instead. + */ +export const documentUploadMulterOptions: MulterOptions = { + limits: { + fileSize: DOCUMENT_UPLOAD_MAX_BYTES, + files: DOCUMENT_UPLOAD_MAX_FILES, + }, +}; diff --git a/apps/edr-freight-api/src/common/dto/pagination-query.dto.ts b/apps/edr-freight-api/src/common/dto/pagination-query.dto.ts index 997ced76d..e705b019d 100644 --- a/apps/edr-freight-api/src/common/dto/pagination-query.dto.ts +++ b/apps/edr-freight-api/src/common/dto/pagination-query.dto.ts @@ -41,4 +41,16 @@ export class PaginationQueryDto { @Transform(({ value }) => String(value).toUpperCase()) @IsIn(['ASC', 'DESC']) sortOrder?: 'ASC' | 'DESC'; + + /** + * Column to sort by, as a public field name (not a raw SQL column). The + * actual whitelist lives in `applySort`'s `sortable` map at each call site, + * not here — a per-DTO `@IsIn` is opt-in and has been forgotten before. + * An unrecognized value falls back silently rather than 400ing, so a stale + * bookmark or shared link never breaks. + */ + @ApiPropertyOptional({ description: 'Public field name; unknown values fall back to the endpoint default.' }) + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : undefined)) + sortBy?: string; } diff --git a/apps/edr-freight-api/src/common/request-log-context.spec.ts b/apps/edr-freight-api/src/common/request-log-context.spec.ts new file mode 100644 index 000000000..e898cb930 --- /dev/null +++ b/apps/edr-freight-api/src/common/request-log-context.spec.ts @@ -0,0 +1,199 @@ +import { Logger } from "@nestjs/common"; +import type { Repository } from "typeorm"; +import { + BaseRepository, + RequestLogMiddleware, + getLogContext, + logCtx, + runWithLogContext, +} from "@edr/api-common"; + +describe("logCtx", () => { + it("is a no-op outside a request", () => { + expect(() => logCtx({ bookingId: "b1" })).not.toThrow(); + expect(getLogContext()).toBeUndefined(); + }); + + it("collects data points across the request and isolates concurrent ones", async () => { + const collect = async (id: string) => + runWithLogContext({ requestId: id }, async () => { + logCtx({ bookingId: id }); + await Promise.resolve(); + logCtx({ from: "DRAFT", to: "SUBMITTED" }, { path: "booking.status" }); + logCtx({ wagonId: "w1" }, { path: "wagons", mode: "push" }); + logCtx({ wagonId: "w2" }, { path: "wagons", mode: "push" }); + logCtx(1, { path: "smsSent", mode: "count" }); + logCtx(1, { path: "smsSent", mode: "count" }); + logCtx("PAID", { path: "payment.state", mode: "set" }); + logCtx({ ignored: true }, (ctx) => { + ctx.custom = "yes"; + }); + return getLogContext(); + }); + + const [a, b] = await Promise.all([collect("r1"), collect("r2")]); + + expect(a).toEqual({ + requestId: "r1", + bookingId: "r1", + booking: { status: { from: "DRAFT", to: "SUBMITTED" } }, + wagons: [{ wagonId: "w1" }, { wagonId: "w2" }], + smsSent: 2, + payment: { state: "PAID" }, + custom: "yes", + }); + expect(b?.requestId).toBe("r2"); + expect(b?.bookingId).toBe("r2"); + }); +}); + +describe("RequestLogMiddleware", () => { + it("emits one canonical JSON line carrying the collected context", () => { + // Raw stdout, not the Nest logger — the line must be parsable JSON with no + // "[Nest] … LOG [request]" prefix in front of it. + const lines: string[] = []; + jest.spyOn(process.stdout, "write").mockImplementation((chunk) => { + lines.push(String(chunk)); + return true; + }); + jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined); + + const listeners: Record void> = {}; + const req = { + method: "POST", + url: "/api/bookings/1/submit", + originalUrl: "/api/bookings/1/submit?dry=1", + baseUrl: "/api/bookings", + route: { path: "/:id/submit" }, + headers: { + "user-agent": "jest", + "x-request-id": "req-42", + authorization: "Bearer tok", + "x-client-app": "freight-backoffice", + "current-project-id": "proj-3", + }, + ip: "10.0.0.1", + query: { dry: "1" }, + user: { + id: "u-7", + sessionId: "sess-9", + userType: "STAFF", + status: "ACTIVE", + username: "nati", + email: "nati@example.com", + phoneNumber: "0911000000", + name: { en: "Nati" }, + roles: [{ key: "freight_operations" }], + permissions: [{ key: "a" }, { key: "b" }], + employee: { + id: "emp-1", + organizationId: "org-1", + unitId: "unit-2", + position: { + id: "pos-5", + key: "ops_officer", + employeePositionId: "ep-6", + isDelegate: true, + delegatorId: "pos-1", + positionType: { key: "operations" }, + }, + }, + }, + }; + const res = { + statusCode: 409, + writableEnded: true, + setHeader: jest.fn(), + on: (event: string, fn: () => void) => { + listeners[event] = fn; + }, + }; + + new RequestLogMiddleware().use(req, res, () => { + logCtx({ bookingId: "b-1" }); + logCtx("REJECTED", { path: "booking.outcome", mode: "set" }); + }); + listeners.finish(); + listeners.close(); // aborts/close after finish must not double-log + + expect(lines).toHaveLength(1); + expect(lines[0].endsWith("\n")).toBe(true); + expect(lines[0].startsWith("{")).toBe(true); + expect(JSON.parse(lines[0])).toMatchObject({ + level: "warn", + logger: "request", + type: "http_request", + requestId: "req-42", + method: "POST", + route: "/api/bookings/:id/submit", + url: "/api/bookings/1/submit?dry=1", + status: 409, + userId: "u-7", + ip: "10.0.0.1", + userAgent: "jest", + query: { dry: "1" }, + bookingId: "b-1", + booking: { outcome: "REJECTED" }, + }); + expect(JSON.parse(lines[0]).auth).toEqual({ + authenticated: true, + hasBearer: true, + clientApp: "freight-backoffice", + userId: "u-7", + sessionId: "sess-9", + userType: "STAFF", + userStatus: "ACTIVE", + roles: ["freight_operations"], + permissionCount: 2, + employeeId: "emp-1", + organizationId: "org-1", + unitId: "unit-2", + positionId: "pos-5", + positionKey: "ops_officer", + positionType: "operations", + employeePositionId: "ep-6", + isDelegate: true, + delegatorId: "pos-1", + projectId: "proj-3", + }); + // No personal data reaches the line, whatever the token carried. + expect(lines[0]).not.toMatch(/nati|example\.com|0911000000/); + expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42"); + jest.restoreAllMocks(); + }); +}); + +describe("BaseRepository write trail", () => { + class TestRepo extends BaseRepository<{ id: string; status?: string }> { + constructor(repo: Repository<{ id: string; status?: string }>) { + super(repo); + } + } + + const typeormRepo = { + metadata: { tableName: "booking" }, + create: (data: unknown) => data, + save: async (data: unknown) => data, + update: async () => undefined, + findOne: async () => ({ id: "b-1", status: "SUBMITTED" }), + softDelete: async () => undefined, + delete: async () => undefined, + } as unknown as Repository<{ id: string; status?: string }>; + + it("records creates, status changes and deletes without any service opting in", async () => { + const ctx = await runWithLogContext({}, async () => { + const repo = new TestRepo(typeormRepo); + await repo.create({ id: "b-1" }); + await repo.update("b-1", { status: "SUBMITTED" }); + await repo.update("b-1", { id: "b-1" }); // no status → no transition entry + await repo.softDelete("b-1"); + return getLogContext(); + }); + + expect(ctx).toEqual({ + db: { created: { booking: 1 }, updated: { booking: 2 } }, + statusChanges: [{ entity: "booking", id: "b-1", status: "SUBMITTED" }], + deleted: [{ entity: "booking", id: "b-1" }], + }); + }); +}); diff --git a/apps/edr-freight-api/src/common/utils/facets.util.ts b/apps/edr-freight-api/src/common/utils/facets.util.ts new file mode 100644 index 000000000..bc80c4b93 --- /dev/null +++ b/apps/edr-freight-api/src/common/utils/facets.util.ts @@ -0,0 +1,48 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +export interface FacetBucket { + value: string; + count: number; +} + +/** + * One `GROUP BY` query per faceted column, each with every OTHER active + * filter applied but its OWN predicate omitted. That omission is the point: + * with `status=SUBMITTED` selected, the status facet still reports + * `APPROVED: 8` so the user can switch, while the freightType facet reflects + * only the SUBMITTED-scoped set. Omit `search` from nothing — it's a scope, + * not a pill, and stays applied in every facet. + * + * Capped at 50 buckets per column — FK-id facets (warehouseId, yardId) can + * have real cardinality; beyond 50 the frontend should fall back to a + * typeahead instead of a checkbox list. Never facet a column whose popover + * would need its own search box (references, plate numbers, free text). + * + * @param base builds a FRESH query builder (soft-delete guard only, + * no filters) — called once per facet column. + * @param applyFilters applies every filter to `qb`, using `omit` to skip + * one column's own predicate. + * @param columns facet key -> "alias.column" SQL reference. + */ +export async function computeFacets( + base: () => SelectQueryBuilder, + applyFilters: (qb: SelectQueryBuilder, omit?: string) => void, + columns: Record, +): Promise> { + const entries = await Promise.all( + Object.entries(columns).map(async ([key, column]) => { + const qb = base(); + applyFilters(qb, key); + const rows = await qb + .select(column, 'value') + .addSelect('COUNT(*)::int', 'count') + .andWhere(`${column} IS NOT NULL`) + .groupBy(column) + .orderBy('count', 'DESC') + .limit(50) + .getRawMany<{ value: string; count: number }>(); + return [key, rows.map((r) => ({ value: String(r.value), count: Number(r.count) }))] as const; + }), + ); + return Object.fromEntries(entries); +} diff --git a/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts b/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts new file mode 100644 index 000000000..e4a3465d0 --- /dev/null +++ b/apps/edr-freight-api/src/common/utils/iam-user-name.util.ts @@ -0,0 +1,49 @@ +import { DataSource } from "typeorm"; + +/** + * `iam.users.name` is a localized object ({ en, am, … }), not a string — a + * plain `String(name)` there yields "[object Object]" in an audit trail. + */ +export interface IamUserRow { + name?: Record | string | null; + username?: string | null; + email?: string | null; +} + +/** Best display name for a user row: English label → any locale → login → email. */ +export function pickUserName(user: IamUserRow): string | null { + const { name } = user; + if (typeof name === "string" && name.trim()) return name.trim(); + if (name && typeof name === "object") { + const localized = + name.en ?? + Object.values(name).find((v) => typeof v === "string" && v.trim()); + if (localized?.trim()) return localized.trim(); + } + return user.username?.trim() || user.email?.trim() || null; +} + +/** + * Display names for a set of IAM user ids — one query for the whole set. + * `iam.users` is owned by the auth system and has no entity here, so it is read + * directly. A miss is not an error: the caller still holds the id and can fall + * back to it. + */ +export async function resolveIamUserNames( + dataSource: DataSource, + userIds: (string | null | undefined)[], +): Promise> { + const resolved = new Map(); + const ids = [...new Set(userIds.filter((id): id is string => Boolean(id)))]; + if (ids.length === 0) return resolved; + + const rows = (await dataSource.query( + `SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`, + [ids], + )) as Array; + for (const row of rows) { + const name = pickUserName(row); + if (name) resolved.set(row.id, name); + } + return resolved; +} diff --git a/apps/edr-freight-api/src/common/utils/pagination.util.spec.ts b/apps/edr-freight-api/src/common/utils/pagination.util.spec.ts new file mode 100644 index 000000000..05bfe7585 --- /dev/null +++ b/apps/edr-freight-api/src/common/utils/pagination.util.spec.ts @@ -0,0 +1,76 @@ +import { SelectQueryBuilder } from 'typeorm'; +import { applySort, buildPaginationMeta, normalizePagination } from './pagination.util'; + +/** Minimal fake — just enough of the SelectQueryBuilder chain applySort touches. */ +function fakeQb() { + const calls: Array<{ method: string; args: unknown[] }> = []; + const qb = { + alias: 'contract', + orderBy(...args: unknown[]) { + calls.push({ method: 'orderBy', args }); + return qb; + }, + addOrderBy(...args: unknown[]) { + calls.push({ method: 'addOrderBy', args }); + return qb; + }, + }; + return { qb: qb as unknown as SelectQueryBuilder, calls }; +} + +const SORTABLE = { + createdAt: 'contract.createdAt', + contractValidUntil: 'contract.contractValidUntil', +}; + +describe('applySort', () => { + it('resolves a whitelisted sortBy to its column', () => { + const { qb, calls } = fakeQb(); + applySort(qb, { sortBy: 'contractValidUntil', sortOrder: 'ASC' }, SORTABLE, 'createdAt'); + expect(calls[0]).toEqual({ + method: 'orderBy', + args: ['contract.contractValidUntil', 'ASC'], + }); + }); + + it('falls back to the default column for an unknown sortBy instead of throwing', () => { + const { qb, calls } = fakeQb(); + // A stale bookmark or shared link naming a removed/renamed column must + // never 400 — it should silently behave as if sortBy were absent. + expect(() => + applySort(qb, { sortBy: "id; DROP TABLE contracts; --" }, SORTABLE, 'createdAt'), + ).not.toThrow(); + expect(calls[0]).toEqual({ method: 'orderBy', args: ['contract.createdAt', 'DESC'] }); + }); + + it('defaults sortOrder to DESC when absent or not ASC', () => { + const { qb, calls } = fakeQb(); + applySort(qb, {}, SORTABLE, 'createdAt'); + expect(calls[0]).toEqual({ method: 'orderBy', args: ['contract.createdAt', 'DESC'] }); + }); + + it('always appends an id ASC tiebreaker', () => { + const { qb, calls } = fakeQb(); + applySort(qb, { sortBy: 'createdAt' }, SORTABLE, 'createdAt'); + expect(calls[1]).toEqual({ method: 'addOrderBy', args: ['contract.id', 'ASC'] }); + }); +}); + +describe('normalizePagination / buildPaginationMeta', () => { + it('clamps page to >= 1 and pageSize to the configured max', () => { + const p = normalizePagination({ page: 0, pageSize: 999 }, { maxPageSize: 100 }); + expect(p).toEqual({ page: 1, pageSize: 100, skip: 0, take: 100 }); + }); + + it('computes hasNextPage/hasPreviousPage from total', () => { + const meta = buildPaginationMeta(45, 2, 20); + expect(meta).toEqual({ + page: 2, + pageSize: 20, + total: 45, + totalPages: 3, + hasNextPage: true, + hasPreviousPage: true, + }); + }); +}); diff --git a/apps/edr-freight-api/src/common/utils/pagination.util.ts b/apps/edr-freight-api/src/common/utils/pagination.util.ts index 310b2da6d..ca4ed35a1 100644 --- a/apps/edr-freight-api/src/common/utils/pagination.util.ts +++ b/apps/edr-freight-api/src/common/utils/pagination.util.ts @@ -83,3 +83,32 @@ export function paginateArray( meta: buildPaginationMeta(rows.length, page, pageSize), }; } + +/** + * Apply `ORDER BY` from a query DTO's `sortBy`/`sortOrder`, resolved against a + * whitelist — never interpolate `sortBy` into a query builder directly, it is + * unvalidated user input and an unwhitelisted `orderBy(\`alias.${sortBy}\`)` + * is a SQL-injection primitive (see the deleted `findAllWithFilters` methods + * on drivers/vehicles repositories, which had exactly that bug). + * + * An unknown `sortBy` falls back to `fallback` instead of throwing — a stale + * bookmark or shared link should never 400. + * + * Always appends `id ASC` as a tiebreaker: sorting by a non-unique column + * (status, createdAt on bulk-imported rows) without one can drop or + * duplicate rows across pages once LIMIT/OFFSET is involved. + * + * @param sortable public sort key -> "alias.column" SQL reference. Also + * doubles as the Swagger enum / frontend's sortable-column list. + * @param fallback a key that must exist in `sortable`. + */ +export function applySort( + qb: SelectQueryBuilder, + query: { sortBy?: string; sortOrder?: 'ASC' | 'DESC' }, + sortable: Record, + fallback: string, +): SelectQueryBuilder { + const column = (query.sortBy && sortable[query.sortBy]) || sortable[fallback]; + qb.orderBy(column, query.sortOrder === 'ASC' ? 'ASC' : 'DESC'); + return qb.addOrderBy(`${qb.alias}.id`, 'ASC'); +} diff --git a/apps/edr-freight-api/src/config/chat.config.ts b/apps/edr-freight-api/src/config/chat.config.ts new file mode 100644 index 000000000..ce20b610b --- /dev/null +++ b/apps/edr-freight-api/src/config/chat.config.ts @@ -0,0 +1,59 @@ +import { registerAs } from '@nestjs/config'; + +export interface ChatConfig { + enabled: boolean; + /** Synapse base URL reachable from this container (client + admin APIs). */ + baseUrl: string; + /** Synapse's public_baseurl — what Element itself is configured to call. Only + * used to seed the sso.html handoff; server-to-server calls use {@link baseUrl}. */ + publicBaseUrl: string; + /** Public Element Web origin — the SSO handoff link points here. */ + webUrl: string; + /** Matrix server_name — the `:domain` half of every MXID. */ + serverName: string; + /** HS256 secret. Must exactly match Synapse's jwt_config.secret. */ + jwtSecret: string; + /** Bearer token for a Synapse server admin account (room/user provisioning). */ + adminToken: string; +} + +const REQUIRED_VARS = [ + 'MATRIX_BASE_URL', + 'MATRIX_PUBLIC_BASE_URL', + 'MATRIX_CHAT_WEB_URL', + 'MATRIX_SERVER_NAME', + 'MATRIX_JWT_SECRET', + 'MATRIX_ADMIN_TOKEN', +] as const; + +export default registerAs('chat', (): ChatConfig => { + const enabled = (process.env.MATRIX_ENABLED ?? 'false').toLowerCase() === 'true'; + if (!enabled) { + return { + enabled: false, + baseUrl: '', + publicBaseUrl: '', + webUrl: '', + serverName: '', + jwtSecret: '', + adminToken: '', + }; + } + + const missing = REQUIRED_VARS.filter((name) => !process.env[name]); + if (missing.length > 0) { + throw new Error( + `Internal chat is enabled (MATRIX_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`, + ); + } + + return { + enabled: true, + baseUrl: process.env.MATRIX_BASE_URL!.replace(/\/$/, ''), + publicBaseUrl: process.env.MATRIX_PUBLIC_BASE_URL!.replace(/\/$/, ''), + webUrl: process.env.MATRIX_CHAT_WEB_URL!.replace(/\/$/, ''), + serverName: process.env.MATRIX_SERVER_NAME!, + jwtSecret: process.env.MATRIX_JWT_SECRET!, + adminToken: process.env.MATRIX_ADMIN_TOKEN!, + }; +}); diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index af4a5b017..77f4b37e4 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -56,11 +56,6 @@ import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/i import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity"; import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity"; import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity"; -import { AuditLog, AuditLogCommand } from "@tria-plc/auditlog"; - -// @tria-plc/auditlog's entities live in node_modules, same as the iam ones — -// the glob below only matches this app's own src/**/*.entity.ts. -const auditEntities = [AuditLog, AuditLogCommand]; const iamEntities = [ UnitSetting, @@ -185,7 +180,6 @@ export function buildDataSourceOptions(): DataSourceOptions { entities: [ __dirname + "/../**/*.entity.{ts,js}", ...iamEntities, - ...auditEntities, ], migrations: [], }; diff --git a/apps/edr-freight-api/src/config/eims.config.spec.ts b/apps/edr-freight-api/src/config/eims.config.spec.ts new file mode 100644 index 000000000..127b3ea62 --- /dev/null +++ b/apps/edr-freight-api/src/config/eims.config.spec.ts @@ -0,0 +1,131 @@ +import eimsConfigFactory from "./eims.config"; + +const REQUIRED = { + EIMS_ENABLED: "true", + EIMS_CLIENT_ID: "cid", + EIMS_CLIENT_SECRET: "secret", + EIMS_API_KEY: "apikey", + EIMS_TIN: "0000000000", +}; + +const withEnv = (vars: Record, fn: () => void) => { + const prior: Record = {}; + for (const [key, value] of Object.entries(vars)) { + prior[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + fn(); + } finally { + for (const [key, value] of Object.entries(prior)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}; + +describe("eims.config — private key / certificate resolution", () => { + it("unescapes a literal \\n when the PEM was pasted without real newlines", () => { + withEnv( + { ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" }, + () => { + expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2"); + }, + ); + }); + + it("leaves a PEM with real newlines untouched", () => { + withEnv( + { ...REQUIRED, EIMS_PRIVATE_KEY: "line1\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" }, + () => { + expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2"); + }, + ); + }); + + it("throws naming all three key/cert options when none are set", () => { + withEnv( + { + ...REQUIRED, + EIMS_PRIVATE_KEY_PATH: undefined, + EIMS_PRIVATE_KEY_BASE64: undefined, + EIMS_PRIVATE_KEY: undefined, + EIMS_CERTIFICATE_PATH: "/dev/null", + }, + () => { + expect(() => eimsConfigFactory()).toThrow( + /EIMS_PRIVATE_KEY_PATH or EIMS_PRIVATE_KEY_BASE64 or EIMS_PRIVATE_KEY/, + ); + }, + ); + }); + + it("is satisfied by any single one of the three key options", () => { + withEnv( + { ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" }, + () => { + expect(() => eimsConfigFactory()).not.toThrow(); + }, + ); + }); +}); + +describe("eims.config — baked-in Ethiopia region/zone/woreda codes", () => { + it("resolves a known region/wereda/zone with no env var set at all", () => { + withEnv( + { ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" }, + () => { + const cfg = eimsConfigFactory(); + expect(cfg.invoice.buyerRegionCodes.Somali).toBe("05"); + expect(cfg.invoice.buyerWeredaCodes["Jijiga Town"]).toBe("02"); + expect(cfg.invoice.buyerCityCodes.Fafan).toBe("01"); + }, + ); + }); + + it("an env var entry overrides the baked-in code for the same name", () => { + withEnv( + { + ...REQUIRED, + EIMS_PRIVATE_KEY: "x", + EIMS_CERTIFICATE_PATH: "/dev/null", + EIMS_BUYER_REGION_CODES: "Somali=99", + }, + () => { + expect(eimsConfigFactory().invoice.buyerRegionCodes.Somali).toBe("99"); + }, + ); + }); + + it("an env var still adds a name the baked-in table doesn't have (a spelling variant)", () => { + withEnv( + { + ...REQUIRED, + EIMS_PRIVATE_KEY: "x", + EIMS_CERTIFICATE_PATH: "/dev/null", + EIMS_BUYER_CITY_CODES: "Fafen=01", + }, + () => { + const codes = eimsConfigFactory().invoice.buyerCityCodes; + expect(codes.Fafen).toBe("01"); + expect(codes.Fafan).toBe("01"); // baked-in entry still present alongside it + }, + ); + }); + + it("resolves the bare Addis Ababa sub-city name a buyer profile actually stores, not the CSV's example-woreda name", () => { + withEnv( + { ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" }, + () => { + const codes = eimsConfigFactory().invoice.buyerWeredaCodes; + expect(codes.Bole).toBe("01"); + expect(codes.Arada).toBe("01"); + expect(codes.Kirkos).toBe("01"); + expect(codes.Yeka).toBe("01"); + expect(codes["Nifas Silk Lafto"]).toBe("13"); + expect(codes["Nefas Silk-Lafto"]).toBe("13"); + }, + ); + }); +}); diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index a8a929ca5..e5530eaf2 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -1,5 +1,7 @@ import { registerAs } from "@nestjs/config"; +import { ETHIOPIA_REGION_CODES, ETHIOPIA_WOREDA_CODES, ETHIOPIA_ZONE_CODES } from "./ethiopia-geo-codes"; + /** * Ethiopian MoR EIMS e-invoicing gateway. * @@ -30,6 +32,23 @@ export interface EimsConfig { privateKeyPath: string; /** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */ certificatePath: string; + /** + * Inline alternative to `privateKeyPath` — the key file's own bytes, base64-encoded, so a + * container that can't be given a host bind mount can still receive it as a plain env var. + * Either one must be present when EIMS is enabled. Precedence: `privateKeyPem` > `privateKeyBase64` + * > `privateKeyPath`. + */ + privateKeyBase64: string; + /** Inline alternative to `certificatePath`, same precedence rule as the key. */ + certificateBase64: string; + /** + * The PEM key pasted directly into the env var, no encoding step at all — the most direct of the + * three inline forms, and the hardest for a broken transport step to mangle since there's no + * decode stage to get wrong. Wins over `privateKeyBase64`/`privateKeyPath` when set. + */ + privateKeyPem: string; + /** Inline alternative to `certificateBase64`, same precedence rule. */ + certificatePem: string; httpTimeoutMs: number; /** Re-authenticate this many ms before the access token actually expires. */ tokenSkewMs: number; @@ -80,7 +99,19 @@ export interface EimsInvoiceConfig { paymentMode: string; paymentTerm: string; unitDefault: string; + /** + * Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the + * column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign + * buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never + * applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia. + */ buyerCountryCode: string | null; + /** + * Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format + * unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them — + * this is not validated against a fixed digit pattern, only looked up by name. + */ + buyerCountryCodes: Record; /** * Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES` * ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails @@ -89,18 +120,48 @@ export interface EimsInvoiceConfig { buyerRegionCodes: Record; /** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */ buyerWeredaCodes: Record; + /** + * Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has + * no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike + * Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already + * succeeds with it null), so an unmapped zone falls back to null rather than failing the + * mapping. + */ + buyerCityCodes: Record; + /** + * Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` + + * `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to + * `taxCode`/`taxRatePercent`. Needed for an invoice whose lines carry different MoR tax + * treatment (e.g. zero-rated freight next to a taxed accessorial) — the flat `taxCode` above + * cannot express that. Values are raw strings; the context builder parses/validates them. + */ + taxCodeByChargeType: Record; + taxRateByChargeType: Record; + /** Same mechanism, for `EIMS_EXCISE_BY_CHARGE_TYPE` / `EIMS_DISCOUNT_BY_CHARGE_TYPE`. Charge + * types not listed fall back to `exciseTaxValue` / 0 respectively. */ + exciseByChargeType: Record; + discountByChargeType: Record; cashierName: string | null; salesPersonName: string | null; + /** + * TEMPORARY / experimental — `EIMS_BUYER_ID_TYPE` + `EIMS_BUYER_ID_NUMBER`, applied to every + * buyer regardless of who they are. Only exists to test whether rule 7004 ("Id types should be + * one of NID, KID, SID, WID, PST, DLS, MRS") is satisfied by *any* IdType/IdNumber pair, ahead + * of MoR's answer on whether it's required for a TIN-only corporate buyer and which value fits. + * Wrong for a real, non-self buyer — remove once MoR answers and a real per-buyer field exists. + */ + buyerIdType: string | null; + buyerIdNumber: string | null; } -const REQUIRED_VARS = [ - "EIMS_CLIENT_ID", - "EIMS_CLIENT_SECRET", - "EIMS_API_KEY", - "EIMS_TIN", - "EIMS_PRIVATE_KEY_PATH", - "EIMS_CERTIFICATE_PATH", -] as const; +const REQUIRED_VARS = ["EIMS_CLIENT_ID", "EIMS_CLIENT_SECRET", "EIMS_API_KEY", "EIMS_TIN"] as const; + +// Key/cert each have three ways in (file path, inline base64, or raw PEM) — checked separately +// from REQUIRED_VARS since it's "at least one of", not "this exact var". +const REQUIRED_ANY_OF: string[][] = [ + ["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64", "EIMS_PRIVATE_KEY"], + ["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64", "EIMS_CERTIFICATE"], +]; const positiveInt = (raw: string | undefined, fallback: number, name: string): number => { if (raw === undefined || raw === "") return fallback; @@ -121,6 +182,14 @@ const parseCodeMap = (raw: string | undefined): Record => { return map; }; +// Some env stores (single-line .env files, certain secret managers) can't hold a literal newline +// and expect the caller to write "\n" as two characters instead. If the raw value already has a +// real newline, leave it alone; otherwise unescape "\n" so a PEM pasted that way still parses. +const normalizePem = (raw: string | undefined): string => { + if (!raw) return ""; + return raw.includes("\n") ? raw : raw.replace(/\\n/g, "\n"); +}; + /** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */ const optionalNumber = (raw: string | undefined, name: string): number | null => { if (raw === undefined || raw === "") return null; @@ -147,6 +216,10 @@ export default registerAs("eims", (): EimsConfig => { systemType: process.env.EIMS_SYSTEM_TYPE ?? "", privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "", certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "", + privateKeyBase64: process.env.EIMS_PRIVATE_KEY_BASE64 ?? "", + privateKeyPem: normalizePem(process.env.EIMS_PRIVATE_KEY), + certificatePem: normalizePem(process.env.EIMS_CERTIFICATE), + certificateBase64: process.env.EIMS_CERTIFICATE_BASE64 ?? "", httpTimeoutMs, tokenSkewMs, autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true", @@ -186,16 +259,29 @@ export default registerAs("eims", (): EimsConfig => { paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "", unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "", buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null, - buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES), - buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES), + buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES), + // Baked-in Ethiopia reference table first, env var entries win on a name collision — lets a + // deployment override or add to it without a redeploy. See ethiopia-geo-codes.ts. + buyerRegionCodes: { ...ETHIOPIA_REGION_CODES, ...parseCodeMap(process.env.EIMS_BUYER_REGION_CODES) }, + buyerWeredaCodes: { ...ETHIOPIA_WOREDA_CODES, ...parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES) }, + buyerCityCodes: { ...ETHIOPIA_ZONE_CODES, ...parseCodeMap(process.env.EIMS_BUYER_CITY_CODES) }, + taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE), + taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE), + exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE), + discountByChargeType: parseCodeMap(process.env.EIMS_DISCOUNT_BY_CHARGE_TYPE), cashierName: process.env.EIMS_CASHIER_NAME || null, salesPersonName: process.env.EIMS_SALESPERSON_NAME || null, + buyerIdType: process.env.EIMS_BUYER_ID_TYPE || null, + buyerIdNumber: process.env.EIMS_BUYER_ID_NUMBER || null, }, }; if (!enabled) return base; - const missing = REQUIRED_VARS.filter((name) => !process.env[name]); + const missing: string[] = REQUIRED_VARS.filter((name) => !process.env[name]); + for (const vars of REQUIRED_ANY_OF) { + if (vars.every((name) => !process.env[name])) missing.push(vars.join(" or ")); + } if (missing.length > 0) { throw new Error( `EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`, diff --git a/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts b/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts new file mode 100644 index 000000000..ca48a7c5f --- /dev/null +++ b/apps/edr-freight-api/src/config/ethiopia-geo-codes.ts @@ -0,0 +1,160 @@ +/** + * MoR EIMS region/zone/woreda codes, by name — the baked-in fallback under + * `EIMS_BUYER_REGION_CODES`/`EIMS_BUYER_WEREDA_CODES`/`EIMS_BUYER_CITY_CODES` (zone is the closest + * match to EIMS's "City", per `eims-invoice.mapper.ts`). + * + * Before this existed, every buyer from a not-yet-seen region/zone/woreda crashed EIMS filing until + * someone hunted down the code and added it to an env var by hand — happened three times in one + * afternoon (2026-08-17: Somali region, Fafan zone, Jigjiga woreda, even the Ethiopia country code + * itself were all unset). Ethiopia's administrative divisions are fixed, known, reference data, not + * something that should be maintained reactively per buyer. Source: `ethiopia_administrative_ + * hierarchy_master.csv`, supplied 2026-08-17 — NOT exhaustive (a representative sample per region, + * not all ~1000 real woredas), extend as new gaps surface. + * + * The env vars stay wired in ahead of this table (see `eims.config.ts`) — for a quick correction + * without a redeploy, or a name spelled differently in a buyer's profile than in this table (already + * hit live: DB has zone "Fafen", this table's official spelling is "Fafan" — same zone, matching is + * case/space-insensitive but not spelling-tolerant, so the env var override is still how that buyer + * actually resolves; this table mainly helps the *next* buyer whose profile spelling matches). + * + * ponytail: region names are unique nationwide (only ~15), safe as a flat map. Zone and woreda names + * are not always unique across different regions (e.g. "North Shewa" is both an Amhara zone and an + * Oromia zone, different codes) — `Company` stores region/zone/woreda as three independent strings, + * no parent linkage, so a flat name lookup can't disambiguate. First occurrence in the source data + * wins on a collision. Only affects the optional `City` field (zone) — never blocks filing, unlike + * Region/Wereda. A correct fix needs `Company` to store a linked hierarchy, not just three strings; + * out of scope here. Upgrade path: key this by `${region}/${zone}` once that linkage exists. + */ +const ROWS: Array<[region: string, zone: string, woreda: string, regionCode: string, zoneCode: string, woredaCode: string]> = [ + ["Tigray", "Western Tigray", "Humera", "01", "01", "01"], + ["Tigray", "Western Tigray", "Kafta Humera", "01", "01", "02"], + ["Tigray", "Western Tigray", "Tsegede", "01", "01", "03"], + ["Tigray", "North Western Tigray", "Shire Endaselassie", "01", "02", "01"], + ["Tigray", "North Western Tigray", "Sheraro", "01", "02", "02"], + ["Tigray", "Central Tigray", "Axum", "01", "03", "01"], + ["Tigray", "Central Tigray", "Adwa", "01", "03", "02"], + ["Tigray", "Eastern Tigray", "Adigrat", "01", "04", "01"], + ["Tigray", "Southern Tigray", "Maychew", "01", "05", "01"], + ["Tigray", "Mekelle Special Zone", "Mekelle City", "01", "06", "01"], + ["Afar", "Awusi Rasu (Zone 1)", "Asayita", "02", "01", "01"], + ["Afar", "Awusi Rasu (Zone 1)", "Semera-Logiya", "02", "01", "02"], + ["Afar", "Kilbet Rasu (Zone 2)", "Abala", "02", "02", "01"], + ["Afar", "Gabi Rasu (Zone 3)", "Awash Fentale", "02", "03", "01"], + ["Afar", "Fantena Rasu (Zone 4)", "Yalo", "02", "04", "01"], + ["Afar", "Hari Rasu (Zone 5)", "Telalak", "02", "05", "01"], + ["Amhara", "North Gondar", "Debark", "03", "01", "01"], + ["Amhara", "South Gondar", "Debre Tabor", "03", "02", "01"], + ["Amhara", "North Wollo", "Woldiya", "03", "03", "01"], + ["Amhara", "South Wollo", "Dessie Town", "03", "04", "01"], + ["Amhara", "North Shewa", "Debre Berhan", "03", "05", "01"], + ["Amhara", "East Gojjam", "Debre Markos", "03", "06", "01"], + ["Amhara", "West Gojjam", "Finote Selam", "03", "07", "01"], + ["Amhara", "Wag Hemra", "Sekota", "03", "08", "01"], + ["Amhara", "Awi", "Injibara", "03", "09", "01"], + ["Amhara", "Oromia Special Zone", "Kemise", "03", "10", "01"], + ["Amhara", "Bahir Dar Special Zone", "Bahir Dar City", "03", "11", "01"], + ["Amhara", "Gondar Special Zone", "Gondar City", "03", "12", "01"], + ["Oromia", "North Shewa", "Fiche", "04", "01", "01"], + ["Oromia", "South West Shewa", "Waliso", "04", "02", "01"], + ["Oromia", "East Shewa", "Adama Town", "04", "03", "01"], + ["Oromia", "East Shewa", "Bishoftu Town", "04", "03", "02"], + ["Oromia", "West Shewa", "Ambo", "04", "04", "01"], + ["Oromia", "Arsi", "Asella", "04", "05", "01"], + ["Oromia", "West Arsi", "Shashemene", "04", "06", "01"], + ["Oromia", "Bale", "Robe", "04", "07", "01"], + ["Oromia", "East Hararghe", "Harar Outskirts", "04", "08", "01"], + ["Oromia", "West Hararghe", "Chiro", "04", "09", "01"], + ["Oromia", "Jimma", "Jimma Town", "04", "10", "01"], + ["Oromia", "Illubabor", "Mettu", "04", "11", "01"], + ["Oromia", "Buno Bedele", "Bedele", "04", "12", "01"], + ["Oromia", "Welega (West)", "Gimbi", "04", "13", "01"], + ["Oromia", "Welega (East)", "Nekemte", "04", "14", "01"], + ["Oromia", "Horo Guduru Welega", "Shambu", "04", "15", "01"], + ["Oromia", "Kelam Welega", "Dembidolo", "04", "16", "01"], + ["Oromia", "Borena", "Yabelo", "04", "17", "01"], + ["Oromia", "Guji", "Negele Borana", "04", "18", "01"], + ["Oromia", "West Guji", "Bule Hora", "04", "19", "01"], + ["Oromia", "East Bale", "Ginir", "04", "20", "01"], + ["Oromia", "Sheger City", "Sululta", "04", "21", "01"], + ["Somali", "Fafan", "Jijiga Woreda", "05", "01", "01"], + ["Somali", "Fafan", "Jijiga Town", "05", "01", "02"], + ["Somali", "Fafan", "Awbare", "05", "01", "03"], + ["Somali", "Sitti", "Shinile", "05", "02", "01"], + ["Somali", "Erer", "Fiq", "05", "03", "01"], + ["Somali", "Jarar", "Degehabur", "05", "04", "01"], + ["Somali", "Nogob", "Segeg", "05", "05", "01"], + ["Somali", "Korahe", "Kebridehar", "05", "06", "01"], + ["Somali", "Shabelle", "Gode", "05", "07", "01"], + ["Somali", "Afder", "Afder Woreda", "05", "08", "01"], + ["Somali", "Liben", "Filtu", "05", "09", "01"], + ["Somali", "Dhawa", "Mubarak", "05", "10", "01"], + ["Somali", "Dollo", "Warder", "05", "11", "01"], + ["Benishangul-Gumuz", "Asosa", "Asosa Woreda", "06", "01", "01"], + ["Benishangul-Gumuz", "Kamasashi", "Kamasashi Woreda", "06", "02", "01"], + ["Benishangul-Gumuz", "Metekel", "Gilgel Beles", "06", "03", "01"], + ["Southern Ethiopia", "Wolayta", "Sodo Zuria", "07", "01", "01"], + ["Southern Ethiopia", "Wolayta", "Sodo Town", "07", "01", "02"], + ["Southern Ethiopia", "Gamo", "Arba Minch Town", "07", "02", "01"], + ["Southern Ethiopia", "Gofa", "Sawla", "07", "03", "01"], + ["Southern Ethiopia", "Konso", "Konso Woreda", "07", "04", "01"], + ["Southern Ethiopia", "South Omo", "Jinka", "07", "05", "01"], + ["Gambela", "Anywaa", "Gambela Zuria", "08", "01", "01"], + ["Gambela", "Nuer", "Lare", "08", "02", "01"], + ["Gambela", "Majang", "Metu Zuria part", "08", "03", "01"], + ["Harari", "Harar Hundanee", "Amir Nur Woreda", "09", "01", "01"], + ["Harari", "Harar Hundanee", "Abadir Woreda", "09", "01", "02"], + ["Addis Ababa", "Bole Sub-City", "Bole Woreda 01", "10", "01", "01"], + ["Addis Ababa", "Kirkos Sub-City", "Kirkos Woreda 01", "10", "02", "01"], + ["Addis Ababa", "Nifas Silk Lafto", "NSL Woreda 13", "10", "03", "13"], + ["Addis Ababa", "Yeka Sub-City", "Yeka Woreda 01", "10", "04", "01"], + ["Addis Ababa", "Arada Sub-City", "Arada Woreda 01", "10", "05", "01"], + ["Dire Dawa", "Dire Dawa Urban", "Melka Jebdu", "11", "01", "01"], + ["Dire Dawa", "Dire Dawa Rural", "Gurgura", "11", "02", "01"], + ["Sidama", "Hawassa City Admin", "Hayek Chereka", "12", "01", "01"], + ["Sidama", "Sidama Zuria", "Yirgalem Town", "12", "02", "01"], + ["Sidama", "Sidama Zuria", "Aleta Wendo", "12", "02", "02"], + ["Southwest Ethiopia", "Keffa", "Bonga Town", "13", "01", "01"], + ["Southwest Ethiopia", "Sheka", "Mappi Zuria", "13", "02", "01"], + ["Southwest Ethiopia", "Bench Sheko", "Mizan Aman", "13", "03", "01"], + ["Central Ethiopia", "Gurage", "Wolkite", "14", "01", "01"], + ["Central Ethiopia", "Hadiya", "Hosaina", "14", "02", "01"], + ["Central Ethiopia", "Silte", "Worabe", "14", "03", "01"], + ["Gedeo State", "Gedeo Zone", "Dilla Zuria", "15", "01", "01"], + ["Gedeo State", "Gedeo Zone", "Yirgacheffe", "15", "01", "02"], +]; + +/** First occurrence wins on a name collision — see the class comment. */ +const buildMap = (pick: (row: (typeof ROWS)[number]) => [string, string]): Record => { + const map: Record = {}; + for (const row of ROWS) { + const [name, code] = pick(row); + if (!(name in map)) map[name] = code; + } + return map; +}; + +export const ETHIOPIA_REGION_CODES: Record = buildMap((r) => [r[0], r[3]]); +/** Zone name → code. Fed into `buyerCityCodes` — EIMS's "City" is really the buyer's zone. */ +export const ETHIOPIA_ZONE_CODES: Record = buildMap((r) => [r[1], r[4]]); +export const ETHIOPIA_WOREDA_CODES: Record = buildMap((r) => [r[2], r[5]]); + +/** + * Buyer records commonly store just the bare Addis Ababa sub-city name ("Bole", "Arada") as their + * woreda, not the source CSV's specific example-woreda name ("Bole Woreda 01") — confirmed live + * 2026-08-17 across three different buyers before any of them actually got past this check. Since + * the CSV lists exactly one representative woreda per Addis sub-city, alias the bare name to that + * same code rather than wait on a fuller table. + */ +const ADDIS_SUBCITY_ALIASES: Array<[bareName: string, csvZoneName: string]> = [ + ["Bole", "Bole Sub-City"], + ["Kirkos", "Kirkos Sub-City"], + ["Nifas Silk Lafto", "Nifas Silk Lafto"], + // Matches EIMS_BUYER_WEREDA_CODES' own existing spelling in .env — same zone, different hyphenation. + ["Nefas Silk-Lafto", "Nifas Silk Lafto"], + ["Yeka", "Yeka Sub-City"], + ["Arada", "Arada Sub-City"], +]; +for (const [bareName, csvZoneName] of ADDIS_SUBCITY_ALIASES) { + const row = ROWS.find((r) => r[1] === csvZoneName); + if (row && !(bareName in ETHIOPIA_WOREDA_CODES)) ETHIOPIA_WOREDA_CODES[bareName] = row[5]; +} diff --git a/apps/edr-freight-api/src/contracts/contract-provider-stamp.spec.ts b/apps/edr-freight-api/src/contracts/contract-provider-stamp.spec.ts new file mode 100644 index 000000000..023e4ac8c --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-provider-stamp.spec.ts @@ -0,0 +1,99 @@ +import { ContractViewModelBuilder, ContractSignatureView } from "./contract-view-model.builder"; + +/** + * The EDR side of a contract is sealed with the ONE global company stamp, read + * live at render time; the client side keeps whatever stamp the customer + * uploaded. These specs pin that asymmetry — the standing rule is that + * centralizing the EDR seal must not touch customer stamps. + */ +describe("ContractViewModelBuilder.attachProviderStamp", () => { + const STAMP = "data:image/png;base64,RURS"; + + const build = (stampImageUrl: string | null = STAMP) => { + const getStampImageUrl = jest.fn().mockResolvedValue(stampImageUrl); + const builder = Object.create( + ContractViewModelBuilder.prototype, + ) as ContractViewModelBuilder; + Object.assign(builder, { stampSettings: { getStampImageUrl } }); + return { builder, getStampImageUrl }; + }; + + const sig = (role: "STAFF" | "CUSTOMER", extra: Partial = {}) => + ({ + role, + signerDisplayName: `${role} signer`, + signedAt: "1 January 2026", + signatureImageUrl: "https://minio.local/sig.png", + ...extra, + }) as ContractSignatureView; + + it("stamps the EDR side with the global stamp", async () => { + const { builder } = build(); + const signatures = [sig("STAFF")]; + + await builder.attachProviderStamp(signatures); + + expect(signatures[0]!.stampImageUrl).toBe(STAMP); + }); + + it("leaves the customer side untouched", async () => { + const { builder } = build(); + const customerStamp = "data:image/png;base64,Q1VTVA=="; + const signatures = [ + sig("CUSTOMER", { stampImageUrl: customerStamp }), + sig("STAFF"), + ]; + + await builder.attachProviderStamp(signatures); + + expect(signatures[0]!.stampImageUrl).toBe(customerStamp); + expect(signatures[1]!.stampImageUrl).toBe(STAMP); + }); + + it("does not read the stamp at all when EDR has not signed yet", async () => { + const { builder, getStampImageUrl } = build(); + const signatures = [sig("CUSTOMER")]; + + await builder.attachProviderStamp(signatures); + + expect(getStampImageUrl).not.toHaveBeenCalled(); + expect(signatures[0]!.stampImageUrl).toBeUndefined(); + }); + + it("renders unstamped rather than failing when no stamp is configured", async () => { + const { builder } = build(null); + const signatures = [sig("STAFF")]; + + await expect(builder.attachProviderStamp(signatures)).resolves.toBeUndefined(); + expect(signatures[0]!.stampImageUrl).toBeNull(); + }); + + it("reads the stamp once for every EDR signature row", async () => { + const { builder, getStampImageUrl } = build(); + const signatures = [sig("STAFF"), sig("STAFF")]; + + await builder.attachProviderStamp(signatures); + + expect(getStampImageUrl).toHaveBeenCalledTimes(1); + expect(signatures.map((s) => s.stampImageUrl)).toEqual([STAMP, STAMP]); + }); + + it("is applied by loadSignatures, so the HTML view and the PDF agree", async () => { + const { builder } = build(); + Object.assign(builder, { + bookingsRepository: { + findContractSignatures: jest.fn().mockResolvedValue([ + { signerRole: "STAFF", signerDisplayName: "EDR", signedAt: new Date() }, + ]), + }, + }); + + const views = await ( + builder as unknown as { + loadSignatures(id: string): Promise; + } + ).loadSignatures("b-1"); + + expect(views[0]!.stampImageUrl).toBe(STAMP); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts index c62a875bf..2f1991df2 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -44,6 +44,7 @@ const UNIT_LABELS: Record = { PER_CONTAINER: 'per container', PER_KM: 'per km', PER_TON_KM: 'per ton per km', + PER_LITER: 'per liter', PER_INVOICE: 'per invoice', FLAT: 'flat', }; @@ -66,6 +67,7 @@ const TRIGGER_ROUTE_LABELS: Partial> = { DEMURRAGE: 'Demurrage / wagon detention', PIL_EXTRA_FEE: 'PIL shipping line extra fee', CUSTOMS_CLEARANCE: 'Customs clearance service', + FUEL: 'Fuel surcharge', }; @Injectable() @@ -101,6 +103,15 @@ export class ContractRateScheduleBuilder { continue; } + // Fuel is sold per lane + commodity — only lanes matching the contract's + // direction belong on its schedule, labeled with their leg. + if (rate.trigger === 'FUEL') { + if (this.fuelDirectionMatches(rate, direction)) { + surcharges.push(this.fuelRow(rate)); + } + continue; + } + // Everything left is a trigger-based charge (surcharge / demurrage / customs). surcharges.push(this.surchargeRow(rate)); } @@ -176,6 +187,35 @@ export class ContractRateScheduleBuilder { }; } + private fuelDirectionMatches(rate: Rate, direction: ContractDirection): boolean { + const want = + direction === 'IMP' ? 'IMPORT' : direction === 'EXP' ? 'EXPORT' : 'DOMESTIC'; + return rate.tradeDirection === want; + } + + /** + * Fuel row — the lane matters, so it rides along in the charge label. + * Per-liter collapses to one flat total (base liters × rate value); the + * customer only ever sees the final price. + */ + private fuelRow(rate: Rate): RateScheduleRow { + const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—'; + const destination = + rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—'; + const perLiter = rate.rateUnit === 'PER_LITER'; + return { + route: `Fuel surcharge (${origin} → ${destination})`, + cargo: this.cargoLabel(rate), + currency: rate.currency, + amount: this.formatAmount( + perLiter + ? Number(rate.baseLiters ?? 0) * Number(rate.rateValue) + : rate.rateValue, + ), + unit: perLiter ? 'flat' : this.unitLabel(rate.rateUnit), + }; + } + private surchargeRow(rate: Rate): RateScheduleRow { return { route: TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger), 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 d1158d035..517934bb9 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 @@ -9,6 +9,8 @@ import { import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder'; import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder'; import { ContractTemplateResolver } from './contract-template.resolver'; +import { StampSettingsService } from '../modules/stamp-settings/stamp-settings.service'; +import { LogoSettingsService } from '../modules/logo-settings/logo-settings.service'; import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry'; export interface ContractSignatureView { @@ -16,6 +18,11 @@ export interface ContractSignatureView { signerDisplayName: string; signedAt: string; signatureImageUrl?: string | null; + /** + * Round company seal shown beside the signature. Populated for the EDR + * (STAFF) side only, from the single global stamp — see attachProviderStamp. + */ + stampImageUrl?: string | null; } /** @@ -105,6 +112,8 @@ export interface ContractViewModel { hasCustomerSignature: boolean; hasStaffSignature: boolean; dynamicTemplate?: ContractDynamicTemplateView; + /** Company logo for the cover-page header (LogoSettingsService); null renders the "EDR" mark. */ + logoImageUrl?: string | null; } @Injectable() @@ -114,6 +123,8 @@ export class ContractViewModelBuilder { private readonly templateResolver: ContractTemplateResolver, private readonly pricingBuilder: ContractPricingScheduleBuilder, private readonly rateScheduleBuilder: ContractRateScheduleBuilder, + private readonly stampSettings: StampSettingsService, + private readonly logoSettings: LogoSettingsService, ) {} async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> { @@ -131,6 +142,7 @@ export class ContractViewModelBuilder { template.freight, ); const signatures = await this.loadSignatures(bookingId); + const logoImageUrl = await this.logoSettings.getLogoImageUrl(); const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); const hasStaff = signatures.some((s) => s.role === 'STAFF'); @@ -187,6 +199,7 @@ export class ContractViewModelBuilder { hasContractDocument: hasContractFile, hasCustomerSignature: hasCustomer, hasStaffSignature: hasStaff, + logoImageUrl, }; return { booking, view }; @@ -194,7 +207,30 @@ export class ContractViewModelBuilder { private async loadSignatures(bookingId: string): Promise { const rows = await this.bookingsRepository.findContractSignatures(bookingId); - return rows.map((s) => this.toSignatureView(s)); + const views = rows.map((s) => this.toSignatureView(s)); + await this.attachProviderStamp(views); + return views; + } + + /** + * Stamp the EDR side of the contract with the ONE global company stamp + * (StampSettingsService) — staff never upload or pick a stamp, so nothing is + * stored per signature and the seal is read live at render time. The client + * side is left alone: a customer's own stamp is their business. + * + * Read live and deliberately not snapshotted, so replacing the company stamp + * re-seals contracts on their next render. `getStampImageUrl()` never throws + * and returns a data URL, which `signatures_block.hbs` renders as-is and the + * signature inliner skips. + */ + async attachProviderStamp(signatures: ContractSignatureView[]): Promise { + const staff = signatures.filter((s) => s.role === 'STAFF'); + if (staff.length === 0) return; + + const stampImageUrl = await this.stampSettings.getStampImageUrl(); + for (const sig of staff) { + sig.stampImageUrl = stampImageUrl; + } } toSignatureView(row: BookingContractSignature): ContractSignatureView { 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 d9bc9927f..09bf3031e 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs @@ -77,6 +77,12 @@ letter-spacing: 0.08em; width: 72px; } + .logo-mark img { + display: block; + max-height: 100%; + max-width: 100%; + object-fit: contain; + } .kicker { color: #0e5b45; font-family: Arial, sans-serif; diff --git a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs index 6ba7c1610..81631e5d2 100644 --- a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs +++ b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs @@ -11,7 +11,7 @@ {{!-- ─────────────────────────── Cover page ─────────────────────────── --}}
-
EDR
+
{{#if logoImageUrl}}Company logo{{else}}EDR{{/if}}

Ethio-Djibouti Standard Gauge Railway Share Company

Freight Transport Services

diff --git a/apps/edr-freight-api/src/contracts/templates/generic.hbs b/apps/edr-freight-api/src/contracts/templates/generic.hbs index 75f795bce..f576c99ce 100644 --- a/apps/edr-freight-api/src/contracts/templates/generic.hbs +++ b/apps/edr-freight-api/src/contracts/templates/generic.hbs @@ -9,7 +9,7 @@
-
EDR
+
{{#if logoImageUrl}}Company logo{{else}}EDR{{/if}}

Ethio-Djibouti Standard Gauge Railway Share Company

Freight Transport Contract

diff --git a/apps/edr-freight-api/src/contracts/templates/last-mile.hbs b/apps/edr-freight-api/src/contracts/templates/last-mile.hbs index 9437bb18b..0cfef51d5 100644 --- a/apps/edr-freight-api/src/contracts/templates/last-mile.hbs +++ b/apps/edr-freight-api/src/contracts/templates/last-mile.hbs @@ -9,6 +9,7 @@ main { padding: 32px 40px; } .brand-row { display: flex; align-items: center; gap: 14px; border-bottom: 3px solid #1a5632; padding-bottom: 14px; } .logo-mark { background: #1a5632; color: #fff; font-weight: 700; font-size: 18px; padding: 10px 14px; border-radius: 6px; } + .logo-mark img { display: block; max-height: 32px; max-width: 100px; object-fit: contain; } .kicker { margin: 0; font-weight: 700; } .muted { margin: 0; color: #666; } h1 { font-size: 20px; margin: 24px 0 4px; } @@ -32,7 +33,7 @@
-
EDR
+
{{#if logoImageUrl}}Company logo{{else}}EDR{{/if}}

Ethio-Djibouti Standard Gauge Railway Share Company

Last-Mile Delivery Contract

diff --git a/apps/edr-freight-api/src/logger.middleware.ts b/apps/edr-freight-api/src/logger.middleware.ts deleted file mode 100644 index dd7532ec8..000000000 --- a/apps/edr-freight-api/src/logger.middleware.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Injectable, NestMiddleware, Logger } from "@nestjs/common"; -import { Request, Response, NextFunction } from "express"; - -@Injectable() -export class LoggerMiddleware implements NestMiddleware { - private readonly logger = new Logger("HTTP"); - - use(req: Request, res: Response, next: NextFunction) { - const start = Date.now(); - - res.on("finish", () => { - const duration = Date.now() - start; - - this.logger.log( - `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`, - ); - }); - - next(); - } -} diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index c9a718f5d..98fed950e 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -10,17 +10,25 @@ import { ResponseTransformInterceptor, createValidationPipe, } from "@edr/api-common"; -import { getAuditLoggerConfig } from "@tria-plc/auditlog"; import { AppModule } from "./app.module"; /** - * JSON body ceiling. Signing posts the signature AND the company stamp as - * base64 in one JSON body, and base64 inflates bytes by ~4/3 — a 10MB stamp is - * ~13.4MB on the wire. Express defaults to 100kb, which rejected any real stamp - * image with a 413 "request entity too large". + * JSON body ceiling. Customer signing posts the signature AND the customer's + * own company stamp as base64 in one JSON body, and base64 inflates bytes by + * ~4/3 — a 50MB asset is ~67MB on the wire. Express defaults to 100kb, which + * rejected any real stamp image with a 413 "request entity too large". + * (Staff signing posts only a signature: EDR's seal is the one global stamp, + * read server-side. Uploading that stamp under Settings goes through this same + * ceiling, so the headroom is still needed on both counts.) + * + * Sized to clear the 50MB per-document ceiling + * (`DOCUMENT_UPLOAD_MAX_BYTES`) after base64 inflation, with room for the + * surrounding JSON. Note that the reverse proxy applies its own + * `client_max_body_size` and rejects oversized bodies before Nest sees them — + * raising this alone does not lift the limit end to end (see docs/uploads.md). */ -const JSON_BODY_LIMIT = "20mb"; +const JSON_BODY_LIMIT = "100mb"; /** * Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as @@ -161,11 +169,6 @@ export async function createFreightApp(): Promise { app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new ResponseTransformInterceptor()); - // Audit listener: consumes the RMQ events MezgebModule's client interceptor - // (app.module.ts) emits and persists them via the AuditLogController / - // AuditLogCommandController @EventPattern handlers. Same queue config the - // client side uses, reused from the package so the two never drift apart. - app.connectMicroservice(getAuditLoggerConfig()); await app.startAllMicroservices(); const config = new DocumentBuilder() diff --git a/apps/edr-freight-api/src/migrations/3380000000000-RaiseDocumentUploadSizeLimit.ts b/apps/edr-freight-api/src/migrations/3380000000000-RaiseDocumentUploadSizeLimit.ts new file mode 100644 index 000000000..99d949e2e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3380000000000-RaiseDocumentUploadSizeLimit.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Raises the per-field document ceiling from 10MB to 50MB. + * + * `max_size_mb` is what the portal enforces client-side (SmartFileInput blocks + * the file and shows "File size exceeds the limit of NMB"), so the seeded 10 + * was the visible limit for every existing form even after the server-side caps + * were lifted. The seeder only writes these rows on first insert, so deployed + * environments keep their old value until this runs. + * + * Only rows still sitting at the old default are touched — a field an admin has + * deliberately tuned to something else keeps that value. + */ +export class RaiseDocumentUploadSizeLimit3380000000000 + implements MigrationInterface +{ + name = "RaiseDocumentUploadSizeLimit3380000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.file_upload_fields + ALTER COLUMN max_size_mb SET DEFAULT 50 + `); + await queryRunner.query(` + UPDATE freight.file_upload_fields + SET max_size_mb = 50 + WHERE max_size_mb = 10 + `); + } + + /** + * Restores the column default only. The old per-row values are not + * recoverable (10 and an admin-chosen 10 are indistinguishable after `up`), + * and shrinking a customer's limit back down would reject documents they have + * already uploaded, so the rows are deliberately left at 50. + */ + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.file_upload_fields + ALTER COLUMN max_size_mb SET DEFAULT 10 + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3390000000000-CreateAuditLogs.ts b/apps/edr-freight-api/src/migrations/3390000000000-CreateAuditLogs.ts new file mode 100644 index 000000000..b5b865ad7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3390000000000-CreateAuditLogs.ts @@ -0,0 +1,81 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Backoffice audit trail for every state-changing freight endpoint. + * + * No `updated_at` / `deleted_at` columns, unlike every other table here: audit + * rows are insert-only evidence. A soft-delete column would let an actor erase + * their own trail and TypeORM would then hide those rows from default queries + * silently — see the entity comment. + * + * `user_id` intentionally carries NO foreign key to the `iam` schema. + * Cross-schema FKs are forbidden platform-wide, and one here would let user + * deletion cascade away the record of what that user did. + * + * DDL is idempotent (`IF NOT EXISTS`) because watch-mode API instances race + * `migrationsRun` against each other on the shared dev database. + */ +export class CreateAuditLogs3390000000000 implements MigrationInterface { + name = "CreateAuditLogs3390000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.audit_logs ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + title varchar(255) NOT NULL, + method varchar(10) NOT NULL, + url text NOT NULL, + route_path varchar(255), + type varchar(50) NOT NULL, + is_success boolean NOT NULL, + user_id uuid, + resource_id varchar(64), + request jsonb, + status_code smallint, + error_message text, + user_name varchar(150), + user_role varchar(100), + ip_address inet, + user_agent text, + request_id varchar(64), + duration_ms integer, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT "PK_audit_logs" PRIMARY KEY (id) + ) + `); + + // Every audit query is time-bounded, so created_at leads each index. + // DESC matches the "newest first" read path the controller exposes. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_audit_logs_created_at" + ON freight.audit_logs (created_at DESC) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_audit_logs_user_id_created_at" + ON freight.audit_logs (user_id, created_at DESC) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_audit_logs_type_created_at" + ON freight.audit_logs (type, created_at DESC) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_audit_logs_type_resource_id" + ON freight.audit_logs (type, resource_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_audit_logs_route_path_created_at" + ON freight.audit_logs (route_path, created_at DESC) + `); + // Failures are a small slice of the table but carry the security signal + // (403s especially), so they get their own partial index. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_audit_logs_failures" + ON freight.audit_logs (created_at DESC) + WHERE is_success = false + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.audit_logs`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3390000000000-RemoveGeneralManager.ts b/apps/edr-freight-api/src/migrations/3390000000000-RemoveGeneralManager.ts new file mode 100644 index 000000000..055c3771b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3390000000000-RemoveGeneralManager.ts @@ -0,0 +1,210 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Onboarding revamp: one company, one verified identity. + * + * The general manager is removed outright — it named who to talk to and gated + * nothing — and the company's people become its **owner** (whoever the eTrade + * licence names as the business's manager) and its **Power of Attorney**. + * Exactly one of them is identity-verified, chosen by the company's own answer + * to "does anyone hold power of attorney for you?", stored as + * `attributes.poaDeclared`. + * + * The order below matters — each step depends on data a later step destroys: + * + * 1. Rescue notification addresses. `companyNotifyEmailExpr` used to fall + * through to `attributes->>'generalManagerEmail'`, and `companies.email` was + * only ever written for a Fayda-verified owner — so every foreign company + * had none and was reached solely through that fallback. Promote it to the + * column before the key is stripped, or those companies stop receiving mail + * in silence. + * 2. Backfill the owner. `ownerName`/`ownerEmail`/`ownerPhone` are now required + * onboarding fields; without this every already-onboarded company would + * report three missing fields the moment it opened its settings page. + * 3. Resolve `poaSameAsOwner`. That flag waived the DARS delegation paper. It + * is gone, so the companies holding it must be re-expressed: + * - NOT a freight forwarder → "no PoA" (the owner represents themselves, + * nothing to delegate). Their PoA details are cleared. + * - A freight forwarder → "yes" and details KEPT. A forwarder signs on + * other companies' behalf, so a representative is non-negotiable and the + * waiver no longer exists. These companies will be asked for a + * delegation paper they were previously excused — an intentional, + * visible consequence, not an oversight. Count them before deploying. + * 4. Derive the declaration for everyone else, from whether PoA details exist. + * 5. Move drafts off the deleted "personnel" wizard step. + * 6. Only now drop the columns and strip the retired attribute keys. + * + * Irreversible by design: `down()` restores the columns' shape but cannot + * recover the values, and re-deriving `poaSameAsOwner` from `poaDeclared` would + * be a guess. + */ +export class RemoveGeneralManager3390000000000 implements MigrationInterface { + name = "RemoveGeneralManager3390000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // 1. Rescue the notification address before the key it lives under is gone. + await queryRunner.query(` + UPDATE freight.companies + SET email = COALESCE( + NULLIF(email, ''), + NULLIF(attributes->>'ownerEmail', ''), + NULLIF(attributes->>'generalManagerEmail', ''), + NULLIF(attributes->>'contactPersonEmail', '') + ) + WHERE COALESCE(email, '') = '' + `); + + // 2. Backfill the owner from the best source each company actually has: + // its Fayda-verified owner claims (already under owner*), then the general + // manager it named, then its contact person. A company with none of these + // never finished onboarding, and will be asked on its next visit. + await queryRunner.query(` + UPDATE freight.companies + SET attributes = attributes + || jsonb_strip_nulls(jsonb_build_object( + 'ownerName', COALESCE( + NULLIF(attributes->>'ownerName', ''), + NULLIF(attributes->>'generalManagerName', ''), + NULLIF(attributes->>'contactPersonName', '') + ), + 'ownerEmail', COALESCE( + NULLIF(attributes->>'ownerEmail', ''), + NULLIF(attributes->>'generalManagerEmail', ''), + NULLIF(attributes->>'contactPersonEmail', ''), + NULLIF(email, '') + ), + 'ownerPhone', COALESCE( + NULLIF(attributes->>'ownerPhone', ''), + NULLIF(attributes->>'generalManagerPhone', ''), + NULLIF(attributes->>'contactPersonPhone', ''), + NULLIF(phone, '') + ) + )) + WHERE attributes IS NOT NULL + `); + + // 2b. Capture eTrade's manager for the owner-vs-licence check the + // backoffice now makes. Nothing stored it before, so the best we have is + // the owner name itself — which makes existing companies read as "matches" + // rather than as a false mismatch on data nobody ever compared. The value + // is refreshed for real on the company's next eTrade lookup. + await queryRunner.query(` + UPDATE freight.companies + SET attributes = jsonb_set( + attributes, '{etradeManagerName}', to_jsonb(attributes->>'ownerName') + ) + WHERE COALESCE(attributes->>'ownerName', '') <> '' + AND attributes->>'etradeManagerName' IS NULL + AND COALESCE(licence_number, '') <> '' + `); + + // 3a. Owner-represents-themselves, and NOT a forwarder → "no PoA". + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = (c.attributes - 'poaName' - 'poaPhone' - 'poaEmail' + - 'poaLocation' - 'poaAddress' - 'poaFaydaSub' + - 'poaFaydaVerifiedAt' - 'poaBirthdate' - 'poaGender') + || jsonb_build_object('poaDeclared', 'no') + WHERE (c.attributes->>'poaSameAsOwner')::boolean IS TRUE + AND NOT EXISTS ( + SELECT 1 FROM freight.company_profiles cp + WHERE cp.company_id = c.id + AND cp.type = 'freight_forwarder' + AND cp.deleted_at IS NULL + ) + `); + + // 3b. Forwarders keep their representative and lose the waiver. + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = c.attributes || jsonb_build_object('poaDeclared', 'yes') + WHERE (c.attributes->>'poaSameAsOwner')::boolean IS TRUE + AND EXISTS ( + SELECT 1 FROM freight.company_profiles cp + WHERE cp.company_id = c.id + AND cp.type = 'freight_forwarder' + AND cp.deleted_at IS NULL + ) + `); + + // 4. Everyone else: "yes" if a representative was named or the company is a + // forwarder, "no" if it finished onboarding without one. A company still + // mid-onboarding is left unanswered — it will be asked, which is the point. + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = COALESCE(c.attributes, '{}'::jsonb) + || jsonb_build_object('poaDeclared', 'yes') + WHERE c.attributes->>'poaDeclared' IS NULL + AND ( + COALESCE(c.attributes->>'poaName', '') <> '' + OR COALESCE(c.attributes->>'poaPhone', '') <> '' + OR COALESCE(c.attributes->>'poaEmail', '') <> '' + OR COALESCE(c.attributes->>'poaLocation', '') <> '' + OR COALESCE(c.attributes->>'poaAddress', '') <> '' + OR EXISTS ( + SELECT 1 FROM freight.company_profiles cp + WHERE cp.company_id = c.id + AND cp.type = 'freight_forwarder' + AND cp.deleted_at IS NULL + ) + ) + `); + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = COALESCE(c.attributes, '{}'::jsonb) + || jsonb_build_object('poaDeclared', 'no') + WHERE c.attributes->>'poaDeclared' IS NULL + AND EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = c.id + AND ep.onboarding_completed = true + AND ep.deleted_at IS NULL + ) + `); + + // 5. The "personnel" (general manager) wizard step no longer exists; a + // draft resting on it would fall back to the very first step and make the + // customer walk the whole wizard again. + await queryRunner.query(` + UPDATE freight.external_profiles + SET onboarding_step = 'owner' + WHERE onboarding_step = 'personnel' + `); + + // 6. Retire the general manager and the flag it shared the model with. + await queryRunner.query(` + UPDATE freight.companies + SET attributes = attributes - 'generalManagerName' - 'generalManagerEmail' + - 'generalManagerPhone' - 'gmSameAsOwner' - 'gmFaydaSub' + - 'gmFaydaVerifiedAt' - 'gmName' - 'gmEmail' - 'gmPhone' + - 'gmAddress' - 'gmBirthdate' - 'gmGender' + - 'poaSameAsOwner' + WHERE attributes IS NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS general_manager_name, + DROP COLUMN IF EXISTS general_manager_email, + DROP COLUMN IF EXISTS general_manager_phone + `); + } + + /** + * Restores the columns' shape only. The values, the `gm*` attributes and the + * `poaSameAsOwner` flag are not recoverable — this migration folded them into + * `ownerEmail` / `poaDeclared`, and there is no way back that isn't a guess. + */ + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS general_manager_name varchar(100), + ADD COLUMN IF NOT EXISTS general_manager_email varchar(150), + ADD COLUMN IF NOT EXISTS general_manager_phone varchar(20) + `); + await queryRunner.query(` + UPDATE freight.external_profiles + SET onboarding_step = 'personnel' + WHERE onboarding_step = 'owner' + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3400000000000-StampSettings.ts b/apps/edr-freight-api/src/migrations/3400000000000-StampSettings.ts new file mode 100644 index 000000000..9556318f9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3400000000000-StampSettings.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Single-row table holding the one company stamp/seal image stamped onto + * generated invoice/receipt PDFs (see StampSettingsService / + * InvoiceDocumentService). Same single-row shape as exchange_settings; the + * app never inserts more than one row. + */ +export class StampSettings3400000000000 implements MigrationInterface { + name = "StampSettings3400000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.stamp_settings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + stamp_file_id uuid REFERENCES freight.files(id), + updated_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.stamp_settings;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3400000000000-TransferRequestPreferredWagons.ts b/apps/edr-freight-api/src/migrations/3400000000000-TransferRequestPreferredWagons.ts new file mode 100644 index 000000000..b86327147 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3400000000000-TransferRequestPreferredWagons.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Wagons the requester specifically asked for. A transfer request stays + * count-driven (`quantity` is what must be delivered), but a requester who + * picked wagons off the yard desk now records WHICH ones — OCC sees the numbers + * on the queue and the fulfil picker pre-selects them. + * + * Stored as a uuid[] column rather than a join table: the list is read and + * written whole, never queried by wagon, and a preference carries no lifecycle + * of its own (no FK — a purged wagon simply drops out of the display). + */ +export class TransferRequestPreferredWagons3400000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_transfer_requests + ADD COLUMN IF NOT EXISTS preferred_wagon_ids uuid[] + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_transfer_requests + DROP COLUMN IF EXISTS preferred_wagon_ids + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3410000000000-ScheduleVoyageNumber.ts b/apps/edr-freight-api/src/migrations/3410000000000-ScheduleVoyageNumber.ts new file mode 100644 index 000000000..dd62276d8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3410000000000-ScheduleVoyageNumber.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Voyage number for one departure. + * + * `train_number` already exists on a schedule (the run number), but operations + * also quote a VOYAGE number — the sailing/run identifier yards and customs use + * for a specific departure. It belongs on the schedule, not the built train: one + * train serves many departures and each carries its own voyage. + * + * Nullable and un-indexed: it is display/reference data typed by staff, not a + * lookup key, and older schedules simply have none. + */ +export class ScheduleVoyageNumber3410000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS voyage_number varchar(20) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS voyage_number + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3420000000000-BulkTemplateTradeDirection.ts b/apps/edr-freight-api/src/migrations/3420000000000-BulkTemplateTradeDirection.ts new file mode 100644 index 000000000..e76591205 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3420000000000-BulkTemplateTradeDirection.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Bulk contract templates gain trade direction, so the unique key becomes + * (cargo type, direction, customs option) instead of (cargo type, customs). + * + * Intercity is domestic and crosses no border, so it has no customs variant at + * all: with_customs stays NULL there, enforced by ck_bulk_intercity_no_customs. + * The unique index coalesces that NULL so two intercity templates for the same + * cargo type still collide (plain NULLs never do). + * + * No backfill: staff-created bulk templates are keyed by cargo_type_id and no + * such row exists yet — the seeded direction-keyed bulk rows were retired by + * 3320000000000 and carry a NULL cargo_type_id. The five system container + * templates are untouched: cargo_type_id IS NULL keeps them out of both the + * index and the check. + */ +export class BulkTemplateTradeDirection3420000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD COLUMN IF NOT EXISTS trade_direction varchar(20) + `); + + await queryRunner.query( + `DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_customs`, + ); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs + ON freight.contract_templates + (cargo_type_id, trade_direction, COALESCE(with_customs, false)) + WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL + `); + + await queryRunner.query(` + ALTER TABLE freight.contract_templates + DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs + `); + await queryRunner.query(` + ALTER TABLE freight.contract_templates + ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK ( + cargo_type_id IS NULL + OR ( + trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY') + AND (trade_direction = 'INTERCITY') = (with_customs IS NULL) + ) + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contract_templates + DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs + `); + await queryRunner.query( + `DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_customs + ON freight.contract_templates (cargo_type_id, with_customs) + WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.contract_templates DROP COLUMN IF EXISTS trade_direction + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3430000000000-FuelSurcharge.ts b/apps/edr-freight-api/src/migrations/3430000000000-FuelSurcharge.ts new file mode 100644 index 000000000..dfd0d25cf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3430000000000-FuelSurcharge.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Fuel surcharge, sold per lane + commodity: + * + * - cargo_types.has_fuel marks the commodities that incur it (same shape as + * has_lashing — the booking's cargo type flag is what fires the charge). + * - rates.base_liters carries the liters a PER_LITER fuel rate bills + * (price = base_liters × rate_value, once per booking). NULL on every other + * rate shape, including PER_WAGON fuel rates (wagons × rate_value). + * - CK_rates_yard_scope gains FUEL in its yard-carrying branch: fuel is priced + * per origin → destination leg like customs clearance and container return. + */ +export class FuelSurcharge3430000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS has_fuel boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + ALTER TABLE freight.rates + ADD COLUMN IF NOT EXISTS base_liters numeric(14,4) + `); + + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR + CASE + WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR + CASE + WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR trigger IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ) + `); + await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS base_liters`); + await queryRunner.query( + `ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS has_fuel`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3440000000000-ShippingLineCompany.ts b/apps/edr-freight-api/src/migrations/3440000000000-ShippingLineCompany.ts new file mode 100644 index 000000000..91e78ab14 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3440000000000-ShippingLineCompany.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Shipping lines — carriers registered by backoffice staff who sign in to the + * portal directly. + * + * Separate from `freight.companies` on purpose: a shipping line has no TIN, + * business licence, eTrade record, operational profile or onboarding state, so + * it shares none of the customer columns. `user_id` sits on the company row + * itself because the company IS the account — there is no contact-person row. + * + * No FK on `user_id`: `iam.users` belongs to the IAM service's schema, which + * this API reads but never owns. + */ +export class ShippingLineCompany3440000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE freight.shipping_line_companies_status_enum + AS ENUM ('active', 'suspended'); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$ + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.shipping_line_companies ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL, + name varchar(200) NOT NULL, + scac_code varchar(4), + imo_number varchar(20), + bic_code varchar(20), + email varchar(150) NOT NULL, + phone_number varchar(30), + status freight.shipping_line_companies_status_enum + NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + // One login per shipping line. Partial so a soft-deleted row frees its + // account for re-registration rather than blocking it forever. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_user" + ON freight.shipping_line_companies (user_id) + WHERE deleted_at IS NULL + `); + + // SCAC identifies the carrier globally — two live lines cannot share one. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_scac" + ON freight.shipping_line_companies (scac_code) + WHERE scac_code IS NOT NULL AND deleted_at IS NULL + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_shipping_line_companies_email" + ON freight.shipping_line_companies (lower(email)) + WHERE deleted_at IS NULL + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_shipping_line_companies_status" + ON freight.shipping_line_companies (status) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.shipping_line_companies`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS freight.shipping_line_companies_status_enum`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3450000000000-BookingShippingLine.ts b/apps/edr-freight-api/src/migrations/3450000000000-BookingShippingLine.ts new file mode 100644 index 000000000..d53914ef3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3450000000000-BookingShippingLine.ts @@ -0,0 +1,117 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Shipping lines book rail capacity directly, without a contract. + * + * A booking has always been owned by `company_id` (a customer `companies` row), + * but a shipping line is a `shipping_line_companies` row and deliberately NOT a + * company — it carries no TIN, licence or operational profiles. So it gets its + * own nullable owner column rather than a synthetic company row. + * + * Exactly one of the two is set: `company_id` for a customer booking, + * `shipping_line_company_id` for a shipping-line one. Existing rows keep + * `company_id` and a NULL `shipping_line_company_id`, so nothing needs + * backfilling and every customer query filtering on `company_id` behaves + * exactly as before. Government bookings already bill to a seeded government + * company, so they satisfy the CHECK unchanged. + * + * NOTE: not to be confused with the existing `bookings.shipping_line_id`, which + * is cargo metadata naming the carrier line that moves the goods + * (`freight.shipping_lines`, reference data). This column points at + * `freight.shipping_line_companies` — the portal account — and is unrelated. + */ +export class BookingShippingLine3450000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_shipping_line_company_id + ON freight.bookings (shipping_line_company_id) + `); + + // `company_id` / `company_profile_id` are NOT NULL and point at the customer + // tables, so a shipping-line booking could not be inserted at all. Relax + // them to nullable; their foreign keys are left in place and keep validating + // every non-NULL value, so a customer booking is constrained exactly as + // before. The CHECK below is what now guarantees an owner is present. + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN company_id DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN company_profile_id DROP NOT NULL + `); + + // Route and service are inherited from the contract on a customer booking. + // A shipping line initiates before any of that is known — the bare booking + // exists only to hang documents off — so these are relaxed too and filled + // in when the booking is completed. Existing rows all have values, and the + // customer paths still always set them. + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN origin_yard_id DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN destination_yard_id DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN service_type_id DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN freight_type DROP NOT NULL + `); + + // No FK: kept consistent with how the column is populated at the service + // layer, and avoids a lock on shipping_line_companies during deploy. + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS chk_bookings_single_owner + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD CONSTRAINT chk_bookings_single_owner + CHECK ( + (company_id IS NOT NULL AND shipping_line_company_id IS NULL) + OR (company_id IS NULL AND shipping_line_company_id IS NOT NULL) + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS chk_bookings_single_owner + `); + + // Only reinstate NOT NULL if no shipping-line booking exists; those rows + // have a NULL company_id by design and would make the ALTER fail. Leaving + // the columns nullable is the safe outcome — the constraint is additive. + const [{ count }] = (await queryRunner.query(` + SELECT COUNT(*)::int AS count FROM freight.bookings + WHERE shipping_line_company_id IS NOT NULL + `)) as Array<{ count: number }>; + + if (count === 0) { + for (const column of [ + "company_id", + "company_profile_id", + "origin_yard_id", + "destination_yard_id", + "service_type_id", + "freight_type", + ]) { + await queryRunner.query(` + ALTER TABLE freight.bookings ALTER COLUMN ${column} SET NOT NULL + `); + } + } + + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_bookings_shipping_line_company_id + `); + await queryRunner.query(` + ALTER TABLE freight.bookings DROP COLUMN IF EXISTS shipping_line_company_id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts b/apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts new file mode 100644 index 000000000..ec3faf9eb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3450000000000-WidenEimsIrn.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * `eims_irn varchar(64)` was sized for a guess; the real value MoR returns is longer. Confirmed + * live 2026-08-12 — a genuine `POST /v1/register` acceptance came back as + * `test-aeedcbf496f0b63bb035ba3ca1dc674e3c81c980cafeb1c5038421999a23a215` (69 chars: a `test-` + * prefix + a 64-hex-char body), which overflowed the column and threw *after* MoR had already + * accepted the document — `settleSuccess` never committed, leaving the invoice stuck `SUBMITTING` + * and the system-wide reservation stuck in-flight with no block/alert (see + * `EimsInvoiceRegistrationService` for the accompanying code fix). + * + * Widened to `text` rather than a new fixed length: MoR has never documented an IRN format or + * length, and a `test-` prefix on a *production* endpoint suggests this may not even be MoR's + * real production shape — guessing another fixed bound risks the exact same failure again. + */ +export class WidenEimsIrn3450000000000 implements MigrationInterface { + name = "WidenEimsIrn3450000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ALTER COLUMN eims_irn TYPE text + `); + } + + /** Only safe if nothing stored so far exceeds 64 chars — true only until this migration ran. */ + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ALTER COLUMN eims_irn TYPE varchar(64) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts b/apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts new file mode 100644 index 000000000..dedf2d05f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3460000000000-AddEimsSignedQr.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** `DocumentDetails`/register-response field `signedQR`, persisted alongside `eims_irn`. */ +export class AddEimsSignedQr3460000000000 implements MigrationInterface { + name = "AddEimsSignedQr3460000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_signed_qr text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_signed_qr + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3460000000000-ShippingLineCredits.ts b/apps/edr-freight-api/src/migrations/3460000000000-ShippingLineCredits.ts new file mode 100644 index 000000000..5a59d9d80 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3460000000000-ShippingLineCredits.ts @@ -0,0 +1,201 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Shipping lines consume services before paying for them. + * + * A shipping line books rail capacity and the booking proceeds with no payment + * gate at all — unlike a customer booking, which cannot advance until its + * PREPAID invoice settles. What the line owes is instead recorded here as a + * credit: one row per booking, priced once and never recalculated. Finance + * later selects a batch of unbilled credits, generates a single invoice for + * them, and the line pays that invoice through the normal CBE flow. When the + * invoice settles, its credits are marked paid and stop counting as debt. + * + * This is deliberately NOT a wallet or a stored balance. There is no money in + * the system to draw down: a credit is a debt the line already incurred, so + * the outstanding figure is always derived (`SUM(amount) WHERE status <> + * 'PAID'`) rather than kept in a column that UPDATEs can drift out of sync. + * + * `invoices.company_id` / `company_profile_id` are relaxed to nullable for the + * same reason `bookings` was in {@link BookingShippingLine3450000000000}: a + * shipping line is not a `companies` row and never will be, so an invoice + * billed to one has no customer to point at. Both FKs stay in place and keep + * validating every non-NULL value, so a customer invoice is constrained + * exactly as before; the CHECK below is what now guarantees a payer exists. + */ +export class ShippingLineCredits3460000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // ── Invoices: allow a shipping-line payer ──────────────────────────────── + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_invoices_shipping_line_company_id + ON freight.invoices (shipping_line_company_id) + `); + + await queryRunner.query(` + ALTER TABLE freight.invoices ALTER COLUMN company_id DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.invoices ALTER COLUMN company_profile_id DROP NOT NULL + `); + + // Exactly one payer. Mirrors chk_bookings_single_owner so the two tables + // answer "who owes this?" the same way. Existing rows all have company_id + // and a NULL shipping_line_company_id, so nothing needs backfilling. + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP CONSTRAINT IF EXISTS chk_invoices_single_payer + `); + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD CONSTRAINT chk_invoices_single_payer + CHECK ( + (company_id IS NOT NULL AND shipping_line_company_id IS NULL) + OR (company_id IS NULL AND shipping_line_company_id IS NOT NULL) + ) + `); + + // ── The credit ledger ──────────────────────────────────────────────────── + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE freight.shipping_line_credits_status_enum AS ENUM ( + 'UNBILLED', 'BILLED', 'PAID', 'CANCELLED' + ); + EXCEPTION WHEN duplicate_object THEN NULL; END $$ + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.shipping_line_credits ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + shipping_line_company_id uuid NOT NULL, + booking_id uuid NOT NULL, + amount numeric(14,2) NOT NULL, + currency character varying(8) DEFAULT 'ETB'::character varying NOT NULL, + status freight.shipping_line_credits_status_enum + DEFAULT 'UNBILLED'::freight.shipping_line_credits_status_enum NOT NULL, + description character varying(255), + invoice_id uuid, + billed_at timestamp with time zone, + paid_at timestamp with time zone, + cancelled_at timestamp with time zone, + cancellation_reason character varying(255), + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + deleted_at timestamp with time zone, + CONSTRAINT pk_shipping_line_credits PRIMARY KEY (id), + CONSTRAINT chk_shipping_line_credits_amount CHECK (amount >= 0), + -- The state machine, enforced in the DB rather than trusted to the + -- service: an UNBILLED credit has no invoice, and anything past + -- UNBILLED must name the invoice it was billed on. Without this a + -- half-applied batch could leave BILLED rows with a NULL invoice_id + -- and silently vanish from both the unbilled list and the invoice. + CONSTRAINT chk_shipping_line_credits_invoice_link CHECK ( + (status = 'UNBILLED' AND invoice_id IS NULL) + OR (status IN ('BILLED', 'PAID') AND invoice_id IS NOT NULL) + OR status = 'CANCELLED' + ) + ) + `); + + await queryRunner.query(` + ALTER TABLE freight.shipping_line_credits + DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_shipping_line + `); + await queryRunner.query(` + ALTER TABLE freight.shipping_line_credits + ADD CONSTRAINT fk_shipping_line_credits_shipping_line + FOREIGN KEY (shipping_line_company_id) + REFERENCES freight.shipping_line_companies(id) ON DELETE RESTRICT + `); + + await queryRunner.query(` + ALTER TABLE freight.shipping_line_credits + DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_booking + `); + await queryRunner.query(` + ALTER TABLE freight.shipping_line_credits + ADD CONSTRAINT fk_shipping_line_credits_booking + FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id) ON DELETE RESTRICT + `); + + // SET NULL rather than CASCADE: deleting an invoice must never delete the + // record of what was owed. The row would then violate the link CHECK, so a + // credit whose invoice is removed has to be walked back to UNBILLED + // explicitly — which is the correct, visible outcome. + await queryRunner.query(` + ALTER TABLE freight.shipping_line_credits + DROP CONSTRAINT IF EXISTS fk_shipping_line_credits_invoice + `); + await queryRunner.query(` + ALTER TABLE freight.shipping_line_credits + ADD CONSTRAINT fk_shipping_line_credits_invoice + FOREIGN KEY (invoice_id) + REFERENCES freight.invoices(id) ON DELETE SET NULL + `); + + // One live credit per booking. Partial so a soft-deleted or cancelled row + // does not block re-pricing a booking that was voided and rebooked. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_shipping_line_credits_booking + ON freight.shipping_line_credits (booking_id) + WHERE deleted_at IS NULL AND status <> 'CANCELLED' + `); + + // Drives the two hot reads: finance's unbilled worklist per line, and the + // outstanding total on the shipping-line detail page. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_shipping_line_credits_line_status + ON freight.shipping_line_credits (shipping_line_company_id, status) + WHERE deleted_at IS NULL + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_shipping_line_credits_invoice_id + ON freight.shipping_line_credits (invoice_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP TABLE IF EXISTS freight.shipping_line_credits + `); + await queryRunner.query(` + DROP TYPE IF EXISTS freight.shipping_line_credits_status_enum + `); + + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP CONSTRAINT IF EXISTS chk_invoices_single_payer + `); + + // Only reinstate NOT NULL if no shipping-line invoice exists; those rows + // have a NULL company_id by design and would make the ALTER fail. Leaving + // the columns nullable is the safe outcome — the constraint is additive. + const [{ count }] = (await queryRunner.query(` + SELECT COUNT(*)::int AS count FROM freight.invoices + WHERE shipping_line_company_id IS NOT NULL + `)) as Array<{ count: number }>; + + if (count === 0) { + await queryRunner.query(` + ALTER TABLE freight.invoices ALTER COLUMN company_id SET NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.invoices ALTER COLUMN company_profile_id SET NOT NULL + `); + } + + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_invoices_shipping_line_company_id + `); + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS shipping_line_company_id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts b/apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts new file mode 100644 index 000000000..7092e5518 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3470000000000-EimsCancellation.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** Columns for `POST /v1/cancel` — see `EimsCancellationService`. */ +export class EimsCancellation3470000000000 implements MigrationInterface { + name = "EimsCancellation3470000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_cancelled_at timestamptz, + ADD COLUMN IF NOT EXISTS eims_cancellation_date varchar(64), + ADD COLUMN IF NOT EXISTS eims_cancellation_reason_code varchar(8), + ADD COLUMN IF NOT EXISTS eims_cancellation_remark text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_cancelled_at, + DROP COLUMN IF EXISTS eims_cancellation_date, + DROP COLUMN IF EXISTS eims_cancellation_reason_code, + DROP COLUMN IF EXISTS eims_cancellation_remark + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3470000000000-ShippingLineRates.ts b/apps/edr-freight-api/src/migrations/3470000000000-ShippingLineRates.ts new file mode 100644 index 000000000..05e0bb799 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3470000000000-ShippingLineRates.ts @@ -0,0 +1,127 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-shipping-line rates. + * + * A shipping line books rail capacity directly (see BookingShippingLine3450000000000) + * and negotiates its own prices, so the rate table gains an owner column: + * `shipping_line_company_id` NULL = the standard rate every customer pays, + * NOT NULL = a rate that only that line's bookings resolve. + * + * Points at `freight.shipping_line_companies` (the portal account that owns the + * booking), NOT `freight.shipping_lines` — the latter is carrier reference data + * naming who physically moves the goods, and the existing SHIPPING_LINE trigger + * already keys off it. Both stay independent. + * + * Line rates OVERRIDE rather than stack: a booking owned by a line prices off + * that line's rate for the lane, and is hard-blocked when none exists (the + * standard rate is deliberately not a fallback — see RuleEngineService). + * + * Every existing row keeps a NULL owner, so nothing needs backfilling and the + * standard-rate lookups behave exactly as before. + */ +export class ShippingLineRates3470000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.rates + ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid + `); + + await queryRunner.query(` + ALTER TABLE freight.rates + DROP CONSTRAINT IF EXISTS "FK_rates_shipping_line_company" + `); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "FK_rates_shipping_line_company" + FOREIGN KEY (shipping_line_company_id) + REFERENCES freight.shipping_line_companies (id) + ON DELETE RESTRICT + `); + + // Rate resolution always filters by owner, so the lookups this column + // participates in are (owner, lane) — indexed together with rate_type, + // which every lookup also pins. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_rates_shipping_line_company_id + ON freight.rates (shipping_line_company_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_rates_shipping_line_lane + ON freight.rates (shipping_line_company_id, rate_type, origin_yard_id, destination_yard_id) + WHERE shipping_line_company_id IS NOT NULL + `); + + // A shipping line sells import freight only — the export leg is contracted + // through the customer, not the carrier. Enforced here so a line rate can + // never be filed against an export lane regardless of which API path wrote + // it. Surcharges carry no direction and are unaffected. + await queryRunner.query(` + ALTER TABLE freight.rates + DROP CONSTRAINT IF EXISTS "CK_rates_shipping_line_import_only" + `); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_shipping_line_import_only" CHECK ( + deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR + shipping_line_company_id IS NULL OR + trade_direction IS NULL OR trade_direction = 'IMPORT' + ) + `); + + // The owner joins the rate's identity. Without it MSC's 20ft Djibouti→Modjo + // rate collides with the standard rate for the same lane — same rate_type, + // same scope, same unit — and the insert fails on UQ_rates_pattern. NULL + // (the standard rate) collapses to the zero uuid like every other nullable + // scope column, so existing rows keep their current uniqueness exactly. + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree ( + rate_type, + COALESCE(shipping_line_company_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''::character varying), + COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + rate_unit, + COALESCE(min_km, '-1'::numeric) + ) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text)) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Restore the pre-owner pattern index (as left by LastMileRateBands). + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern"`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" ON freight.rates USING btree ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''::character varying), + COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + rate_unit, + COALESCE(min_km, '-1'::numeric) + ) WHERE ((deleted_at IS NULL) AND ((status)::text <> 'SUPERSEDED'::text)) + `); + + await queryRunner.query(` + ALTER TABLE freight.rates + DROP CONSTRAINT IF EXISTS "CK_rates_shipping_line_import_only" + `); + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_rates_shipping_line_lane`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_rates_shipping_line_company_id`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + DROP CONSTRAINT IF EXISTS "FK_rates_shipping_line_company" + `); + await queryRunner.query(` + ALTER TABLE freight.rates DROP COLUMN IF EXISTS shipping_line_company_id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts b/apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts new file mode 100644 index 000000000..15a343f06 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3480000000000-WidenPreviousIrn.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Same bug as `3450000000000-WidenEimsIrn`, same fix: `previous_irn` stores a real MoR IRN too + * (fed into the next registration's `ReferenceDetails.PreviousIrn`) and would overflow the same + * varchar(64) on the next successful registration. + */ +export class WidenPreviousIrn3480000000000 implements MigrationInterface { + name = "WidenPreviousIrn3480000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + ALTER COLUMN previous_irn TYPE text + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.eims_system_state + ALTER COLUMN previous_irn TYPE varchar(64) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts b/apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts new file mode 100644 index 000000000..3d5ab02e9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3490000000000-EimsReceipts.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** `freight.eims_receipts` — see `EimsReceipt` entity. */ +export class EimsReceipts3490000000000 implements MigrationInterface { + name = "EimsReceipts3490000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.eims_receipts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + invoice_id uuid NOT NULL REFERENCES freight.invoices(id), + kind varchar(16) NOT NULL, + status varchar(20) NOT NULL DEFAULT 'NOT_SUBMITTED', + receipt_number varchar(64) NOT NULL, + rrn text, + qr text, + ack_status varchar(8), + submitted_at timestamptz, + last_error jsonb, + request jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_eims_receipts_invoice_id ON freight.eims_receipts (invoice_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.eims_receipts`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3500000000000-LogoSettings.ts b/apps/edr-freight-api/src/migrations/3500000000000-LogoSettings.ts new file mode 100644 index 000000000..36d70906f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3500000000000-LogoSettings.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Single-row table holding the one company logo image stamped onto every + * generated document (see LogoSettingsService). Same single-row shape as + * stamp_settings; the app never inserts more than one row. + */ +export class LogoSettings3500000000000 implements MigrationInterface { + name = "LogoSettings3500000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.logo_settings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + logo_file_id uuid REFERENCES freight.files(id), + updated_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.logo_settings;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3510000000000-TrainScheduleShippingLine.ts b/apps/edr-freight-api/src/migrations/3510000000000-TrainScheduleShippingLine.ts new file mode 100644 index 000000000..e1713db94 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3510000000000-TrainScheduleShippingLine.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * A train schedule can be dedicated to one shipping line. + * + * NULL = a normal train, visible and bookable to customers as before. Set = + * the departure exists for that shipping line alone: it is excluded from every + * customer-facing read (booking windows, day pools, portal home cards) and + * surfaces only in the assigned line's portal (home page + booking detail). + */ +export class TrainScheduleShippingLine3510000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS shipping_line_company_id uuid + REFERENCES freight.shipping_line_companies (id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_schedules_shipping_line_company_id + ON freight.train_schedules (shipping_line_company_id) + WHERE shipping_line_company_id IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_train_schedules_shipping_line_company_id + `); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS shipping_line_company_id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3520000000000-DefaultDeskHours24h.ts b/apps/edr-freight-api/src/migrations/3520000000000-DefaultDeskHours24h.ts new file mode 100644 index 000000000..8cd71eff9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3520000000000-DefaultDeskHours24h.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Default the daily booking desk to 24 hours: window_close_hour equal to + * window_open_hour means the desk never pauses overnight. Aligns the column + * default and the existing global-rules row; per-schedule overrides keep + * whatever staff set on them. + */ +export class DefaultDeskHours24h3520000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ALTER COLUMN window_close_hour SET DEFAULT 8 + `); + await queryRunner.query(` + UPDATE freight.train_scheduling_global_rules + SET window_close_hour = window_open_hour + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ALTER COLUMN window_close_hour SET DEFAULT 17 + `); + await queryRunner.query(` + UPDATE freight.train_scheduling_global_rules + SET window_close_hour = 17 + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3530000000000-ShippingLineInvoiceApprovals.ts b/apps/edr-freight-api/src/migrations/3530000000000-ShippingLineInvoiceApprovals.ts new file mode 100644 index 000000000..4183be8ff --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3530000000000-ShippingLineInvoiceApprovals.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Maker–checker for manual actions on shipping-line credit invoices. + * + * A shipping-line credit invoice is normally settled by the CBE webhook. Two + * manual paths exist for finance: recording an offline payment (MARK_PAID) + * and voiding an invoice raised in error (CANCEL, which releases its credits + * back to the unbilled pool). Both erase or move real debt, so neither is a + * single-person action: one permission raises the request, a different + * permission — held by a chief, and never the requester themselves — approves + * or rejects it. Rows are never deleted; decided requests are the audit trail. + * + * One PENDING row per invoice at a time (partial unique index): a second + * request while one is undecided is a coordination failure, not a workflow. + */ +export class ShippingLineInvoiceApprovals3530000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE freight.shipping_line_invoice_approvals_action_enum + AS ENUM ('MARK_PAID', 'CANCEL'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$ + `); + + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE freight.shipping_line_invoice_approvals_status_enum + AS ENUM ('PENDING', 'APPROVED', 'REJECTED'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$ + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.shipping_line_invoice_approvals ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + invoice_id uuid NOT NULL REFERENCES freight.invoices (id), + action freight.shipping_line_invoice_approvals_action_enum NOT NULL, + status freight.shipping_line_invoice_approvals_status_enum NOT NULL DEFAULT 'PENDING', + requested_by uuid NOT NULL, + reason varchar(500) NOT NULL, + payment_reference varchar(255), + decided_by uuid, + decided_at timestamptz, + decision_note varchar(500), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_sl_invoice_approvals_invoice_status + ON freight.shipping_line_invoice_approvals (invoice_id, status) + `); + + // The workflow invariant, enforced where it cannot race: at most one + // undecided request per invoice. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_sl_invoice_approvals_one_pending + ON freight.shipping_line_invoice_approvals (invoice_id) + WHERE status = 'PENDING' AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.shipping_line_invoice_approvals`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS freight.shipping_line_invoice_approvals_status_enum`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS freight.shipping_line_invoice_approvals_action_enum`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3540000000000-EmptyReturnTrainLoad.ts b/apps/edr-freight-api/src/migrations/3540000000000-EmptyReturnTrainLoad.ts new file mode 100644 index 000000000..55ef08262 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3540000000000-EmptyReturnTrainLoad.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Empty containers ride an export departure back to Djibouti, so a return now + * records which train schedule carries it and on which wagon slot. Size is + * captured too: the wagon rule is one 40ft OR two 20ft per wagon, which cannot + * be enforced without knowing the box size. + */ +export class EmptyReturnTrainLoad3540000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.empty_container_returns + ADD COLUMN IF NOT EXISTS container_size character varying(10), + ADD COLUMN IF NOT EXISTS train_schedule_id uuid, + ADD COLUMN IF NOT EXISTS wagon_sequence_no integer + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_empty_container_returns_train_schedule_id + ON freight.empty_container_returns (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_empty_container_returns_train_schedule_id + `); + await queryRunner.query(` + ALTER TABLE freight.empty_container_returns + DROP COLUMN IF EXISTS container_size, + DROP COLUMN IF EXISTS train_schedule_id, + DROP COLUMN IF EXISTS wagon_sequence_no + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3540000000000-FilterableListIndexes.ts b/apps/edr-freight-api/src/migrations/3540000000000-FilterableListIndexes.ts new file mode 100644 index 000000000..1569b13d0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3540000000000-FilterableListIndexes.ts @@ -0,0 +1,56 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Supports the Stripe-style pill filter bar: every list it lands on filters + * and sorts server-side now. `@Index` decorators alone do nothing — + * `synchronize: false` means an index exists only if a migration created it + * (see the `RepairSynchronizeDrift`-style gaps this closes). + * + * `idx_warehouse_inventory_status` already exists (FreightBaseline). Bookings + * and wagons have no plain `status` index — `idx_bookings_route_day` and + * `idx_wagons_readiness` only cover `status` as a trailing/partial column, + * not a standalone `WHERE status = $1`, and `status` is the single + * most-filtered column on both lists (bookings: 37 values). + * + * `(created_at DESC, id ASC)` partials match the default sort + id + * tiebreaker `applySort` now appends everywhere, and none of these tables + * had a created_at index at all. + */ +export class FilterableListIndexes3540000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_status + ON freight.bookings USING btree (status) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagons_status + ON freight.wagons USING btree (status) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_contracts_created_at_id + ON freight.contracts (created_at DESC, id ASC) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_created_at_id + ON freight.bookings (created_at DESC, id ASC) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_created_at_id + ON freight.warehouse_inventory (created_at DESC, id ASC) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagons_created_at_id + ON freight.wagons (created_at DESC, id ASC) WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_created_at_id`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_created_at_id`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_created_at_id`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_contracts_created_at_id`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_status`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_status`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3550000000000-EimsDebitCreditNotes.ts b/apps/edr-freight-api/src/migrations/3550000000000-EimsDebitCreditNotes.ts new file mode 100644 index 000000000..cf36df757 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3550000000000-EimsDebitCreditNotes.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Debit/credit note filing — confirmed directly by MoR support: same `/v1/register` endpoint, + * distinguished by `DocumentDetails.Type` ("DEB"/"CRE") + a `Reason`, linked to the original + * invoice via `ReferenceDetails.RelatedDocument`. See `Invoice.eimsDocumentType`. + */ +export class EimsDebitCreditNotes3550000000000 implements MigrationInterface { + name = "EimsDebitCreditNotes3550000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS eims_document_type varchar(8) NOT NULL DEFAULT 'INV', + ADD COLUMN IF NOT EXISTS eims_reason text, + ADD COLUMN IF NOT EXISTS related_invoice_id uuid REFERENCES freight.invoices(id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS eims_document_type, + DROP COLUMN IF EXISTS eims_reason, + DROP COLUMN IF EXISTS related_invoice_id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3560000000000-ManualPaymentSettings.ts b/apps/edr-freight-api/src/migrations/3560000000000-ManualPaymentSettings.ts new file mode 100644 index 000000000..0c4f5698f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3560000000000-ManualPaymentSettings.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Single-row table controlling whether Finance may settle invoices by hand, + * per currency (see ManualPaymentSettingsService). Defaults preserve the + * pre-toggle behaviour: USD was always bank-transfer-only (ON), ETB manual + * settlement is the new capability and must be switched on deliberately (OFF). + */ +export class ManualPaymentSettings3560000000000 implements MigrationInterface { + name = "ManualPaymentSettings3560000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.manual_payment_settings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + etb_enabled boolean NOT NULL DEFAULT false, + usd_enabled boolean NOT NULL DEFAULT true, + updated_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query(` + INSERT INTO freight.manual_payment_settings (etb_enabled, usd_enabled) + SELECT false, true + WHERE NOT EXISTS (SELECT 1 FROM freight.manual_payment_settings); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.manual_payment_settings;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3560000000000-YardPositions.ts b/apps/edr-freight-api/src/migrations/3560000000000-YardPositions.ts new file mode 100644 index 000000000..102a5b85d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3560000000000-YardPositions.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Which desks work at which yard — the input to yard access scoping. + * + * Many-to-many: a position (what the user-management tree calls a department) + * can cover several yards, and a yard is staffed by several positions. The + * scope resolver reads it to answer "which yards may this caller touch?". + * + * `yard_id` carries a real FK; `position_id` deliberately does NOT. Positions + * live in `iam`, which is owned by the vendored @tria-plc/iamapi-common package + * and shared with the passenger app: a hard FK would let freight block an IAM + * delete, and would have to be dropped the day IAM moves to its own database. + * Reads join `iam.positions … WHERE deleted_at IS NULL` instead, so a + * soft-deleted position silently drops out of scope rather than granting it. + * + * The unique index is PARTIAL — soft-deleted rows must not block re-adding the + * same pair later. + */ +export class YardPositions3560000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.yard_positions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + yard_id uuid NOT NULL REFERENCES freight.yards(id) ON DELETE CASCADE, + position_id uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_yard_positions_pair + ON freight.yard_positions (yard_id, position_id) + WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS ix_yard_positions_position + ON freight.yard_positions (position_id) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_positions`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3570000000000-ConsolidationApprovals.ts b/apps/edr-freight-api/src/migrations/3570000000000-ConsolidationApprovals.ts new file mode 100644 index 000000000..879c9b472 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3570000000000-ConsolidationApprovals.ts @@ -0,0 +1,89 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Approval gate for consolidated (shared-wagon) bookings. + * + * A booking that fills its own wagons goes straight from GL completion to the + * operations queue. A CONSOLIDATED booking does not: it shares one physical + * wagon with another customer's booking, which means two customers' cargo, two + * invoices and two liabilities riding the same wagon. That pairing is a + * commercial decision, so it is reviewed by a person before Operations sees it. + * + * The pair is approved as a UNIT — one row covers both halves (booking_id + + * partner_booking_id) so an approver can never approve one side of a shared + * wagon and leave the other pending. Rows are never deleted; decided rows are + * the audit trail of who approved which pairing and when. + * + * One PENDING row per booking at a time (partial unique index on each side of + * the pair): a second request while one is undecided is a coordination failure, + * not a workflow. + */ +export class ConsolidationApprovals3570000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE freight.consolidation_approvals_status_enum + AS ENUM ('PENDING', 'APPROVED', 'REJECTED'); + EXCEPTION WHEN duplicate_object THEN NULL; END $$ + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.consolidation_approvals ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + booking_id uuid NOT NULL REFERENCES freight.bookings (id), + partner_booking_id uuid NOT NULL REFERENCES freight.bookings (id), + status freight.consolidation_approvals_status_enum NOT NULL DEFAULT 'PENDING', + -- Who put the pairing up for review (the GL user who completed it) and + -- who decided it. Both are recorded: the point of the gate is that they + -- are different people. + requested_by uuid, + requested_at timestamptz NOT NULL DEFAULT now(), + decided_by uuid, + decided_at timestamptz, + decision_note varchar(500), + -- Snapshot of what was approved, so the audit trail still reads + -- correctly after the bookings themselves move on. + scheduled_date timestamptz, + booking_reference varchar(50), + partner_booking_reference varchar(50), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_consolidation_approvals_booking_status + ON freight.consolidation_approvals (booking_id, status) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_consolidation_approvals_status + ON freight.consolidation_approvals (status) + `); + + // The workflow invariant, enforced where it cannot race: at most one + // undecided request per booking — on EITHER side of the pair, so the same + // wagon can never collect two pending requests from its two halves. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_consolidation_approvals_one_pending + ON freight.consolidation_approvals (booking_id) + WHERE status = 'PENDING' AND deleted_at IS NULL + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_consolidation_approvals_one_pending_partner + ON freight.consolidation_approvals (partner_booking_id) + WHERE status = 'PENDING' AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.consolidation_approvals`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS freight.consolidation_approvals_status_enum`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3570000000000-YardViewAllPermission.ts b/apps/edr-freight-api/src/migrations/3570000000000-YardViewAllPermission.ts new file mode 100644 index 000000000..0a53bd530 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3570000000000-YardViewAllPermission.ts @@ -0,0 +1,54 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Seed `edr_freight_app:yards:view_all` — the cross-yard bypass for yard access + * scoping. + * + * The permission catalog is otherwise written by `EdrOrgSeeder`, which skips + * itself unless `SEED_EDR_ORG` is set. That flag is off in normal environments, + * so a key added to the registry never reaches `iam.permissions` and cannot be + * granted to anyone — the bypass would exist in code and be unusable in the + * database. A migration is the one path that runs everywhere. + * + * Idempotent on `key`, which is the identity every consumer resolves by (the + * registry's uuid is only used where a seed row needs one). Skips silently when + * the freight application row is absent, since there is nothing to attach to. + */ +export class YardViewAllPermission3570000000000 implements MigrationInterface { + private static readonly KEY = 'edr_freight_app:yards:view_all'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `INSERT INTO iam.permissions (id, key, name, application_id) + SELECT gen_random_uuid(), + $1::varchar, + '{"am": "Access every yard (bypass yard scoping)", "en": "Access every yard (bypass yard scoping)"}'::jsonb, + a.id + FROM iam.application a + WHERE a.key = 'edr_freight_app' + AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`, + [YardViewAllPermission3570000000000.KEY], + ); + } + + /** + * Removes only the permission row itself. Any grant of it goes first, or the + * delete trips the position/role permission foreign keys — and a half-removed + * permission is worse than one left in place. + */ + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM iam.position_permissions + WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`, + [YardViewAllPermission3570000000000.KEY], + ); + await queryRunner.query( + `DELETE FROM iam.role_permissions + WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`, + [YardViewAllPermission3570000000000.KEY], + ); + await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [ + YardViewAllPermission3570000000000.KEY, + ]); + } +} diff --git a/apps/edr-freight-api/src/migrations/3590000000000-BookingClearanceCharge.ts b/apps/edr-freight-api/src/migrations/3590000000000-BookingClearanceCharge.ts new file mode 100644 index 000000000..042e47554 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3590000000000-BookingClearanceCharge.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Post-finalization clearance charges billed to the customer: one PORT_CHARGES + * and one MISCELLANEOUS row max per booking, each carrying a document, amount, + * currency and its own payable invoice. + */ +export class BookingClearanceCharge3590000000000 implements MigrationInterface { + name = 'BookingClearanceCharge3590000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "freight"."booking_clearance_charge" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + "booking_id" uuid NOT NULL, + "type" character varying(20) NOT NULL, + "status" character varying(20) NOT NULL DEFAULT 'DOC_UPLOADED', + "file_record_id" uuid, + "amount" numeric(14,2), + "currency" character varying(8), + "invoice_id" uuid, + "uploaded_by_staff_id" uuid, + "uploaded_at" timestamptz, + "billed_by_staff_id" uuid, + "billed_at" timestamptz, + "paid_at" timestamptz, + CONSTRAINT "pk_booking_clearance_charge" PRIMARY KEY ("id"), + CONSTRAINT "fk_booking_clearance_charge_booking" FOREIGN KEY ("booking_id") + REFERENCES "freight"."bookings"("id") ON DELETE CASCADE + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "uq_booking_clearance_charge_booking_type" + ON "freight"."booking_clearance_charge" ("booking_id", "type") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS "freight"."booking_clearance_charge"`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit-actor.ts b/apps/edr-freight-api/src/modules/audit/audit-actor.ts new file mode 100644 index 000000000..f89a7ee26 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit-actor.ts @@ -0,0 +1,84 @@ +/** + * Who acted, and does this API audit them? + * + * The staff/customer split reuses the exact discriminator the permission guards + * already apply (`freight-permission.guard.ts`): `userType === 'employee'` is + * backoffice, `individual` / `external_organization` are customers. Restating + * the rule instead of importing it would let the two drift apart silently. + */ + +const EMPLOYEE_USER_TYPE = 'employee'; +const SUPER_ADMIN_ROLE = 'super_admin'; + +/** The subset of the JWT payload this module reads. */ +export interface AuditActorSource { + id?: string; + sub?: string; + userType?: string; + username?: string; + name?: string | { en?: string; am?: string }; + firstName?: string; + lastName?: string; + email?: string; + roles?: { key?: string; name?: string }[]; + employee?: unknown; +} + +export interface AuditActor { + userId: string | null; + userName: string | null; + userRole: string | null; +} + +function isSuperAdmin(user: AuditActorSource): boolean { + return Boolean(user.roles?.some((role) => role.key === SUPER_ADMIN_ROLE)); +} + +/** + * Is this caller a backoffice user whose actions are audited? + * + * Only employees qualify. Customers are excluded by request, and unauthenticated + * callers are excluded too — which means failed logins, OTP sends and password + * resets produce no audit rows. That was a deliberate call: those endpoints are + * not backoffice actions. Note the trade-off, since failed-auth attempts are + * often what an incident review looks for first. + */ +export function isAuditableActor(user: AuditActorSource | null | undefined): boolean { + if (!user) return false; + // Super admins may not carry an `employee` userType on every token, but are + // unambiguously staff — the permission guards treat them the same way. + return user.userType === EMPLOYEE_USER_TYPE || isSuperAdmin(user); +} + +/** Best-effort display name, tolerating the several shapes tokens use. */ +function resolveUserName(user: AuditActorSource): string | null { + if (typeof user.name === 'string' && user.name.trim()) return user.name.trim(); + + if (user.name && typeof user.name === 'object') { + const localized = user.name.en ?? user.name.am; + if (localized?.trim()) return localized.trim(); + } + + const composed = [user.firstName, user.lastName].filter(Boolean).join(' ').trim(); + if (composed) return composed; + + return user.username?.trim() || user.email?.trim() || null; +} + +/** + * Snapshot the actor at the moment of the action. + * + * Name and role are copied, never referenced: resolving them from IAM at read + * time would rewrite history whenever someone is renamed, changes role or is + * deleted. An audit row from last year must still say who acted and with what + * authority *then*. + */ +export function resolveAuditActor(user: AuditActorSource): AuditActor { + const roleKey = user.roles?.[0]?.key ?? user.roles?.[0]?.name ?? null; + + return { + userId: user.id ?? user.sub ?? null, + userName: resolveUserName(user), + userRole: roleKey, + }; +} diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoint-matcher.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoint-matcher.ts new file mode 100644 index 000000000..75ae51fa6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoint-matcher.ts @@ -0,0 +1,140 @@ +import { AUDIT_ENDPOINTS, type AuditEndpointMeta } from './audit-endpoints'; + +/** What a matched request resolved to. */ +export interface MatchedAuditEndpoint { + /** Human-readable action, e.g. "Approve contract". */ + title: string; + /** Primary entity, e.g. "Contract". */ + type: string; + /** The route template, e.g. `/api/contracts/:id/cancel`. */ + routePath: string; + /** First path parameter of the template, when the route has one. */ + resourceId: string | null; +} + +interface CompiledRoute { + regex: RegExp; + /** Param names in capture-group order, e.g. ['id', 'stepId']. */ + paramNames: string[]; + routePath: string; + meta: AuditEndpointMeta; + /** Literal (non-parameter) segment count — used to rank specificity. */ + staticSegments: number; +} + +const ESCAPE_REGEX = /[.*+?^${}()|[\]\\]/g; + +/** + * Two keys in AUDIT_ENDPOINTS point at the same path: one route is declared by + * two different controllers, so the generator suffixed the second with + * ` [modules/...controller.ts]` to keep both entries. Only the path itself is + * matchable, so the suffix is stripped here. + */ +function stripSourceSuffix(key: string): string { + const bracket = key.indexOf(' ['); + return bracket === -1 ? key : key.slice(0, bracket); +} + +/** + * Compile one `"/api/contracts/:id/cancel"` template into an anchored regex. + * + * A parameter matches a single path segment only (`[^/]+`), so + * `/api/contracts/:id` cannot swallow `/api/contracts/:id/cancel`. + */ +function compileTemplate(path: string): { regex: RegExp; paramNames: string[] } { + const paramNames: string[] = []; + const pattern = path + .split('/') + .map((segment) => { + if (!segment.startsWith(':')) { + return segment.replace(ESCAPE_REGEX, '\\$&'); + } + paramNames.push(segment.slice(1)); + return '([^/]+)'; + }) + .join('/'); + + return { regex: new RegExp(`^${pattern}$`), paramNames }; +} + +/** + * Method-bucketed lookup table for the audited routes. + * + * A direct `AUDIT_ENDPOINTS[url]` lookup cannot work: the keys are templates + * with `:params` while a live request carries real ids and a query string, so + * every parameterized route — most of the 488 — would miss. Templates are + * compiled to regexes once at module load and matched per request. + * + * Within a method, routes are ordered by literal-segment count descending, so + * a specific route always wins over a parameterized one that could also match + * (`/api/routes/:id/permanent` before `/api/routes/:id`). + */ +class AuditEndpointMatcher { + private readonly byMethod = new Map(); + + constructor() { + for (const [key, meta] of Object.entries(AUDIT_ENDPOINTS)) { + const [method, rawPath] = stripSourceSuffix(key).split(' '); + if (!method || !rawPath) continue; + + const { regex, paramNames } = compileTemplate(rawPath); + const bucket = this.byMethod.get(method) ?? []; + bucket.push({ + regex, + paramNames, + routePath: rawPath, + meta, + staticSegments: rawPath + .split('/') + .filter((s) => s && !s.startsWith(':')).length, + }); + this.byMethod.set(method, bucket); + } + + for (const bucket of this.byMethod.values()) { + bucket.sort((a, b) => b.staticSegments - a.staticSegments); + } + } + + /** + * Resolve a live request to its audit metadata, or null when the route is + * not audited (every GET, and anything absent from AUDIT_ENDPOINTS). + * + * `url` may include a query string; it is ignored for matching. + */ + match(method: string, url: string): MatchedAuditEndpoint | null { + const bucket = this.byMethod.get(method.toUpperCase()); + if (!bucket) return null; + + const path = stripQuery(url); + + for (const route of bucket) { + const result = route.regex.exec(path); + if (!result) continue; + + const [title, , type] = route.meta; + return { + title, + type, + routePath: route.routePath, + // The first path parameter is the affected record in this API's + // conventions (`/api/contracts/:id/...`). Routes with no parameter + // (a create) legitimately have no resource id yet. + resourceId: route.paramNames.length > 0 ? result[1] : null, + }; + } + + return null; + } +} + +/** Strip query string and hash from a URL, leaving the path. */ +export function stripQuery(url: string): string { + const queryIndex = url.indexOf('?'); + const path = queryIndex === -1 ? url : url.slice(0, queryIndex); + const hashIndex = path.indexOf('#'); + return hashIndex === -1 ? path : path.slice(0, hashIndex); +} + +/** Compiled once at module load and shared by the interceptor. */ +export const auditEndpointMatcher = new AuditEndpointMatcher(); diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts new file mode 100644 index 000000000..a0c82fa4d --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -0,0 +1,685 @@ +/** + * Freight API — every state-changing endpoint (POST / PUT / PATCH / DELETE). + * + * Shape: " ": [title, method, entity] + * + * Keyed by method + path rather than path alone: 50 paths serve more than one + * method (PATCH and DELETE on /api/contracts/:id, for example), so a path-only + * key would collide and drop those endpoints. + * + * Paths include the global prefix `api` (see app.setGlobalPrefix in src/main.ts). + * Titles come from each route's @ApiOperation summary, falling back to a + * humanized handler name where a route has none. + * + * Excludes the AI Assist and Account entities. + * Generated from the controllers under src/ — 517 endpoints. + */ +/** [title, method, entity] for one auditable route. */ +export type AuditEndpointMeta = readonly [title: string, method: string, entity: string]; + +export const AUDIT_ENDPOINTS: Readonly> = { + // Approval Rule + "POST /api/approval-rules": ["Create an approval rule step", "POST", "Approval Rule"], + "PATCH /api/approval-rules/:id": ["Update an approval rule", "PATCH", "Approval Rule"], + "DELETE /api/approval-rules/:id": ["Soft-delete an approval rule", "DELETE", "Approval Rule"], + "POST /api/approval-rules/:id/move-order": ["Move an approval step up or down within its chain", "POST", "Approval Rule"], + "POST /api/approval-rules/reorder": ["Bulk reorder approval steps within a chain", "POST", "Approval Rule"], + + // Booking + "POST /api/bookings": ["Create a new freight booking (DRAFT)", "POST", "Booking"], + "POST /api/bookings/:bookingId/allocate-containers": ["Allocate containers to vehicles", "POST", "Booking"], + "PATCH /api/bookings/:id": ["Update booking", "PATCH", "Booking"], + "DELETE /api/bookings/:id": ["Soft-delete DRAFT booking", "DELETE", "Booking"], + "POST /api/bookings/:id/cancel": ["Cancel booking", "POST", "Booking"], + "POST /api/bookings/:id/cancel-hold": ["Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED);", "POST", "Booking"], + "POST /api/bookings/:id/clearance/declaration": ["GL ET uploads customs declaration on booking (GENERAL customs)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/delivery-order": ["Upload Booking Delivery Order", "POST", "Booking"], + "POST /api/bookings/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration": ["GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration/accept": ["Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration/change": ["Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/duty": ["GL ET sets duty/tax on booking with notice attachment", "POST", "Booking"], + "POST /api/bookings/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on booking", "POST", "Booking"], + "POST /api/bookings/:id/clearance/export-release": ["Confirm Booking Export Release", "POST", "Booking"], + "POST /api/bookings/:id/clearance/finalize": ["GL finalizes clearance (requires 100% approved) → CLEARANCE_READY", "POST", "Booking"], + "POST /api/bookings/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance on booking", "POST", "Booking"], + "POST /api/bookings/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"], + "POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"], + "POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"], + "POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"], + "POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"], + "POST /api/bookings/:id/clearance/transit-permit": ["Upload Booking Transit Permit", "POST", "Booking"], + "POST /api/bookings/:id/confirm-submit": ["Confirm submit after price change", "POST", "Booking"], + "POST /api/bookings/:id/consolidation": ["Request freight consolidation", "POST", "Booking"], + "DELETE /api/bookings/:id/consolidation": ["Remove consolidation pairing", "DELETE", "Booking"], + "POST /api/bookings/:id/contract/generate": ["Generate contract PDF from template", "POST", "Booking"], + "POST /api/bookings/:id/contract/sign": ["Apply digital signature (customer or staff)", "POST", "Booking"], + "POST /api/bookings/:id/customer-cancel": ["Customer cancels their own booking before payment — no cancellation fee", "POST", "Booking"], + "POST /api/bookings/:id/customer-truck-assignment": ["Customer assigns external truck and driver for terminal pickup", "POST", "Booking"], + "POST /api/bookings/:id/customer-trucks": ["Add a customer self-haul truck carrying 1–2 of the booking containers", "POST", "Booking"], + "PATCH /api/bookings/:id/customer-trucks/:assignmentId": ["Edit a not-yet-arrived customer truck (plate/driver/type + containers)", "PATCH", "Booking"], + "DELETE /api/bookings/:id/customer-trucks/:assignmentId": ["Remove a not-yet-arrived customer truck from a booking", "DELETE", "Booking"], + "POST /api/bookings/:id/customer-trucks/:assignmentId/depart": ["Register an import truck leaving: containers loaded + weighed gross (staff)", "POST", "Booking"], + "POST /api/bookings/:id/customer-trucks/:assignmentId/load": ["Truck_dispatch: load selected containers onto a truck (staff)", "POST", "Booking"], + "POST /api/bookings/:id/customer-trucks/bulk": ["Bulk add customer trucks from array payload (Excel parsed)", "POST", "Booking"], + "POST /api/bookings/:id/customer/sign": ["Customer digital signature (deprecated — use POST contract/sign)", "POST", "Booking"], + "POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "POST", "Booking"], + "PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"], + "POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"], + "POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"], + "POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"], + "POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"], + "POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"], + "POST /api/bookings/:id/operations/complete": ["Mark completed", "POST", "Booking"], + "POST /api/bookings/:id/operations/start-transit": ["Mark in transit", "POST", "Booking"], + "POST /api/bookings/:id/reject": ["Customer reject price estimate", "POST", "Booking"], + "POST /api/bookings/:id/staff/accept": ["Staff accept intake → set contract validity window + start approval chain", "POST", "Booking"], + "POST /api/bookings/:id/staff/reject": ["Staff final reject", "POST", "Booking"], + "POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "POST", "Booking"], + "POST /api/bookings/:id/submit": ["Customer submit booking", "POST", "Booking"], + "POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"], + "POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"], + "POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"], + "POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"], + "POST /api/bookings/consolidation-approvals/:approvalId/approve": ["Approve a shared wagon: both bookings leave the gate and continue to Operations together.", "POST", "Booking"], + "POST /api/bookings/consolidation-approvals/:approvalId/reject": ["Reject a shared wagon: both bookings go back to GL for changes with the reason.", "POST", "Booking"], + "POST /api/bookings/:id/paired-decision": ["Apply a staff decision (accept / cancel / operationAccept / requestChanges) to BOTH halves of a consolidated pair, all-or-nothing.", "POST", "Booking"], + + // Cargo + "POST /api/cargoes": ["Create a new cargo", "POST", "Cargo"], + "PATCH /api/cargoes/:id": ["Update a cargo", "PATCH", "Cargo"], + "DELETE /api/cargoes/:id": ["Delete a cargo", "DELETE", "Cargo"], + "POST /api/cargoes/:id/deliver": ["Mark cargo as delivered", "POST", "Cargo"], + "POST /api/cargoes/:id/load": ["Load cargo into a container", "POST", "Cargo"], + "POST /api/cargoes/:id/unload": ["Unload cargo from container", "POST", "Cargo"], + + // Cargo Type + "POST /api/cargo-types": ["Create a cargo type", "POST", "Cargo Type"], + "PATCH /api/cargo-types/:id": ["Update a cargo type", "PATCH", "Cargo Type"], + "DELETE /api/cargo-types/:id": ["Soft-delete a cargo type", "DELETE", "Cargo Type"], + "POST /api/cargo-types/:id/move-order": ["Move a cargo type up or down in display order", "POST", "Cargo Type"], + "POST /api/cargo-types/reorder": ["Bulk reorder cargo types by ID list", "POST", "Cargo Type"], + + // Chat + "POST /api/chat/sync": ["Re-run the chat room/membership reconcile immediately (normally nightly)", "POST", "Chat"], + + // Company + "POST /api/companies": ["Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", "POST", "Company"], + "POST /api/companies/:companyId/documents": ["Upload documents for a company (onboarding)", "POST", "Company"], + "POST /api/companies/:companyId/profiles": ["Add a profile (employee) to a company", "POST", "Company"], + "PATCH /api/companies/:id": ["Update a company", "PATCH", "Company"], + "DELETE /api/companies/:id": ["Soft-delete a company", "DELETE", "Company"], + "POST /api/companies/change-requests/:id/approve": ["Approve a pending profile change request (applies the changes)", "POST", "Company"], + "POST /api/companies/change-requests/:id/reject": ["Reject a pending profile change request with a note", "POST", "Company"], + "POST /api/companies/change-requests/:id/request-changes": ["Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)", "POST", "Company"], + "POST /api/companies/company-profile": ["Create a single operational profile for the current user's company. The role starts pending and does not become the active mode", "POST", "Company"], + "POST /api/companies/company-profiles": ["Add operational profile(s) (importer/exporter/forwarder) to the current user's company", "POST", "Company"], + "POST /api/companies/company-profiles/:profileId/license": ["Add business-license document(s) to a profile. For an approved company", "POST", "Company"], + "DELETE /api/companies/company-profiles/:profileId/license/:fileId": ["Remove a business-license file (staged for review on an approved company)", "DELETE", "Company"], + "POST /api/companies/company-profiles/:profileId/license/:fileId/replace": ["Replace a business-license file with a newly uploaded one (staged for", "POST", "Company"], + "POST /api/companies/company-profiles/:profileId/reapply": ["Resubmit a rejected operational role for approval (→ pending)", "POST", "Company"], + "PATCH /api/companies/company-profiles/:profileId/status": ["Update a company profile's approval status", "PATCH", "Company"], + "POST /api/companies/create": ["Create a company with its associated external profile (onboarding)", "POST", "Company"], + "POST /api/companies/documents/:fileId/request-change": ["Ask the customer to correct one uploaded document", "POST", "Company"], + "POST /api/companies/fetch-etrade-info": ["Fetch company info from eTrade by TIN", "POST", "Company"], + "POST /api/companies/identity/fayda/complete": ["Bind a completed Fayda verification to the company's owner or Power of Attorney", "POST", "Company"], + "PATCH /api/companies/onboarding-step": ["Persist the user's current onboarding wizard step", "PATCH", "Company"], + "POST /api/companies/onboarding/complete": ["Mark the current user's onboarding as complete", "POST", "Company"], + "POST /api/companies/onboarding/start": ["Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", "POST", "Company"], + "POST /api/companies/poa-delegation": ["Upload the Power of Attorney delegation letter, replacing any existing one", "POST", "Company"], + "DELETE /api/companies/poa-delegation/:fileId": ["Remove the Power of Attorney delegation letter (staged for review on an approved company)", "DELETE", "Company"], + "PATCH /api/companies/profile": ["Update profile (flattened settings page)", "PATCH", "Company"], + "PATCH /api/companies/identity/poa-declared": ["Answer whether anyone holds power of attorney for this company — the question that decides whose identity is verified.", "PATCH", "Company"], + "POST /api/companies/onboarding/revert-to-etrade": ["Drop the manual-registration route (co-operative or foreign investment licence): clear the typed registration and reopen onboarding so the TIN is verified against eTrade", "POST", "Company"], + + // Compliance + "POST /api/compliance": ["Create a compliance record", "POST", "Compliance"], + "PATCH /api/compliance/:id": ["Update a compliance record", "PATCH", "Compliance"], + "DELETE /api/compliance/:id": ["Soft-delete a compliance record", "DELETE", "Compliance"], + + // Consignment + "POST /api/consignments": ["Create a new consignment", "POST", "Consignment"], + + // Container + "POST /api/containers": ["Create a new container", "POST", "Container"], + "PATCH /api/containers/:id": ["Update a container", "PATCH", "Container"], + "DELETE /api/containers/:id": ["Delete a container", "DELETE", "Container"], + "POST /api/containers/:id/assign-wagon": ["Assign container to a wagon", "POST", "Container"], + "POST /api/containers/:id/unassign-wagon": ["Unassign container from wagon", "POST", "Container"], + + // Container Type + "POST /api/container-types": ["Create a container type", "POST", "Container Type"], + "PATCH /api/container-types/:id": ["Update a container type", "PATCH", "Container Type"], + "DELETE /api/container-types/:id": ["Soft-delete a container type", "DELETE", "Container Type"], + "POST /api/container-types/:id/move-order": ["Move a container type up or down in display order", "POST", "Container Type"], + "POST /api/container-types/reorder": ["Bulk reorder container types by ID list", "POST", "Container Type"], + + // Contract + "POST /api/contracts": ["Create a new contract (DRAFT) with routes + cargo scope", "POST", "Contract"], + "PATCH /api/contracts/:id": ["Update contract", "PATCH", "Contract"], + "DELETE /api/contracts/:id": ["Soft-delete DRAFT contract", "DELETE", "Contract"], + "POST /api/contracts/:id/approval-steps/:stepId/approve": ["Approve one approval step in sequence", "POST", "Contract"], + "POST /api/contracts/:id/approval-steps/:stepId/reject": ["Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)", "POST", "Contract"], + "POST /api/contracts/:id/booking-requests": ["Customer submits a shipment request on a GENERAL customs contract", "POST", "Contract"], + "POST /api/contracts/:id/bookings": ["Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia)", "POST", "Contract"], + "POST /api/contracts/:id/bookings/:bookingId/complete": ["Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing", "POST", "Contract"], + "POST /api/contracts/:id/bookings/initiate": ["Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request", "POST", "Contract"], + "POST /api/contracts/:id/cancel": ["Customer cancels their own contract (blocked while a booking is live)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/declaration": ["GL ET uploads customs declaration documents (multi-file)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/delivery-order": ["GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates", "POST", "Contract"], + "POST /api/contracts/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/documents/:fileKey/replace": ["GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving", "POST", "Contract"], + "POST /api/contracts/:id/clearance/duty": ["GL ET sets duty/tax requirement and advises amount with notice attachment", "POST", "Contract"], + "POST /api/contracts/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on contract", "POST", "Contract"], + "POST /api/contracts/:id/clearance/duty/dispute": ["Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/export-release": ["GL ET confirms export release after declaration", "POST", "Contract"], + "POST /api/contracts/:id/clearance/finalize": ["GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/finalize-export-clearance": ["GL ET finalizes export clearance after post-booking transit permit upload", "POST", "Contract"], + "POST /api/contracts/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance — unlocks Djibouti DO upload", "POST", "Contract"], + "POST /api/contracts/:id/clearance/ops-finalize": ["Operations finalizes self-clearance → customer may create the booking", "POST", "Contract"], + "POST /api/contracts/:id/clearance/ops-review": ["Operations reviews a customer self-clearance document (Approve | Query)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…) pre-booking", "POST", "Contract"], + "POST /api/contracts/:id/clearance/release-order": ["GL DJ uploads Release Order + vessel departure date (export)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/review": ["GL ET reviews a clearance document (Approve | Query)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/ro-amendment": ["GL DJ requests port amendment when RO vessel window is too short", "POST", "Contract"], + "POST /api/contracts/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Contract"], + "POST /api/contracts/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the customs declaration", "POST", "Contract"], + "POST /api/contracts/:id/clearance/transit-permit": ["GL ET uploads import transit permit documents (multi-file)", "POST", "Contract"], + "POST /api/contracts/:id/confirm-submit": ["Confirm submit after a price change", "POST", "Contract"], + "POST /api/contracts/:id/contract/generate": ["Generate contract document → CONTRACT_READY", "POST", "Contract"], + "POST /api/contracts/:id/contract/send-signing-otp": ["Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)", "POST", "Contract"], + "POST /api/contracts/:id/contract/sign": ["Apply digital signature (customer or staff/director/ceo)", "POST", "Contract"], + "PUT /api/contracts/:id/document/articles": ["Edit this contract\\'s document articles only (per-contract; never touches the six shared templates)", "PUT", "Contract"], + "POST /api/contracts/:id/documents": ["Upload intake documents for a contract (DRAFT only)", "POST", "Contract"], + "POST /api/contracts/:id/generate-price": ["Generate unit-rate breakdown (no totals at contract phase)", "POST", "Contract"], + "POST /api/contracts/:id/milestones/:code/complete": ["GL marks a pre-booking (contract) milestone complete", "POST", "Contract"], + "POST /api/contracts/:id/renew": ["Create a renewal draft linked via renewalOfId", "POST", "Contract"], + "POST /api/contracts/:id/resume": ["Staff lift a suspension — contract returns to its prior status", "POST", "Contract"], + "POST /api/contracts/:id/staff/accept": ["Staff accept → set validity window + start approval chain", "POST", "Contract"], + "POST /api/contracts/:id/staff/reject": ["Staff reject contract", "POST", "Contract"], + "POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "POST", "Contract"], + "POST /api/contracts/:id/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"], + "POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"], + "POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"], + "POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"], + "POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"], + "POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/documents": ["GL uploads post-booking operational documents (DO/RO/T1/…)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/duty": ["GL ET advises duty & tax amount + declaration serial", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/duty-slip": ["Customer uploads the duty/tax payment slip", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice": ["GL DJ raises the post-offload final invoice (amount + invoice document)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice-slip": ["Customer attaches the payment slip for the final invoice", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice/approve": ["Customer approves the drafted final invoice — unlocks the payment slip", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice/confirm": ["GL (ET or DJ) confirms the payment slip — settles the final invoice", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/incidents": ["GL DJ logs a cargo exception with photo evidence", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/milestones/:code/complete": ["GL / Ops / Terminal marks a post-booking milestone complete", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/risk": ["GL ET assigns a customs risk level (GREEN/YELLOW/RED)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/second-duty": ["GL ET advises (or skips) the post-arrival additional duty/tax round (import)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/second-duty-slip": ["Customer attaches the additional duty/tax payment slip", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/station-assign": ["GL station manager routes the shipment + binds staff", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/t1-close": ["Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/t1-documents": ["GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/transport-document": ["GL ET uploads export transit permit documents (multi-file)", "POST", "Contract"], + "POST /api/gl-exchange/:entityId": ["Share a document with the other GL desk", "POST", "Contract"], + "PATCH /api/gl-exchange/documents/:documentId": ["Uploader edits a shared document (title, visibility, file)", "PATCH", "Contract"], + "DELETE /api/gl-exchange/documents/:documentId": ["Uploader removes a shared document", "DELETE", "Contract"], + "POST /api/contracts/:id/bookings/:bookingId/complete-consolidated": ["Complete this booking and its chosen shared-wagon partner together (all-or-nothing). Each booking is priced and invoiced separately — only the wagon is shared.", "POST", "Contract"], + + // Contract Template + "POST /api/contract-templates": ["Create a bulk contract template for a (cargo type, customs option) pair", "POST", "Contract Template"], + "PATCH /api/contract-templates/:code": ["Update template metadata (name, title, recitals, active flag)", "PATCH", "Contract Template"], + "DELETE /api/contract-templates/:code": ["Delete a staff-created bulk template (system templates refuse)", "DELETE", "Contract Template"], + "POST /api/contract-templates/:code/articles": ["Add an article to the template", "POST", "Contract Template"], + "PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "Contract Template"], + "PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"], + "DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"], + "POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"], + + // Driver + "POST /api/drivers": ["Create a new driver", "POST", "Driver"], + "PATCH /api/drivers/:id": ["Update a driver", "PATCH", "Driver"], + "DELETE /api/drivers/:id": ["Delete a driver", "DELETE", "Driver"], + "POST /api/drivers/:id/documents": ["Upload driver documents (code driver_docs)", "POST", "Driver"], + "DELETE /api/drivers/:id/documents/:fileId": ["Delete a driver document", "DELETE", "Driver"], + + // Dropdown Setting + "POST /api/dropdown-settings": ["Create a new dropdown setting", "POST", "Dropdown Setting"], + "PATCH /api/dropdown-settings/:id": ["Update a dropdown setting's metadata", "PATCH", "Dropdown Setting"], + "DELETE /api/dropdown-settings/:id": ["Soft-delete a dropdown setting", "DELETE", "Dropdown Setting"], + "POST /api/dropdown-settings/:id/options": ["Append a single option to a setting", "POST", "Dropdown Setting"], + "PUT /api/dropdown-settings/:id/options": ["Replace the full option list for a setting", "PUT", "Dropdown Setting"], + "PATCH /api/dropdown-settings/options/:optionId": ["Update a single option", "PATCH", "Dropdown Setting"], + "DELETE /api/dropdown-settings/options/:optionId": ["Soft-delete a single option", "DELETE", "Dropdown Setting"], + + // EIMS Invoice + "POST /api/invoices/:id/eims/register": ["Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged", "POST", "EIMS Invoice"], + "POST /api/invoices/:id/eims/resolve": ["Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block", "POST", "EIMS Invoice"], + "POST /api/invoices/:id/eims/verify": ["Verify the invoice's stored IRN against EIMS", "POST", "EIMS Invoice"], + "POST /api/invoices/:id/eims/cancel": ["Cancel the invoice", "POST", "EIMS Invoice"], + "POST /api/invoices/:id/eims/receipt/sales": ["Register a sales receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"], + "POST /api/invoices/:id/eims/receipt/withholding": ["Register a withholding receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"], + "POST /api/invoices/eims/bulk-cancel": ["Cancel multiple invoices", "POST", "EIMS Invoice"], + + // Exchange Setting + "PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"], + + // Facility + "POST /api/facilities": ["Create a new facility", "POST", "Facility"], + "PATCH /api/facilities/:id": ["Update a facility", "PATCH", "Facility"], + "DELETE /api/facilities/:id": ["Delete a facility (soft delete)", "DELETE", "Facility"], + + // Fayda Verification + "POST /api/fayda/verification/start": ["Start a VeriFayda 2.0 verification session", "POST", "Fayda Verification"], + + // File Upload Setting + "POST /api/file-upload-settings": ["Create a new file upload setting", "POST", "File Upload Setting"], + "PATCH /api/file-upload-settings/:id": ["Update a file upload setting's metadata", "PATCH", "File Upload Setting"], + "DELETE /api/file-upload-settings/:id": ["Soft-delete a file upload setting", "DELETE", "File Upload Setting"], + "POST /api/file-upload-settings/:id/fields": ["Append a single field to a setting", "POST", "File Upload Setting"], + "PUT /api/file-upload-settings/:id/fields": ["Replace the full field list for a setting", "PUT", "File Upload Setting"], + "PATCH /api/file-upload-settings/fields/:fieldId": ["Update a single field", "PATCH", "File Upload Setting"], + "DELETE /api/file-upload-settings/fields/:fieldId": ["Soft-delete a single field", "DELETE", "File Upload Setting"], + + // First Mile + "POST /api/first-mile": ["Create a first-mile leg", "POST", "First Mile"], + "PATCH /api/first-mile/:id": ["Update a first-mile leg", "PATCH", "First Mile"], + "DELETE /api/first-mile/:id": ["Soft-delete a first-mile leg", "DELETE", "First Mile"], + "POST /api/first-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "First Mile"], + "POST /api/first-mile/:id/invoice": ["Generate the first-mile delivery-fee invoice", "POST", "First Mile"], + "POST /api/first-mile/:id/vehicles": ["Set the vehicles assigned to a first-mile pickup (multi-truck)", "POST", "First Mile"], + "POST /api/first-mile/accept/:reference": ["Accept a paid booking and create a first-mile leg", "POST", "First Mile"], + + // Fuel + "POST /api/fuel/purchases": ["Record fuel purchase", "POST", "Fuel"], + + // GPS Tracking + "POST /api/gps/devices": ["Register a GPS tracker", "POST", "GPS Tracking"], + "PATCH /api/gps/devices/:id": ["Update a GPS tracker (name / assigned vehicle)", "PATCH", "GPS Tracking"], + "DELETE /api/gps/devices/:id": ["Delete a GPS tracker", "DELETE", "GPS Tracking"], + + // Import Operation + "POST /api/import-operations/customs/:bookingId/declaration": ["Batch 12: record declaration serial number", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/documents": ["Batch 12: upload IM4/IM5/T1/permit/payment-slip documents", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/duties-taxes-paid": ["Batch 12: mark duties and taxes paid", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/notify-duties-taxes": ["Batch 12: notify duties and taxes", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/release-permitted": ["Batch 12: mark import release permitted", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/risk": ["Batch 12: assign customs risk", "POST", "Import Operation"], + "POST /api/import-operations/djibouti-incidents": ["Batch 8: report a Djibouti import incident / exception", "POST", "Import Operation"], + "POST /api/import-operations/empty-container-returns": ["Batch 16: create an empty container return record", "POST", "Import Operation"], + "POST /api/import-operations/empty-container-returns/:id/status": ["Batch 16: advance empty container return workflow", "POST", "Import Operation"], + "POST /api/import-operations/empty-container-returns/load-on-train": ["Load returned empties onto an export train (1×40ft or 2×20ft per wagon)", "POST", "Import Operation"], + + // Incident + "POST /api/incidents": ["Report an incident", "POST", "Incident"], + "PATCH /api/incidents/:id": ["Update an incident", "PATCH", "Incident"], + "DELETE /api/incidents/:id": ["Delete an incident", "DELETE", "Incident"], + + // Interchange Document + "PATCH /api/interchange-documents/:id/acknowledge": ["Acknowledge an interchange document", "PATCH", "Interchange Document"], + "PATCH /api/interchange-documents/:id/dispute": ["Dispute an interchange document", "PATCH", "Interchange Document"], + "POST /api/interchange-documents/generate-from-schedule": ["Generate interchange document from a train schedule handover", "POST", "Interchange Document"], + + // Last Mile + "POST /api/last-mile": ["Create a last-mile leg", "POST", "Last Mile"], + "PATCH /api/last-mile/:id": ["Update a last-mile leg", "PATCH", "Last Mile"], + "DELETE /api/last-mile/:id": ["Soft-delete a last-mile leg", "DELETE", "Last Mile"], + "POST /api/last-mile/:id/detention-times": ["Set each truck\\'s own detention window (arrived at destination / returned)", "POST", "Last Mile"], + "POST /api/last-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "Last Mile"], + "POST /api/last-mile/:id/invoice": ["Generate the delivery-fee invoice for a last-mile leg", "POST", "Last Mile"], + "POST /api/last-mile/:id/proof-of-delivery": ["Record proof of delivery (signature + photos) and complete the leg", "POST", "Last Mile"], + "POST /api/last-mile/:id/vehicles": ["Set the vehicles assigned to a last-mile delivery (multi-truck)", "POST", "Last Mile"], + "POST /api/last-mile/:id/warehouse-gate-times": ["Set each truck\\'s warehouse gate arrival/departure times", "POST", "Last Mile"], + "POST /api/last-mile/accept/:reference": ["Accept a paid booking and create a last-mile leg", "POST", "Last Mile"], + + // Last Mile Request + "POST /api/last-mile-requests/:id/approve": ["Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature", "POST", "Last Mile Request"], + "POST /api/last-mile-requests/:id/contract/sign": ["Customer agrees and signs the LM contract — then the advance invoice is issued", "POST", "Last Mile Request"], + "POST /api/last-mile-requests/:id/reject": ["Truck & Machinery chief rejects the request with a reason", "POST", "Last Mile Request"], + "POST /api/last-mile-requests/:id/submit": ["Customer confirms which containers go via EDR last-mile", "POST", "Last Mile Request"], + + // Locomotive + "POST /api/locomotives": ["Create a locomotive", "POST", "Locomotive"], + "PATCH /api/locomotives/:id": ["Update a locomotive", "PATCH", "Locomotive"], + "POST /api/locomotives/:id/decommission": ["Decommission a locomotive", "POST", "Locomotive"], + "DELETE /api/locomotives/:id/permanent": ["Permanently delete a locomotive (irreversible; refused if any train references it)", "DELETE", "Locomotive"], + + // Logo Setting + "PUT /api/logo-settings": ["Replace the company logo", "PUT", "Logo Setting"], + "DELETE /api/logo-settings": ["Clear the company logo (documents fall back to their text mark)", "DELETE", "Logo Setting"], + + // Maintenance + "POST /api/maintenance/costs": ["Record maintenance cost", "POST", "Maintenance"], + "POST /api/maintenance/intervals": ["Define/adjust a service interval (e.g. oil change every 10,000 km)", "POST", "Maintenance"], + "DELETE /api/maintenance/intervals/:id": ["Deactivate a service interval (stops auto-scheduling)", "DELETE", "Maintenance"], + "POST /api/maintenance/parts": ["Create part", "POST", "Maintenance"], + "PATCH /api/maintenance/parts/:id": ["Update part", "PATCH", "Maintenance"], + "DELETE /api/maintenance/parts/:id": ["Delete part", "DELETE", "Maintenance"], + "POST /api/maintenance/schedules": ["Schedule maintenance", "POST", "Maintenance"], + "PATCH /api/maintenance/schedules/:id": ["Update maintenance schedule", "PATCH", "Maintenance"], + "POST /api/maintenance/warranties": ["Create warranty", "POST", "Maintenance"], + "DELETE /api/maintenance/warranties/:id": ["Delete warranty", "DELETE", "Maintenance"], + "POST /api/maintenance/work-orders": ["Create work order", "POST", "Maintenance"], + "PATCH /api/maintenance/work-orders/:id": ["Update work order", "PATCH", "Maintenance"], + "DELETE /api/maintenance/work-orders/:id": ["Delete work order", "DELETE", "Maintenance"], + + // Notification Inbox + "PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "Notification Inbox"], + "POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"], + + // Organization User + "PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "Organization User"], + "POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"], + + // OTP + "POST /api/otp/send": ["Send OTP", "POST", "OTP"], + "POST /api/otp/verify": ["Verify OTP", "POST", "OTP"], + + // Password Reset + "POST /api/auth/forgot-password/request": ["Send a password-reset code to the account's email AND phone", "POST", "Password Reset"], + "POST /api/auth/forgot-password/resolve-link": ["Validate a staff-issued reset link and return its set-password ticket", "POST", "Password Reset"], + "POST /api/auth/forgot-password/verify": ["Exchange a valid reset code for a single-use set-password ticket", "POST", "Password Reset"], + "POST /api/backoffice/customers/:companyId/reset-password": ["Send a password-reset link to a customer's primary contact", "POST", "Password Reset"], + + // Payment + "POST /api/billing/invoices/:id/confirm-offline": ["Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance", "POST", "Payment"], + "POST /api/billing/my-invoices/:id/confirm": ["Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices", "POST", "Payment"], + "POST /api/billing/my-invoices/:id/pay": ["Initiate payment for one of the customer's invoices", "POST", "Payment"], + "POST /api/internal/payments/bill-query": ["Live still-payable check + payer name for a CBE bill (called while CBE is on the line)", "POST", "Payment"], + "POST /api/internal/payments/mark-paid": ["Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", "POST", "Payment"], + "POST /api/payments/initiate": ["Initiate payment for an invoice", "POST", "Payment"], + "POST /api/payments/redirect-success/:bookingId": ["Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)", "POST", "Payment"], + "POST /api/billing/invoices/:id/memo": ["Issue a credit or debit memo against a registered invoice (MoR DEB/CRE). Filing-equivalent — the auto-submit sweep picks it up like any other issued invoice.", "POST", "Payment"], + + // Payment Setting + "PATCH /api/payment-settings/manual": ["Enable or disable manual invoice settlement for ETB and/or USD", "PATCH", "Payment Setting"], + + // Priority Config + "POST /api/priority-configs": ["Create a priority config", "POST", "Priority Config"], + "PATCH /api/priority-configs/:id": ["Update a priority config", "PATCH", "Priority Config"], + "DELETE /api/priority-configs/:id": ["Soft-delete a priority config", "DELETE", "Priority Config"], + "POST /api/priority-configs/:id/move-order": ["Move a priority config up or down in display order", "POST", "Priority Config"], + "POST /api/priority-configs/reorder": ["Bulk reorder priority configs by ID list", "POST", "Priority Config"], + + // Priority Rule Change Request + "POST /api/priority-rule-change-requests": ["Submit a priority-rule change for approval", "POST", "Priority Rule Change Request"], + "POST /api/priority-rule-change-requests/:id/approve": ["Approve and apply a pending change", "POST", "Priority Rule Change Request"], + "POST /api/priority-rule-change-requests/:id/reject": ["Reject a pending change", "POST", "Priority Rule Change Request"], + + // Procurement + "POST /api/procurement/acquisitions": ["Create an asset acquisition", "POST", "Procurement"], + "PATCH /api/procurement/acquisitions/:id": ["Update an asset acquisition", "PATCH", "Procurement"], + "DELETE /api/procurement/acquisitions/:id": ["Delete an asset acquisition", "DELETE", "Procurement"], + "POST /api/procurement/disposals": ["Create an asset disposal", "POST", "Procurement"], + "DELETE /api/procurement/disposals/:id": ["Delete an asset disposal", "DELETE", "Procurement"], + "POST /api/procurement/vendors": ["Create a vendor", "POST", "Procurement"], + "PATCH /api/procurement/vendors/:id": ["Update a vendor", "PATCH", "Procurement"], + "DELETE /api/procurement/vendors/:id": ["Delete a vendor", "DELETE", "Procurement"], + + // Rate + "POST /api/rates": ["Create a rate (DRAFT)", "POST", "Rate"], + "PATCH /api/rates/:id": ["Update a DRAFT rate", "PATCH", "Rate"], + "DELETE /api/rates/:id": ["Soft-delete a rate", "DELETE", "Rate"], + "POST /api/rates/:id/approve": ["CEO approves a rate", "POST", "Rate"], + "POST /api/rates/:id/submit": ["Submit rate for CEO approval", "POST", "Rate"], + + // Rate Change Request + "POST /api/rate-change-requests": ["Propose a change to a LIVE rate", "POST", "Rate Change Request"], + "POST /api/rate-change-requests/:id/approve": ["Approve a rate change and put it into effect", "POST", "Rate Change Request"], + "POST /api/rate-change-requests/:id/reject": ["Reject a rate change — the rate keeps its current value", "POST", "Rate Change Request"], + + // Route + "POST /api/routes": ["Create route", "POST", "Route"], + "PATCH /api/routes/:id": ["Update route", "PATCH", "Route"], + "DELETE /api/routes/:id": ["Deactivate route", "DELETE", "Route"], + "DELETE /api/routes/:id/permanent": ["Permanently delete a route (irreversible; refused while any train schedule references it)", "DELETE", "Route"], + + // Schedule + // NOTE: duplicate route — also declared in modules/scheduling-reschedule/scheduling-reschedule.controller.ts:52. + // Two controllers register this same path; Nest serves whichever module loads first. + "POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"], + "POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"], + "POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"], + + // Service Type + "POST /api/service-types": ["Create a service type", "POST", "Service Type"], + "PATCH /api/service-types/:id": ["Update a service type", "PATCH", "Service Type"], + "DELETE /api/service-types/:id": ["Soft-delete a service type", "DELETE", "Service Type"], + "POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"], + "POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"], + + // Shipping Line (rule-engine lookup list — a code/label bookings reference, + // not an account) + "POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"], + "PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"], + "DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"], + + // Shipping Line Booking + "POST /api/shipping-line-bookings/initiate": ["Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.", "POST", "Shipping Line Booking"], + "POST /api/shipping-line-bookings/:id/cancel": ["Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.", "POST", "Shipping Line Booking"], + "POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"], + "POST /api/shipping-line-bookings/:id/complete": ["Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day.", "POST", "Shipping Line Booking"], + + // Shipping Line Credit + "POST /api/shipping-line-credits/invoice": ["Bill a batch of unbilled credits as one invoice. All credits must belong to the same shipping line.", "POST", "Shipping Line Credit"], + "POST /api/shipping-line-credits/:creditId/cancel": ["Write off an unbilled credit. Once billed, cancel the invoice instead.", "POST", "Shipping Line Credit"], + "POST /api/shipping-line-credits/invoices/:invoiceId/mark-paid-request": ["Request recording a full offline payment against a credit invoice (awaits chief approval).", "POST", "Shipping Line Credit"], + "POST /api/shipping-line-credits/invoices/:invoiceId/cancel-request": ["Request voiding a credit invoice — its credits return to the unbilled pool (awaits chief approval).", "POST", "Shipping Line Credit"], + "POST /api/shipping-line-credits/invoice-actions/:approvalId/approve": ["Approve a pending invoice request — executes the offline settlement or the cancellation.", "POST", "Shipping Line Credit"], + "POST /api/shipping-line-credits/invoice-actions/:approvalId/reject": ["Reject a pending invoice request — nothing is changed.", "POST", "Shipping Line Credit"], + + // Shipping Line Company (carrier with a portal login, registered by staff) + "POST /api/shipping-line-companies": ["Register a shipping line company and send its activation link", "POST", "Shipping Line Company"], + "POST /api/shipping-line-companies/:id/resend-activation": ["Resend a shipping line company's activation link", "POST", "Shipping Line Company"], + + // Signature + "PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"], + + // Stamp Setting + "PUT /api/stamp-settings": ["Replace the company stamp", "PUT", "Stamp Setting"], + "DELETE /api/stamp-settings": ["Clear the company stamp (invoices fall back to the plain seal)", "DELETE", "Stamp Setting"], + + // Support Chat + "POST /api/support/agent/conversations": ["Start chatting with a company (returns the thread if one exists)", "POST", "Support Chat"], + "POST /api/support/agent/conversations/:id/messages": ["Reply as an agent, optionally with attachments", "POST", "Support Chat"], + "POST /api/support/agent/conversations/:id/read": ["Mark a thread read (agent side)", "POST", "Support Chat"], + "POST /api/support/conversation/messages": ["Send a message as the customer (optionally with attachments), opening the thread if needed", "POST", "Support Chat"], + "POST /api/support/conversation/read": ["Mark my company's thread read (customer side)", "POST", "Support Chat"], + + // Support Content + "PATCH /api/support-content/documents/:slug": ["Replace a document's payload, recording a new version", "PATCH", "Support Content"], + "POST /api/support-content/documents/:slug/versions/:version/restore": ["Restore a version — re-saves it as a new version, never destructive", "POST", "Support Content"], + "POST /api/support-content/media": ["Upload an image or video for a help section", "POST", "Support Content"], + + // Train + "POST /api/trains": ["Register a new train", "POST", "Train"], + "PATCH /api/trains/:id": ["Update a train", "PATCH", "Train"], + "DELETE /api/trains/:id": ["Delete a train", "DELETE", "Train"], + + // Train Build + "POST /api/train-builder": ["Build a train: code + yard + 2+ locomotives (+ optional wagons)", "POST", "Train Build"], + "DELETE /api/train-builder/:id": ["Disband the train (release wagons and locomotives)", "DELETE", "Train Build"], + "POST /api/train-builder/:id/activate": ["Reactivate a deactivated train back to AVAILABLE", "POST", "Train Build"], + "POST /api/train-builder/:id/deactivate": ["Deactivate the train (park it) — only allowed with no active schedule", "POST", "Train Build"], + "PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "Train Build"], + "PUT /api/train-builder/:id/locomotives": ["Replace the locomotive set (minimum 1, same yard)", "PUT", "Train Build"], + "POST /api/train-builder/:id/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"], + "POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the consist", "POST", "Train Build"], + "DELETE /api/train-builder/:id/wagons/:wagonId": ["Detach one wagon from the consist", "DELETE", "Train Build"], + "POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"], + "PATCH /api/train-builder/:id/yard": ["Relocate the train — its locomotives and wagons move to the new yard with it", "PATCH", "Train Build"], + + // Train Schedule + "POST /api/train-scheduling/bookings/:bookingId/allocate": ["Staff: place a paid booking onto a fitting train (notifies customer on date change)", "POST", "Train Schedule"], + "POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "POST", "Train Schedule"], + "POST /api/train-scheduling/bookings/:bookingId/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"], + "POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/assign-unassigned-booking": ["Assign one linked unallocated booking to wagons (preserves existing assignments)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/booking-window": ["Open or close a schedule booking window", "PATCH", "Train Schedule"], + "DELETE /api/train-scheduling/schedules/:id/bookings/:bookingId": ["Unassign a booking from a train schedule", "DELETE", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/bookings/:bookingId/load": ["Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/bookings/:bookingId/unload": ["Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/checkpoints": ["Log the train passing a station (final station triggers arrival)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/confirm-loading": ["Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/container-items/:itemId": ["Update a container number on a wagon slot", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/dispatch": ["Dispatch a scheduled train", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/doc-review-complete": ["Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/finalize": ["Finalize a draft train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/depart": ["Depart loaded import train from Djibouti", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/documents": ["Upload/check an import Djibouti-side document", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/gatepass-granted": ["Mark import Djibouti gatepass permission granted", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/load-list": ["Generate import load list / marshalling document summary", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/loaded-on-train": ["Confirm import cargo loaded on train at Djibouti", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/ready-for-loading": ["Mark import train ready for loading at Djibouti", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/import-loading-status": ["Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/intercity/:bookingId/load": ["Confirm intercity cargo loaded (train must be at the booking's origin yard)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/intercity/:bookingId/unload": ["Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/intercity/accept": ["Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/loading-status": ["Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/pin-wagons": ["Pin physical wagons to train set slots", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/run-allocation": ["Run wagon-level allocation for all eligible linked bookings", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/run-batch": ["Manually run the batch fill for a schedule", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/schedule-date": ["Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window", "PATCH", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/train-number": ["Edit a departure's train number and voyage number — allowed only until the train is dispatched", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/switch-government-booking": ["Switch out commercial bookings to allocate a government booking in their place", "POST", "Train Schedule"], + "DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/merge": ["Merge another train into this schedule: its wagons join this consist, a same-day schedule on it is absorbed, and the emptied train is deactivated", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/checkpoints/:sequenceNo": ["Edit a logged leg", "PATCH", "Train Schedule"], + + // Transit Agent + "POST /api/transit-agents": ["Create a transit agent", "POST", "Transit Agent"], + "PATCH /api/transit-agents/:id": ["Update a transit agent", "PATCH", "Transit Agent"], + "DELETE /api/transit-agents/:id": ["Soft-delete a transit agent", "DELETE", "Transit Agent"], + + // Truck Type + "POST /api/truck-types": ["Create a truck type", "POST", "Truck Type"], + "PATCH /api/truck-types/:id": ["Update a truck type", "PATCH", "Truck Type"], + "DELETE /api/truck-types/:id": ["Soft-delete a truck type", "DELETE", "Truck Type"], + + // User Trade Access + "PUT /api/user-trade-access/:userId": ["Set the trade directions a backoffice user may see", "PUT", "User Trade Access"], + + // Vehicle + "POST /api/vehicles": ["Create a new vehicle", "POST", "Vehicle"], + "PATCH /api/vehicles/:id": ["Update a vehicle", "PATCH", "Vehicle"], + "DELETE /api/vehicles/:id": ["Delete a vehicle", "DELETE", "Vehicle"], + + // Wagon + "POST /api/wagons": ["Create a new wagon", "POST", "Wagon"], + "PATCH /api/wagons/:id": ["Update a wagon", "PATCH", "Wagon"], + "DELETE /api/wagons/:id": ["Delete a wagon", "DELETE", "Wagon"], + "POST /api/wagons/:id/assign-train": ["Assign wagon to a train", "POST", "Wagon"], + "DELETE /api/wagons/:id/permanent": ["Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)", "DELETE", "Wagon"], + "POST /api/wagons/:id/unassign-train": ["Unassign wagon from train", "POST", "Wagon"], + "POST /api/wagons/bulk-status": ["Set the status of multiple wagons (audited in wagon_status_logs)", "POST", "Wagon"], + "POST /api/wagons/bulk-transfer": ["Transfer multiple wagons to a destination yard", "POST", "Wagon"], + + // Wagon Transfer Request + "POST /api/wagon-transfer-requests": ["File a count-only wagon-transfer request", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/:id/cancel": ["Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/:id/close-short": ["OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/:id/fulfill": ["OCC: pick wagons and execute the transfer", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/bulk-fulfill": ["OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING)", "POST", "Wagon Transfer Request"], + + // Wagon Type + "POST /api/wagon-types": ["Create a wagon type", "POST", "Wagon Type"], + "PATCH /api/wagon-types/:id": ["Update a wagon type", "PATCH", "Wagon Type"], + "DELETE /api/wagon-types/:id": ["Soft-delete a wagon type", "DELETE", "Wagon Type"], + + // Warehouse + "POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "Warehouse"], + "PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"], + "DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"], + "POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"], + "POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"], + "PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"], + "DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"], + "POST /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Acknowledge / snooze an item fee-accrual alert", "POST", "Warehouse"], + "DELETE /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Remove an accrual acknowledgement (re-surface for alerts)", "DELETE", "Warehouse"], + "POST /api/warehouses": ["Create warehouse", "POST", "Warehouse"], + "PATCH /api/warehouses/:id": ["Update warehouse", "PATCH", "Warehouse"], + "POST /api/warehouses/:warehouseId/yards": ["Create a yard within a warehouse", "POST", "Warehouse"], + + // Warehouse Fee Invoice + "POST /api/last-mile/:id/generate-truck-detention-invoice": ["Generate a truck-detention invoice for a last-mile leg (per truck per day)", "POST", "Warehouse Fee Invoice"], + "PATCH /api/warehouse-fee-invoices/:id/cancel": ["Cancel a warehouse fee invoice", "PATCH", "Warehouse Fee Invoice"], + "POST /api/warehouse-fee-invoices/:id/pay": ["Record a payment against a warehouse fee invoice", "POST", "Warehouse Fee Invoice"], + "POST /api/warehouse-fee-invoices/:id/pay-online": ["Initiate Telebirr/Waafi payment for a warehouse fee invoice", "POST", "Warehouse Fee Invoice"], + "POST /api/warehouse-inventory/:id/generate-fee-invoice": ["Generate a warehouse fee invoice from Batch 5 fee calculation", "POST", "Warehouse Fee Invoice"], + + // Warehouse Inspection Report + "PATCH /api/warehouse-inspection-reports/:id": ["Update an inspection report", "PATCH", "Warehouse Inspection Report"], + "POST /api/warehouse-inspection-reports/:id/attachments": ["Upload inspection images / documents", "POST", "Warehouse Inspection Report"], + "POST /api/warehouse-inventory/:inventoryId/inspection-reports": ["Create an inspection / damage report for an inventory item", "POST", "Warehouse Inspection Report"], + + // Warehouse Inventory + "POST /api/warehouse-inventory/:id/deliver": ["Deliver import goods to the customer + capture proof of delivery", "POST", "Warehouse Inventory"], + "PATCH /api/warehouse-inventory/:id/dispatch": ["Mark loaded inventory DISPATCHED (left the terminal)", "PATCH", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/gate-clearance": ["Final terminal release / gate clearance (blocked while fees unpaid)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/load": ["Load READY_FOR_LOADING inventory onto a wagon", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/move": ["Move inventory to another warehouse/yard/zone", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/ready-for-loading": ["Mark reserved inventory READY_FOR_LOADING", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/ready-for-pickup": ["Mark inspected IMPORT inventory READY_FOR_PICKUP", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/release": ["Issue a DO / release order for ready-for-pickup inventory", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/store": ["Mark received inventory as STORED (optional explicit warehouse/yard/zone)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/auto-load-ready": ["Auto-load READY_FOR_LOADING inventory with PAID bookings", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/auto-unload-arrived": ["Bulk auto-unload all arrived bookings into the warehouse", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bookings/:bookingId/approve-delivery": ["Approve delivery — customer records their full name (signature optional)", "POST", "Warehouse Inventory"], + "PATCH /api/warehouse-inventory/bookings/:bookingId/double-handling": ["Record Yes/No double handling after unloading (Yes applies the double-handling fee rule)", "PATCH", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bookings/:bookingId/request-handover-signature": ["Ask the customer to sign the handover (creates one if none, then notifies)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bookings/:bookingId/unload": ["Unload a single arrived booking into a location", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bulk-dispatch-export": ["Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bulk-mark-inspected": ["Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/export/auto-unload-at-djibouti": ["Unload all eligible export items assigned to an arrived Djibouti-side train", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/handovers/:handoverId/sign": ["Customer signs one handover (EDR last-mile: one signature per truck)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/import/auto-unload-arrived-bookings": ["Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/receive": ["Receive inventory at a warehouse location", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/receive-bulk": ["Bulk-receive selected eligible PAID bookings into a location", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/reserve": ["Reserve stored inventory for a PAID booking", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/train/:scheduleId/load": ["Load selected inventory items onto their allocated wagons for a train", "POST", "Warehouse Inventory"], + + // Warehouse Yard + "PATCH /api/warehouse-yards/:id": ["Update warehouse yard", "PATCH", "Warehouse Yard"], + "POST /api/warehouse-yards/:yardId/zones": ["Create a zone within a yard", "POST", "Warehouse Yard"], + + // Warehouse Zone + "PATCH /api/warehouse-zones/:id": ["Update warehouse zone", "PATCH", "Warehouse Zone"], + + // Weight Limit Rule + "POST /api/weight-limit-rules": ["Create a weight limit rule", "POST", "Weight Limit Rule"], + "PATCH /api/weight-limit-rules/:id": ["Update a weight limit rule", "PATCH", "Weight Limit Rule"], + "DELETE /api/weight-limit-rules/:id": ["Soft-delete a weight limit rule", "DELETE", "Weight Limit Rule"], + + // Yard + // Yard Position (desk↔yard mapping — an input to yard access scoping, so + // every change to it is evidence of who widened or narrowed someone's reach) + "PUT /api/yard-positions/yard/:yardId": ["Replace a yard's whole position set", "PUT", "Yard Position"], + "PUT /api/yard-positions/position/:positionId": ["Replace a position's whole yard set", "PUT", "Yard Position"], + + "POST /api/yards": ["Create a yard", "POST", "Yard"], + "PATCH /api/yards/:id": ["Update a yard", "PATCH", "Yard"], + "DELETE /api/yards/:id": ["Soft-delete a yard", "DELETE", "Yard"], + "POST /api/yards/:id/move-order": ["Move a yard up or down in display order", "POST", "Yard"], + "POST /api/yards/reorder": ["Bulk reorder yards by ID list", "POST", "Yard"], + + // Yard Distance + "POST /api/yard-distances": ["Create a yard distance", "POST", "Yard Distance"], + "PATCH /api/yard-distances/:id": ["Update a yard distance", "PATCH", "Yard Distance"], + "DELETE /api/yard-distances/:id": ["Soft-delete a yard distance", "DELETE", "Yard Distance"], +}; diff --git a/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts b/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts new file mode 100644 index 000000000..91d6c9901 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from '@edr/api-common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Between, FindOptionsWhere, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; +import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; + +import { AuditLog } from './entities/audit-log.entity'; + +export interface AuditLogQuery { + type?: string; + userId?: string; + method?: string; + isSuccess?: boolean; + resourceId?: string; + from?: Date; + to?: Date; + skip: number; + take: number; +} + +@Injectable() +export class AuditLogRepository extends BaseRepository { + constructor( + @InjectRepository(AuditLog) + private readonly auditLogRepository: Repository, + ) { + super(auditLogRepository); + } + + /** + * Insert one audit row. + * + * `insert` rather than `save`: save would issue a SELECT first to decide + * between insert and update, which is wasted work for a table that is only + * ever appended to. + */ + async record(entry: Partial): Promise { + await this.auditLogRepository.insert( + entry as QueryDeepPartialEntity, + ); + } + + /** + * Paginated, filtered read. Newest first — every index on this table is + * ordered `created_at DESC` to match. + */ + async search(query: AuditLogQuery): Promise<[AuditLog[], number]> { + const where: FindOptionsWhere = {}; + + if (query.type) where.type = query.type; + if (query.userId) where.userId = query.userId; + if (query.method) where.method = query.method; + if (query.resourceId) where.resourceId = query.resourceId; + if (query.isSuccess !== undefined) where.isSuccess = query.isSuccess; + + // Date range: either bound may be supplied alone. + if (query.from && query.to) where.createdAt = Between(query.from, query.to); + else if (query.from) where.createdAt = MoreThanOrEqual(query.from); + else if (query.to) where.createdAt = LessThanOrEqual(query.to); + + return this.auditLogRepository.findAndCount({ + where, + order: { createdAt: 'DESC' }, + skip: query.skip, + take: query.take, + }); + } + + /** Distinct entity types present, for populating a filter dropdown. */ + async distinctTypes(): Promise { + const rows = await this.auditLogRepository + .createQueryBuilder('audit_log') + .select('DISTINCT audit_log.type', 'type') + .orderBy('audit_log.type', 'ASC') + .getRawMany<{ type: string }>(); + + return rows.map((row) => row.type); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit.controller.ts b/apps/edr-freight-api/src/modules/audit/audit.controller.ts index a7c8782b2..1a07a5fe5 100644 --- a/apps/edr-freight-api/src/modules/audit/audit.controller.ts +++ b/apps/edr-freight-api/src/modules/audit/audit.controller.ts @@ -1,32 +1,46 @@ -import { Controller, Get, Query } from "@nestjs/common"; -import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { PaginatedResponse } from '@edr/types'; -import { BookingStaff } from "../../common/booking-guards"; -import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; -import { AuditService } from "./audit.service"; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { AuditService } from './audit.service'; +import { AuditLog } from './entities/audit-log.entity'; +import { AuditLogQueryDto } from './dto/audit-log-query.dto'; -@ApiTags("audit") -@Controller("audit") -@BookingStaff(FREIGHT_PERMS.audit.view) +/** + * Read-only view over the audit trail. + * + * Gated on `edr_freight_app:audit_log:view` — a dedicated view key rather than + * the broad `admin` key, so reading the trail can be granted without also + * granting write access to everything else. + * + * There is deliberately no write, update or delete endpoint here — rows are + * created only by `AuditInterceptor`, and an audit trail that can be edited + * through the API is not an audit trail. + */ +@ApiTags('audit') +@ApiBearerAuth() +@Controller('audit') export class AuditController { constructor(private readonly auditService: AuditService) {} - @Get("logs") - @ApiOperation({ summary: "List freight-api audit log commands" }) - @ApiQuery({ name: "skip", type: Number, required: false }) - @ApiQuery({ name: "take", type: Number, required: false }) - list(@Query("skip") skip?: string, @Query("take") take?: string) { - // Same fallback chain @tria-plc/auditlog's client interceptor uses to - // stamp AuditLog.application (mezgeb/client/client-audit.interceptor.js) - // — reading it here instead of a hardcoded literal means this can't - // silently drift out of sync with whatever APPLICATION_NAME/APP_NAME - // actually is at runtime. - const application = - process.env.APPLICATION_NAME ?? process.env.APP_NAME ?? "DEFAULT"; - return this.auditService.list( - application, - skip !== undefined ? parseInt(skip, 10) : undefined, - take !== undefined ? parseInt(take, 10) : undefined, - ); + @Get('logs') + @BookingStaff(FREIGHT_PERMS.auditLog.view) + @ApiOperation({ + summary: + 'List backoffice audit logs — filter by entity type, user, method, outcome and date range', + }) + list(@Query() query: AuditLogQueryDto): Promise> { + return this.auditService.search(query); + } + + @Get('types') + @BookingStaff(FREIGHT_PERMS.auditLog.view) + @ApiOperation({ + summary: 'Distinct entity types present in the audit log (filter dropdown)', + }) + types(): Promise { + return this.auditService.listTypes(); } } diff --git a/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts b/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts new file mode 100644 index 000000000..cf12fd4d3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts @@ -0,0 +1,189 @@ +import { + CallHandler, + ExecutionContext, + HttpException, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable, tap } from 'rxjs'; +import type { Request, Response } from 'express'; + +import { AuditService } from './audit.service'; +import { + auditEndpointMatcher, + type MatchedAuditEndpoint, +} from './audit-endpoint-matcher'; +import { + isAuditableActor, + resolveAuditActor, + type AuditActorSource, +} from './audit-actor'; +import { redactUrlQuery, sanitizeRequestPayload } from './audit.sanitizer'; + +/** Methods that can change state. Everything else is never audited. */ +const AUDITED_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +/** `error_message` ceiling — stack traces do not belong in this column. */ +const MAX_ERROR_LENGTH = 2_000; + +type RequestWithUser = Request & { + user?: AuditActorSource; + files?: unknown; + file?: unknown; + id?: string; +}; + +/** + * Writes one `audit_logs` row per state-changing backoffice request. + * + * An interceptor rather than the two middlewares originally sketched, for one + * decisive reason: Express middleware runs BEFORE guards, so `req.user` is not + * populated yet. Both the backoffice-only rule and `user_id` would be + * unavailable there. Interceptors run after guards and wrap the handler's + * result, so a single class covers both halves — request context on the way in, + * outcome on the way out — sharing one timer for `duration_ms`. + * + * Registered globally (see `audit.module.ts`), so new routes are covered + * automatically as long as they appear in `AUDIT_ENDPOINTS`. + */ +@Injectable() +export class AuditInterceptor implements NestInterceptor { + constructor(private readonly auditService: AuditService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + // Non-HTTP contexts (the RabbitMQ microservice transport) have no request. + if (context.getType() !== 'http') return next.handle(); + + const httpContext = context.switchToHttp(); + const request = httpContext.getRequest(); + + if (!AUDITED_METHODS.has(request.method)) return next.handle(); + + // Backoffice only. Customers and unauthenticated callers are skipped + // outright — decided in `audit-actor.ts`, which reuses the same + // `userType` discriminator as the permission guards. + if (!isAuditableActor(request.user)) return next.handle(); + + const matched = auditEndpointMatcher.match(request.method, request.originalUrl); + // Not in AUDIT_ENDPOINTS means the route is not a known auditable action; + // recording it would produce rows with no title or entity. + if (!matched) return next.handle(); + + const startedAt = Date.now(); + // The body is captured up front: handlers are free to mutate the DTO they + // are given, so reading it after the fact can record post-mutation values. + const requestPayload = sanitizeRequestPayload( + request.body, + request.files ?? request.file, + ); + + return next.handle().pipe( + tap({ + next: () => { + const response = httpContext.getResponse(); + void this.write(request, matched, requestPayload, startedAt, { + isSuccess: true, + // Nest has not applied the handler's @HttpCode yet at this point + // for some routes; statusCode on the response object is the value + // actually being sent. + statusCode: response.statusCode, + errorMessage: null, + }); + }, + error: (error: unknown) => { + void this.write(request, matched, requestPayload, startedAt, { + isSuccess: false, + statusCode: resolveErrorStatus(error), + errorMessage: resolveErrorMessage(error), + }); + }, + }), + ); + } + + /** + * Build and persist the row. + * + * Deliberately not awaited by `intercept`: the audit write must not add + * latency to the request, and `AuditService.record` already swallows its own + * failures so a rejected promise cannot surface as an unhandled rejection. + */ + private async write( + request: RequestWithUser, + matched: MatchedAuditEndpoint, + requestPayload: Record | null, + startedAt: number, + outcome: { + isSuccess: boolean; + statusCode: number | null; + errorMessage: string | null; + }, + ): Promise { + const actor = resolveAuditActor(request.user as AuditActorSource); + + await this.auditService.record({ + title: matched.title, + method: request.method, + // Full URL including query string, with sensitive query values redacted. + url: redactUrlQuery(request.originalUrl), + routePath: matched.routePath, + type: matched.type, + isSuccess: outcome.isSuccess, + statusCode: outcome.statusCode, + errorMessage: outcome.errorMessage, + userId: actor.userId, + userName: actor.userName, + userRole: actor.userRole, + resourceId: matched.resourceId, + request: requestPayload, + ipAddress: resolveIp(request), + userAgent: request.headers['user-agent'] ?? null, + requestId: resolveRequestId(request), + durationMs: Date.now() - startedAt, + }); + } +} + +/** HTTP status for the failure, falling back to 500 for non-HTTP errors. */ +function resolveErrorStatus(error: unknown): number { + return error instanceof HttpException ? error.getStatus() : 500; +} + +/** Message only — stack traces belong in application logs, not this column. */ +function resolveErrorMessage(error: unknown): string | null { + if (error instanceof HttpException) { + const response = error.getResponse(); + const message = + typeof response === 'string' + ? response + : ((response as { message?: unknown })?.message ?? error.message); + const text = Array.isArray(message) ? message.join('; ') : String(message); + return text.slice(0, MAX_ERROR_LENGTH); + } + + if (error instanceof Error) return error.message.slice(0, MAX_ERROR_LENGTH); + return error ? String(error).slice(0, MAX_ERROR_LENGTH) : null; +} + +/** + * Client IP. The API sits behind a reverse proxy, so `req.ip` is the proxy + * unless `trust proxy` is set; the forwarded header is preferred and its first + * entry (the original client) taken. + */ +function resolveIp(request: Request): string | null { + const forwarded = request.headers['x-forwarded-for']; + const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded; + const candidate = raw?.split(',')[0]?.trim() || request.ip; + if (!candidate) return null; + + // Normalize IPv4-mapped IPv6 (`::ffff:10.0.0.1`), which the `inet` column + // accepts but which reads badly and breaks grouping by address. + return candidate.startsWith('::ffff:') ? candidate.slice(7) : candidate; +} + +/** Correlation id from the proxy/tracing layer, when present. */ +function resolveRequestId(request: RequestWithUser): string | null { + const header = request.headers['x-request-id'] ?? request.headers['x-correlation-id']; + const value = Array.isArray(header) ? header[0] : header; + return (value ?? request.id ?? null)?.toString().slice(0, 64) ?? null; +} diff --git a/apps/edr-freight-api/src/modules/audit/audit.module.ts b/apps/edr-freight-api/src/modules/audit/audit.module.ts index 635973fc6..608af3672 100644 --- a/apps/edr-freight-api/src/modules/audit/audit.module.ts +++ b/apps/edr-freight-api/src/modules/audit/audit.module.ts @@ -1,13 +1,34 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; -import { AuditLogCommand } from "@tria-plc/auditlog"; +import { Global, Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { TypeOrmModule } from '@nestjs/typeorm'; -import { AuditController } from "./audit.controller"; -import { AuditService } from "./audit.service"; +import { AuditController } from './audit.controller'; +import { AuditInterceptor } from './audit.interceptor'; +import { AuditLog } from './entities/audit-log.entity'; +import { AuditLogRepository } from './audit-log.repository'; +import { AuditService } from './audit.service'; +/** + * Backoffice audit trail. + * + * `AuditInterceptor` is bound through `APP_INTERCEPTOR`, so it applies to every + * route in the application without touching the 488 mutating handlers + * individually. Coverage therefore follows `AUDIT_ENDPOINTS`: a new route is + * audited as soon as it appears in that map, and unknown routes are skipped + * rather than recorded with an empty title. + * + * Global so other modules can inject `AuditService` to record domain events + * that do not map cleanly onto an HTTP request. + */ +@Global() @Module({ - imports: [TypeOrmModule.forFeature([AuditLogCommand])], + imports: [TypeOrmModule.forFeature([AuditLog])], controllers: [AuditController], - providers: [AuditService], + providers: [ + AuditLogRepository, + AuditService, + { provide: APP_INTERCEPTOR, useClass: AuditInterceptor }, + ], + exports: [AuditService, AuditLogRepository], }) export class AuditModule {} diff --git a/apps/edr-freight-api/src/modules/audit/audit.sanitizer.ts b/apps/edr-freight-api/src/modules/audit/audit.sanitizer.ts new file mode 100644 index 000000000..3a934beea --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.sanitizer.ts @@ -0,0 +1,195 @@ +/** + * Redaction and shrinking for anything copied into `audit_logs.request`. + * + * This matters more here than in a typical audit log. `main.ts` raises the JSON + * body ceiling to 100MB so contract signing can post a signature AND a company + * stamp as base64 in one request. Copying a body like that verbatim would put + * both a credential-grade artefact and a 100MB blob into the audit table, on + * the write path of every audited endpoint. + */ + +const REDACTED = '[REDACTED]'; + +/** + * Substring-matched against lower-cased key names, so `newPassword`, + * `otpCode` and `x-authorization` are all caught without enumerating variants. + * + * `signature` and `stamp` are here because contract signing posts both as + * base64 — they are simultaneously the largest and the most sensitive fields + * this API accepts. + */ +const SENSITIVE_KEY_PATTERNS = [ + 'password', + 'otp', + 'token', + 'secret', + 'pin', + 'authorization', + 'signature', + 'stamp', + 'apikey', + 'api_key', + 'credential', + 'ssn', +]; + +/** Serialized `request` ceiling. Beyond this the payload is dropped for a marker. */ +const MAX_REQUEST_BYTES = 64 * 1024; + +/** Depth guard: deep nesting is never worth the recursion cost here. */ +const MAX_DEPTH = 6; + +/** Long strings (base64 blobs) are truncated rather than stored whole. */ +const MAX_STRING_LENGTH = 2_000; + +function isSensitiveKey(key: string): boolean { + const lower = key.toLowerCase(); + return SENSITIVE_KEY_PATTERNS.some((pattern) => lower.includes(pattern)); +} + +/** + * Multer file shape, reduced to a descriptor. The buffer is never stored — + * Postgres is the wrong home for file bytes, and `audit_logs` doubly so. + */ +function isMulterFile(value: unknown): boolean { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as Record; + return ( + typeof candidate.originalname === 'string' && + (typeof candidate.mimetype === 'string' || typeof candidate.size === 'number') + ); +} + +function describeFile(value: Record): Record { + return { + __file: true, + originalName: value.originalname ?? null, + mimeType: value.mimetype ?? null, + size: typeof value.size === 'number' ? value.size : null, + fieldName: value.fieldname ?? null, + }; +} + +function sanitizeValue(value: unknown, depth: number): unknown { + if (value === null || value === undefined) return value ?? null; + + if (typeof value === 'string') { + return value.length > MAX_STRING_LENGTH + ? `${value.slice(0, MAX_STRING_LENGTH)}…[truncated ${value.length} chars]` + : value; + } + + if (typeof value === 'number' || typeof value === 'boolean') return value; + if (value instanceof Date) return value.toISOString(); + // Buffers are file bytes by definition — never persisted, only described. + if (Buffer.isBuffer(value)) return { __buffer: true, size: value.length }; + + if (depth >= MAX_DEPTH) return '[MAX_DEPTH]'; + + if (Array.isArray(value)) { + // Cap array length: bulk endpoints post large collections. + const capped = value.slice(0, 50).map((item) => sanitizeValue(item, depth + 1)); + if (value.length > 50) capped.push(`…[${value.length - 50} more items]`); + return capped; + } + + if (typeof value === 'object') { + if (isMulterFile(value)) return describeFile(value as Record); + + const out: Record = {}; + for (const [key, nested] of Object.entries(value as Record)) { + out[key] = isSensitiveKey(key) ? REDACTED : sanitizeValue(nested, depth + 1); + } + return out; + } + + // Functions, symbols and anything else are not audit data. + return null; +} + +/** + * Sanitize a request body (or query object) for storage. + * + * Returns null when there is nothing worth keeping, so empty bodies do not + * occupy jsonb rows. + */ +export function sanitizeRequestPayload( + body: unknown, + files?: unknown, +): Record | null { + const payload: Record = {}; + + if (body && typeof body === 'object' && Object.keys(body).length > 0) { + const sanitizedBody = sanitizeValue(body, 0); + if (sanitizedBody && typeof sanitizedBody === 'object') { + Object.assign(payload, sanitizedBody as Record); + } + } + + // Multer puts uploads on `req.files`, outside `req.body`, so they are folded + // in explicitly — otherwise a pure-upload request records an empty payload. + if (files) { + const sanitizedFiles = sanitizeValue(files, 0); + if ( + sanitizedFiles && + (Array.isArray(sanitizedFiles) || typeof sanitizedFiles === 'object') + ) { + const hasEntries = Array.isArray(sanitizedFiles) + ? sanitizedFiles.length > 0 + : Object.keys(sanitizedFiles as object).length > 0; + if (hasEntries) payload.__uploads = sanitizedFiles; + } + } + + if (Object.keys(payload).length === 0) return null; + + // Final size guard. A body can stay under every per-field cap and still be + // enormous in aggregate, so the serialized form is measured before storing. + const serialized = JSON.stringify(payload); + if (serialized && Buffer.byteLength(serialized, 'utf8') > MAX_REQUEST_BYTES) { + return { + __truncated: true, + reason: 'Payload exceeded the audit size limit', + bytes: Buffer.byteLength(serialized, 'utf8'), + keys: Object.keys(payload).slice(0, 50), + }; + } + + return payload; +} + +/** + * Rebuild a URL with sensitive query values redacted. + * + * `url` is stored with its full query string, and query strings are a common + * place for one-time tokens and signed links, so the same deny-list that + * protects the body is applied to the query. + */ +export function redactUrlQuery(url: string): string { + const queryIndex = url.indexOf('?'); + if (queryIndex === -1) return url; + + const path = url.slice(0, queryIndex); + const query = url.slice(queryIndex + 1); + if (!query) return path; + + const redacted = query + .split('&') + .map((pair) => { + const eq = pair.indexOf('='); + if (eq === -1) return pair; + const key = pair.slice(0, eq); + // Keys arrive percent-encoded; decode before matching so `api%2Dkey` + // is not treated as harmless. + let decodedKey = key; + try { + decodedKey = decodeURIComponent(key); + } catch { + /* malformed encoding — fall back to the raw key */ + } + return isSensitiveKey(decodedKey) ? `${key}=${REDACTED}` : pair; + }) + .join('&'); + + return `${path}?${redacted}`; +} diff --git a/apps/edr-freight-api/src/modules/audit/audit.service.ts b/apps/edr-freight-api/src/modules/audit/audit.service.ts index 04ea4beed..de7dfd956 100644 --- a/apps/edr-freight-api/src/modules/audit/audit.service.ts +++ b/apps/edr-freight-api/src/modules/audit/audit.service.ts @@ -1,70 +1,71 @@ -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; -import { AuditLogCommand } from "@tria-plc/auditlog"; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { PaginatedResponse } from '@edr/types'; -import { CLIENT_APP_HEADER } from "../auth/login-audience.middleware"; +import { AuditLog } from './entities/audit-log.entity'; +import { AuditLogRepository } from './audit-log.repository'; +import { AuditLogQueryDto } from './dto/audit-log-query.dto'; +import { + buildPaginationMeta, + normalizePagination, +} from '../../common/utils/pagination.util'; -export interface AuditLogListResult { - count: number; - items: AuditLogCommand[]; -} - -/** - * Own read path onto @tria-plc/auditlog's tables, gated by AuditController's - * @BookingStaff — the package's own AuditLogCommandController (mounted at - * /api/audit-log-commands) ships with no guards at all, so it can't be used - * directly for a permission-gated UI. Query mirrors the package's - * AuditLogCommandService.buildAuditLogQuery/getAllAuditLogs exactly. - */ @Injectable() export class AuditService { - constructor( - @InjectRepository(AuditLogCommand) - private readonly auditLogCommandRepository: Repository, - ) {} + private readonly logger = new Logger(AuditService.name); - async list( - application: string, - skip = 0, - take = 10, - ): Promise { - const [items, count] = await this.auditLogCommandRepository - .createQueryBuilder("audit_log_commands") - .leftJoinAndSelect("audit_log_commands.auditLog", "auditLog") - .andWhere( - "(audit_log_commands.auditLogId IS NULL OR auditLog.application = :application)", - { application }, - ) - .andWhere( - "(audit_log_commands.auditLogId IS NULL OR auditLog.status = :status)", - { status: "Commit" }, - ) - // Backoffice-only view: portal (customer-facing) writes carry the same - // request-header set by every axios call from that app — see - // login-audience.middleware.ts. Rows with no linked auditLog (child/ - // event commands with no request context) stay visible; they aren't - // attributable to any frontend, so they're not portal noise either. - .andWhere( - "(audit_log_commands.auditLogId IS NULL OR auditLog.requestHeader ->> :clientAppHeader = :clientApp)", - { clientAppHeader: CLIENT_APP_HEADER, clientApp: "backoffice" }, - ) - .select([ - "audit_log_commands.id", - "audit_log_commands.createdAt", - "audit_log_commands.deletedAt", - "audit_log_commands.entityName", - "audit_log_commands.queryMethod", - "audit_log_commands.changes", - "audit_log_commands.payload", - "auditLog.id", - "auditLog.user", - ]) - .addOrderBy("audit_log_commands.createdAt", "DESC") - .skip(skip) - .take(take) - .getManyAndCount(); + constructor(private readonly auditLogRepository: AuditLogRepository) {} - return { count, items }; + /** + * Persist one audit row, swallowing any failure. + * + * An audit write must never turn a successful business action into an error + * for the user: if this table is full, misconfigured or mid-migration, + * contract approvals still need to work. Failures are logged so the gap is + * visible in application logs rather than silent. + */ + async record(entry: Partial): Promise { + try { + await this.auditLogRepository.record(entry); + } catch (error) { + this.logger.error( + `Failed to write audit log for ${entry.method} ${entry.routePath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + /** Paginated, filtered audit history, newest first. */ + async search(query: AuditLogQueryDto): Promise> { + const { page, pageSize, skip, take } = normalizePagination(query); + + const from = query.from ? new Date(query.from) : undefined; + const to = query.to ? new Date(query.to) : undefined; + + // A reversed range silently returns zero rows, which reads as "nothing + // happened" rather than "your filter is wrong" — reject it explicitly. + if (from && to && from > to) { + throw new BadRequestException('`from` must be earlier than `to`'); + } + + const [items, total] = await this.auditLogRepository.search({ + type: query.type, + userId: query.userId, + method: query.method, + resourceId: query.resourceId, + isSuccess: + query.isSuccess === undefined ? undefined : query.isSuccess === 'true', + from, + to, + skip, + take, + }); + + return { items, meta: buildPaginationMeta(total, page, pageSize) }; + } + + /** Distinct entity types, for the filter dropdown on the audit screen. */ + async listTypes(): Promise { + return this.auditLogRepository.distinctTypes(); } } diff --git a/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts b/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts new file mode 100644 index 000000000..5a5199538 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts @@ -0,0 +1,64 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBooleanString, IsIn, IsISO8601, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; + +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; + +const AUDITED_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'] as const; + +/** + * Filters for the audit log read endpoint. + * + * Extends the shared pagination DTO so page/pageSize behave (and are capped) + * exactly as they do on every other list endpoint. + */ +export class AuditLogQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ + description: 'Entity type, e.g. "Contract", "Booking", "Locomotive".', + example: 'Contract', + }) + @IsOptional() + @IsString() + @MaxLength(50) + type?: string; + + @ApiPropertyOptional({ description: 'IAM id of the acting backoffice user.' }) + @IsOptional() + @IsUUID() + userId?: string; + + @ApiPropertyOptional({ enum: AUDITED_METHODS }) + @IsOptional() + @Transform(({ value }) => String(value).toUpperCase()) + @IsIn([...AUDITED_METHODS]) + method?: string; + + @ApiPropertyOptional({ description: 'Id of the affected record.' }) + @IsOptional() + @IsString() + @MaxLength(64) + resourceId?: string; + + @ApiPropertyOptional({ + description: 'Filter by outcome: true = succeeded, false = failed.', + }) + @IsOptional() + @IsBooleanString() + isSuccess?: string; + + @ApiPropertyOptional({ + description: 'Inclusive start of the range (ISO 8601).', + example: '2026-01-01T00:00:00.000Z', + }) + @IsOptional() + @IsISO8601() + from?: string; + + @ApiPropertyOptional({ + description: 'Inclusive end of the range (ISO 8601).', + example: '2026-01-31T23:59:59.999Z', + }) + @IsOptional() + @IsISO8601() + to?: string; +} diff --git a/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts b/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts new file mode 100644 index 000000000..0ff7727ff --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts @@ -0,0 +1,132 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +/** + * One backoffice action against a state-changing endpoint. + * + * Deliberately does NOT extend `BaseEntity`, which is the repo standard + * everywhere else. `BaseEntity` carries `updatedAt` and `deletedAt`, and both + * are wrong here: + * + * - `updatedAt` implies an audit row can be edited. A record that can be + * rewritten after the fact is not evidence. + * - `deletedAt` (soft delete) would let anyone who can delete erase their own + * trail, and TypeORM would then hide those rows from every default query — + * the failure would be silent, which is the worst property an audit log can + * have. + * + * Rows are insert-only: nothing in this module updates or deletes them. + * + * `userId` is a bare uuid with NO foreign key into the `iam` schema. Two + * reasons: cross-schema FKs are forbidden platform-wide, and a FK would let + * deleting a user cascade away the record of what that user did — exactly + * backwards. `userName` / `userRole` are point-in-time snapshots for the same + * reason: resolving them at read time would rewrite history whenever somebody + * is renamed or changes role. + */ +@Entity({ schema: 'freight', name: 'audit_logs' }) +// Every audit query is time-bounded, so created_at leads most indexes. +@Index('IDX_audit_logs_created_at', ['createdAt']) +@Index('IDX_audit_logs_user_id_created_at', ['userId', 'createdAt']) +@Index('IDX_audit_logs_type_created_at', ['type', 'createdAt']) +@Index('IDX_audit_logs_type_resource_id', ['type', 'resourceId']) +@Index('IDX_audit_logs_route_path_created_at', ['routePath', 'createdAt']) +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id!: string; + + /** + * Human-readable action, e.g. "Approve contract" — taken from the matched + * entry in `AUDIT_ENDPOINTS`, which sources it from each route's + * `@ApiOperation` summary. + */ + @Column({ name: 'title', type: 'varchar', length: 255 }) + title!: string; + + @Column({ name: 'method', type: 'varchar', length: 10 }) + method!: string; + + /** + * The URL as actually called, real ids and query string included + * (`/api/contracts/abc-123/cancel?force=true`). Query values run through the + * same redaction pass as the body, so a `?token=` never lands here. + */ + @Column({ name: 'url', type: 'text' }) + url!: string; + + /** + * The route template (`/api/contracts/:id/cancel`). + * + * `url` alone cannot be grouped — every contract cancel is a distinct string. + * This column is the join key back to `AUDIT_ENDPOINTS` and makes + * "every contract cancellation" one indexed query instead of a regex scan. + */ + @Column({ name: 'route_path', type: 'varchar', length: 255, nullable: true }) + routePath?: string | null; + + /** Primary entity the action touched: `Contract`, `Booking`, `Locomotive`. */ + @Column({ name: 'type', type: 'varchar', length: 50 }) + type!: string; + + @Column({ name: 'is_success', type: 'boolean' }) + isSuccess!: boolean; + + /** IAM user id. Nullable by design — see the class comment. */ + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId?: string | null; + + /** + * Id of the affected record, recovered from the first path parameter of the + * matched template. + * + * `varchar`, not `uuid`: not every identifier is a uuid + * (`/api/contract-templates/:code`), and a create has no id at all until it + * succeeds. A `uuid NOT NULL` column would throw during the write and lose + * the audit row rather than the id. + */ + @Column({ name: 'resource_id', type: 'varchar', length: 64, nullable: true }) + resourceId?: string | null; + + /** + * Sanitized request body. Secrets are replaced with `[REDACTED]` and uploads + * are reduced to `{ __file, originalName, mimeType, size }` descriptors — + * never raw bytes. See `audit.sanitizer.ts`. + */ + @Column({ name: 'request', type: 'jsonb', nullable: true }) + request?: Record | null; + + /** + * `isSuccess` alone cannot separate 403 (denied — the security signal worth + * alerting on) from 500 (broke). Both are simply `false`. + */ + @Column({ name: 'status_code', type: 'smallint', nullable: true }) + statusCode?: number | null; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage?: string | null; + + /** Snapshot of the actor's display name at the time of the action. */ + @Column({ name: 'user_name', type: 'varchar', length: 150, nullable: true }) + userName?: string | null; + + /** Snapshot of the actor's role at the time of the action. */ + @Column({ name: 'user_role', type: 'varchar', length: 100, nullable: true }) + userRole?: string | null; + + /** Non-repudiation: the first thing asked in any incident review. */ + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress?: string | null; + + /** Helps separate a real browser session from a script using a stolen token. */ + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent?: string | null; + + /** Correlates this row with application logs/traces for the same request. */ + @Column({ name: 'request_id', type: 'varchar', length: 64, nullable: true }) + requestId?: string | null; + + @Column({ name: 'duration_ms', type: 'integer', nullable: true }) + durationMs?: number | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; +} 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 index 4eb6ecc31..0a7ed942e 100644 --- a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts @@ -10,9 +10,10 @@ import { ResetChannel } from "./dto/forgot-password.dto"; import { ForgotPasswordService, RESET_LINK_TTL_MS, + type ResetTicket, } from "./forgot-password.service"; import { maskOtpTarget } from "./mask-target.util"; -import { isDomesticPhone } from "../otp/otp.service"; +import { isDomesticPhone, type OtpTarget } from "../otp/otp.service"; /** The account a staff-triggered reset would land on. */ export interface CustomerResetTarget { @@ -86,28 +87,113 @@ export class CustomerResetService { const resolved = await this.resolvePrimaryContactUser(companyId); if (!resolved) return null; - const { user, userId } = resolved; - const target = this.forgotPasswordService.targetFor(user, channel); - if (!target) return null; + return this.sendResetLinkToUser(resolved.userId, channel, { + scope: `company ${companyId}`, + }); + } + + /** + * Mint and deliver a reset link to a specific IAM account. + * + * The delivery half of {@link sendResetLinkToCustomer}, split out so callers + * that resolve their target differently can reuse it: a customer is found via + * the company's primary contact, while a shipping line has no contact row at + * all and resolves straight off its own record. Everything below the lookup — + * active-account gating, the domestic-SMS rule, mint-before-send, the + * undelivered-link diagnostic — is identical for both and must stay that way. + * + * `scope` only labels the log line with whatever the caller resolved from. + * + * `allowWithoutCredential` relaxes the lookup for first-time activation: + * the default gate requires an existing active credential (so a reset cannot + * revive a suspended account), but an account that has never set a password + * has no credential row yet and would be excluded from its own activation + * link. Callers pass it only when the account is expected to be + * password-less — see ShippingLineCompaniesService. + */ + async sendResetLinkToUser( + userId: string, + channel: ResetChannel, + options?: { scope?: string; allowWithoutCredential?: boolean }, + ): Promise { + const sent = await this.sendResetLinkToUserOnChannels(userId, [channel], options); + return sent[0] ?? null; + } + + /** + * One ticket, several channels. Minting retires every earlier ticket for the + * user (`mintResetTicket`), so sending email and SMS as two separate mints + * makes the first link dead on arrival — the same link must go to both. + * Returns one entry per channel that was actually sent (unreachable channels + * are skipped, not errors). + */ + async sendResetLinkToUserOnChannels( + userId: string, + channels: ResetChannel[], + options?: { scope?: string; allowWithoutCredential?: boolean }, + ): Promise { + const user = options?.allowWithoutCredential + ? await this.forgotPasswordService.resolveActivatableUserById(userId) + : await this.forgotPasswordService.resolveActiveUserById(userId); + + if (!user?.id) { + this.logger.warn( + `User ${userId} is not an active account${ + options?.allowWithoutCredential + ? "" + : " (or has no active credential — pass allowWithoutCredential for first-time activation)" + }`, + ); + return []; + } + + // Mint once, before any send: a failed send leaves an unused ticket that + // simply expires, whereas sending a link before the ticket exists would + // hand the customer a URL that is dead on arrival. + let ticket: ResetTicket | null = null; + const sent: SentResetLink[] = []; + for (const channel of channels) { + const target = this.forgotPasswordService.targetFor(user, channel); + if (!target) continue; + // The gateway silently drops foreign numbers — treat like a missing phone + // rather than reporting "link sent" for a message that will never arrive. + // The backoffice disables the channel up front via `phoneIsDomestic`; this + // guards direct API calls. + if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) { + this.logger.warn( + `Staff reset via SMS refused for user ${userId} — non-domestic phone`, + ); + continue; + } + ticket ??= await this.forgotPasswordService.mintResetTicket( + user.id, + RESET_LINK_TTL_MS, + ); + const result = await this.deliverResetLink( + target, + user.id, + channel, + ticket, + options?.scope, + ); + if (result) sent.push(result); + } + return sent; + } + + /** + * Shared tail: send the already-minted ticket to a resolved target → report. + */ + private async deliverResetLink( + target: OtpTarget, + userId: string, + channel: ResetChannel, + ticket: ResetTicket, + scope?: string, + ): Promise { // A foreign number is unreachable by the domestic-only SMS gateway — treat // it like a missing phone rather than reporting "link sent" for a message - // that will never arrive. The backoffice disables the channel up front via - // `phoneIsDomestic`; this guards direct API calls. - if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) { - this.logger.warn( - `Staff reset via SMS refused for user ${userId} — non-domestic phone`, - ); - return null; - } - - // Mint first, send second: a failed send leaves an unused ticket that simply - // expires, whereas sending a link before the ticket exists would hand the - // customer a URL that is dead on arrival. - const ticket = await this.forgotPasswordService.mintResetTicket( - userId, - RESET_LINK_TTL_MS, - ); const link = this.buildResetLink(ticket.userId, ticket.verificationCode); const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS); @@ -127,7 +213,22 @@ export class CustomerResetService { }); this.logger.log( - `Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`, + `Staff-triggered shipping line ${channel} reset link sent to user ${userId}${ + scope ? ` (${scope})` : "" + } queued=${queued}`, + ); + + // SECURITY: logs a live password-reset credential in cleartext. Anyone with + // read access to the log stream can set the password for the account named + // on the same line — including on sends that succeeded, not just failures. + // Kept deliberately: log aggregation is the debugging path for flaky + // email/SMS here, the same tradeoff otp.service.ts makes for OTP codes. If + // that is ever revisited, gate this on an env flag rather than deleting it, + // so dev keeps its workflow. + this.logger.warn( + `reset-link.cleartext channel=${channel} user=${userId}${ + scope ? ` (${scope})` : "" + } link=${link}`, ); if (!queued) { 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 index a3dbf1061..673541747 100644 --- a/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts +++ b/apps/edr-freight-api/src/modules/auth/forgot-password.service.ts @@ -89,6 +89,28 @@ export class ForgotPasswordService { .getOne(); } + /** + * Active account by id, WITHOUT requiring an existing credential. + * + * {@link activeUserQuery} inner-joins an active `user_credentials` row, which + * is right for a *reset*: it stops a staff-triggered link from reactivating a + * suspended account. But an account that has never set a password has no + * credential row yet, so that join excludes exactly the accounts a first-time + * *activation* link is for — shipping lines are created deliberately without + * one (see ShippingLineCompaniesService.register). + * + * The `isActive` gate is kept; only the credential requirement is dropped. + */ + async resolveActivatableUserById(userId: string): Promise { + if (!userId) return null; + return await this.userRepository + .createQueryBuilder("u") + .where("u.isActive = true") + .andWhere("u.id = :userId", { userId }) + .orderBy("u.createdAt", "DESC") + .getOne(); + } + /** * Base query for accounts eligible to reset. `.where()` is claimed here so * callers must use `.andWhere()` — TypeORM's `.where()` resets the clause, @@ -190,7 +212,9 @@ export class ForgotPasswordService { * is the proof). */ async mintResetTicket(userId: string, ttlMs: number): Promise { - const code = randomBytes(24).toString("base64url"); + // Hex, not base64url: the token rides in an SMS, and the GSM-7 alphabet has + // no "_" — gateways substitute a space and the link arrives broken. + const code = randomBytes(24).toString("hex"); const verificationCode = await hashPassword(code); await this.dataSource.transaction(async (manager) => { @@ -233,9 +257,22 @@ export class ForgotPasswordService { "This password-reset link is invalid or has expired. Request a new one.", ); - const user = await this.resolveActiveUserById(userId); + // Credential-less on purpose: this resolves links for *setting* a password, + // which includes first-time activation of an account that has never had one + // (shipping lines are created without a credential row). Requiring one here + // rejected a perfectly valid activation link before its token was ever + // checked. The ticket checks below are what actually authorise the reset. + const user = await this.resolveActivatableUserById(userId); const identifier = user && this.identifierFor(user); - if (!user || !identifier) throw invalid; + if (!user || !identifier) { + // Logged because the early return above bypasses the rejection warning + // below — without this, an account that fails the lookup produces no + // diagnostic at all and looks identical to a bad token. + this.logger.warn( + `Reset link rejected for user ${userId} — no active account or no usable identifier`, + ); + throw invalid; + } const verification = await this.dataSource .getRepository(UserVerification) 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 ff8f803b9..557e50fb3 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 @@ -51,5 +51,8 @@ import { ListUsersService } from './list-users.service'; ForgotPasswordService, CustomerResetService, ], + // Shipping-line registration mints activation links through the same + // staff-triggered reset path customers use. + exports: [CustomerResetService], }) export class FreightAuthModule {} diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 5bf1448fd..810c1a55a 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Get, @@ -29,6 +30,7 @@ import { actorLabel } from "../warehouses/current-actor.util"; import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; import { BillingService } from "./billing.service"; import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; +import { IssueMemoDto } from "./dto/issue-memo.dto"; @ApiTags("billing") @Controller("billing") @@ -38,6 +40,7 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, FREIGHT_PERMS.invoices.confirmOffline, + FREIGHT_PERMS.invoices.memoIssue, ]) @ApiBearerAuth() export class BillingController { @@ -63,6 +66,23 @@ export class BillingController { }); } + @Get("invoices/summary") + @ApiOperation({ + summary: + "Total collected (paidAmount) across every filtered invoice, grouped by currency", + }) + async collectedSummary( + @Query() query: FilterInvoiceDto, + @CurrentUser() user: TCurrentUser, + ) { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.billingService.collectedSummary({ + ...query, + tradeDirections: allowed ?? undefined, + }); + } + @Get("invoices/:id") @ApiOperation({ summary: "Get an invoice with its line items" }) findById(@Param("id", ParseUUIDPipe) id: string) { @@ -72,7 +92,7 @@ export class BillingController { @Get("offline-usd") @ApiOperation({ summary: - "Finance worklist: USD invoices settled offline by bank transfer, with booking pay-window context", + "Finance worklist: USD and ETB invoices settled manually (bank transfer / counter), with booking pay-window context", }) findOfflineUsd(@Query() query: FilterInvoiceDto) { return this.billingService.findOfflineUsdPaginated(query); @@ -84,7 +104,7 @@ export class BillingController { @ApiConsumes("multipart/form-data") @ApiOperation({ summary: - "Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance", + "Finance confirms an invoice (USD or ETB) paid manually — slip file required, settles the full balance", }) confirmOffline( @Param("id", ParseUUIDPipe) id: string, @@ -99,11 +119,31 @@ export class BillingController { }); } + @Post("invoices/:id/memo") + @BookingStaff(FREIGHT_PERMS.invoices.memoIssue) + @ApiOperation({ + summary: + "Issue a credit or debit memo against a registered invoice (MoR DEB/CRE). Filing-equivalent — the auto-submit sweep picks it up like any other issued invoice.", + }) + issueMemo(@Param("id", ParseUUIDPipe) id: string, @Body() dto: IssueMemoDto) { + return this.billingService.issueMemo(id, dto); + } + @Get("invoices/:id/document") @BookingStaff(FREIGHT_PERMS.invoices.export) - @ApiOperation({ summary: "Download the sealed invoice PDF" }) - async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { - const { filename, buffer } = await this.billingService.document(id); + @ApiOperation({ + summary: + 'Download the sealed invoice PDF. ?format=a4 (default) or ?format=thermal for the 80mm thermal layout (ADD-P001).', + }) + async document( + @Param("id", ParseUUIDPipe) id: string, + @Query("format") format: string | undefined, + @Res() res: Response, + ) { + if (format !== undefined && format !== "a4" && format !== "thermal") { + throw new BadRequestException(`Unsupported format "${format}" — use "a4" or "thermal".`); + } + const { filename, buffer } = await this.billingService.document(id, format === "thermal" ? "thermal" : "a4"); sendPdf(res, filename, buffer); } diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 7ec5c333b..06849d560 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -19,7 +19,8 @@ import { FilesModule } from "../files/files.module"; imports: [ TypeOrmModule.forFeature([Invoice, InvoiceLine]), forwardRef(() => PaymentModule), - CompaniesModule, + // Cycles back via ShippingLineCompaniesModule, which imports this module. + forwardRef(() => CompaniesModule), DocumentsModule, UserTradeAccessModule, FilesModule, @@ -29,3 +30,4 @@ import { FilesModule } from "../files/files.module"; exports: [BillingService], }) export class BillingModule {} + \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 576c7b166..c5cc9a81e 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -80,6 +80,8 @@ describe("BillingService.generateInvoice", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); }); @@ -118,6 +120,160 @@ describe("BillingService.generateInvoice", () => { }); }); +describe("BillingService.issueMemo", () => { + const ORIGINAL_ID = "original-invoice-1"; + + function originalInvoice(overrides: Record = {}) { + return { + id: ORIGINAL_ID, + invoiceNumber: "INV-20260807-00042", + eimsIrn: "irn-value", + eimsDocumentType: "INV", + eimsStatus: "REGISTERED", + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + companyId: "company-1", + companyProfileId: "profile-1", + shippingLineCompanyId: null, + currency: "ETB", + totalAmount: 1500, + lines: [ + { chargeType: "RAIL_FREIGHT", description: "Rail freight", quantity: 2, unitRate: 500, amount: 1000, currency: "ETB", metadata: null }, + { chargeType: "HAZARD_SURCHARGE", description: "Hazard surcharge", quantity: 2, unitRate: 250, amount: 500, currency: "ETB", metadata: null }, + ], + ...overrides, + }; + } + + function build(original: ReturnType) { + const savedLines: unknown[] = []; + const manager = makeManager(savedLines); + const dataSource = { + transaction: jest.fn().mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)), + manager, + }; + const invoices = { findById: jest.fn().mockResolvedValue(original) }; + const invoiceLines = { findAll: jest.fn().mockResolvedValue(original.lines) }; + const service = new BillingService( + dataSource as never, + invoices as never, + invoiceLines as never, + makeEvents() as never, + {} as never, + {} as never, + {} as never, + {} as never, + { get: () => undefined } as never, + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + ); + return { service, manager, savedLines }; + } + + it("creates a settled credit memo copying the original's lines, linked via relatedInvoiceId", async () => { + const { service, savedLines } = build(originalInvoice()); + + const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "Overbilled freight charge" }); + + expect(memo.invoiceNumber).toMatch(/^CRE-\d{8}-00001$/); + expect(memo.totalAmount).toBe(1500); + expect(memo.status).toBe(Freight.InvoiceStatus.Paid); + expect((memo as unknown as Record).eimsDocumentType).toBe("CRE"); + expect((memo as unknown as Record).eimsReason).toBe("Overbilled freight charge"); + expect((memo as unknown as Record).relatedInvoiceId).toBe(ORIGINAL_ID); + expect((memo as unknown as Record).paidAmount).toBe(1500); + expect((memo as unknown as Record).balanceAmount).toBe(0); + expect(savedLines).toHaveLength(2); + }); + + it("creates an open, unpaid debit memo — a genuine new receivable, not force-settled", async () => { + const { service } = build(originalInvoice()); + + const memo = await service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "Additional handling fee" }); + + expect(memo.invoiceNumber).toMatch(/^DEB-\d{8}-00001$/); + expect(memo.status).toBe(Freight.InvoiceStatus.Pending); + expect(memo.balanceAmount).toBe(1500); + expect(memo.paidAmount).toBe(0); + }); + + it("keys the memo's sourceId to the original invoice's own id, not the original's sourceId", async () => { + const { service } = build(originalInvoice()); + + const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "test" }); + + expect(memo.sourceId).toBe(ORIGINAL_ID); + expect(memo.sourceId).not.toBe("booking-1"); + }); + + it("allows a partial memo with explicit lines instead of copying the original", async () => { + const { service } = build(originalInvoice()); + + const memo = await service.issueMemo(ORIGINAL_ID, { + type: "CRE", + reason: "Partial credit", + lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 200, amount: 200 }], + }); + + expect(memo.totalAmount).toBe(200); + }); + + it("refuses a memo against an invoice never registered with EIMS", async () => { + const { service } = build(originalInvoice({ eimsIrn: null })); + + await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toMatchObject({ + response: expect.objectContaining({ code: "EIMS_RELATED_INVOICE_NOT_REGISTERED" }), + }); + }); + + it("refuses a memo against a memo", async () => { + const { service } = build(originalInvoice({ eimsDocumentType: "CRE" })); + + await expect(service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "x" })).rejects.toThrow( + "cannot issue a memo against a memo", + ); + }); + + it("refuses a memo against an EIMS-cancelled invoice", async () => { + const { service } = build(originalInvoice({ eimsStatus: "CANCELLED" })); + + await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toThrow( + "cancelled with EIMS", + ); + }); + + it("refuses a credit memo whose total exceeds the original", async () => { + const { service } = build(originalInvoice({ totalAmount: 1500 })); + + await expect( + service.issueMemo(ORIGINAL_ID, { + type: "CRE", + reason: "too much", + lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 2000, amount: 2000 }], + }), + ).rejects.toThrow(/exceeds/); + }); + + it("does NOT bound a debit memo by the original's total — it is a new charge, not a refund", async () => { + const { service } = build(originalInvoice({ totalAmount: 1500 })); + + const memo = await service.issueMemo(ORIGINAL_ID, { + type: "DEB", + reason: "additional charge", + lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 5000, amount: 5000 }], + }); + + expect(memo.totalAmount).toBe(5000); + }); + + it("refuses a blank reason", async () => { + const { service } = build(originalInvoice()); + + await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: " " })).rejects.toThrow( + "requires a reason", + ); + }); +}); + describe("BillingService.markInvoiceAsPaid", () => { it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => { const open = { @@ -142,6 +298,8 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -196,6 +354,8 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -240,6 +400,8 @@ describe("BillingService.settleByPaymentId", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, mg, events }; } @@ -352,6 +514,8 @@ describe("BillingService.recordPayment", () => { {} as never, // companies {} as never, // invoiceDocuments {} as never, // files + { get: () => undefined } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, mg, events }; } @@ -468,6 +632,8 @@ describe("BillingService.expirePayable — locked write runs in a transaction", {} as never, {} as never, {} as never, + {} as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, defaultManager, txManager, transaction }; }; @@ -540,6 +706,8 @@ describe("BillingService.issuePayable", () => { {} as never, {} as never, {} as never, + {} as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, manager }; }; @@ -630,6 +798,8 @@ describe("BillingService — CAC Bank (OTP debit)", () => { {} as never, {} as never, {} as never, + {} as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, repo }; }; @@ -712,6 +882,8 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () => {} as never, {} as never, {} as never, + {} as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, repo }; }; @@ -740,3 +912,124 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () => }); }); }); + +describe("BillingService.document", () => { + const invoiceRow = (over: Record = {}) => ({ + id: "inv-1", + invoiceNumber: "INV-20260812-00001", + source: "booking", + sourceId: "booking-1", + status: Freight.InvoiceStatus.Pending, + type: "freight", + currency: "ETB", + subtotalAmount: 100, + taxAmount: 0, + totalAmount: 100, + paidAmount: 0, + balanceAmount: 100, + issuedAt: new Date(2026, 7, 12), + dueAt: new Date(2026, 7, 19), + eimsIrn: null, + eimsSignedQr: null, + company: { name: "ABC Trading PLC", tin: "0999930000", vatNumber: "123475885858" }, + ...over, + }); + + const build = (invoice: Record) => { + const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }); + const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") }); + const service = new BillingService( + {} as never, + { findById: jest.fn().mockResolvedValue(invoice) } as never, + { findAll: jest.fn().mockResolvedValue([]) } as never, + {} as never, + {} as never, + {} as never, + { render, renderThermal } as never, + {} as never, + { + get: (key: string) => + key === "eims" + ? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } } + : undefined, + } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + ); + return { service, render, renderThermal }; + }; + + it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => { + const { service, render } = build(invoiceRow()); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary.find((r: { label: string }) => r.label === "EIMS IRN")).toBeUndefined(); + expect(model.qrImageUrl).toBeNull(); + }); + + it("shows the buyer's name, TIN and VAT number on every invoice", async () => { + const { service, render } = build(invoiceRow()); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary).toContainEqual({ label: "Buyer", value: "ABC Trading PLC" }); + expect(model.summary).toContainEqual({ label: "Buyer TIN", value: "0999930000" }); + expect(model.summary).toContainEqual({ label: "Buyer VAT No.", value: "123475885858" }); + }); + + it("omits the VAT row when the buyer company has none", async () => { + const { service, render } = build(invoiceRow({ company: { name: "Acme", tin: "0011223344" } })); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary.find((r: { label: string }) => r.label === "Buyer VAT No.")).toBeUndefined(); + }); + + it("shows EDR's own seller TIN and VAT number from EIMS config", async () => { + const { service, render } = build(invoiceRow()); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary).toContainEqual({ label: "Seller TIN", value: "0053481357" }); + expect(model.summary).toContainEqual({ + label: "Seller VAT No.", + value: "43256663343256663322", + }); + }); + + it("adds the EIMS IRN to the summary and renders the QR for a registered invoice", async () => { + const { service, render } = build( + invoiceRow({ eimsIrn: "IRN-123", eimsSignedQr: "signed-payload" }), + ); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" }); + expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload"); + }); + + it("calls render (not renderThermal) for the default format", async () => { + const { service, render, renderThermal } = build(invoiceRow()); + jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never); + + await service.document("inv-1"); + + expect(render).toHaveBeenCalledTimes(1); + expect(renderThermal).not.toHaveBeenCalled(); + }); + + it("calls renderThermal (not render) for format 'thermal'", async () => { + const { service, render, renderThermal } = build(invoiceRow()); + jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never); + + await service.document("inv-1", "thermal"); + + expect(renderThermal).toHaveBeenCalledTimes(1); + expect(render).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 9a31466f0..d64361bf8 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,4 +1,5 @@ import { Freight, PaymentReferenceType } from "@edr/types"; +import { ConfigService } from "@nestjs/config"; import { BadRequestException, forwardRef, @@ -8,10 +9,18 @@ import { NotFoundException, } from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; -import { DataSource, EntityManager, In } from "typeorm"; +import { logCtx } from "@edr/api-common"; +import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; +// Entity-only import (no module edge): portal reads resolve shipping-line +// payers straight off the table. +import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; +import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity"; +import { ManualPaymentSettingsService } from "../payment-settings/manual-payment-settings.service"; +import { EimsConfig } from "../../config/eims.config"; import { CompaniesService } from "../companies/companies.service"; +import { EimsInvoiceStatus } from "../eims/eims-registration.types"; import { FilesService } from "../files/files.service"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { PaymentService } from "../payment/payment.service"; @@ -19,6 +28,7 @@ import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto"; import { InvoiceDocumentModel, InvoiceDocumentService, + pngDataUrl, } from "./documents/invoice-document.service"; import { InvoiceLine } from "./entities/invoice-line.entity"; import { Invoice, InvoicePayment } from "./entities/invoice.entity"; @@ -40,10 +50,18 @@ export interface PayInvoiceOptions { export interface OfflineUsdBookingInfo { id: string; reference: string; + tradeDirection: string | null; paymentDeadline: Date | null; paymentStatus: string; } +/** Row shape of the manual-payments worklist. */ +export type OfflineUsdInvoiceRow = Invoice & { + booking: OfflineUsdBookingInfo | null; + /** Shipping-line credit invoices span many bookings — one entry per credit. */ + bookings: { id: string; reference: string; tradeDirection: string | null }[]; +}; + /** A single manual/offline settlement to record against an invoice. */ export interface RecordPaymentInput { /** Amount settled by this payment; must be > 0. */ @@ -111,8 +129,16 @@ export interface GenerateInvoiceInput { sourceId: string; /** What the invoice is for (e.g. "prepaid", "credit"). */ type: string; - companyId: string; - companyProfileId: string; + /** The customer billed. Omit only when billing a shipping line instead. */ + companyId?: string | null; + companyProfileId?: string | null; + /** + * The shipping line billed, for an invoice covering batched shipping-line + * credits. Mutually exclusive with `companyId` — the DB enforces this via + * `chk_invoices_single_payer`, and {@link createInvoice} rejects a payload + * setting both or neither before it ever reaches the constraint. + */ + shippingLineCompanyId?: string | null; lines: InvoiceLineInput[]; currency?: string; /** Explicit pre-tax subtotal; defaults to the sum of line amounts. */ @@ -131,6 +157,18 @@ export interface GenerateInvoiceInput { status?: Freight.InvoiceStatus; } +/** MoR `DocumentDetails.Type` for a memo — see `EIMS_DOCUMENT_TYPES` in `eims-invoice.mapper.ts`. */ +export type MemoType = "CRE" | "DEB"; + +/** Everything needed to issue a credit or debit memo against an already-registered invoice. */ +export interface IssueMemoInput { + type: MemoType; + /** Why the memo was issued — required by MoR as `DocumentDetails.Reason`. */ + reason: string; + /** Omit to copy every line of the original verbatim (a full reversal/charge, the common case). */ + lines?: InvoiceLineInput[]; +} + /** Payload broadcast on `${source}.invoice.`. */ export interface InvoiceEventPayload { invoiceId: string; @@ -138,8 +176,11 @@ export interface InvoiceEventPayload { source: Freight.InvoiceSource; sourceId: string; type: string; - companyId: string; - companyProfileId: string; + /** Null when the payer is a shipping line rather than a customer company. */ + companyId: string | null; + companyProfileId: string | null; + /** Set only on shipping-line invoices; mutually exclusive with `companyId`. */ + shippingLineCompanyId?: string | null; totalAmount: number; currency: string; status: Freight.InvoiceStatus; @@ -160,6 +201,8 @@ export class BillingService { private readonly companies: CompaniesService, private readonly invoiceDocuments: InvoiceDocumentService, private readonly files: FilesService, + private readonly config: ConfigService, + private readonly manualPaymentSettings: ManualPaymentSettingsService, ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -174,6 +217,40 @@ export class BillingService { * company (customer detail "Invoices" tab) and/or status/search (global * invoices page). */ + /** Same list filters `findAllPaginated` and `collectedSummary` both narrow by. */ + private applyInvoiceFilters( + qb: SelectQueryBuilder, + filter: { + companyId?: string; + status?: Freight.InvoiceStatus; + search?: string; + tradeDirections?: string[]; + }, + ) { + if (filter.companyId) { + qb.andWhere("invoice.companyId = :companyId", { + companyId: filter.companyId, + }); + } + if (filter.status) { + qb.andWhere("invoice.status = :status", { status: filter.status }); + } + if (filter.search) { + qb.andWhere( + "(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)", + { search: `%${filter.search}%` }, + ); + } + if (filter.tradeDirections) { + applyBookingRefDirectionScope( + qb, + "invoice.source_id", + filter.tradeDirections, + ); + } + return qb; + } + async findAllPaginated( filter: { companyId?: string; @@ -197,63 +274,122 @@ export class BillingService { .skip((page - 1) * pageSize) .take(pageSize); - if (filter.companyId) { - qb.andWhere("invoice.companyId = :companyId", { - companyId: filter.companyId, - }); - } - if (filter.status) { - qb.andWhere("invoice.status = :status", { status: filter.status }); - } - if (filter.search) { - qb.andWhere( - "(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)", - { search: `%${filter.search}%` }, - ); - } - - if (filter.tradeDirections) { - applyBookingRefDirectionScope( - qb, - "invoice.source_id", - filter.tradeDirections, - ); - } + this.applyInvoiceFilters(qb, filter); const [items, total] = await qb.getManyAndCount(); - return { items, total }; + return { items: await this.attachShippingLineCompanies(items), total }; } /** - * Finance's offline-settlement worklist: USD invoices (paid by bank transfer, - * never through the gateway), open ones by default or a single status when - * filtered. Booking-sourced rows carry the booking's reference and pay-window - * deadline so the UI can show the countdown and link to the booking. + * Batch-hydrate `shippingLineCompany` for any invoice billed to a shipping + * line (`companyId` null). No relation on `Invoice` to eager-load — see the + * entity's doc comment — so this is a second query keyed off the ids + * already loaded, same shape as `company`. + */ + private async attachShippingLineCompanies( + invoices: T[], + ): Promise { + const ids = [ + ...new Set( + invoices + .map((i) => i.shippingLineCompanyId) + .filter((id): id is string => id != null), + ), + ]; + if (!ids.length) return invoices; + const lines = await this.dataSource + .getRepository(ShippingLineCompany) + .find({ where: { id: In(ids) } }); + const byId = new Map(lines.map((l) => [l.id, l])); + return invoices.map((invoice) => { + const line = invoice.shippingLineCompanyId + ? byId.get(invoice.shippingLineCompanyId) + : undefined; + return line + ? ({ + ...invoice, + shippingLineCompany: { + id: line.id, + name: line.name, + email: line.email, + phoneNumber: line.phoneNumber, + }, + } as T) + : invoice; + }); + } + + /** + * Total collected (`paidAmount`) across every invoice matching the same + * filters as `findAllPaginated`, grouped by currency — unpaginated, so the + * invoices summary card reflects the whole filtered set, not just the + * visible page. + */ + async collectedSummary( + filter: { + companyId?: string; + status?: Freight.InvoiceStatus; + search?: string; + tradeDirections?: string[]; + } = {}, + ): Promise> { + const qb = this.dataSource + .getRepository(Invoice) + .createQueryBuilder("invoice") + .select("invoice.currency", "currency") + .addSelect("SUM(invoice.paidAmount)", "collected") + .groupBy("invoice.currency"); + + this.applyInvoiceFilters(qb, filter); + + const rows: { currency: string; collected: string }[] = + await qb.getRawMany(); + + return Object.fromEntries( + rows.map((row) => [row.currency, Number(row.collected) || 0]), + ); + } + + /** + * Finance's manual-settlement worklist: USD invoices (paid by bank transfer, + * never through the gateway) and ETB invoices Finance settles by hand (bank + * transfer / counter) instead of the customer paying online. Open ones by + * default or a single status when filtered; both currencies unless + * `currency` narrows it. Booking-sourced rows carry the booking's reference, + * trade direction and pay-window deadline so the UI can show the countdown + * and link to the booking. */ async findOfflineUsdPaginated( filter: { status?: Freight.InvoiceStatus; search?: string; + currency?: "USD" | "ETB"; page?: number; pageSize?: number; } = {}, - ): Promise<{ - items: (Invoice & { booking: OfflineUsdBookingInfo | null })[]; - total: number; - }> { + ): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> { const page = filter.page && filter.page > 0 ? filter.page : 1; const pageSize = filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; + // Only currencies whose manual-payment channel is switched on are listed: + // a row Finance cannot act on is noise, and the confirm endpoint would + // refuse it anyway. All off → nothing to work. + const enabled = await this.manualPaymentSettings.enabledCurrencies(); + if (!enabled.length) return { items: [], total: 0 }; + const currencies = filter.currency + ? enabled.filter((c) => c === filter.currency) + : enabled; + if (!currencies.length) return { items: [], total: 0 }; + const qb = this.dataSource .getRepository(Invoice) .createQueryBuilder("invoice") .leftJoinAndSelect("invoice.company", "company") - .where("UPPER(invoice.currency) = 'USD'") + .where("UPPER(invoice.currency) IN (:...currencies)", { currencies }) .orderBy("invoice.issuedAt", "DESC") .skip((page - 1) * pageSize) .take(pageSize); - if (filter.status) { qb.andWhere("invoice.status = :status", { status: filter.status }); } else { @@ -266,7 +402,8 @@ export class BillingService { ); } - const [items, total] = await qb.getManyAndCount(); + const [rawItems, total] = await qb.getManyAndCount(); + const items = await this.attachShippingLineCompanies(rawItems); const bookingIds = items .filter((i) => i.source === "booking") @@ -274,11 +411,43 @@ export class BillingService { const bookings = bookingIds.length ? await this.dataSource.getRepository(Booking).find({ where: { id: In(bookingIds) }, - select: ["id", "reference", "paymentDeadline", "paymentStatus"], + select: [ + "id", + "reference", + "tradeDirection", + "paymentDeadline", + "paymentStatus", + ], }) : []; const byId = new Map(bookings.map((b) => [b.id, b])); + // Shipping-line credit invoices bill many bookings at once; each credit + // keeps its own booking link, so collect them per invoice. + const creditInvoiceIds = items + .filter((i) => i.source === Freight.InvoiceSource.ShippingLineCredit) + .map((i) => i.id); + const credits = creditInvoiceIds.length + ? await this.dataSource.getRepository(ShippingLineCredit).find({ + where: { invoiceId: In(creditInvoiceIds) }, + relations: { booking: true }, + }) + : []; + const bookingsByInvoice = new Map< + string, + OfflineUsdInvoiceRow["bookings"] + >(); + for (const c of credits) { + if (!c.invoiceId || !c.booking) continue; + const list = bookingsByInvoice.get(c.invoiceId) ?? []; + list.push({ + id: c.booking.id, + reference: c.booking.reference, + tradeDirection: c.booking.tradeDirection ?? null, + }); + bookingsByInvoice.set(c.invoiceId, list); + } + return { items: items.map((inv) => { const b = byId.get(inv.sourceId); @@ -288,19 +457,23 @@ export class BillingService { ? { id: b.id, reference: b.reference, + tradeDirection: b.tradeDirection ?? null, paymentDeadline: b.paymentDeadline ?? null, paymentStatus: b.paymentStatus, } : null, - } as Invoice & { booking: OfflineUsdBookingInfo | null }; + bookings: bookingsByInvoice.get(inv.id) ?? [], + } as OfflineUsdInvoiceRow; }), total, }; } /** - * Finance confirms a USD invoice as paid by bank transfer: stores the slip - * against the invoice and settles the FULL outstanding balance through + * Finance confirms an invoice (USD or ETB) as paid manually — bank transfer + * or counter payment. Refused when that currency's manual-payment channel is + * switched off in settings. Stores the slip against the invoice and settles the + * FULL outstanding balance through * {@link recordPayment}, which flips the invoice to PAID and (for bookings) * emits `booking.invoice.paid` — the same event an online payment fires, so * the booking advances exactly as if it had been paid through the gateway. @@ -319,9 +492,11 @@ export class BillingService { ): Promise { const invoice = await this.invoices.findById(invoiceId); if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); - if (invoice.currency?.toUpperCase() !== "USD") { + // The channel is a setting, not a role: even a permitted user cannot + // settle by hand in a currency whose channel is switched off. + if (!(await this.manualPaymentSettings.isEnabled(invoice.currency))) { throw new BadRequestException( - "Offline confirmation is only for USD invoices — this invoice is paid online.", + `Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Manual payments first.`, ); } if (!file) { @@ -370,21 +545,30 @@ export class BillingService { relations: { company: true, companyProfile: true }, }); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); + const [hydrated] = await this.attachShippingLineCompanies([invoice]); const lines = await this.invoiceLines.findAll({ where: { invoiceId: id }, order: { createdAt: "ASC" }, }); - return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] }; + return { ...hydrated, lines } as Invoice & { lines: InvoiceLine[] }; } // ── Documents (central PDF) ────────────────────────────────────────────────── - /** Sealed PDF invoice for any source, rendered by the shared document service. */ - async document(id: string): Promise<{ filename: string; buffer: Buffer }> { + /** + * Sealed PDF invoice for any source, rendered by the shared document service. `format` + * validation (rejecting anything but `"a4"`/`"thermal"`) is the controller's job — an input + * boundary check, not a business rule. + */ + async document( + id: string, + format: "a4" | "thermal" = "a4", + ): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); - return this.invoiceDocuments.render( - this.toDocumentModel(invoice, "INVOICE"), - ); + const model = await this.toDocumentModel(invoice, "INVOICE"); + return format === "thermal" + ? this.invoiceDocuments.renderThermal(model) + : this.invoiceDocuments.render(model); } /** Sealed PDF receipt; available once any payment has been recorded. */ @@ -396,15 +580,41 @@ export class BillingService { ); } return this.invoiceDocuments.render( - this.toDocumentModel(invoice, "RECEIPT"), + await this.toDocumentModel(invoice, "RECEIPT"), ); } + /** Route + wagon count summary rows for a booking-sourced invoice; empty for every other source. */ + private async bookingSummaryRows( + invoice: Invoice, + ): Promise { + if (invoice.source !== Freight.InvoiceSource.Booking) return []; + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: invoice.sourceId }, + relations: { originYard: true, destinationYard: true }, + }); + if (!booking) return []; + return [ + { + label: "Route", + value: + booking.originYard && booking.destinationYard + ? `${booking.originYard.label} → ${booking.destinationYard.label}` + : null, + }, + { + label: "Wagons", + value: + booking.wagonsRequired != null ? String(booking.wagonsRequired) : null, + }, + ]; + } + /** Map a global invoice (+ lines) onto the source-agnostic document model. */ - private toDocumentModel( + private async toDocumentModel( invoice: Invoice & { lines: InvoiceLine[] }, kind: "INVOICE" | "RECEIPT", - ): InvoiceDocumentModel { + ): Promise { const title = invoice.source ? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1) : "EDR"; @@ -422,6 +632,55 @@ export class BillingService { totals.push({ label: "Paid", amount: Number(invoice.paidAmount) }); totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) }); + const summary: InvoiceDocumentModel["summary"] = [ + // Buyer identity — was missing entirely; a MoR-registered invoice must show who it was + // filed against, not just the seller. VatNumber shown only when the company has one. + { label: "Buyer", value: invoice.company?.name ?? null }, + { label: "Buyer TIN", value: invoice.company?.tin ?? null }, + ...(invoice.company?.vatNumber + ? [{ label: "Buyer VAT No.", value: invoice.company.vatNumber }] + : []), + { label: "Status", value: invoice.status }, + { label: "Type", value: invoice.type }, + { label: "Reference", value: invoice.sourceId }, + ...(await this.bookingSummaryRows(invoice)), + { label: "Currency", value: invoice.currency }, + { + label: "Issued", + value: invoice.issuedAt + ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") + : null, + }, + { + label: "Due", + value: invoice.dueAt + ? new Date(invoice.dueAt).toLocaleDateString("en-GB") + : null, + }, + ]; + + // Seller identity — EDR's own legal TIN/VAT live only in EIMS config (nowhere else in this + // codebase). Shown only when actually configured, same as the buyer VAT row. + const eimsCfg = this.config.get("eims"); + if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin }); + if (eimsCfg?.invoice?.sellerVatNumber) { + summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber }); + } + + // MoR EIMS reference — only once actually registered, never a placeholder row. + if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn }); + + // PNR — the CBE_BILL reference the customer pays against, written onto the booking at + // payment-initiation time (see initiatePayment()). Not a column on Invoice/Payment, so + // look it up by source id; only shown once a payment actually generated one. + if (invoice.source === Freight.InvoiceSource.Booking) { + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: invoice.sourceId }, + select: ["id", "pnrCode"], + }); + if (booking?.pnrCode) summary.push({ label: "PNR", value: booking.pnrCode }); + } + return { kind, title, @@ -429,24 +688,7 @@ export class BillingService { issuedAt: invoice.issuedAt ?? invoice.createdAt, status: invoice.status, currency: invoice.currency, - summary: [ - { label: "Status", value: invoice.status }, - { label: "Type", value: invoice.type }, - { label: "Reference", value: invoice.sourceId }, - { label: "Currency", value: invoice.currency }, - { - label: "Issued", - value: invoice.issuedAt - ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") - : null, - }, - { - label: "Due", - value: invoice.dueAt - ? new Date(invoice.dueAt).toLocaleDateString("en-GB") - : null, - }, - ], + summary, categoryHeader: "Charge type", lines: invoice.lines.map((l) => ({ description: l.description ?? l.chargeType, @@ -457,6 +699,7 @@ export class BillingService { currency: l.currency, })), totals, + qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null, }; } @@ -502,23 +745,61 @@ export class BillingService { }); } - /** Invoices for the signed-in customer; empty when they have no company. */ + /** + * Resolve a shipping-line company from the signed-in user (null for ordinary + * customers). Queried straight off the entity rather than through + * ShippingLineCompaniesService — that module already imports billing, so a + * service edge back would deepen the forwardRef cycle for one lookup. + */ + private async resolveShippingLineCompanyId( + userId: string, + ): Promise { + const line = await this.dataSource + .getRepository(ShippingLineCompany) + .findOne({ where: { userId } }); + return line?.id ?? null; + } + + /** + * Invoices for the signed-in portal user; empty when they have no company. + * A payer is either a customer company or a shipping line (enforced by the + * DB's single-payer check), so the two lookups cannot both match. + */ async findForUser( userId: string, filter: { source?: string; sourceId?: string } = {}, ): Promise { const companyId = await this.resolveCompanyId(userId); - return companyId ? this.findByCompany(companyId, filter) : []; + if (companyId) return this.findByCompany(companyId, filter); + + const shippingLineCompanyId = + await this.resolveShippingLineCompanyId(userId); + if (!shippingLineCompanyId) return []; + return this.invoices.findAll({ + where: { + shippingLineCompanyId, + ...(filter.source ? { source: filter.source } : {}), + ...(filter.sourceId ? { sourceId: filter.sourceId } : {}), + }, + order: { createdAt: "DESC" }, + }); } - /** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */ + /** Payer-scoped invoice detail (+ lines); 404 when not owned by the user. */ async findByIdForUser( id: string, userId: string, ): Promise { - const companyId = await this.resolveCompanyId(userId); const invoice = await this.findById(id); - if (!companyId || invoice.companyId !== companyId) { + const ownedByCompany = + invoice.companyId != null && + invoice.companyId === (await this.resolveCompanyId(userId)); + const ownedByShippingLine = + !ownedByCompany && + invoice.shippingLineCompanyId != null && + invoice.shippingLineCompanyId === + (await this.resolveShippingLineCompanyId(userId)); + if (!ownedByCompany && !ownedByShippingLine) { throw new NotFoundException(`Invoice ${id} not found`); } return invoice; @@ -585,11 +866,16 @@ export class BillingService { // ── Generation ─────────────────────────────────────────────────────────────── - /** `-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */ - private nextInvoiceNumber(mg: EntityManager): Promise { + /** + * `-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. `code` + * defaults to `INV`; a memo (`issueMemo`) uses `CRE`/`DEB` instead, which is its own independent + * daily sequence (different prefix hashes to a different advisory lock, see + * `nextDailyInvoiceNumber`) — not a collision risk with ordinary invoice numbers. + */ + private nextInvoiceNumber(mg: EntityManager, code = "INV"): Promise { return nextDailyInvoiceNumber(mg, { table: "freight.invoices", - code: "INV", + code, }); } @@ -608,19 +894,147 @@ export class BillingService { input: GenerateInvoiceInput, manager?: EntityManager, ): Promise { - console.log("oooooooooo", input); const run = (mg: EntityManager) => this.createInvoice(input, mg); return manager ? run(manager) : this.dataSource.transaction(run); } + /** + * Issue a credit or debit memo against an already-registered invoice, per MoR's confirmed + * DEB/CRE filing mechanism (same `/v1/register` endpoint, `DocumentDetails.Type` + `Reason`, + * `ReferenceDetails.RelatedDocument` — see `eims-invoice.mapper.ts`). Reuses `createInvoice` + * unchanged: it has no side effects (no events, no notifications, no payment records — every + * event in this service fires from `runTransition` on a *transition*, not on create), so a memo + * is just an ordinary invoice with three extra columns set. + * + * `sourceId` is deliberately the *original invoice's own id*, not the original's `sourceId` + * (e.g. a booking id): `findPayable`, `expirePayable` and `billQuery` all resolve by + * `sourceId` with no `type` filter, so a memo sharing the booking's `sourceId` would be the + * newest matching row and could hijack a payer's balance at a CBE teller. An invoice's own + * `id` is never a value those lookups are ever queried with, so this isolates a memo from all + * of them regardless of its status — no `type`-based exclusion needed anywhere else. + * + * A credit note is created settled (PAID, balance 0) — nothing is ever collected against it, so + * leaving it payable would only add a phantom receivable that no payment flow will ever close. + * A debit note genuinely IS a new receivable and is created open/unpaid like any ordinary + * invoice (`createInvoice`'s own defaults: PENDING, `balanceAmount = totalAmount`) — it is + * findable and collectible through the normal invoice list/detail/payment tooling, safe from + * the CBE/booking-linked lookups above for the `sourceId` reason just given. + */ + async issueMemo( + originalId: string, + input: IssueMemoInput, + ): Promise { + const reason = input.reason?.trim(); + if (!reason) { + throw new BadRequestException("A memo requires a reason."); + } + + const original = await this.findById(originalId); + if (!original.eimsIrn) { + throw new BadRequestException({ + code: "EIMS_RELATED_INVOICE_NOT_REGISTERED", + message: `Invoice ${original.invoiceNumber} was never registered with EIMS — nothing to reference.`, + }); + } + if (original.eimsDocumentType && original.eimsDocumentType !== "INV") { + throw new BadRequestException( + `Invoice ${original.invoiceNumber} is itself a ${original.eimsDocumentType} — cannot issue a memo against a memo.`, + ); + } + if (original.eimsStatus === EimsInvoiceStatus.Cancelled) { + throw new BadRequestException( + `Invoice ${original.invoiceNumber} was cancelled with EIMS — nothing to adjust.`, + ); + } + + const sourceLines = input.lines?.length ? input.lines : original.lines; + const lines: InvoiceLineInput[] = sourceLines.map((l) => ({ + chargeType: l.chargeType, + description: l.description, + quantity: Number(l.quantity), + unitRate: Number(l.unitRate), + amount: Number(l.amount), + currency: l.currency, + metadata: l.metadata ?? null, + })); + + const total = round2(lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0)); + if (!(total > 0)) { + throw new BadRequestException("A memo must have a positive total."); + } + // Only a credit note is bounded by the original — it can only give back what was charged. A + // debit note is an additional charge, not a refund, so no such ceiling applies to it (do not + // assume the credit-note ceiling is correct for DEB). + if (input.type === "CRE" && total > Number(original.totalAmount)) { + throw new BadRequestException( + `Credit memo total (${total}) exceeds invoice ${original.invoiceNumber}'s total (${original.totalAmount}).`, + ); + } + + const code = input.type === "CRE" ? "CRE" : "DEB"; + const settled = input.type === "CRE"; + + return this.dataSource.transaction(async (mg) => { + const memo = await this.createInvoice( + { + source: original.source as Freight.InvoiceSource, + sourceId: original.id, + type: input.type === "CRE" ? "credit_note" : "debit_note", + companyId: original.companyId, + companyProfileId: original.companyProfileId, + shippingLineCompanyId: original.shippingLineCompanyId, + lines, + currency: original.currency, + subtotalAmount: total, + taxAmount: 0, + totalAmount: total, + ...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}), + }, + mg, + code, + ); + + const patch: Record = { + eimsDocumentType: input.type, + eimsReason: reason, + relatedInvoiceId: original.id, + ...(settled + ? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() } + : {}), + }; + await mg.update(Invoice, memo.id, patch); + + this.logger.log( + `Issued ${input.type} memo ${memo.invoiceNumber} (${memo.id}) against invoice ${original.invoiceNumber}`, + ); + return { ...memo, ...patch } as Invoice & { lines: InvoiceLine[] }; + }); + } + private async createInvoice( input: GenerateInvoiceInput, mg: EntityManager, + code = "INV", ): Promise { const currency = input.currency ?? "ETB"; const status = input.status ?? Freight.InvoiceStatus.Pending; const issued = status !== Freight.InvoiceStatus.Draft; + // Exactly one payer, checked here so a bad payload fails with a clear + // message instead of a raw `chk_invoices_single_payer` violation. + const billsCompany = Boolean(input.companyId); + const billsShippingLine = Boolean(input.shippingLineCompanyId); + if (billsCompany === billsShippingLine) { + throw new BadRequestException( + "An invoice must be billed to exactly one payer: either companyId or shippingLineCompanyId.", + ); + } + if (billsCompany && !input.companyProfileId) { + throw new BadRequestException( + "companyProfileId is required when billing a company.", + ); + } + const lines = input.lines.map((l) => { const quantity = l.quantity ?? 1; const unitRate = l.unitRate ?? 0; @@ -648,7 +1062,7 @@ export class BillingService { (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, ); - const invoiceNumber = await this.nextInvoiceNumber(mg); + const invoiceNumber = await this.nextInvoiceNumber(mg, code); const invoice = await mg.save( mg.create(Invoice, { @@ -656,8 +1070,9 @@ export class BillingService { source: input.source, sourceId: input.sourceId, type: input.type, - companyId: input.companyId, - companyProfileId: input.companyProfileId, + companyId: input.companyId ?? null, + companyProfileId: input.companyProfileId ?? null, + shippingLineCompanyId: input.shippingLineCompanyId ?? null, subtotalAmount: round2(subtotalAmount), taxAmount: round2(taxAmount), totalAmount: round2(totalAmount), @@ -951,6 +1366,24 @@ export class BillingService { await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); + // Every invoice status move in the app funnels through here — money + // changing state is the single most-asked question in support. + logCtx( + { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + source: invoice.source, + sourceId: invoice.sourceId, + from: invoice.status, + to: status, + event, + amount: Number(invoice.totalAmount), + currency: invoice.currency, + paymentId: extra.paymentId ?? invoice.paymentId ?? undefined, + }, + { path: "invoiceTransitions", mode: "push" }, + ); + const updated = { ...invoice, ...extra, status } as Invoice; return { result: updated, @@ -969,6 +1402,7 @@ export class BillingService { type: invoice.type, companyId: invoice.companyId, companyProfileId: invoice.companyProfileId, + shippingLineCompanyId: invoice.shippingLineCompanyId ?? null, totalAmount: invoice.totalAmount, currency: invoice.currency, status: invoice.status, @@ -1320,6 +1754,21 @@ export class BillingService { throw new BadRequestException("Invoice has no outstanding balance."); } + logCtx( + { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + source: invoice.source, + sourceId: invoice.sourceId, + companyId: invoice.companyId, + amountDue, + currency: invoice.currency, + method: opts.method ?? "TELEBIRR", + platform: opts.platform, + }, + { path: "payment.payInvoice" }, + ); + // CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is // required up front (the payment service rejects it otherwise, as a 502 here). if ( @@ -1443,7 +1892,24 @@ export class BillingService { // first on DESC, which would hand back an unissued invoice. order: { issuedAt: { direction: "DESC", nulls: "LAST" } }, }); - if (!invoice) return null; + if (!invoice) { + logCtx( + { paymentId, outcome: "no-invoice-for-payment" }, + { path: "payment.settleInvoice" }, + ); + return null; + } + + logCtx( + { + paymentId, + providerTxnId, + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + invoiceStatus: invoice.status, + }, + { path: "payment.settleInvoice" }, + ); const settleable: Freight.InvoiceStatus[] = [ ...OPEN_STATUSES, @@ -1453,6 +1919,12 @@ export class BillingService { // Already PAID is the ordinary idempotent no-op (redelivery, or settled // inline by payInvoice). Anything else means money was captured with // nowhere to land — that needs a person, so say so loudly. + logCtx( + invoice.status === Freight.InvoiceStatus.Paid + ? "already-paid" + : "captured-with-nowhere-to-land", + { path: "payment.settleInvoice.outcome", mode: "set" }, + ); if (invoice.status !== Freight.InvoiceStatus.Paid) { this.logger.error( `Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` + diff --git a/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts index c320a5d44..363cda3f0 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts @@ -7,7 +7,9 @@ import { PdfRenderService } from "./pdf-render.service"; * Standalone document infrastructure — generic HTML→PDF plus the shared * invoice/receipt renderer. Has no domain dependencies, so any module (billing, * warehouses, …) can import it to print invoices without coupling to the - * billing payment graph. + * billing payment graph. StampSettingsService is @Global (see + * StampSettingsModule) so InvoiceDocumentService can inject it without this + * module declaring an explicit import. */ @Module({ providers: [PdfRenderService, InvoiceDocumentService], diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts new file mode 100644 index 000000000..ebdf51be1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts @@ -0,0 +1,89 @@ +import { InvoiceDocumentModel, InvoiceDocumentService } from "./invoice-document.service"; + +const model = (over: Partial = {}): InvoiceDocumentModel => ({ + kind: "INVOICE", + title: "Freight", + documentNumber: "INV-20260812-00001", + issuedAt: new Date(2026, 7, 12), + status: "PENDING", + currency: "ETB", + summary: [{ label: "Status", value: "PENDING" }], + lines: [], + totals: [{ label: "Total", amount: 100, grand: true }], + ...over, +}); + +describe("InvoiceDocumentService.buildHtml — EIMS QR", () => { + const service = new InvoiceDocumentService({} as never, {} as never, {} as never); + + it("renders no QR block when qrImageUrl is unset", () => { + const html = service.buildHtml(model()); + expect(html).not.toContain('class="qr"'); + }); + + it("renders the QR image when qrImageUrl is set", () => { + const html = service.buildHtml(model({ qrImageUrl: "data:image/png;base64,QR" })); + expect(html).toContain('class="qr"'); + expect(html).toContain('src="data:image/png;base64,QR"'); + }); + + it("still shows the IRN text row via the ordinary summary grid", () => { + const html = service.buildHtml( + model({ summary: [{ label: "EIMS IRN", value: "IRN-123" }] }), + ); + expect(html).toContain("EIMS IRN"); + expect(html).toContain("IRN-123"); + }); + + it("widens the summary's right margin only when a QR is present, to clear the QR block", () => { + // "summary-with-qr" also appears in the always-present + + +
+ ${logoInner} +
Ethio-Djibouti Railway S.C.
+
${esc(heading)}
+
${esc(model.documentNumber)} · ${esc(formatDate(model.issuedAt))}
+
+ ${summaryRows} +
+ ${itemBlocks} +
+ ${totalRows} + ${qrMarkup} + +
+ +`; + } + /** * Vector-drawn styled invoice/receipt used when headless Chromium is * unavailable. Mirrors the HTML layout closely enough to pass as the same @@ -203,21 +382,17 @@ export class InvoiceDocumentService { } buildHtml(model: InvoiceDocumentModel): string { - const esc = (value: unknown) => - String(value ?? "-") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - const money = (amount: unknown, currency = model.currency) => - `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; - const date = (value: unknown) => - value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-"; - + const date = formatDate; const showCategory = Boolean(model.categoryHeader); const sealText = model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); + const sealInner = sealMarkup(model.stampImageUrl, sealText); + const sealCssClass = sealClass(model.stampImageUrl); + const logoInner = logoMarkup(model.logoImageUrl); + + const qrMarkup = model.qrImageUrl + ? `
EIMS verification QRScan to verify (MoR EIMS)
` + : ""; const summaryRows = model.summary .map((row) => `
${esc(row.label)}${esc(row.value)}
`) @@ -238,7 +413,7 @@ export class InvoiceDocumentService { const totalRows = model.totals .map( (total) => - `
${esc(total.label)}${esc(money(total.amount))}
`, + `
${esc(total.label)}${esc(money(total.amount, model.currency))}
`, ) .join(""); @@ -256,8 +431,21 @@ export class InvoiceDocumentService { .meta { text-align: right; font-size: 12px; color: #475569; } .meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; } .seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; } + ${sealImageCss()} + ${logoImageCss()} + .qr { position: absolute; right: 160px; top: 118px; width: 90px; text-align: center; } + .qr img { width: 90px; height: 90px; } + .qr span { display: block; font-size: 7px; color: #64748b; margin-top: 3px; } .summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; } - .summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; } + /* The QR block (right:160px, width:90px) sits further inward than the seal alone did — the + 150px margin above only ever cleared the seal, so a QR-bearing invoice needs more room. */ + .summary.summary-with-qr { margin-right: 270px; } + /* min-width: 0 overrides Grid's default min-width:auto on grid items — without it, a long + unbroken value (a 20-digit VAT number) forces its column wider to fit un-wrapped rather than + honouring overflow-wrap, which is what actually let text bleed into the seal/QR overlay + (confirmed by isolating the two: margin-right alone already positioned the box correctly; + the text itself was still escaping the box's own right edge until this was added). */ + .summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; overflow-wrap: break-word; min-width: 0; } .summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; } table { width: 100%; border-collapse: collapse; margin-top: 18px; } th { text-align: left; background: #f8fafc; color: #475569; } @@ -274,6 +462,7 @@ export class InvoiceDocumentService {
+ ${logoInner}
Ethio-Djibouti Railway S.C.

${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}

@@ -283,8 +472,9 @@ export class InvoiceDocumentService { Issued: ${esc(date(model.issuedAt))}
-
${esc(sealText)}
-
${summaryRows}
+
${sealInner}
+ ${qrMarkup} +
${summaryRows}
diff --git a/apps/edr-freight-api/src/modules/billing/documents/logo-markup.util.ts b/apps/edr-freight-api/src/modules/billing/documents/logo-markup.util.ts new file mode 100644 index 000000000..f78109855 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/logo-markup.util.ts @@ -0,0 +1,35 @@ +/** + * The single decision every EDR document makes about its header logo: draw + * the one uploaded company logo when configured (LogoSettingsService), or + * render nothing — the existing "Ethio-Djibouti Railway S.C." text brand next + * to it already covers the no-logo case, so there is no text fallback here + * (contrast seal-markup.util.ts, whose seal has no text of its own). + */ + +function escapeHtml(value: unknown): string { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * `` markup for the header logo, or "" when unset. `logoImageUrl` is + * expected to be a data URL from LogoSettingsService.getLogoImageUrl(). + * `className` defaults to "doc-logo" — each document supplies that class's + * sizing in its own `; +/** + * Physical roll width. Content stays within `THERMAL_MARGIN_MM` of each edge — every mainstream + * ESC/POS thermal head (Epson TM-T88, Star, Bixolon) has a dead zone near the edge of an 80mm roll + * it physically can't reach, so the page itself must stay 80mm (matching the roll the printer + * driver expects) with the safe area carved out by margin, not by shrinking the page. + */ +const THERMAL_PAGE_WIDTH_MM = 80; +const THERMAL_MARGIN_MM = 4; +/** Extra length past the measured content, so the cut isn't flush against the last line. */ +const THERMAL_FEED_MM = 6; +/** Guard against a runaway line-item list producing an absurd page. */ +const THERMAL_MAX_HEIGHT_MM = 1500; + export interface PdfRenderOptions { /** Label used in logs to identify the document kind. */ label?: string; + /** Landscape A4 instead of the default portrait — wide tables need it. */ + landscape?: boolean; + /** + * Render as an 80mm continuous thermal receipt instead of a fixed A4 page: content width is + * measured and the page height grows to fit it, rather than a fixed page with the format's + * `format: "A4"`. + */ + thermal?: boolean; + /** + * Refuse to degrade to a fallback PDF on failure — throw instead. For a thermal request, a + * generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal printer + * output" (it silently hands back a different document shape than what was asked for); the + * caller has an existing A4 download to point the user at instead. Ignored when `fallback` is + * also supplied — an explicit fallback always wins. + */ + noFallback?: boolean; /** * Degraded renderer used when Chromium is unavailable. Receives the * print-prepared HTML and must return a valid PDF buffer (≥ 2KB, `%PDF-` - * header). When omitted, a generic single-page fallback is produced. + * header). When omitted (and `noFallback` is not set), a generic single-page fallback is + * produced. */ fallback?: (preparedHtml: string) => Buffer; } @@ -52,16 +82,37 @@ export class PdfRenderService { const browser = await puppeteer.default.launch(launchOptions); try { const page = await browser.newPage(); - await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); + const thermal = opts.thermal ?? false; + const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794; + // Thermal viewport height is deliberately tiny (not a real page height at all): scrollHeight + // is defined as the LARGER of the content's height and the viewport's own height, so a + // receipt shorter than the viewport would otherwise report the viewport height back, not + // its true content height — a real page-length trailing blank space bug, not theoretical + // (confirmed by actually rendering one). A short viewport forces content to overflow it, + // so scrollHeight always reflects the content, never the viewport. + await page.setViewport({ width: viewportWidth, height: thermal ? 100 : 1123, deviceScaleFactor: 1 }); await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 }); await page.emulateMediaType("print"); await new Promise((resolve) => setTimeout(resolve, 250)); - const pdf = await page.pdf({ - format: "A4", - printBackground: true, - margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" }, - }); + const pdf = thermal + ? await page.pdf({ + width: `${THERMAL_PAGE_WIDTH_MM}mm`, + height: `${await this.thermalContentHeightMm(page)}mm`, + printBackground: true, + margin: { + top: `${THERMAL_MARGIN_MM}mm`, + bottom: `${THERMAL_MARGIN_MM + THERMAL_FEED_MM}mm`, + left: `${THERMAL_MARGIN_MM}mm`, + right: `${THERMAL_MARGIN_MM}mm`, + }, + }) + : await page.pdf({ + format: "A4", + landscape: opts.landscape ?? false, + printBackground: true, + margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" }, + }); const buffer = Buffer.from(pdf); if (!this.isValidPdf(buffer)) { @@ -76,6 +127,15 @@ export class PdfRenderService { } } catch (error) { this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`); + if (!opts.fallback && opts.noFallback) { + // A generic A4-shaped, QR-less fallback is not an acceptable stand-in for "the thermal + // printer output" — it silently hands back a different document than what was asked for. + // Fail loudly instead; the caller already has a working A4 download to fall back to. + throw new InternalServerErrorException( + `${label} could not be generated — thermal rendering requires Chromium. ` + + "Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH, or download the A4 PDF instead.", + ); + } const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml); if (this.isValidPdf(fallback)) { this.logger.warn( @@ -89,6 +149,20 @@ export class PdfRenderService { } } + /** + * Thermal receipts are continuous-roll — there is no fixed page height. Measures the rendered + * content's actual height and adds feed clearance, so the PDF page is exactly as long as the + * receipt, not a fixed A4-length page with blank space at the bottom. + */ + private async thermalContentHeightMm(page: import("puppeteer").Page): Promise { + // String form, not a typed closure: this project's tsconfig has no `dom` lib, so `document` + // isn't a known global to type-check against — the string is evaluated in the page's own + // browser context regardless, same as the closure form would be. + const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number; + const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM; + return Math.min(THERMAL_MAX_HEIGHT_MM, Math.round(contentMm * 100) / 100); + } + private injectPdfPrintStyles(html: string): string { if (html.includes("edr-pdf-print-fix")) return html; if (html.includes("")) { diff --git a/apps/edr-freight-api/src/modules/billing/documents/seal-markup.util.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/seal-markup.util.spec.ts new file mode 100644 index 000000000..d07f8ebf6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/seal-markup.util.spec.ts @@ -0,0 +1,78 @@ +import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util"; + +/** + * These three helpers are the single image-vs-text branch shared by every + * EDR document's round seal, so a regression here silently unstamps invoices, + * warehouse release papers and handover papers at once. + */ +describe("seal markup helpers", () => { + const STAMP = "data:image/png;base64,QUJD"; + + describe("sealMarkup", () => { + it("renders the stamp image when one is configured", () => { + expect(sealMarkup(STAMP, ["EDR", "Warehouse"])).toBe( + `Company stamp`, + ); + }); + + it("falls back to text rings when no stamp is configured", () => { + expect(sealMarkup(null, ["EDR", "Warehouse", "Cleared"])).toBe( + "EDR
Warehouse
Cleared
", + ); + }); + + it("treats undefined as unset", () => { + expect(sealMarkup(undefined, "EDR")).toBe("EDR"); + }); + + it("accepts a bare string as a single line", () => { + expect(sealMarkup(null, "EDR")).toBe("EDR"); + }); + + it("escapes text lines so document data cannot inject markup", () => { + expect(sealMarkup(null, [''])).toBe( + "<script>alert("x")</script>", + ); + }); + + it("escapes the image src so it cannot break out of the attribute", () => { + expect(sealMarkup('data:image/png;base64,A" onerror="x', "EDR")).toBe( + 'Company stamp', + ); + }); + }); + + describe("sealClass", () => { + it("adds the image modifier only when stamped", () => { + expect(sealClass(STAMP)).toBe("seal seal-image"); + expect(sealClass(null)).toBe("seal"); + }); + + it("honours a document's own seal selector", () => { + expect(sealClass(STAMP, "sig-stamp-box")).toBe( + "sig-stamp-box sig-stamp-box-image", + ); + expect(sealClass(null, "sig-stamp-box")).toBe("sig-stamp-box"); + }); + }); + + describe("sealImageCss", () => { + it("neutralizes the drawn ring and rotation for a real stamp image", () => { + const css = sealImageCss(); + + expect(css).toContain(".seal.seal-image { border: none;"); + expect(css).toContain("transform: none;"); + // The ::before pseudo-element draws the inner ring of the text seal. + expect(css).toContain(".seal.seal-image::before { content: none; }"); + expect(css).toContain(".seal img { max-width: 100%;"); + }); + + it("scopes every rule to the given selector", () => { + const css = sealImageCss("sig-stamp-box"); + + expect(css).not.toContain(".seal"); + expect(css).toContain(".sig-stamp-box.sig-stamp-box-image"); + expect(css).toContain(".sig-stamp-box img"); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/documents/seal-markup.util.ts b/apps/edr-freight-api/src/modules/billing/documents/seal-markup.util.ts new file mode 100644 index 000000000..2dd9c4715 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/seal-markup.util.ts @@ -0,0 +1,64 @@ +/** + * The single decision every EDR document makes about its round seal: draw the + * one uploaded company stamp when one is configured (StampSettingsService), or + * fall back to the plain text rings the document styles itself. + * + * Only the image-vs-text branch and the image overrides live here — each + * document keeps its own `.seal` geometry (the invoice's seal is absolutely + * positioned top-right, the warehouse papers' sit inline above the signature + * lines), so centralizing the source of the stamp does not relayout anything. + * + * These helpers are for the HTML/Chromium render path. The hand-built vector + * fallbacks in styled-pdf.util.ts cannot embed a raster image and continue to + * draw their vector seal — see InvoiceDocumentService for that caveat. + */ + +/** Escape a value for interpolation into HTML text or a quoted attribute. */ +function escapeHtml(value: unknown): string { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * CSS overrides that neutralize a document's own ring/rotation styling when the + * seal is a real stamp image. Append inside a document's +

${esc(def.title)}

+

${esc(def.description)}

+ ${kpiHtml} +
${head}${body}
+ `; + } +} diff --git a/apps/edr-freight-api/src/modules/reports/report-queries.ts b/apps/edr-freight-api/src/modules/reports/report-queries.ts deleted file mode 100644 index 9e4a6f617..000000000 --- a/apps/edr-freight-api/src/modules/reports/report-queries.ts +++ /dev/null @@ -1,669 +0,0 @@ -import { DataSource } from 'typeorm'; - -export interface ReportFilters { - /** ISO timestamp, inclusive lower bound. null = no lower bound (all time). */ - dateFrom: string | null; - /** ISO timestamp, exclusive upper bound. null = no upper bound. */ - dateTo: string | null; - granularity: 'day' | 'week' | 'month'; - companyIds: string[] | null; - routeIds: string[] | null; - yardIds: string[] | null; - cargoTypeIds: string[] | null; - statuses: string[] | null; - /** Trade-scope-resolved directions. null = unrestricted, [] = show nothing. */ - directions: string[] | null; - freightType: string | null; -} - -export interface ReportKpi { - label: string; - value: number; - unit?: string; -} - -export interface ReportResult { - kpis: ReportKpi[]; - rows: Record[]; -} - -type ReportQuery = (ds: DataSource, f: ReportFilters) => Promise; - -// For PER_ITEM bulk bookings cargo_total_weight_vgm holds an item COUNT, and -// the real tonnage lives in bulk_total_weight_tons — hence the COALESCE order. -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; -// adjusted_total_amount silently overrides total_amount when set. -const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; -// GENERAL contract_kind rows are umbrella contracts, not shipments; counting -// them double-counts every child booking (same guard as overview.repository). -const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; -const DEAD_STATUSES = "'DRAFT','CANCELLED','REJECTED','EXPIRED'"; - -const num = (v: unknown): number => (v === null || v === undefined ? 0 : Number(v)); -const sum = (rows: Record[], col: string): number => - rows.reduce((acc, r) => acc + num(r[col]), 0); - -/** - * Shared WHERE for booking-based reports (alias `b`). - * Params occupy $1..$8 in this fixed order; report SQL continues at $9. - */ -function bookingWhere(f: ReportFilters): { where: string; params: unknown[] } { - return { - where: ` - b.deleted_at IS NULL - AND ${NOT_UMBRELLA} - AND ($1::timestamptz IS NULL OR b.created_at >= $1) - AND ($2::timestamptz IS NULL OR b.created_at < $2) - AND ($3::uuid[] IS NULL OR b.company_id = ANY($3)) - AND ($4::uuid[] IS NULL OR b.cargo_type_id = ANY($4)) - AND ($5::text[] IS NULL OR b.trade_direction = ANY($5)) - AND ($6::text IS NULL OR b.freight_type = $6) - AND (CASE WHEN $7::text[] IS NULL - THEN b.status NOT IN (${DEAD_STATUSES}) - ELSE b.status = ANY($7) END) - AND ($8::uuid[] IS NULL OR b.origin_yard_id = ANY($8) OR b.destination_yard_id = ANY($8))`, - params: [ - f.dateFrom, - f.dateTo, - f.companyIds, - f.cargoTypeIds, - f.directions, - f.freightType, - f.statuses, - f.yardIds, - ], - }; -} - -/** - * Direction scope for rows that reference a booking through a varchar id - * column (invoices.source_id, payments.ref_id). Rows not pointing at a - * booking stay visible — they carry no direction to scope by. - * (Positional-param port of trade-scope.util's bookingRefScopeSql.) - */ -const refDirScope = (refColumn: string, param: string): string => ` - (${param}::text[] IS NULL OR NOT EXISTS ( - SELECT 1 FROM freight.bookings sb - WHERE sb.id::text = ${refColumn} AND NOT (sb.trade_direction = ANY(${param}))))`; - -const bookingsTrend: ReportQuery = async (ds, f) => { - const { where, params } = bookingWhere(f); - const rows = await ds.query( - `SELECT to_char(date_trunc($9, b.created_at), 'YYYY-MM-DD') AS period, - COUNT(*)::int AS bookings, - ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons, - ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue - FROM freight.bookings b - WHERE ${where} - GROUP BY 1 ORDER BY 1`, - [...params, f.granularity], - ); - return { - kpis: [ - { label: 'Bookings', value: sum(rows, 'bookings') }, - { label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' }, - { label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' }, - ], - rows, - }; -}; - -const revenueByCustomer: ReportQuery = async (ds, f) => { - const { where, params } = bookingWhere(f); - const rows = await ds.query( - `SELECT c.name AS customer, - COUNT(*)::int AS bookings, - ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons, - ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue - FROM freight.bookings b - JOIN freight.companies c ON c.id = b.company_id - WHERE ${where} - GROUP BY c.name ORDER BY revenue DESC LIMIT 100`, - params, - ); - const total = sum(rows, 'revenue'); - return { - kpis: [ - { label: 'Customers', value: rows.length }, - { label: 'Revenue', value: total, unit: 'ETB' }, - { - label: 'Top customer share', - value: total > 0 ? Math.round((num(rows[0]?.revenue) / total) * 100) : 0, - unit: '%', - }, - ], - rows, - }; -}; - -const revenueByLane: ReportQuery = async (ds, f) => { - const { where, params } = bookingWhere(f); - const rows = await ds.query( - `SELECT o.label AS origin, d.label AS destination, - COUNT(*)::int AS bookings, - ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons, - ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue - FROM freight.bookings b - JOIN freight.yards o ON o.id = b.origin_yard_id - JOIN freight.yards d ON d.id = b.destination_yard_id - WHERE ${where} - GROUP BY 1, 2 ORDER BY revenue DESC LIMIT 100`, - params, - ); - return { - kpis: [ - { label: 'Lanes', value: rows.length }, - { label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' }, - { label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' }, - ], - rows, - }; -}; - -const contractUtilization: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT ct.reference, c.name AS customer, ct.status, ct.contract_kind AS kind, - to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from, - to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until, - cap.committed::float8 AS committed, - booked.tons::float8 AS booked_tons, - booked.cnt AS bookings, - CASE WHEN cap.committed > 0 - THEN ROUND(booked.tons / cap.committed * 100)::float8 END AS utilization_pct - FROM freight.contracts ct - LEFT JOIN freight.companies c ON c.id = ct.company_id - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(s.quantity_cap), 0) AS committed - FROM freight.contract_cargo_scope s - WHERE s.contract_id = ct.id AND s.deleted_at IS NULL) cap ON true - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(${TONS}), 0) AS tons, COUNT(*)::int AS cnt - FROM freight.bookings b - WHERE b.contract_id = ct.id AND b.deleted_at IS NULL - AND b.status NOT IN (${DEAD_STATUSES})) booked ON true - WHERE ct.deleted_at IS NULL - AND ct.status NOT IN ('DRAFT') - AND ct.contract_valid_from < COALESCE($2::timestamptz, 'infinity') - AND (ct.contract_valid_until IS NULL - OR ct.contract_valid_until >= COALESCE($1::timestamptz, '-infinity')) - AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3)) - AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4)) - AND ($5::text[] IS NULL OR ct.status = ANY($5)) - ORDER BY utilization_pct DESC NULLS LAST LIMIT 200`, - [f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses], - ); - const capped = rows.filter((r: Record) => num(r.committed) > 0); - return { - kpis: [ - { label: 'Contracts', value: rows.length }, - { - label: 'Avg utilization', - value: capped.length - ? Math.round(sum(capped, 'utilization_pct') / capped.length) - : 0, - unit: '%', - }, - { label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' }, - ], - rows, - }; -}; - -// ponytail: 60-min departure grace is a constant; make it a query param if ops -// ever wants a configurable threshold. -const trainOnTime: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT o.label AS origin, d.label AS destination, - COUNT(*)::int AS trips, - COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL)::int AS departed, - ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_departure_at - ts.scheduled_departure_date)) / 60) - FILTER (WHERE ts.actual_departure_at IS NOT NULL))::float8 AS avg_dep_delay_min, - ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.scheduled_arrival_date)) / 60) - FILTER (WHERE ts.actual_arrival_at IS NOT NULL - AND ts.scheduled_arrival_date IS NOT NULL))::float8 AS avg_arr_delay_min, - ROUND(100.0 * COUNT(*) FILTER (WHERE ts.actual_departure_at - <= ts.scheduled_departure_date + interval '60 minutes') - / NULLIF(COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL), 0))::float8 AS on_time_pct - FROM freight.train_schedules ts - JOIN freight.yards o ON o.id = ts.origin_station_id - JOIN freight.yards d ON d.id = ts.destination_station_id - WHERE ts.deleted_at IS NULL - AND ts.status IN ('DISPATCHED', 'ARRIVED') - AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) - AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) - AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3)) - AND ($4::text[] IS NULL OR ts.direction = ANY($4)) - AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) - GROUP BY 1, 2 ORDER BY trips DESC`, - [f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds], - ); - const departed = sum(rows, 'departed'); - const weighted = rows.reduce( - (acc: number, r: Record) => - acc + (num(r.on_time_pct) * num(r.departed)) / 100, - 0, - ); - return { - kpis: [ - { label: 'Trips', value: sum(rows, 'trips') }, - { - label: 'On-time departures', - value: departed > 0 ? Math.round((weighted / departed) * 100) : 0, - unit: '%', - }, - { - label: 'Avg departure delay', - value: rows.length ? Math.round(sum(rows, 'avg_dep_delay_min') / rows.length) : 0, - unit: 'min', - }, - ], - rows, - }; -}; - -const scheduleFillRate: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT ts.train_number, ts.reference, - to_char(ts.scheduled_departure_date, 'YYYY-MM-DD') AS departure, - o.label AS origin, d.label AS destination, ts.direction, ts.status, - ts.max_wagons, tset.wagon_count, - ROUND(w.cap_tons)::float8 AS capacity_tons, - ROUND(w.booked_tons)::float8 AS booked_tons, - CASE WHEN w.cap_tons > 0 - THEN ROUND(w.booked_tons / w.cap_tons * 100)::float8 END AS fill_pct - FROM freight.train_schedules ts - JOIN freight.yards o ON o.id = ts.origin_station_id - JOIN freight.yards d ON d.id = ts.destination_station_id - LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(tw.capacity_tons), 0) AS cap_tons, - COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons - FROM freight.train_set_wagons tw - WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true - WHERE ts.deleted_at IS NULL - AND ts.status <> 'CANCELLED' - AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) - AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) - AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3)) - AND ($4::text[] IS NULL OR ts.direction = ANY($4)) - AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) - ORDER BY ts.scheduled_departure_date DESC LIMIT 200`, - [f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds], - ); - const withCap = rows.filter((r: Record) => num(r.capacity_tons) > 0); - const capTons = sum(withCap, 'capacity_tons'); - return { - kpis: [ - { label: 'Schedules', value: rows.length }, - { - label: 'Avg fill rate', - value: capTons > 0 ? Math.round((sum(withCap, 'booked_tons') / capTons) * 100) : 0, - unit: '%', - }, - { label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' }, - ], - rows, - }; -}; - -const tripsPerRoute: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT o.label AS origin, d.label AS destination, ts.direction, - COUNT(*)::int AS trips, - ROUND(COALESCE(SUM(w.booked_tons), 0))::float8 AS tons_hauled, - ROUND(COALESCE(AVG(w.booked_tons), 0))::float8 AS avg_tons_per_trip - FROM freight.train_schedules ts - JOIN freight.yards o ON o.id = ts.origin_station_id - JOIN freight.yards d ON d.id = ts.destination_station_id - LEFT JOIN LATERAL ( - SELECT COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons - FROM freight.train_set_wagons tw - WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true - WHERE ts.deleted_at IS NULL - AND ts.status IN ('DISPATCHED', 'ARRIVED') - AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) - AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) - AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3)) - AND ($4::text[] IS NULL OR ts.direction = ANY($4)) - AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) - GROUP BY 1, 2, 3 ORDER BY trips DESC`, - [f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds], - ); - return { - kpis: [ - { label: 'Trips', value: sum(rows, 'trips') }, - { label: 'Routes served', value: rows.length }, - { label: 'Tonnage hauled', value: sum(rows, 'tons_hauled'), unit: 't' }, - ], - rows, - }; -}; - -const invoicedVsCollected: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT to_char(date_trunc($5, COALESCE(i.issued_at, i.created_at)), 'YYYY-MM-DD') AS period, - COUNT(*)::int AS invoices, - ROUND(SUM(i.total_amount))::float8 AS invoiced, - ROUND(SUM(i.paid_amount))::float8 AS collected, - ROUND(SUM(i.balance_amount))::float8 AS outstanding - FROM freight.invoices i - WHERE i.deleted_at IS NULL - AND i.status NOT IN ('DRAFT', 'CANCELLED') - AND ($1::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) >= $1) - AND ($2::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) < $2) - AND ($3::uuid[] IS NULL OR i.company_id = ANY($3)) - AND ${refDirScope('i.source_id', '$4')} - GROUP BY 1 ORDER BY 1`, - [f.dateFrom, f.dateTo, f.companyIds, f.directions, f.granularity], - ); - const invoiced = sum(rows, 'invoiced'); - const collected = sum(rows, 'collected'); - return { - kpis: [ - { label: 'Invoiced', value: invoiced, unit: 'ETB' }, - { label: 'Collected', value: collected, unit: 'ETB' }, - { - label: 'Collection rate', - value: invoiced > 0 ? Math.round((collected / invoiced) * 100) : 0, - unit: '%', - }, - { label: 'Outstanding', value: sum(rows, 'outstanding'), unit: 'ETB' }, - ], - rows, - }; -}; - -// Aging is an as-of snapshot: dateTo is the as-of moment (default now), -// dateFrom is ignored. -const agingReceivables: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT c.name AS customer, - COUNT(*)::int AS invoices, - ROUND(SUM(i.balance_amount))::float8 AS outstanding, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at >= COALESCE($1::timestamptz, now())), 0))::float8 AS current, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '30 days'), 0))::float8 AS overdue_0_30, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '30 days' - AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '60 days'), 0))::float8 AS overdue_31_60, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '60 days' - AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_61_90, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_90_plus - FROM freight.invoices i - JOIN freight.companies c ON c.id = i.company_id - WHERE i.deleted_at IS NULL - AND i.status IN ('ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE') - AND i.balance_amount > 0 - AND ($1::timestamptz IS NULL OR i.created_at < $1) - AND ($2::uuid[] IS NULL OR i.company_id = ANY($2)) - AND ${refDirScope('i.source_id', '$3')} - GROUP BY 1 ORDER BY outstanding DESC LIMIT 200`, - [f.dateTo, f.companyIds, f.directions], - ); - const outstanding = sum(rows, 'outstanding'); - return { - kpis: [ - { label: 'Outstanding', value: outstanding, unit: 'ETB' }, - { label: 'Overdue', value: outstanding - sum(rows, 'current'), unit: 'ETB' }, - { label: 'Customers with balance', value: rows.length }, - ], - rows, - }; -}; - -const revenueByPaymentMethod: ReportQuery = async (ds, f) => { - // payments.status values are lowercase-hyphenated ('success'), unlike every - // other status enum in the schema. No deleted_at on this table. - const rows = await ds.query( - `SELECT p.method::text AS method, - COUNT(*)::int AS payments, - ROUND(SUM(p.amount))::float8 AS amount - FROM freight.payments p - WHERE p.status = 'success' - AND ($1::timestamptz IS NULL OR p.created_at >= $1) - AND ($2::timestamptz IS NULL OR p.created_at < $2) - AND ${refDirScope('p.ref_id', '$3')} - GROUP BY 1 ORDER BY amount DESC`, - [f.dateFrom, f.dateTo, f.directions], - ); - const total = sum(rows, 'amount'); - return { - kpis: [ - { label: 'Collected', value: total, unit: 'ETB' }, - { label: 'Payments', value: sum(rows, 'payments') }, - { - label: 'Top method share', - value: total > 0 ? Math.round((num(rows[0]?.amount) / total) * 100) : 0, - unit: '%', - }, - ], - rows, - }; -}; - -// --------------------------------------------------------------------------- -// Record-level list exports. Same engine, raw rows instead of aggregates. -// ponytail: flat LIMIT 5000 per list — stream/paginate the export if a table -// ever outgrows that. -const LIST_LIMIT = 5000; - -const bookingsList: ReportQuery = async (ds, f) => { - const { where, params } = bookingWhere(f); - const rows = await ds.query( - `SELECT b.reference, - to_char(b.created_at, 'YYYY-MM-DD') AS created, - c.name AS customer, b.status, b.freight_type, - b.trade_direction AS direction, - o.label AS origin, d.label AS destination, - COALESCE(cty.cargo_type_name, b.cargo_free_text) AS cargo, - ROUND(${TONS})::float8 AS tons, - ROUND(${REVENUE})::float8 AS amount, - b.payment_status, b.scheduling_status - FROM freight.bookings b - JOIN freight.companies c ON c.id = b.company_id - JOIN freight.yards o ON o.id = b.origin_yard_id - JOIN freight.yards d ON d.id = b.destination_yard_id - LEFT JOIN freight.cargo_types cty ON cty.id = b.cargo_type_id - WHERE ${where} - ORDER BY b.created_at DESC LIMIT ${LIST_LIMIT}`, - params, - ); - return { - kpis: [ - { label: 'Bookings', value: rows.length }, - { label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' }, - { label: 'Amount', value: sum(rows, 'amount'), unit: 'ETB' }, - ], - rows, - }; -}; - -const contractsList: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT ct.reference, c.name AS customer, ct.contract_kind AS kind, - ct.status, ct.trade_direction AS direction, ct.freight_type, - to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from, - to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until, - to_char(ct.created_at, 'YYYY-MM-DD') AS created - FROM freight.contracts ct - LEFT JOIN freight.companies c ON c.id = ct.company_id - WHERE ct.deleted_at IS NULL - AND ($1::timestamptz IS NULL OR ct.created_at >= $1) - AND ($2::timestamptz IS NULL OR ct.created_at < $2) - AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3)) - AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4)) - AND ($5::text[] IS NULL OR ct.status = ANY($5)) - ORDER BY ct.created_at DESC LIMIT ${LIST_LIMIT}`, - [f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses], - ); - const active = rows.filter((r: Record) => - ['CONTRACT_ACTIVE', 'ACTIVE_SHIPMENT_IN_PROGRESS'].includes(String(r.status)), - ).length; - return { - kpis: [ - { label: 'Contracts', value: rows.length }, - { label: 'Active', value: active }, - ], - rows, - }; -}; - -const schedulesList: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT ts.train_number, ts.reference, ts.direction, ts.status, - o.label AS origin, d.label AS destination, - to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI') AS scheduled_departure, - to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI') AS actual_departure, - to_char(ts.scheduled_arrival_date, 'YYYY-MM-DD HH24:MI') AS scheduled_arrival, - to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI') AS actual_arrival, - ts.max_wagons, tset.wagon_count - FROM freight.train_schedules ts - JOIN freight.yards o ON o.id = ts.origin_station_id - JOIN freight.yards d ON d.id = ts.destination_station_id - LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id - WHERE ts.deleted_at IS NULL - AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) - AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) - AND ($3::text[] IS NULL OR ts.direction = ANY($3)) - AND ($4::text[] IS NULL OR ts.status = ANY($4)) - AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) - ORDER BY ts.scheduled_departure_date DESC LIMIT ${LIST_LIMIT}`, - [f.dateFrom, f.dateTo, f.directions, f.statuses, f.yardIds], - ); - const count = (s: string) => - rows.filter((r: Record) => r.status === s).length; - return { - kpis: [ - { label: 'Schedules', value: rows.length }, - { label: 'Dispatched', value: count('DISPATCHED') }, - { label: 'Arrived', value: count('ARRIVED') }, - ], - rows, - }; -}; - -const fleetWagons: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT w.wagon_number, wt.name AS type, - wt.capacity_tons::float8 AS capacity_tons, - w.status, y.label AS current_yard - FROM freight.wagons w - JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id - LEFT JOIN freight.yards y ON y.id = w.current_yard_id - WHERE w.deleted_at IS NULL - AND ($1::text[] IS NULL OR w.status = ANY($1)) - AND ($2::uuid[] IS NULL OR w.current_yard_id = ANY($2)) - ORDER BY w.wagon_number LIMIT ${LIST_LIMIT}`, - [f.statuses, f.yardIds], - ); - const count = (s: string) => - rows.filter((r: Record) => r.status === s).length; - return { - kpis: [ - { label: 'Wagons', value: rows.length }, - { label: 'Available', value: count('AVAILABLE') }, - { label: 'Assigned', value: count('ASSIGNED') }, - { label: 'Maintenance', value: count('MAINTENANCE') }, - ], - rows, - }; -}; - -const fleetLocomotives: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT l.code, l.name, l.locomotive_type, - l.max_pull_weight_tons::float8 AS max_pull_tons, - l.status, y.label AS current_yard - FROM freight.locomotives l - LEFT JOIN freight.yards y ON y.id = l.current_yard_id - WHERE l.deleted_at IS NULL - AND ($1::text[] IS NULL OR l.status = ANY($1)) - AND ($2::uuid[] IS NULL OR l.current_yard_id = ANY($2)) - ORDER BY l.code LIMIT ${LIST_LIMIT}`, - [f.statuses, f.yardIds], - ); - const available = rows.filter( - (r: Record) => r.status === 'AVAILABLE', - ).length; - return { - kpis: [ - { label: 'Locomotives', value: rows.length }, - { label: 'Available', value: available }, - ], - rows, - }; -}; - -const customersList: ReportQuery = async (ds, f) => { - const rows = await ds.query( - `SELECT c.name, c.type, c.kind, c.status, c.tin, - to_char(c.approved_at, 'YYYY-MM-DD') AS approved, - to_char(c.created_at, 'YYYY-MM-DD') AS created - FROM freight.companies c - WHERE c.deleted_at IS NULL - AND ($1::timestamptz IS NULL OR c.created_at >= $1) - AND ($2::timestamptz IS NULL OR c.created_at < $2) - AND ($3::text[] IS NULL OR c.status = ANY($3)) - ORDER BY c.created_at DESC LIMIT ${LIST_LIMIT}`, - [f.dateFrom, f.dateTo, f.statuses], - ); - const active = rows.filter( - (r: Record) => r.status === 'active', - ).length; - return { - kpis: [ - { label: 'Customers', value: rows.length }, - { label: 'Active', value: active }, - ], - rows, - }; -}; - -const paymentsList: ReportQuery = async (ds, f) => { - // No deleted_at on freight.payments; statuses are lowercase-hyphenated. - const rows = await ds.query( - `SELECT to_char(p.created_at, 'YYYY-MM-DD HH24:MI') AS created, - p.method::text AS method, p.status::text AS status, - p.currency::text AS currency, - ROUND(p.amount)::float8 AS amount, - p.transaction_id, p.merchant_order_id, - to_char(p.paid_at, 'YYYY-MM-DD') AS paid - FROM freight.payments p - WHERE ($1::timestamptz IS NULL OR p.created_at >= $1) - AND ($2::timestamptz IS NULL OR p.created_at < $2) - AND ($3::text[] IS NULL OR p.status::text = ANY($3)) - AND ${refDirScope('p.ref_id', '$4')} - ORDER BY p.created_at DESC LIMIT ${LIST_LIMIT}`, - [f.dateFrom, f.dateTo, f.statuses, f.directions], - ); - const success = rows.filter( - (r: Record) => r.status === 'success', - ); - return { - kpis: [ - { label: 'Payments', value: rows.length }, - { label: 'Successful', value: success.length }, - { label: 'Collected', value: sum(success, 'amount'), unit: 'ETB' }, - ], - rows, - }; -}; - -export const REPORT_QUERIES: Record = { - 'bookings-list': bookingsList, - 'contracts-list': contractsList, - 'schedules-list': schedulesList, - 'fleet-wagons': fleetWagons, - 'fleet-locomotives': fleetLocomotives, - 'customers-list': customersList, - 'payments-list': paymentsList, - 'bookings-trend': bookingsTrend, - 'revenue-by-customer': revenueByCustomer, - 'revenue-by-lane': revenueByLane, - 'contract-utilization': contractUtilization, - 'train-on-time': trainOnTime, - 'schedule-fill-rate': scheduleFillRate, - 'trips-per-route': tripsPerRoute, - 'invoiced-vs-collected': invoicedVsCollected, - 'aging-receivables': agingReceivables, - 'revenue-by-payment-method': revenueByPaymentMethod, -}; diff --git a/apps/edr-freight-api/src/modules/reports/report-runner.service.ts b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts new file mode 100644 index 000000000..a9b662077 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report-runner.service.ts @@ -0,0 +1,152 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { + buildPaginationMeta, + normalizePagination, +} from '../../common/utils/pagination.util'; +import { applyBookingRefDirectionScope } from '../user-trade-access/trade-scope.util'; +import { ReportDefinition, ReportRunResult } from './report.types'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Raw query params, minus the pagination/sort keys the runner owns. */ +export type RawReportQuery = Record; + +/** + * Coerce raw query strings into typed filter params per the report's own + * filter declarations. Unknown filter keys are ignored — `forbidNonWhitelisted` + * can't police a per-report bag, so extras are just dropped, not rejected. + */ +function coerceParams( + def: ReportDefinition, + raw: RawReportQuery, +): Record { + const params: Record = {}; + for (const filter of def.filters) { + if (filter.type === 'daterange') { + const from = raw[`${filter.key}From`]; + const to = raw[`${filter.key}To`]; + params[`${filter.key}From`] = from ? new Date(from).toISOString() : null; + // Inclusive end date, exclusive bound in SQL. + params[`${filter.key}To`] = to + ? new Date(new Date(to).getTime() + DAY_MS).toISOString() + : null; + } else if (filter.type === 'multiselect') { + const csv = raw[filter.key]; + const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? []; + params[filter.key] = items.length ? items : null; + } else { + params[filter.key] = raw[filter.key]?.trim() || null; + } + } + // idKey, when the report declares one, is a plain string param. + if (def.idKey) { + params[def.idKey.key] = raw[def.idKey.key]?.trim() || null; + } + return params; +} + +/** + * Sort expression for a column with no explicit `sortExpr`: the SELECT alias + * TypeORM emitted for it, quoted. TypeORM always double-quotes `addSelect` + * aliases in the generated SQL (preserving case) — ordering by the bare, + * unquoted key instead lets Postgres fold it to lowercase and 42703 on any + * camelCase alias (e.g. "utilizationPct" -> unquoted "utilizationpct"). + */ +const aliasSortExpr = (key: string): string => `"${key.replace(/"/g, '""')}"`; + +/** Resolve a client-requested sort column against the report's own whitelist. */ +function resolveSort( + def: ReportDefinition, + sortBy?: string, + sortOrder?: string, +): { key: string; expr: string; dir: 'ASC' | 'DESC' } | null { + const dir = sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + const requested = sortBy && def.columns.find((c) => c.key === sortBy && c.sortable); + if (requested) { + return { key: requested.key, expr: requested.sortExpr ?? aliasSortExpr(requested.key), dir }; + } + if (!def.defaultSort) return null; + const fallback = def.columns.find((c) => c.key === def.defaultSort!.key); + if (!fallback) return null; + return { + key: fallback.key, + expr: fallback.sortExpr ?? aliasSortExpr(fallback.key), + dir: def.defaultSort.dir, + }; +} + +@Injectable() +export class ReportRunnerService { + constructor(@InjectDataSource() private readonly ds: DataSource) {} + + async run( + def: ReportDefinition, + raw: RawReportQuery, + directions: string[] | null, + ): Promise { + const params = coerceParams(def, raw); + const ctx = { ds: this.ds, params, directions }; + + const qb = def.query(ctx); + const sort = resolveSort(def, raw.sortBy, raw.sortOrder); + if (sort) qb.orderBy(sort.expr, sort.dir); + + const { page: pageNum, pageSize, skip, take } = normalizePagination({ + page: raw.page ? Number(raw.page) : undefined, + pageSize: raw.pageSize ? Number(raw.pageSize) : undefined, + }); + + const [sql, sqlParams] = qb.getQueryAndParameters(); + // getCount() re-derives its own (wrong) select list for GROUP BY queries — + // wrapping the real query as a subquery counts exactly what will be paged. + const countRow = await this.ds.query( + `SELECT COUNT(*)::int AS c FROM (${sql}) report_count`, + sqlParams, + ); + const total = Number(countRow[0]?.c ?? 0); + + // .offset()/.limit(), not .skip()/.take() — skip/take route raw & grouped + // selects through TypeORM's DISTINCT-id subquery path, which is wrong here. + const items = await qb.offset(skip).limit(take).getRawMany(); + + const kpis = def.summary ? await def.summary(ctx) : []; + + return { + columns: def.columns, + items, + meta: buildPaginationMeta(total, pageNum, pageSize), + kpis, + }; + } + + /** Same query, no paging — used by the export path. */ + async runAll( + def: ReportDefinition, + raw: RawReportQuery, + directions: string[] | null, + limit: number, + ): Promise<{ columns: typeof def.columns; items: Record[]; kpis: ReportRunResult['kpis'] }> { + const params = coerceParams(def, raw); + const ctx = { ds: this.ds, params, directions }; + const qb = def.query(ctx); + // Same sort the on-screen table is using, not always the default — an + // export is supposed to match what the user is looking at. + const sort = resolveSort(def, raw.sortBy, raw.sortOrder); + if (sort) qb.orderBy(sort.expr, sort.dir); + const items = await qb.limit(limit).getRawMany(); + if (items.length >= limit) { + throw new BadRequestException( + `Export exceeds the ${limit}-row cap for this format. Narrow the filters.`, + ); + } + const kpis = def.summary ? await def.summary(ctx) : []; + return { columns: def.columns, items, kpis }; + } +} + +// Re-exported so definitions can scope ACL columns without importing the +// trade-scope module directly. +export { applyBookingRefDirectionScope }; diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts b/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts new file mode 100644 index 000000000..ccec53c3d --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report.registry.spec.ts @@ -0,0 +1,52 @@ +import { REPORT_KEYS } from '../../seed/freight-permissions.registry'; +import { REPORTS, getReport } from './report.registry'; + +describe('REPORTS', () => { + it('has exactly one definition per seeded REPORT_KEYS entry', () => { + const defKeys = REPORTS.map((r) => r.key).sort(); + expect(defKeys).toEqual([...REPORT_KEYS].sort()); + }); + + it('has no duplicate keys', () => { + const keys = REPORTS.map((r) => r.key); + expect(new Set(keys).size).toBe(keys.length); + }); + + it('resolves every key via getReport', () => { + for (const key of REPORT_KEYS) { + expect(getReport(key)?.key).toBe(key); + } + }); + + it('every sortable column and defaultSort point at a real column key', () => { + for (const def of REPORTS) { + const columnKeys = new Set(def.columns.map((c) => c.key)); + if (def.defaultSort) { + expect(columnKeys.has(def.defaultSort.key)).toBe(true); + } + // Every column marked sortable must have a resolvable key (itself, since + // the runner falls back to `key` when `sortExpr` is absent). + for (const col of def.columns.filter((c) => c.sortable)) { + expect(col.key.length).toBeGreaterThan(0); + } + } + }); + + it('idKey, when declared, is not also listed as a user-facing filter', () => { + for (const def of REPORTS) { + if (!def.idKey) continue; + expect(def.filters.some((f) => f.key === def.idKey!.key)).toBe(false); + } + }); + + it('chart.x and chart.y, when declared, point at real column keys', () => { + for (const def of REPORTS) { + if (!def.chart) continue; + const columnKeys = new Set(def.columns.map((c) => c.key)); + expect(columnKeys.has(def.chart.x)).toBe(true); + for (const y of def.chart.y) { + expect(columnKeys.has(y)).toBe(true); + } + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/report.registry.ts b/apps/edr-freight-api/src/modules/reports/report.registry.ts new file mode 100644 index 000000000..da280a271 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report.registry.ts @@ -0,0 +1,80 @@ +import { ReportKey } from '../../seed/freight-permissions.registry'; +import { bookingsListReport } from './definitions/bookings-list.report'; +import { revenueByCustomerReport } from './definitions/revenue-by-customer.report'; +import { agingReceivablesReport } from './definitions/aging-receivables.report'; +import { contractUtilizationReport } from './definitions/contract-utilization.report'; +import { wagonFleetStatusReport } from './definitions/wagon-fleet-status.report'; +import { wagonStatusDurationReport } from './definitions/wagon-status-duration.report'; +import { wagonRequestsReport } from './definitions/wagon-requests.report'; +import { locomotiveFleetStatusReport } from './definitions/locomotive-fleet-status.report'; +import { bookingStatusBreakdownReport } from './definitions/booking-status-breakdown.report'; +import { trainScheduleStatusReport } from './definitions/train-schedule-status.report'; +import { trainTurnaroundReport } from './definitions/train-turnaround.report'; +import { wagonTeuUtilizationReport } from './definitions/wagon-teu-utilization.report'; +import { loadedCapacityReport } from './definitions/loaded-capacity.report'; +import { globalLogisticsWagonsReport } from './definitions/global-logistics-wagons.report'; +import { customerStatusReport } from './definitions/customer-status.report'; +import { contractLifecycleReport } from './definitions/contract-lifecycle.report'; +import { customsDocumentsReport } from './definitions/customs-documents.report'; +import { invoicingPipelineReport } from './definitions/invoicing-pipeline.report'; +import { firstLastMileBookingsReport } from './definitions/first-last-mile-bookings.report'; +import { invoicesByStatusReport } from './definitions/invoices-by-status.report'; +import { paymentsByStatusReport } from './definitions/payments-by-status.report'; +import { revenueSummaryReport } from './definitions/revenue-summary.report'; +import { cargoSummaryReport } from './definitions/cargo-summary.report'; +import { revenueByCategoryReport } from './definitions/revenue-by-category.report'; +import { revenueTransactionsReport } from './definitions/revenue-transactions.report'; +import { revenueByPeriodReport } from './definitions/revenue-by-period.report'; +import { revenueByRouteReport } from './definitions/revenue-by-route.report'; +import { revenueTopCustomersReport } from './definitions/revenue-top-customers.report'; +import { paymentClassificationReport } from './definitions/payment-classification.report'; +import { revenueReconciliationReport } from './definitions/revenue-reconciliation.report'; +import { receivablesPayablesReport } from './definitions/receivables-payables.report'; +import { revenueAnomaliesReport } from './definitions/revenue-anomalies.report'; +import { ReportDefinition } from './report.types'; + +/** + * Every report the platform knows about. Adding one = a new file under + * definitions/ + a key in REPORT_KEYS (freight-permissions.registry.ts) + + * an entry here. Nothing else — no frontend edit, no route, no sidebar edit. + */ +export const REPORTS: ReportDefinition[] = [ + bookingsListReport, + revenueByCustomerReport, + agingReceivablesReport, + contractUtilizationReport, + wagonFleetStatusReport, + wagonStatusDurationReport, + wagonRequestsReport, + locomotiveFleetStatusReport, + bookingStatusBreakdownReport, + trainScheduleStatusReport, + trainTurnaroundReport, + wagonTeuUtilizationReport, + loadedCapacityReport, + globalLogisticsWagonsReport, + customerStatusReport, + contractLifecycleReport, + customsDocumentsReport, + invoicingPipelineReport, + firstLastMileBookingsReport, + invoicesByStatusReport, + paymentsByStatusReport, + revenueSummaryReport, + cargoSummaryReport, + revenueByCategoryReport, + revenueTransactionsReport, + revenueByPeriodReport, + revenueByRouteReport, + revenueTopCustomersReport, + paymentClassificationReport, + revenueReconciliationReport, + receivablesPayablesReport, + revenueAnomaliesReport, +]; + +const BY_KEY = new Map(REPORTS.map((r) => [r.key, r])); + +export function getReport(key: string): ReportDefinition | undefined { + return BY_KEY.get(key as ReportKey); +} diff --git a/apps/edr-freight-api/src/modules/reports/report.types.ts b/apps/edr-freight-api/src/modules/reports/report.types.ts new file mode 100644 index 000000000..ca7578461 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report.types.ts @@ -0,0 +1,132 @@ +import { DataSource, ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { ReportKey } from '../../seed/freight-permissions.registry'; + +export type { ReportKey }; + +export type ReportColumnType = + | 'string' + | 'number' + | 'money' + | 'tons' + | 'percent' + | 'date'; + +export interface ReportColumn { + key: string; + label: string; + type: ReportColumnType; + sortable?: boolean; + /** SQL to ORDER BY when this column is sorted, if different from `key`. */ + sortExpr?: string; +} + +export type ReportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text'; + +export interface ReportFilterOption { + value: string; + label: string; +} + +export interface ReportFilterDef { + key: string; + label: string; + type: ReportFilterType; + /** Static option list for select/multiselect. */ + options?: ReportFilterOption[]; + /** + * Resolves the option list from the database instead of declaring it inline — + * for filters whose choices are reference data (stations, cargo types). + * Called once per catalog request and cached; the result is serialised into + * `options`, so the frontend never sees the difference. + */ + optionsQuery?: (ds: DataSource) => Promise; +} + +/** + * Makes a summary row clickable: the row's values are carried into another + * report as filter params, which is how "drill down from summary to + * transaction level" works. Keys are this report's column keys; values are the + * target report's filter keys. + */ +export interface ReportDrillDef { + to: ReportKey; + carry: Record; +} + +export interface ReportKpi { + label: string; + value: number; + unit?: string; +} + +export type ReportChartType = 'line' | 'bar'; + +/** + * Plots the SAME rows the table gets — no separate query. `x` and `y` are + * column keys from `columns`. A report whose group-by has dimensions beyond + * `x` will render one mark per row (e.g. two rows sharing a date because they + * differ by direction), which is a busier chart, not a wrong one. Pivoting + * rows into one-per-x series is a later add if a report actually needs it. + */ +export interface ReportChartDef { + type: ReportChartType; + x: string; + y: string[]; +} + +/** + * Optional entity scope a report can be embedded against — e.g. a + * contract-utilization report shown on a single contract's detail page. + * Purely descriptive; `query()` reads the resolved value off `ctx.params` + * like any other filter. + */ +export interface ReportIdKey { + key: string; + label: string; +} + +export interface ReportContext { + ds: DataSource; + /** Filter values, already coerced against `def.filters` (CSV → array, etc). */ + params: Record; + /** Trade-scope-resolved directions. null = unrestricted, [] = show nothing. */ + directions: string[] | null; +} + +export interface ReportDefinition { + key: ReportKey; + title: string; + description: string; + group: 'Commercial' | 'Operations' | 'Finance'; + idKey?: ReportIdKey; + filters: ReportFilterDef[]; + columns: ReportColumn[]; + defaultSort?: { key: string; dir: 'ASC' | 'DESC' }; + query(ctx: ReportContext): SelectQueryBuilder; + /** KPIs over the same filtered set; shown above the table and in exports. */ + summary?(ctx: ReportContext): Promise; + /** Optional chart view of the same rows. Table remains the default view. */ + chart?: ReportChartDef; + /** Makes rows clickable, navigating to a transaction-level report. */ + drill?: ReportDrillDef; +} + +/** Catalog shape served by GET /reports — metadata only, no rows. */ +export type ReportCatalogEntry = Omit & { + hasSummary: boolean; +}; + +export interface ReportRunResult { + columns: ReportColumn[]; + items: Record[]; + meta: { + page: number; + pageSize: number; + total: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + kpis: ReportKpi[]; +} diff --git a/apps/edr-freight-api/src/modules/reports/reports.controller.ts b/apps/edr-freight-api/src/modules/reports/reports.controller.ts index dc64773d8..107e11097 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.controller.ts @@ -1,34 +1,127 @@ -import { Controller, Get, Param, Query } from '@nestjs/common'; -import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Controller, Get, NotFoundException, Param, Query, Res } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; import { CurrentUser } from '@edr/api-common'; +import type { Response } from 'express'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { BookingStaff } from '../../common/booking-guards'; -import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; +import { FREIGHT_PERMS, reportPermissionKey } from '../../seed/freight-permissions.registry'; import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; -import { ReportQueryDto } from './dto/report-query.dto'; -import { ReportResultDto } from './dto/report-result.dto'; -import { ReportsService } from './reports.service'; +import { ReportExportService } from './report-export.service'; +import { resolveExportCap, resolveExportColumns, resolveExportFormat } from './report-export-request.util'; +import { RawReportQuery, ReportRunnerService } from './report-runner.service'; +import { REPORTS, getReport } from './report.registry'; +import { ReportCatalogEntry, ReportDefinition, ReportFilterOption } from './report.types'; + +const toCatalogEntry = (def: ReportDefinition): ReportCatalogEntry => { + const { query: _query, summary, ...meta } = def; + return { ...meta, hasSummary: Boolean(summary) }; +}; + +/** + * Filters whose choices are reference data resolve their options here rather + * than declaring them inline, so the catalog the frontend receives looks the + * same either way. Cached for the process lifetime — these are small, rarely + * changing lists (23 stations, 18 cargo types), and the catalog is hit on + * every page load. + */ +const optionsCache = new Map(); + +async function resolveFilterOptions( + def: ReportCatalogEntry, + ds: DataSource, +): Promise { + if (!def.filters.some((f) => f.optionsQuery)) return def; + + const filters = await Promise.all( + def.filters.map(async (filter) => { + if (!filter.optionsQuery) return filter; + let options = optionsCache.get(filter.key); + if (!options) { + options = await filter.optionsQuery(ds); + optionsCache.set(filter.key, options); + } + // Drop the resolver itself — it is a function and would not serialise. + const { optionsQuery: _resolver, ...rest } = filter; + return { ...rest, options }; + }), + ); + return { ...def, filters }; +} @ApiTags('Reports') @ApiBearerAuth() @Controller('reports') +@BookingStaff(FREIGHT_PERMS.reports.view) export class ReportsController { constructor( - private readonly reportsService: ReportsService, + private readonly runner: ReportRunnerService, + private readonly exportService: ReportExportService, private readonly userTradeAccessService: UserTradeAccessService, + @InjectDataSource() private readonly dataSource: DataSource, ) {} + @Get() + @ApiOperation({ summary: 'List reports the caller has permission to run' }) + async catalog(@CurrentUser() user: TCurrentUser): Promise { + const allowed = REPORTS.filter((def) => + hasFreightPermission(user, reportPermissionKey(def.key)), + ).map(toCatalogEntry); + return Promise.all(allowed.map((def) => resolveFilterOptions(def, this.dataSource))); + } + @Get(':key') - @BookingStaff(FREIGHT_PERMS.reports.view) - @ApiOperation({ summary: 'Run a canned report by key with optional filters' }) - @ApiOkResponse({ type: ReportResultDto }) + @ApiOperation({ summary: 'Run a report by key, paginated/sorted/filtered' }) async run( @Param('key') key: string, - @Query() query: ReportQueryDto, + @Query() query: RawReportQuery, @CurrentUser() user: TCurrentUser, - ): Promise { - const allowed = await this.userTradeAccessService.resolveAllowedDirections(user); - return this.reportsService.run(key, query, allowed); + ) { + const def = this.resolve(key, user); + const directions = await this.userTradeAccessService.resolveAllowedDirections(user); + return this.runner.run(def, query, directions); + } + + @Get(':key/export') + @ApiOperation({ summary: 'Export a report to xlsx or pdf' }) + async export( + @Param('key') key: string, + @Query() query: RawReportQuery & { format?: string; fields?: string; limit?: string }, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ): Promise { + const def = this.resolve(key, user); + const directions = await this.userTradeAccessService.resolveAllowedDirections(user); + const format = resolveExportFormat(query.format); + const cap = resolveExportCap(format, query.limit); + const exportColumns = resolveExportColumns(def, query.fields); + + const { items, kpis } = await this.runner.runAll(def, query, directions, cap); + const buffer = + format === 'pdf' + ? await this.exportService.toPdf(def, items, kpis, exportColumns) + : await this.exportService.toXlsx(def, items, kpis, exportColumns); + + const filename = `${def.key}.${format === 'pdf' ? 'pdf' : 'xlsx'}`; + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader( + 'Content-Type', + format === 'pdf' + ? 'application/pdf' + : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + res.send(buffer); + } + + private resolve(key: string, user: TCurrentUser): ReportDefinition { + const def = getReport(key); + if (!def) throw new NotFoundException(`Unknown report: ${key}`); + // Exact-match on purpose — unlike FreightPermissionGuard's :view/:read + // fallback, a report's own key is the only thing that opens it. + assertFreightPermission(user, reportPermissionKey(def.key)); + return def; } } diff --git a/apps/edr-freight-api/src/modules/reports/reports.module.ts b/apps/edr-freight-api/src/modules/reports/reports.module.ts index a7fe792a5..2f98e9e04 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.module.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; +import { DocumentsModule } from '../billing/documents/documents.module'; import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; +import { ReportExportService } from './report-export.service'; +import { ReportRunnerService } from './report-runner.service'; import { ReportsController } from './reports.controller'; -import { ReportsRepository } from './reports.repository'; -import { ReportsService } from './reports.service'; @Module({ - imports: [UserTradeAccessModule], + imports: [UserTradeAccessModule, DocumentsModule], controllers: [ReportsController], - providers: [ReportsService, ReportsRepository], + providers: [ReportRunnerService, ReportExportService], }) export class ReportsModule {} diff --git a/apps/edr-freight-api/src/modules/reports/reports.repository.ts b/apps/edr-freight-api/src/modules/reports/reports.repository.ts deleted file mode 100644 index 65f154b22..000000000 --- a/apps/edr-freight-api/src/modules/reports/reports.repository.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource } from 'typeorm'; - -import { REPORT_QUERIES, ReportFilters, ReportResult } from './report-queries'; - -@Injectable() -export class ReportsRepository { - constructor(@InjectDataSource() private readonly dataSource: DataSource) {} - - run(key: keyof typeof REPORT_QUERIES, filters: ReportFilters): Promise { - return REPORT_QUERIES[key](this.dataSource, filters); - } -} diff --git a/apps/edr-freight-api/src/modules/reports/reports.service.ts b/apps/edr-freight-api/src/modules/reports/reports.service.ts deleted file mode 100644 index 04e6e9a60..000000000 --- a/apps/edr-freight-api/src/modules/reports/reports.service.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; - -import { scopedDirections } from '../user-trade-access/trade-scope.util'; -import { ReportQueryDto } from './dto/report-query.dto'; -import { REPORT_QUERIES, ReportFilters, ReportResult } from './report-queries'; -import { ReportsRepository } from './reports.repository'; -import type { Freight } from '@edr/types'; - -const DAY_MS = 24 * 60 * 60 * 1000; - -const list = (csv?: string): string[] | null => { - const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? []; - return items.length ? items : null; -}; - -@Injectable() -export class ReportsService { - constructor(private readonly repository: ReportsRepository) {} - - run( - key: string, - dto: ReportQueryDto, - allowedDirections: Freight.ScheduleTradeDirection[] | null, - ): Promise { - if (!(key in REPORT_QUERIES)) { - throw new NotFoundException(`Unknown report: ${key}`); - } - // No default range: absent dates mean all time, so exports cover everything. - const to = dto.dateTo ? new Date(dto.dateTo) : null; - const from = dto.dateFrom ? new Date(dto.dateFrom) : null; - const filters: ReportFilters = { - dateFrom: from ? from.toISOString() : null, - // dateTo is inclusive in the API; queries treat the bound as exclusive. - dateTo: to ? new Date(to.getTime() + DAY_MS).toISOString() : null, - granularity: dto.granularity ?? 'day', - companyIds: list(dto.companyIds), - routeIds: list(dto.routeIds), - yardIds: list(dto.yardIds), - cargoTypeIds: list(dto.cargoTypeIds), - statuses: list(dto.statuses), - directions: scopedDirections(allowedDirections, dto.direction), - freightType: dto.freightType ?? null, - }; - return this.repository.run(key, filters); - } -} diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts new file mode 100644 index 000000000..42475d591 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.spec.ts @@ -0,0 +1,102 @@ +import { + BULK_FREIGHT_CHARGES, + PAYMENT_CLASSES, + PAYMENT_CLASS_EXPR, + PERIOD_FILTER, + REVENUE_CATEGORIES, + REVENUE_CATEGORY_EXPR, + periodExpr, +} from './revenue-classification'; + +/** + * `invoice_lines.charge_type` is an unconstrained varchar written by eight + * unrelated code paths. Nothing at the type level stops someone adding a ninth + * spelling, whose revenue would then land silently in the ELSE arm. + * + * This list is every value the codebase writes today. When it grows, these + * tests are what fail — which is the whole trade the const-map design makes. + */ +const KNOWN_CHARGE_TYPES = [ + // booking base freight (rate_type codes) + 'CONTAINER_IMPORT', 'CONTAINER_EXPORT', 'CONTAINER_20FT', 'CONTAINER_40FT', + 'BULK_IMPORT', 'BULK_EXPORT', 'INTERCITY_BULK', 'INTERCITY_CONTAINER', 'FREIGHT', + // surcharges + 'FUEL_SURCHARGE', 'LASHING', 'OVERWEIGHT_PER_TON', 'HAZARD_SURCHARGE', + 'REEFER_SURCHARGE', 'PIL_EXTRA_FEE', 'RETURN_SURCHARGE', 'RETURN_SURCHARGE_20FT', + 'RETURN_SURCHARGE_40FT', 'CONTAINER_WITH_RETURN', 'ADJUSTMENT', 'RATE_ADJUSTMENT', + // customs + 'CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE_20FT', 'CUSTOMS_CLEARANCE_40FT', + // mile legs + 'FIRST_MILE', 'LAST_MILE', 'DELIVERY', 'LAST_MILE_ADVANCE', + // warehouse fees + 'CONTAINER_DEMURRAGE', 'BULK_DEMURRAGE', 'DEMURRAGE', 'STORAGE_FEE', + 'HANDLING_FEE', 'DOUBLE_HANDLING', 'TRUCK_DETENTION', + // other producers + 'CANCELLATION_FEE', 'SHIPPING_LINE_SERVICE', +]; + +/** + * Does the expression name this charge type — either as a literal or through + * one of its `LIKE 'PREFIX%'` arms? + * + * Deliberately a substring check, not a SQL parser: a parser would be more + * fragile than the expression it is guarding. This catches the failure that + * actually happens (a new charge type nobody added to the map) and nothing + * pretends it verifies the branch order. + */ +function isNamed(expr: string, chargeType: string): boolean { + if (expr.includes(`'${chargeType}'`)) return true; + return [...expr.matchAll(/LIKE '([^']*)%'/g)].some(([, prefix]) => + chargeType.startsWith(prefix), + ); +} + +describe('revenue classification', () => { + it('names every charge type the codebase writes in the payment-class map', () => { + const unmapped = KNOWN_CHARGE_TYPES.filter((c) => !isNamed(PAYMENT_CLASS_EXPR, c)); + expect(unmapped).toEqual([]); + }); + + it('names every ancillary charge type in the revenue-category map', () => { + // Bulk freight lines carry no category of their own — the CASE falls + // through to the booking's cargo type and trade direction for those. + const cargoDerived = new Set(BULK_FREIGHT_CHARGES); + const unmapped = KNOWN_CHARGE_TYPES.filter( + (c) => !cargoDerived.has(c) && !isNamed(REVENUE_CATEGORY_EXPR, c), + ); + expect(unmapped).toEqual([]); + }); + + it('emits only categories that are offered as filter options', () => { + const declared = new Set(REVENUE_CATEGORIES.map((c) => c.value)); + const emitted = [...REVENUE_CATEGORY_EXPR.matchAll(/THEN '([A-Z_]+)'/g)].map((m) => m[1]); + expect(emitted.length).toBeGreaterThan(0); + expect(emitted.filter((c) => !declared.has(c))).toEqual([]); + expect(declared.has('UNCLASSIFIED')).toBe(true); + }); + + it('emits only payment classes that are offered as filter options', () => { + const declared = new Set(PAYMENT_CLASSES.map((c) => c.value)); + const emitted = [...PAYMENT_CLASS_EXPR.matchAll(/THEN '([A-Z_]+)'/g)].map((m) => m[1]); + expect(emitted.filter((c) => !declared.has(c))).toEqual([]); + expect(declared.has('ADDITIONAL')).toBe(true); + }); + + it('falls back to a whitelisted period unit instead of interpolating input', () => { + expect(periodExpr({ period: 'quarter' })).toContain("date_trunc('quarter'"); + expect(periodExpr({ period: 'year' })).toContain("date_trunc('year'"); + // Anything unrecognised — including an injection attempt — becomes 'month'. + expect(periodExpr({ period: "day'); DROP TABLE freight.invoices; --" })).toContain( + "date_trunc('month'", + ); + expect(periodExpr({})).toContain("date_trunc('month'"); + }); + + it('offers exactly the period units the expression understands', () => { + const offered = (PERIOD_FILTER.options ?? []).map((o) => o.value); + expect(offered.length).toBe(5); + for (const unit of offered) { + expect(periodExpr({ period: unit })).toContain(`date_trunc('${unit}'`); + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts new file mode 100644 index 000000000..ebe52ef6b --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts @@ -0,0 +1,553 @@ +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +import { InvoiceLine } from '../billing/entities/invoice-line.entity'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { Booking } from '../bookings/entities/booking.entity'; +import { Company } from '../companies/entities/company.entity'; +import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-company.entity'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; +import { Yard } from '../rule-engine/entities/yard.entity'; +import { applyBookingRefDirectionScope } from '../user-trade-access/trade-scope.util'; +import { ReportContext, ReportFilterDef, ReportFilterOption } from './report.types'; + +/** + * The shared vocabulary and SQL behind every revenue report. + * + * The fact table is `invoice_lines`, not `bookings`: `charge_type` is the only + * column in the system that separates customs, first/last mile, demurrage, + * storage and incidental revenue from base freight. A booking total is one + * lump sum and cannot answer the revenue-classification requirement. + * + * Every consumer builds its FROM through {@link revenueLedgerQb}, so the table + * aliases below (`il i b ct oy dy co slc`) are a fixed contract and the SQL + * fragments here can reference them directly. + */ + +// --------------------------------------------------------------------------- +// Revenue categories +// --------------------------------------------------------------------------- + +export const REVENUE_CATEGORIES: ReportFilterOption[] = [ + { value: 'CONTAINER_IMPORT_MULTIMODAL', label: 'Full Container Import — Multimodal' }, + { value: 'CONTAINER_IMPORT_UNIMODAL', label: 'Full Container Import — Unimodal' }, + { value: 'CONTAINER_EXPORT', label: 'Full Container Export' }, + { value: 'EMPTY_CONTAINER_REEXPORT', label: 'Empty Container Re-export' }, + { value: 'FERTILIZER', label: 'Fertilizer Transportation' }, + { value: 'BREAK_BULK', label: 'Break Bulk (Steel, Machineries)' }, + { value: 'RORO', label: 'RoRo Transportation' }, + { value: 'OTHER_IMPORT_BULK', label: 'Other Import Bulk Cargo' }, + { value: 'OTHER_EXPORT_CARGO', label: 'Other Export Cargo' }, + { value: 'DOMESTIC', label: 'Domestic Cargo Transportation' }, + { value: 'INCIDENTAL', label: 'Incidental Charges' }, + { value: 'FIRST_LAST_MILE', label: 'First & Last Mile' }, + { value: 'CUSTOMS_CLEARANCE', label: 'Customs Clearance' }, + { value: 'UNCLASSIFIED', label: 'Unclassified' }, +]; + +/** + * `charge_type` is an unconstrained varchar written by eight different code + * paths, so the same concept arrives under several spellings — three for + * demurrage, four for first/last mile. Every set below absorbs all of them. + */ +export const MILE_CHARGES = ['FIRST_MILE', 'LAST_MILE', 'DELIVERY', 'LAST_MILE_ADVANCE']; + +export const INCIDENTAL_CHARGES = [ + 'FUEL_SURCHARGE', + 'LASHING', + 'OVERWEIGHT_PER_TON', + 'HAZARD_SURCHARGE', + 'REEFER_SURCHARGE', + 'PIL_EXTRA_FEE', + 'CANCELLATION_FEE', + 'ADJUSTMENT', + 'RATE_ADJUSTMENT', + 'DEMURRAGE', + 'CONTAINER_DEMURRAGE', + 'BULK_DEMURRAGE', + 'TRUCK_DETENTION', + 'STORAGE_FEE', + 'HANDLING_FEE', + 'DOUBLE_HANDLING', + 'SHIPPING_LINE_SERVICE', +]; + +export const DOMESTIC_CHARGES = ['INTERCITY_BULK', 'INTERCITY_CONTAINER']; + +export const CONTAINER_FREIGHT_CHARGES = [ + 'CONTAINER_IMPORT', + 'CONTAINER_EXPORT', + 'CONTAINER_20FT', + 'CONTAINER_40FT', +]; + +export const BULK_FREIGHT_CHARGES = ['BULK_IMPORT', 'BULK_EXPORT', 'FREIGHT']; + +/** Cargo codes the business bills as break bulk, wherever the cargo tree puts them. */ +export const BREAK_BULK_CODES = ['STEEL_BILLET', 'STEEL', 'MACHINERY', 'PIPES', 'TIMBER']; + +export const RORO_CODES = ['TRUCK', 'AUTOMOBILE', 'CARS', 'RORO']; + +export const FERTILIZER_CODES = ['FERTILIZER']; + +/** + * Multimodal means EDR carried the sea leg as well as the rail leg. Nothing in + * the schema says so directly; a named sea carrier on the booking is the + * agreed proxy. One constant, deliberately — flip it here if the business + * defines multimodality differently. + */ +const MULTIMODAL_PREDICATE = 'b.shipping_line_id IS NOT NULL'; + +const list = (values: string[]): string => values.map((v) => `'${v}'`).join(', '); + +/** + * Assigns each invoice line exactly one revenue category. First match wins. + * + * Charge-derived rules run BEFORE cargo-derived ones on purpose: a customs or + * demurrage line billed on a container-import booking is customs/incidental + * revenue, not container-import revenue. Reversing the order would fold every + * ancillary charge back into the freight categories. + * + * Nothing falls through silently — an unmatched line lands in UNCLASSIFIED and + * every report surfaces that total as a KPI, because an audit report must + * never quietly drop money. + */ +export const REVENUE_CATEGORY_EXPR = `CASE + WHEN il.charge_type LIKE 'CUSTOMS_CLEARANCE%' THEN 'CUSTOMS_CLEARANCE' + WHEN il.charge_type IN (${list(MILE_CHARGES)}) THEN 'FIRST_LAST_MILE' + WHEN il.charge_type LIKE 'RETURN_SURCHARGE%' + OR il.charge_type = 'CONTAINER_WITH_RETURN' THEN 'EMPTY_CONTAINER_REEXPORT' + WHEN il.charge_type IN (${list(INCIDENTAL_CHARGES)}) THEN 'INCIDENTAL' + WHEN il.charge_type IN (${list(DOMESTIC_CHARGES)}) + OR (oy.country IS NOT NULL AND oy.country = dy.country) THEN 'DOMESTIC' + WHEN il.charge_type IN (${list(CONTAINER_FREIGHT_CHARGES)}) + OR b.freight_type = 'CONTAINER' THEN + CASE + WHEN b.trade_direction = 'EXPORT' THEN 'CONTAINER_EXPORT' + WHEN ${MULTIMODAL_PREDICATE} THEN 'CONTAINER_IMPORT_MULTIMODAL' + ELSE 'CONTAINER_IMPORT_UNIMODAL' + END + WHEN ct.code IN (${list(FERTILIZER_CODES)}) THEN 'FERTILIZER' + WHEN ct.code IN (${list(BREAK_BULK_CODES)}) THEN 'BREAK_BULK' + WHEN ct.code IN (${list(RORO_CODES)}) THEN 'RORO' + WHEN b.trade_direction = 'EXPORT' THEN 'OTHER_EXPORT_CARGO' + WHEN b.trade_direction = 'IMPORT' THEN 'OTHER_IMPORT_BULK' + ELSE 'UNCLASSIFIED' +END`; + +const labelCase = (expr: string, options: ReportFilterOption[]): string => + `CASE ${expr}\n ${options + .map((o) => `WHEN '${o.value}' THEN '${o.label.replace(/'/g, "''")}'`) + .join('\n ')}\nEND`; + +/** The category as a business label rather than its key, for display columns. */ +export const CATEGORY_LABEL_EXPR = labelCase(REVENUE_CATEGORY_EXPR, REVENUE_CATEGORIES); + +/** + * Period-over-period change, as a percentage. + * + * The denominator is `ABS(prior)`, not `prior`. A category can post negative + * revenue in a period — a credit note or rate adjustment outweighing its + * charges — and dividing by a negative prior flips the sign, reporting a + * recovery as a decline. Taking the magnitude keeps the sign of the change + * itself. + */ +export const growthPctExpr = (revenue: string, prior: string): string => + `ROUND(100 * (${revenue} - ${prior}) / NULLIF(ABS(${prior}), 0), 1)::float8`; + +// --------------------------------------------------------------------------- +// Payment classification +// --------------------------------------------------------------------------- + +export const PAYMENT_CLASSES: ReportFilterOption[] = [ + { value: 'RAIL_TRANSPORT', label: 'Rail transport' }, + { value: 'CUSTOMS_CLEARANCE', label: 'Custom clearance' }, + { value: 'FIRST_LAST_MILE', label: 'First and last mile' }, + { value: 'OVERWEIGHT', label: 'Overweight' }, + { value: 'CANCELLATION', label: 'Cancellation' }, + { value: 'DEMURRAGE', label: 'Demurrage' }, + { value: 'STORAGE', label: 'Storage' }, + { value: 'LOADING_UNLOADING', label: 'Loading and unloading' }, + { value: 'ADDITIONAL', label: 'Additional payment' }, +]; + +const RAIL_CHARGES = [...CONTAINER_FREIGHT_CHARGES, ...BULK_FREIGHT_CHARGES, ...DOMESTIC_CHARGES]; + +const DEMURRAGE_CHARGES = ['DEMURRAGE', 'CONTAINER_DEMURRAGE', 'BULK_DEMURRAGE', 'TRUCK_DETENTION']; + +/** + * Charges that legitimately belong in the spec's "additional payment" bucket. + * + * Listed explicitly rather than left to the ELSE arm: ELSE also catches charge + * types nobody has mapped yet, and those two cases must not be + * indistinguishable. Naming these is what lets the spec fail when a genuinely + * new charge type appears. + */ +export const ADDITIONAL_CHARGES = [ + 'FUEL_SURCHARGE', + 'HAZARD_SURCHARGE', + 'REEFER_SURCHARGE', + 'PIL_EXTRA_FEE', + 'RETURN_SURCHARGE', + 'RETURN_SURCHARGE_20FT', + 'RETURN_SURCHARGE_40FT', + 'CONTAINER_WITH_RETURN', + 'ADJUSTMENT', + 'RATE_ADJUSTMENT', + 'SHIPPING_LINE_SERVICE', +]; + +/** + * The nine buckets the revenue spec asks payments to be classified into. + * + * Caveat worth repeating wherever this is shown: there is no dedicated + * loading/unloading charge type in the system. HANDLING_FEE, DOUBLE_HANDLING + * and LASHING are the nearest equivalent, so that bucket is an approximation, + * not an exact match. + */ +export const PAYMENT_CLASS_EXPR = `CASE + WHEN il.charge_type LIKE 'CUSTOMS_CLEARANCE%' THEN 'CUSTOMS_CLEARANCE' + WHEN il.charge_type IN (${list(MILE_CHARGES)}) THEN 'FIRST_LAST_MILE' + WHEN il.charge_type IN (${list(RAIL_CHARGES)}) THEN 'RAIL_TRANSPORT' + WHEN il.charge_type = 'OVERWEIGHT_PER_TON' THEN 'OVERWEIGHT' + WHEN il.charge_type = 'CANCELLATION_FEE' THEN 'CANCELLATION' + WHEN il.charge_type IN (${list(DEMURRAGE_CHARGES)}) THEN 'DEMURRAGE' + WHEN il.charge_type = 'STORAGE_FEE' THEN 'STORAGE' + WHEN il.charge_type IN ('HANDLING_FEE', 'DOUBLE_HANDLING', 'LASHING') + THEN 'LOADING_UNLOADING' + WHEN il.charge_type IN (${list(ADDITIONAL_CHARGES)}) THEN 'ADDITIONAL' + ELSE 'ADDITIONAL' +END`; + +// --------------------------------------------------------------------------- +// Period granularity +// --------------------------------------------------------------------------- + +/** + * Frozen whitelist. The runner coerces a `select` filter to a trimmed string + * or null; that string is used only as an object key here, so the user's value + * never reaches SQL — one of five compile-time constants does. + * + * Every format is zero-padded, so lexicographic order equals chronological + * order. The growth window depends on that. + */ +const PERIOD_UNITS = { + day: { trunc: 'day', fmt: 'YYYY-MM-DD', label: 'Daily', step: '1 day' }, + week: { trunc: 'week', fmt: 'IYYY-"W"IW', label: 'Weekly', step: '1 week' }, + month: { trunc: 'month', fmt: 'YYYY-MM', label: 'Monthly', step: '1 month' }, + // `quarter` is a valid date_trunc unit but NOT a valid interval unit — + // INTERVAL '1 quarter' is a syntax error, so the step is spelled in months. + quarter: { trunc: 'quarter', fmt: 'YYYY-"Q"Q', label: 'Quarterly', step: '3 months' }, + year: { trunc: 'year', fmt: 'YYYY', label: 'Yearly', step: '1 year' }, +} as const; + +export const PERIOD_FILTER: ReportFilterDef = { + key: 'period', + label: 'Granularity', + type: 'select', + options: Object.entries(PERIOD_UNITS).map(([value, u]) => ({ value, label: u.label })), +}; + +/** The timestamp every revenue report buckets and filters on. */ +export const REVENUE_DATE = 'COALESCE(i.issued_at, i.created_at)'; + +/** + * The period label expression, as a string. + * + * Callers must reuse the returned string VERBATIM in the select, the GROUP BY + * and any window `ORDER BY`. Two traps make this non-negotiable: + * + * 1. A window `ORDER BY date_trunc(...)` when the group key is `to_char(date_trunc(...))` + * fails with "column i.issued_at must appear in the GROUP BY clause". + * 2. Ordinal shorthand — `OVER (PARTITION BY 2 ORDER BY 1)` — is NOT a + * positional reference inside a window clause. Postgres reads the integers + * as constants, so it partitions by a constant and applies no ordering. It + * type-checks, it EXPLAINs clean, and it returns plausible garbage. + */ +export function periodExpr(params: Record): string { + const unit = resolvePeriod(params); + return `to_char(${periodTruncExpr(params)}, '${unit.fmt}')`; +} + +function resolvePeriod(params: Record): (typeof PERIOD_UNITS)[keyof typeof PERIOD_UNITS] { + const key = String(params.period ?? '') as keyof typeof PERIOD_UNITS; + return PERIOD_UNITS[key] ?? PERIOD_UNITS.month; +} + +/** The period's start timestamp — what to GROUP BY when a report needs it numerically. */ +export const periodTruncExpr = (params: Record): string => + `date_trunc('${resolvePeriod(params).trunc}', ${REVENUE_DATE})`; + +/** + * The period as a number, for regression: seconds since epoch at the period's + * start. Using the timestamp itself rather than `row_number()` keeps a trend + * calculation to a single window level — Postgres rejects a window function + * nested inside another window function's arguments. + */ +export const periodOrdinalExpr = (params: Record): string => + `EXTRACT(EPOCH FROM ${periodTruncExpr(params)})`; + +/** Same scale, one period later — where a one-step-ahead projection lands. */ +export const nextPeriodOrdinalExpr = (params: Record): string => + `EXTRACT(EPOCH FROM ${periodTruncExpr(params)} + INTERVAL '${resolvePeriod(params).step}')`; + +// --------------------------------------------------------------------------- +// Volume — measured at line grain, never joined from the booking +// --------------------------------------------------------------------------- + +/** + * `invoice_lines.quantity` already carries the billed quantity per line, and + * `metadata->>'unit'` says what it counts (PER_TON / PER_CONTAINER / PER_WAGON). + * Joining booking-level tonnage instead would multiply it by the number of + * lines on the booking. + */ +export const TONS_EXPR = `SUM(il.quantity) FILTER (WHERE il.metadata->>'unit' = 'PER_TON')`; + +export const CONTAINERS_EXPR = `SUM(il.quantity) FILTER (WHERE il.metadata->>'unit' = 'PER_CONTAINER')`; + +/** + * TEU is never stored. It is derived from the charge code's size suffix; lines + * whose code carries no size (CONTAINER_IMPORT / CONTAINER_EXPORT) count as one + * TEU each, which under-counts any 40ft box billed under an unsized code. + */ +export const TEU_EXPR = `SUM(il.quantity * CASE WHEN il.charge_type LIKE '%40FT%' THEN 2 ELSE 1 END) + FILTER (WHERE il.metadata->>'unit' = 'PER_CONTAINER')`; + +/** + * Revenue per unit, against whichever unit the category is actually billed in. + * Exactly one of tons/TEU is non-null per category, so this is per-ton for bulk + * and per-TEU for containers; the `unit` column says which. + */ +export const AVG_PER_UNIT_EXPR = `ROUND( + SUM(il.amount) / NULLIF(COALESCE(${TONS_EXPR}, 0) + COALESCE(${TEU_EXPR}, 0), 0), 2 +)::float8`; + +export const UNIT_LABEL_EXPR = `CASE + WHEN COALESCE(${TONS_EXPR}, 0) > 0 THEN 'per ton' + WHEN COALESCE(${TEU_EXPR}, 0) > 0 THEN 'per TEU' + ELSE '' +END`; + +// --------------------------------------------------------------------------- +// The shared ledger query +// --------------------------------------------------------------------------- + +/** Invoice states that never represent recognised revenue. */ +const DEAD_INVOICE_STATUSES = ['DRAFT', 'CANCELLED']; + +export const PAYMENT_METHOD_OPTIONS: ReportFilterOption[] = [ + 'telebirr', + 'cbe-birr', + 'cbe-bill', + 'ebirr', + 'waafi', + 'dmoney', + 'cac-bank', + 'card', +].map((v) => ({ value: v, label: v })); + +export const CURRENCY_FILTER: ReportFilterDef = { + key: 'currency', + label: 'Currency', + type: 'select', + options: [ + { value: 'ETB', label: 'ETB' }, + { value: 'USD', label: 'USD' }, + ], +}; + +/** + * The filter set shared by every revenue report, so they drill into each other + * without losing context. + * + * `currency` is not optional decoration: the ledger holds both ETB and USD + * lines, and summing across them produces a number that means nothing. It + * defaults to ETB in {@link revenueLedgerQb} rather than being left blank. + */ +export const REVENUE_FILTERS: ReportFilterDef[] = [ + { key: 'date', label: 'Issued', type: 'daterange' }, + CURRENCY_FILTER, + { + key: 'categories', + label: 'Revenue category', + type: 'multiselect', + options: REVENUE_CATEGORIES, + }, + { key: 'origin', label: 'Origin', type: 'select', optionsQuery: yardOptions }, + { key: 'destination', label: 'Destination', type: 'select', optionsQuery: yardOptions }, + { key: 'customer', label: 'Customer / booking ref', type: 'text' }, + { + key: 'methods', + label: 'Payment method', + type: 'multiselect', + options: PAYMENT_METHOD_OPTIONS, + }, +]; + +/** Stations are reference data — 23 rows that change about yearly. */ +export async function yardOptions(ds: ReportContext['ds']): Promise { + return ds + .createQueryBuilder() + .from(Yard, 'y') + .select('y.code', 'value') + .addSelect('y.label', 'label') + .where('y.deleted_at IS NULL AND y.is_active') + .orderBy('y.display_order', 'ASC') + .getRawMany(); +} + +/** Currency the ledger reports in when the caller does not choose one. */ +export const DEFAULT_CURRENCY = 'ETB'; + +export const currencyOf = (params: Record): string => + (params.currency as string) || DEFAULT_CURRENCY; + +/** + * Every revenue report starts here: one invoice line joined out to the booking + * that explains it. Bookings are LEFT joined on purpose — warehouse, demurrage + * and shipping-line-credit invoices carry no booking and must still be counted. + * + * The booking join is `i.source_id = b.id::text`, never `i.source_id::uuid`: + * `source_id` is a varchar with no FK that holds non-UUID values for other + * sources (`eims-self-test-…`), so casting it would throw at runtime. + */ +export function revenueLedgerQb(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + + const qb = ctx.ds + .createQueryBuilder() + .from(InvoiceLine, 'il') + .innerJoin(Invoice, 'i', 'i.id = il.invoice_id AND i.deleted_at IS NULL') + .leftJoin( + Booking, + 'b', + "i.source = 'booking' AND i.source_id = b.id::text AND b.deleted_at IS NULL", + ) + .leftJoin(CargoType, 'ct', 'ct.id = b.cargo_type_id') + .leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id') + .leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id') + .leftJoin(Company, 'co', 'co.id = i.company_id') + .leftJoin(ShippingLineCompany, 'slc', 'slc.id = i.shipping_line_company_id') + .where('il.deleted_at IS NULL') + .andWhere('i.status NOT IN (:...deadInvoiceStatuses)', { + deadInvoiceStatuses: DEAD_INVOICE_STATUSES, + }) + .andWhere("i.source <> 'eims_self_test'") + // An umbrella general contract is paid once and drawn down by many orders; + // counting both double-counts its value. + .andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')") + // Mixing ETB and USD into one SUM produces a meaningless number. + .andWhere('il.currency = :currency', { currency: currencyOf(params) }); + + if (params.dateFrom) { + qb.andWhere(`${REVENUE_DATE} >= :dateFrom`, { dateFrom: params.dateFrom }); + } + if (params.dateTo) { + qb.andWhere(`${REVENUE_DATE} < :dateTo`, { dateTo: params.dateTo }); + } + + const categories = params.categories as string[] | null; + if (categories?.length) { + qb.andWhere(`${REVENUE_CATEGORY_EXPR} IN (:...categories)`, { categories }); + } + + if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin }); + if (params.destination) { + qb.andWhere('dy.code = :destination', { destination: params.destination }); + } + + if (params.customer) { + qb.andWhere( + '(co.name ILIKE :customer OR slc.name ILIKE :customer OR b.reference ILIKE :customer)', + { customer: `%${params.customer as string}%` }, + ); + } + + const methods = params.methods as string[] | null; + if (methods?.length) { + qb.andWhere( + `EXISTS (SELECT 1 FROM freight.payments p + WHERE p.ref_id = i.source_id AND p.status = 'success' + AND p.method IN (:...methods))`, + { methods }, + ); + } + + // Hides lines whose booking sits outside the caller's trade scope. Lines with + // no booking carry no direction and stay visible. + applyBookingRefDirectionScope(qb, 'i.source_id', directions); + + return qb; +} + +/** + * Invoice-grain sibling of {@link revenueLedgerQb}, for the reports that must + * not multiply an invoice by its line count — outstanding balance, + * reconciliation, receivable/payable. Same joins, same filters, minus the + * line-only ones (charge category, currency lives on the invoice here). + */ +export function invoiceLedgerQb(ctx: ReportContext): SelectQueryBuilder { + const { params, directions } = ctx; + + const qb = ctx.ds + .createQueryBuilder() + .from(Invoice, 'i') + .leftJoin( + Booking, + 'b', + "i.source = 'booking' AND i.source_id = b.id::text AND b.deleted_at IS NULL", + ) + .leftJoin(CargoType, 'ct', 'ct.id = b.cargo_type_id') + .leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id') + .leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id') + .leftJoin(Company, 'co', 'co.id = i.company_id') + .leftJoin(ShippingLineCompany, 'slc', 'slc.id = i.shipping_line_company_id') + .where('i.deleted_at IS NULL') + .andWhere('i.status NOT IN (:...deadInvoiceStatuses)', { + deadInvoiceStatuses: DEAD_INVOICE_STATUSES, + }) + .andWhere("i.source <> 'eims_self_test'") + .andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')") + .andWhere('i.currency = :currency', { currency: currencyOf(params) }); + + if (params.dateFrom) qb.andWhere(`${REVENUE_DATE} >= :dateFrom`, { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere(`${REVENUE_DATE} < :dateTo`, { dateTo: params.dateTo }); + if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin }); + if (params.destination) qb.andWhere('dy.code = :destination', { destination: params.destination }); + if (params.customer) { + qb.andWhere( + '(co.name ILIKE :customer OR slc.name ILIKE :customer OR b.reference ILIKE :customer)', + { customer: `%${params.customer as string}%` }, + ); + } + + applyBookingRefDirectionScope(qb, 'i.source_id', directions); + return qb; +} + +/** + * What the payment gateway actually recorded against this invoice, summed. + * `invoices.payment_id` points at a payment-api intent id rather than a + * `freight.payments` row, so the reliable link is the booking id both sides + * carry. + */ +export const GATEWAY_PAID = `( + SELECT COALESCE(SUM(p.amount), 0) FROM freight.payments p + WHERE p.ref_id = i.source_id AND p.status = 'success' +)`; + +/** The payer, whichever of the two mutually exclusive payer columns is set. */ +export const PAYER_EXPR = "COALESCE(co.name, slc.name, 'Unknown')"; + +/** `SUM(amount)`, rounded to whole currency and typed as a JS number. */ +export const REVENUE_SUM = 'ROUND(COALESCE(SUM(il.amount), 0))::float8'; + +/** + * Settled share of a line, apportioned by how much of its invoice was paid. + * Invoice-level `paid_amount` cannot be attributed to a single line any other + * way. + */ +export const PAID_SHARE = + 'il.amount * CASE WHEN i.total_amount > 0 THEN i.paid_amount / i.total_amount ELSE 0 END'; + +/** The payment class as a business label rather than its key. */ +export const PAYMENT_CLASS_LABEL_EXPR = labelCase(PAYMENT_CLASS_EXPR, PAYMENT_CLASSES); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-positions.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-positions.controller.ts new file mode 100644 index 000000000..44ae27607 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-positions.controller.ts @@ -0,0 +1,85 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Put, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CurrentUser } from '@edr/api-common'; +import { StaffReference } from '../../../common/booking-guards'; +import { RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards'; +import { + ListYardPositionsQueryDto, + SetPositionYardsDto, + SetYardPositionsDto, +} from '../dto/yard-positions.dto'; +import { YardPositionsService } from '../services/yard-positions.service'; +import { YardScopeService } from '../services/yard-scope.service'; + +/** + * Desk↔yard mapping — which positions ("departments" in the user-management + * tree) staff which yard. It is yard configuration, so it is gated by the same + * rule-engine yard keys as the rest of the yards screen. + * + * Writes REPLACE the whole set for the side being edited. The admin UI submits + * the full multi-select value; a caller sending a delta will drop everything it + * omits. Both write paths flush the scope resolver's cache so a mapping change + * takes effect on the next request instead of up to a minute later. + */ +@ApiTags('yard-positions') +@Controller('yard-positions') +@ApiBearerAuth() +export class YardPositionsController { + constructor( + private readonly service: YardPositionsService, + private readonly scope: YardScopeService, + ) {} + + @Get() + @RuleEngineView('yards') + @ApiOperation({ summary: 'List desk↔yard mappings, optionally by yard or position' }) + list(@Query() query: ListYardPositionsQueryDto) { + return this.service.list(query); + } + + @Get('positions') + @RuleEngineView('yards') + @ApiOperation({ summary: 'Positions selectable as yard desks' }) + listPositions() { + return this.service.listSelectablePositions(); + } + + @Get('my-yards') + // Any signed-in staff member, NOT gated on the yards keys: this returns the + // caller's own access and nothing else, and the frontend needs it to + // preselect yard filters. Gating it on `rule_engine:yards:view` 403'd every + // desk that does not administer yards — i.e. exactly the users it is for. + @StaffReference() + @ApiOperation({ + summary: "The caller's own yard scope (null yardIds = unrestricted)", + }) + async myYards(@CurrentUser() user: unknown) { + const yardIds = await this.scope.getScopedYardIds(user as never); + return { yardIds, unrestricted: yardIds === null, enforced: this.scope.enforced }; + } + + @Put('yard/:yardId') + @RuleEngineUpdate('yards') + @ApiOperation({ summary: "Replace a yard's whole position set" }) + async setPositionsForYard( + @Param('yardId', ParseUUIDPipe) yardId: string, + @Body() dto: SetYardPositionsDto, + ) { + const rows = await this.service.setPositionsForYard(yardId, dto.positionIds); + this.scope.invalidate(); + return rows; + } + + @Put('position/:positionId') + @RuleEngineUpdate('yards') + @ApiOperation({ summary: "Replace a position's whole yard set" }) + async setYardsForPosition( + @Param('positionId', ParseUUIDPipe) positionId: string, + @Body() dto: SetPositionYardsDto, + ) { + const rows = await this.service.setYardsForPosition(positionId, dto.yardIds); + this.scope.invalidate(); + return rows; + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index d116a4b4a..67f5d2547 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -70,6 +70,15 @@ export class CreateCargoTypeDto { @IsBoolean() hasLashing?: boolean; + @ApiPropertyOptional({ + default: false, + description: + 'When true, bookings of this cargo type incur the lane-scoped FUEL surcharge.', + }) + @IsOptional() + @IsBoolean() + hasFuel?: boolean; + @ApiPropertyOptional({ default: false, description: diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 45a5838bd..bca8ab278 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -7,7 +7,8 @@ import { RATE_UNITS, } from '../entities/rate.entity'; -const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; +// DOMESTIC is accepted only for FUEL rates (an intercity fuel lane). +const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const; // ETB is accepted only for last-mile rates; the service forces USD elsewhere. const CURRENCIES = ['USD', 'ETB'] as const; export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const; @@ -52,7 +53,7 @@ export class CreateRateDto { @ApiPropertyOptional({ enum: CARGO_KINDS, description: - 'Whether a customs clearance rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE. Not stored — container fees carry a containerTypeId, bulk fees none.', + 'Whether a customs clearance / cancellation rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE or CANCELLATION. Not stored — container fees carry a containerTypeId, bulk fees a cargoTypeId.', }) @IsOptional() @IsIn([...CARGO_KINDS]) @@ -74,6 +75,14 @@ export class CreateRateDto { @IsUUID() destinationYardId?: string; + @ApiPropertyOptional({ + description: + 'FK to shipping_line_companies.id — set to price this rate for one shipping line only. Omitted/null = the standard rate every customer pays. A line rate overrides the standard one for that line\'s bookings.', + }) + @IsOptional() + @IsUUID() + shippingLineCompanyId?: string; + @ApiPropertyOptional({ enum: CURRENCIES }) @IsOptional() @IsIn([...CURRENCIES]) @@ -94,6 +103,17 @@ export class CreateRateDto { @IsIn([...RATE_UNITS]) rateUnit?: string; + @ApiPropertyOptional({ + description: + 'FUEL rates billed PER_LITER only: liters the surcharge covers — price = baseLiters × rateValue, once per booking. Required there, rejected elsewhere.', + minimum: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value))) + baseLiters?: number; + @ApiPropertyOptional({ description: 'Distance band start (km, inclusive). Container last-mile rates only (rateUnit = PER_KM).', diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts index d22bd84a7..774037e36 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts @@ -141,6 +141,22 @@ export class ListRatesQueryDto extends PaginationQueryDto { @IsString() @MaxLength(200) trigger?: string; + + @ApiPropertyOptional({ + description: 'Filter to one shipping line\'s rates.', + }) + @IsOptional() + @IsUUID() + shippingLineCompanyId?: string; + + @ApiPropertyOptional({ + description: + 'true = only shipping-line rates (any line), false = only standard customer rates. Omitted = both. Powers the Shipping line tab.', + }) + @IsOptional() + @Transform(toOptionalBoolean) + @IsBoolean() + isShippingLineRate?: boolean; } export class ListWeightLimitRulesQueryDto extends PaginationQueryDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/yard-positions.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/yard-positions.dto.ts new file mode 100644 index 000000000..b18648d78 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/yard-positions.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsUUID } from 'class-validator'; + +export class ListYardPositionsQueryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + positionId?: string; +} + +/** Replaces the yard's whole position set — see the controller's PUT docs. */ +export class SetYardPositionsDto { + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @IsUUID('4', { each: true }) + positionIds!: string[]; +} + +/** Replaces the position's whole yard set. */ +export class SetPositionYardsDto { + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @IsUUID('4', { each: true }) + yardIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index b7717684a..7084e233a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -82,6 +82,14 @@ export class CargoType extends BaseEntity { @Column({ name: 'has_lashing', type: 'boolean', default: false }) hasLashing!: boolean; + /** + * Whether bookings of this cargo incur the fuel surcharge. Billed off the + * lane-scoped FUEL rate for the booking's direction + route + this cargo + * type (per liter or per wagon). + */ + @Column({ name: 'has_fuel', type: 'boolean', default: false }) + hasFuel!: boolean; + /** * Whether staff may write bulk contract templates against this cargo type. * Mutually exclusive between a parent group and its children: if the parent diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts index a5b5bfc30..536e35527 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts @@ -46,6 +46,8 @@ export function deriveRateType(input: { return 'PIL_EXTRA_FEE'; case 'CUSTOMS_CLEARANCE': return 'CUSTOMS_CLEARANCE'; + case 'FUEL': + return 'FUEL_SURCHARGE'; } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts index e44dcdb5f..39d6174de 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts @@ -61,6 +61,22 @@ describe("allowedRateUnits — bulk unit of measure", () => { ).toEqual(["PER_TON"]); }); + it("bills the wagon cancellation fee per wagon only, whatever the cargo kind", () => { + for (const cargoKind of ["CONTAINER", "BULK"] as const) { + expect( + allowedRateUnits({ appliesTo: "OTHER", trigger: "CANCELLATION", cargoKind }), + ).toEqual(["PER_WAGON"]); + } + expect( + allowedRateUnits({ + appliesTo: "OTHER", + trigger: "CANCELLATION", + cargoKind: "BULK", + cargoUnitOfMeasure: "PER_ITEM", + }), + ).toEqual(["PER_WAGON"]); + }); + it("treats per-ton and per-item as the same booking quantity", () => { expect(isBulkQuantityUnit("PER_TON")).toBe(true); expect(isBulkQuantityUnit("PER_ITEM")).toBe(true); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index a3d336605..5adf225ca 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -16,8 +16,7 @@ export const isBulkQuantityUnit = (unit: string): boolean => * Which rate units make sense for a given rate shape. The weighting basis is * driven by the *type* of thing being billed — a container leg bills per * container, bulk freight per ton, an intercity move can be per-km, a - * cancellation is a flat/per-invoice fee, and overweight is always per excess - * ton. This keeps the rate table dynamic yet non-conflicting: the admin can + * cancellation is a per-wagon fee, and overweight is always per excess ton. This keeps the rate table dynamic yet non-conflicting: the admin can * only pick a unit the pricing engine knows how to apply. * * A rate scoped to a break-bulk commodity (unit_of_measure = PER_ITEM) offers @@ -29,7 +28,7 @@ export const isBulkQuantityUnit = (unit: string): boolean => export function allowedRateUnits(input: { appliesTo: RateAppliesTo; trigger: RateTrigger; - /** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */ + /** CUSTOMS_CLEARANCE / CANCELLATION only: which cargo kind the fee covers. */ cargoKind?: 'CONTAINER' | 'BULK' | null; /** Unit of measure of the bulk commodity the rate is scoped to, when any. */ cargoUnitOfMeasure?: CargoUom; @@ -64,7 +63,9 @@ function unitsForShape(input: { // wagon the empties ride back on, or a flat fee. return ['PER_CONTAINER', 'PER_WAGON', 'FLAT']; case 'CANCELLATION': - return ['FLAT', 'PER_INVOICE']; + // Wagon cancellation fee — scales with the cancelled wagon count, so + // per wagon is the only unit the wagon-cancel flow can apply. + return ['PER_WAGON']; case 'CUSTOMS_CLEARANCE': // Sold per cargo kind: container fees bill per box or per wagon, bulk // fees per ton or per wagon. Billed on the booking invoice. @@ -74,6 +75,9 @@ function unitsForShape(input: { case 'LASHING': // Bulk-only cargo securing — per ton or per wagon. return ['PER_TON', 'PER_WAGON']; + case 'FUEL': + // Per wagon (wagons × rate) or per liter (baseLiters × rate, once). + return ['PER_WAGON', 'PER_LITER']; case 'CONSOLIDATION': return ['PER_CONTAINER', 'FLAT']; case 'SHIPPING_LINE': diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index e7089f08c..c60d03d31 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -1,5 +1,6 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; import { CargoType } from './cargo-type.entity'; import { ContainerType } from './container-type.entity'; import { Yard } from './yard.entity'; @@ -24,6 +25,7 @@ export const RATE_TYPES = [ 'RETURN_SURCHARGE', 'PIL_EXTRA_FEE', 'CUSTOMS_CLEARANCE', + 'FUEL_SURCHARGE', ] as const; export type RateType = typeof RATE_TYPES[number]; @@ -41,6 +43,8 @@ export const RATE_UNITS = [ 'PER_KM', // Last-mile bulk: price = tons × km × rateValue. 'PER_TON_KM', + // Fuel surcharge only: price = baseLiters × rateValue, once per booking. + 'PER_LITER', 'PER_INVOICE', 'FLAT', ] as const; @@ -92,6 +96,9 @@ export const RATE_TRIGGERS = [ // Customs clearance service fee — billed up front via a clearance invoice, // never auto-applied to booking pricing (matchesTrigger returns false). 'CUSTOMS_CLEARANCE', + // Fuel surcharge — fires when the booking's cargo type has hasFuel = true, + // billed off the lane-scoped rate (direction + route + cargo type). + 'FUEL', ] as const; export type RateTrigger = typeof RATE_TRIGGERS[number]; @@ -102,6 +109,7 @@ export type RateTrigger = typeof RATE_TRIGGERS[number]; @Index(['trigger']) @Index(['originYardId']) @Index(['destinationYardId']) +@Index(['shippingLineCompanyId']) export class Rate extends BaseEntity { @Column({ name: 'rate_type', type: 'varchar', length: 50 }) rateType!: RateType; @@ -149,6 +157,23 @@ export class Rate extends BaseEntity { @JoinColumn({ name: 'destination_yard_id' }) destinationYard?: Yard | null; + /** + * The shipping line this rate belongs to, or NULL for the standard rate every + * customer pays. A booking owned by a shipping line prices exclusively off + * that line's rates — the standard rate is NOT a fallback, so a missing line + * rate hard-blocks the booking rather than quietly billing the customer price. + * + * Points at `shipping_line_companies` (the portal account that books capacity), + * not `shipping_lines` (carrier reference data behind the SHIPPING_LINE + * trigger). The two are unrelated despite the similar names. + */ + @Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true }) + shippingLineCompanyId?: string | null; + + @ManyToOne(() => ShippingLineCompany, { nullable: true, eager: false }) + @JoinColumn({ name: 'shipping_line_company_id' }) + shippingLineCompany?: ShippingLineCompany | null; + @Column({ name: 'currency', type: 'varchar', length: 5 }) currency!: string; @@ -163,6 +188,14 @@ export class Rate extends BaseEntity { * containerTypeId): the rate applies when minKm <= km < maxKm (maxKm NULL = * open-ended). NULL on every other rate shape. */ + /** + * FUEL rates billed PER_LITER only: the liters the surcharge covers — + * price = baseLiters × rateValue, once per booking. NULL on every other + * rate shape (a PER_WAGON fuel rate bills wagons × rateValue instead). + */ + @Column({ name: 'base_liters', type: 'numeric', precision: 14, scale: 4, nullable: true }) + baseLiters?: number | null; + @Column({ name: 'min_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) minKm?: number | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-position.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-position.entity.ts new file mode 100644 index 000000000..de12b5674 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-position.entity.ts @@ -0,0 +1,28 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Yard } from './yard.entity'; + +/** + * One desk staffed at one yard. + * + * The pairing that yard access scoping resolves against: a caller's active + * position decides which yards they may touch. Position rows live in `iam` + * (`iam.positions` — what the user-management tree labels "departments"), so + * `positionId` is an unconstrained uuid by design; see the migration for why. + */ +@Entity({ schema: 'freight', name: 'yard_positions' }) +@Index(['yardId']) +@Index(['positionId']) +export class YardPosition extends BaseEntity { + @Column({ name: 'yard_id', type: 'uuid' }) + yardId!: string; + + @ManyToOne(() => Yard, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'yard_id' }) + yard?: Yard; + + /** `iam.positions.id`. No FK — IAM is package-owned and soft-deletes. */ + @Column({ name: 'position_id', type: 'uuid' }) + positionId!: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index 9797ff715..ba2509068 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -16,6 +16,8 @@ export interface IRatesRepository { rateType: string; /** Omitted for singly-resolved rates — see the repository implementation. */ rateUnit?: string; + /** Owning shipping line; null/omitted = the standard customer rate. */ + shippingLineCompanyId?: string | null; containerTypeId?: string | null; cargoTypeId?: string | null; tradeDirection?: string | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts index 07993c267..d285deeb0 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts @@ -91,9 +91,8 @@ export class CargoTypesRepository implements ICargoTypesRepository { /** * Diffs the wagon-type links through the relation query builder rather than - * an entity save: junction-row inserts from save() broadcast afterInsert with - * no entity attached, which the @tria-plc/auditlog subscriber (deployed - * builds) dereferences and crashes the request on. + * an entity save, so junction rows are written without broadcasting + * afterInsert events for entity-less inserts. */ private async syncWagonTypes( id: string, diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index 7417987dc..8a739095e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -69,6 +69,7 @@ export class RatesRepository implements IRatesRepository { findByPattern(pattern: { rateType: string; rateUnit?: string; + shippingLineCompanyId?: string | null; containerTypeId?: string | null; cargoTypeId?: string | null; tradeDirection?: string | null; @@ -85,6 +86,16 @@ export class RatesRepository implements IRatesRepository { qb.andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit }); } + // The owner is part of the identity: a line's rate for a lane is a + // different rate from the standard one, not a duplicate of it. + if (pattern.shippingLineCompanyId) { + qb.andWhere('rate.shipping_line_company_id = :shippingLineCompanyId', { + shippingLineCompanyId: pattern.shippingLineCompanyId, + }); + } else { + qb.andWhere('rate.shipping_line_company_id IS NULL'); + } + if (pattern.containerTypeId) { qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId }); } else { @@ -139,8 +150,22 @@ export class RatesRepository implements IRatesRepository { // yards joined the route columns have only ids to render. .leftJoinAndSelect('rate.originYard', 'originYard') .leftJoinAndSelect('rate.destinationYard', 'destinationYard') + // The shipping-line tab renders the owning line's name, not its id. + .leftJoinAndSelect('rate.shippingLineCompany', 'shippingLineCompany') .orderBy('rate.createdAt', query.sortOrder ?? 'DESC'); + if (query.shippingLineCompanyId) { + qb.andWhere('rate.shippingLineCompanyId = :shippingLineCompanyId', { + shippingLineCompanyId: query.shippingLineCompanyId, + }); + } else if (query.isShippingLineRate !== undefined) { + // Tab filter: shipping-line rates (any line) vs standard customer rates. + qb.andWhere( + query.isShippingLineRate + ? 'rate.shippingLineCompanyId IS NOT NULL' + : 'rate.shippingLineCompanyId IS NULL', + ); + } if (query.status) { qb.andWhere('rate.status = :status', { status: query.status }); } @@ -164,7 +189,7 @@ export class RatesRepository implements IRatesRepository { } if (query.search) { qb.andWhere( - '(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)', + '(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search OR shippingLineCompany.name ILIKE :search)', { search: `%${query.search}%` }, ); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index d487387b8..97e89cc3b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -13,6 +13,7 @@ import { ShippingLinesController } from './controllers/shipping-lines.controller import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller'; import { YardDistancesController } from './controllers/yard-distances.controller'; import { YardsController } from './controllers/yards.controller'; +import { YardPositionsController } from './controllers/yard-positions.controller'; import { ApprovalRule } from './entities/approval-rule.entity'; import { CargoType } from './entities/cargo-type.entity'; @@ -28,6 +29,7 @@ import { Yard } from './entities/yard.entity'; import { YardDistance } from './entities/yard-distance.entity'; import { YardFacility } from './entities/yard-facility.entity'; import { YardLocation } from './entities/yard-location.entity'; +import { YardPosition } from './entities/yard-position.entity'; import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface'; import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface'; @@ -65,10 +67,13 @@ import { WeightLimitRulesService } from './services/weight-limit-rules.service'; import { YardsService } from './services/yards.service'; import { YardDistancesService } from './services/yard-distances.service'; import { YardFacilitiesService } from './services/yard-facilities.service'; +import { YardPositionsService } from './services/yard-positions.service'; +import { YardScopeService } from './services/yard-scope.service'; import { RuleEngineService } from './rule-engine.service'; import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { ShippingLineCompaniesModule } from '../shipping-lines/shipping-line-companies.module'; import { WagonTypesModule } from '../wagon-types/wagon-types.module'; import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity'; @@ -90,6 +95,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. YardDistance, YardFacility, YardLocation, + YardPosition, ShippingLine, Rate, ApprovalRule, @@ -102,6 +108,9 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. // Rated wagon capacities — cargo types validate their per-wagon tonnage cap // against them (a cap above the rating is a typo, not a policy). WagonTypesModule, + // Rates may be scoped to one shipping line; creating such a rate validates + // the line exists and is active. + ShippingLineCompaniesModule, ], controllers: [ CargoTypesController, @@ -112,6 +121,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ServiceTypesController, WeightLimitRulesController, YardsController, + YardPositionsController, YardDistancesController, ShippingLinesController, RatesController, @@ -148,6 +158,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. YardsService, YardDistancesService, YardFacilitiesService, + YardPositionsService, + YardScopeService, ShippingLinesService, RatesService, ApprovalRulesService, @@ -164,6 +176,10 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. YardsService, YardDistancesService, YardFacilitiesService, + YardPositionsService, + // Exported so any module can narrow its yard queries through the one + // resolver — the module is @Global, so no import is needed to inject it. + YardScopeService, ShippingLinesService, RatesService, ApprovalRulesService, diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts index a736d4b05..c530e5314 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts @@ -422,3 +422,250 @@ describe('RuleEngineService — lashing (bulk-only, per direction + commodity)', expect(lashingMods(result)).toHaveLength(0); }); }); + +describe('RuleEngineService — fuel surcharge (per lane + cargo type)', () => { + const fuelPerLiter: Rate = { + id: 'rate-fuel-liter', + rateType: 'FUEL_SURCHARGE', + trigger: 'FUEL', + rateValue: 2, + rateUnit: 'PER_LITER', + baseLiters: 100, + currency: 'USD', + status: 'LIVE', + containerTypeId: null, + cargoTypeId: 'cargo-steel', + tradeDirection: 'IMPORT', + originYardId: 'yard-nagad', + destinationYardId: 'yard-mojo', + } as Rate; + + const buildService = (rates: Rate[], hasFuel = true): RuleEngineService => + new RuleEngineService( + { + findById: jest + .fn() + .mockResolvedValue({ hasFuel, hasLashing: false, requiresDirectorApproval: false }), + } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { findLiveRates: jest.fn().mockResolvedValue(rates) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + + const fuelInput = ( + overrides: Partial = {}, + ): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection: 'IMPORT', + isHazardous: false, + cargoTypeId: 'cargo-steel', + originYardId: 'yard-nagad', + destinationYardId: 'yard-mojo', + totalWagons: 0, + bulkTons: 100, + bulkWagons: 4, + containers: [], + ...overrides, + }); + + const fuelMods = (result: Awaited>) => + result.appliedModifiers.filter((m) => m.surchargeCode === 'FUEL_SURCHARGE'); + + it('PER_LITER collapses to one flat total (base liters × rate value), regardless of wagons', async () => { + const result = await buildService([fuelPerLiter]).evaluate(fuelInput()); + const mods = fuelMods(result); + expect(mods).toHaveLength(1); + // Flat: the customer sees only the total, and a frozen contract snapshot + // (also stored flat) multiplies it by quantity 1 — never by the liters. + expect(mods[0].triggerValue).toBe(1); + expect(mods[0].unitPriceUsd).toBe(200); + expect(mods[0].calculatedAmount).toBe(200); + expect(mods[0].billingUnit).toBe('FLAT'); + }); + + it('PER_WAGON bills the wagons the cargo occupies', async () => { + const result = await buildService([ + { ...fuelPerLiter, rateUnit: 'PER_WAGON', baseLiters: null, rateValue: 50 } as Rate, + ]).evaluate(fuelInput()); + const mods = fuelMods(result); + expect(mods[0].triggerValue).toBe(4); + expect(mods[0].calculatedAmount).toBe(200); + }); + + it('a rate for another lane, direction or commodity never bills', async () => { + for (const wrong of [ + { tradeDirection: 'EXPORT' }, + { originYardId: 'yard-other' }, + { destinationYardId: 'yard-other' }, + { cargoTypeId: 'cargo-wheat' }, + ]) { + const result = await buildService([{ ...fuelPerLiter, ...wrong } as Rate]).evaluate( + fuelInput(), + ); + expect(fuelMods(result)).toHaveLength(0); + } + }); + + it('a domestic booking bills the DOMESTIC fuel lane', async () => { + const result = await buildService([ + { ...fuelPerLiter, tradeDirection: 'DOMESTIC' } as Rate, + ]).evaluate(fuelInput({ tradeDirection: 'DOMESTIC' })); + expect(fuelMods(result)).toHaveLength(1); + }); + + it('no fuel charge when the cargo type does not have hasFuel', async () => { + const result = await buildService([fuelPerLiter], false).evaluate(fuelInput()); + expect(fuelMods(result)).toHaveLength(0); + }); + + it('no matching lane rate bills nothing (lenient, like lashing)', async () => { + const result = await buildService([]).evaluate(fuelInput()); + expect(fuelMods(result)).toHaveLength(0); + }); +}); + +describe('RuleEngineService — shipping-line rates override the standard ones', () => { + const LINE = 'slc-msc'; + + /** Standard customer container-import rate on the lane. */ + const standardBase: Rate = { + id: 'rate-standard-20', + rateType: 'CONTAINER_IMPORT', + trigger: 'ALWAYS', + rateValue: 1000, + rateUnit: 'PER_CONTAINER', + currency: 'USD', + status: 'LIVE', + containerTypeId: 'ct-20', + cargoTypeId: null, + shippingLineCompanyId: null, + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + } as Rate; + + /** The same lane, priced for one shipping line. */ + const lineBase: Rate = { + ...standardBase, + id: 'rate-line-20', + rateValue: 1200, + shippingLineCompanyId: LINE, + } as Rate; + + const standardHazard: Rate = { + id: 'rate-hazard-standard', + rateType: 'HAZARD_SURCHARGE', + trigger: 'HAZARDOUS', + rateValue: 50, + rateUnit: 'PER_CONTAINER', + currency: 'USD', + status: 'LIVE', + containerTypeId: null, + cargoTypeId: null, + shippingLineCompanyId: null, + } as Rate; + + const lineHazard: Rate = { + ...standardHazard, + id: 'rate-hazard-line', + rateValue: 80, + shippingLineCompanyId: LINE, + } as Rate; + + const buildService = (rates: Rate[]) => + new RuleEngineService( + { findById: jest.fn().mockResolvedValue(null) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { + findActiveByContainerTypeId: jest + .fn() + .mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]), + } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { findLiveRates: jest.fn().mockResolvedValue(rates) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + + // One 20ft at 25 t against a 20 t limit → 5 t excess. + const bookingInput = ( + overrides: Partial = {}, + ): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection: 'IMPORT', + isHazardous: false, + totalWagons: 1, + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + containers: [ + { containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 }, + ], + ...overrides, + }); + + const overweightOf = (result: { appliedModifiers: Array<{ surchargeCode: string }> }) => + result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON'); + + it('derives a line booking\'s overweight from the LINE\'s base rate, not the standard one', async () => { + const result = await buildService([standardBase, lineBase]).evaluate( + bookingInput({ shippingLineCompanyId: LINE }), + ); + const ow = overweightOf(result); + expect(ow).toHaveLength(1); + // The line's 1200 / (2 × 20) = 30 USD/t, not the standard 1000 → 25 USD/t. + expect(ow[0]).toMatchObject({ + rateId: lineBase.id, + unitPriceUsd: 30, + calculatedAmount: 150, + }); + }); + + it('keeps a customer booking on the standard rate even when a line rate exists', async () => { + const result = await buildService([standardBase, lineBase]).evaluate(bookingInput()); + const ow = overweightOf(result); + expect(ow).toHaveLength(1); + expect(ow[0]).toMatchObject({ + rateId: standardBase.id, + unitPriceUsd: 25, + calculatedAmount: 125, + }); + }); + + it('does not fall back to the standard rate when the line has none for the lane', async () => { + const result = await buildService([standardBase]).evaluate( + bookingInput({ shippingLineCompanyId: LINE }), + ); + // No line rate on the lane → nothing to derive from. Base freight is what + // hard-blocks the booking; the standard 1000 must never be borrowed here. + expect(overweightOf(result)).toHaveLength(0); + }); + + it('bills the line\'s own surcharge and never the standard one alongside it', async () => { + const result = await buildService([ + standardBase, + lineBase, + standardHazard, + lineHazard, + ]).evaluate(bookingInput({ shippingLineCompanyId: LINE, isHazardous: true })); + + const hazard = result.appliedModifiers.filter( + (m) => m.surchargeCode === 'HAZARD_SURCHARGE', + ); + expect(hazard).toHaveLength(1); + expect(hazard[0]).toMatchObject({ rateId: lineHazard.id, calculatedAmount: 80 }); + }); + + it('hard-blocks a requested service the line has no surcharge rate for', async () => { + const result = await buildService([standardBase, lineBase, standardHazard]).evaluate( + bookingInput({ shippingLineCompanyId: LINE, isHazardous: true }), + ); + // The standard hazard rate exists but belongs to customers, so the line's + // hazardous booking must block rather than borrow it. + expect(result.hardBlocked).toHaveLength(1); + expect(result.hardBlocked[0]).toContain('hazardous'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index ac2e854e4..5c9055415 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -74,6 +74,16 @@ export interface BookingEvaluationInput { isGovernment?: boolean; allowConsolidation?: boolean; shippingLineId?: string | null; + /** + * The shipping line that OWNS this booking (`bookings.shipping_line_company_id`), + * when it is a shipping-line booking rather than a customer one. Such a booking + * prices exclusively off that line's own rates — see {@link ratesForOwner}. + * + * Not to be confused with `shippingLineId` above, which is cargo metadata + * naming the carrier that physically moves the goods and only feeds the + * SHIPPING_LINE double-handling trigger. + */ + shippingLineCompanyId?: string | null; /** * The booking's rail leg. Import overweight derives its per-ton price from * this route's own container freight rate, so the engine needs the yards. @@ -175,6 +185,9 @@ export class RuleEngineService { // matchesTrigger can fire the LASHING rate. Falls back to an explicit // input flag when no cargo type is set (e.g. container bookings). let hasLashing = input.hasLashing === true; + // Fuel is likewise a cargo-type property (hasFuel), billed off the + // lane-scoped FUEL rate — see fuelCharges. + let hasFuel = false; if (input.cargoTypeId) { const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId); if (!cargoType) { @@ -186,6 +199,9 @@ export class RuleEngineService { if (cargoType.hasLashing) { hasLashing = true; } + if (cargoType.hasFuel) { + hasFuel = true; + } } } @@ -276,7 +292,10 @@ export class RuleEngineService { // scope) must contribute exactly ONE line. Duplicate LIVE rate rows — e.g. // from a non-idempotent seeder — would otherwise repeat the same surcharge // many times and inflate the total, so we collapse them to one row each. - const liveRates = await this.ratesRepo.findLiveRates(); + const liveRates = this.ratesForOwner( + await this.ratesRepo.findLiveRates(), + input.shippingLineCompanyId, + ); const surchargeRates = this.dedupeRatesBySignature( liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'), ); @@ -328,6 +347,9 @@ export class RuleEngineService { // Lashing is sold per cargo kind + container type — billed by the // kind-aware block below, never by this generic loop. if (rate.trigger === 'LASHING') continue; + // Fuel is sold per lane + cargo type — billed by the route-matched + // block below, never by this route-agnostic loop. + if (rate.trigger === 'FUEL') continue; const triggered = this.matchesTrigger(rate.trigger, { isHazardous: input.isHazardous, hasReefer, @@ -442,6 +464,10 @@ export class RuleEngineService { appliedModifiers.push(...this.lashingCharges(input, liveRates)); } + if (hasFuel) { + appliedModifiers.push(...this.fuelCharges(input, liveRates)); + } + return { priorityScore, appliedModifiers, @@ -632,6 +658,56 @@ export class RuleEngineService { return modifiers; } + /** + * Fuel surcharge — fires when the booking's cargo type has hasFuel = true, + * billed off the FUEL rate matching the booking's lane (trade direction + + * origin + destination) and cargo type. PER_LITER collapses to one FLAT + * amount (baseLiters × rateValue, once per booking) — the customer only ever + * sees the total, and the frozen contract snapshot stores that same flat + * figure so the snapshot-override math bills it exactly once. PER_WAGON + * bills the wagons the cargo occupies. No matching lane rate simply bills + * nothing — same leniency as lashing. + */ + private fuelCharges( + input: BookingEvaluationInput, + liveRates: Rate[], + ): AppliedCargoModifier[] { + const modifiers: AppliedCargoModifier[] = []; + const rate = liveRates.find( + (r) => + r.trigger === 'FUEL' && + r.currency === 'USD' && + r.tradeDirection === input.tradeDirection && + r.originYardId === input.originYardId && + r.destinationYardId === input.destinationYardId && + r.cargoTypeId === input.cargoTypeId, + ); + if (!rate) return modifiers; + + const rateValue = Number(rate.rateValue); + const wagons = Math.max( + 0, + Number(input.bulkWagons ?? 0) || Number(input.totalWagons ?? 0), + ); + const perLiter = rate.rateUnit === 'PER_LITER'; + const billedQty = perLiter ? 1 : wagons; + const unitPrice = perLiter + ? Number(rate.baseLiters ?? 0) * rateValue + : rateValue; + const amount = billedQty * unitPrice; + if (!(amount > 0)) return modifiers; + modifiers.push({ + rateId: rate.id, + surchargeCode: this.surchargeCode(rate), + triggerValue: billedQty, + calculatedAmount: amount, + currency: rate.currency, + unitPriceUsd: unitPrice, + billingUnit: perLiter ? 'FLAT' : rate.rateUnit, + }); + return modifiers; + } + /** * Messages for container lines whose total weight exceeds the hard capacity * ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking @@ -749,6 +825,28 @@ export class RuleEngineService { return rate.rateType ?? rate.trigger; } + /** + * Narrow the LIVE rate pool to the ones this booking's owner may price off. + * + * A customer booking sees only standard rates (no owner) — a shipping line's + * negotiated price must never leak into a customer quote. A shipping-line + * booking sees only that line's own rates: line rates OVERRIDE the standard + * ones rather than stacking on them, and the standard rate is deliberately + * NOT a fallback, so a lane the line has no rate for hard-blocks downstream + * (base freight already blocks on "no rate for this route") instead of + * quietly billing the line at the customer price. + * + * Filtering once, here, is what makes the override apply uniformly: every + * downstream lookup (base freight, derived overweight, empty return, lashing, + * fuel, and the additive surcharges) reads from this same pool, so none of + * them needs its own owner check. + */ + private ratesForOwner(rates: Rate[], shippingLineCompanyId?: string | null): Rate[] { + return shippingLineCompanyId + ? rates.filter((r) => r.shippingLineCompanyId === shippingLineCompanyId) + : rates.filter((r) => !r.shippingLineCompanyId); + } + /** * Collapse rates that describe the same charge to a single representative. * diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts index be7c8984c..f8617cd65 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts @@ -118,6 +118,19 @@ describe('RateChangeRequestsService', () => { expect(request.payload).toEqual({ destinationYardId: 'yard-c' }); }); + it('carries baseLiters — a switch to PER_LITER keeps its billing base', async () => { + const { service } = build({ + rate: liveRate({ rateUnit: 'PER_WAGON', baseLiters: null }), + }); + + const request = await service.submit({ + rateId: 'rate-1', + update: { rateUnit: 'PER_LITER', baseLiters: 3 }, + }); + + expect(request.payload).toEqual({ rateUnit: 'PER_LITER', baseLiters: 3 }); + }); + it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => { const { service } = build(); await expect( diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts index 8cc97f343..8913c9ef9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -42,6 +42,10 @@ const DIFFABLE_FIELDS = [ // LIVE last-mile rate would diff to "nothing changed". 'minKm', 'maxKm', + // PER_LITER fuel surcharge billing base. Missing here, a switch to PER_LITER + // dropped the submitted liters and validation failed with "needs a base + // liters amount" even though the payload carried one. + 'baseLiters', ] as const; /** diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts index 23d96da89..0a4108a4e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts @@ -61,6 +61,8 @@ describe('RatesService — one rate per pattern', () => { })), } as never, { findById: jest.fn().mockResolvedValue(null) } as never, + // Shipping line companies — these rates carry no owner, so it is never hit. + { findById: jest.fn() } as never, ); }); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 971097377..977a31718 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -8,6 +8,8 @@ import { } from '@nestjs/common'; import { PaginatedResponse, YardCountry } from '@edr/types'; import { IsNull, Not } from 'typeorm'; +import { ShippingLineStatus } from '../../shipping-lines/entities/shipping-line-company.entity'; +import { ShippingLineCompaniesService } from '../../shipping-lines/shipping-line-companies.service'; import { CreateRateDto } from '../dto/create-rate.dto'; import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; @@ -23,6 +25,19 @@ import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.reposito /** Categories priced per rail leg — they carry an origin → destination yard pair. */ const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY']; +/** + * Surcharges sold per cargo kind: the admin says container or bulk, a + * container fee then names its container type and a bulk fee its commodity. + */ +const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = ['CUSTOMS_CLEARANCE', 'CANCELLATION']; +/** Surcharges that keep a trade direction (everything else is direction-agnostic). */ +const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [ + 'CUSTOMS_CLEARANCE', + 'CANCELLATION', + 'WITH_RETURN', + 'LASHING', + 'FUEL', +]; /** The yard pair a rate scopes to, already validated against its direction. */ interface YardScope { @@ -39,6 +54,7 @@ export class RatesService { private readonly yardsRepository: IYardsRepository, @Inject(CARGO_TYPES_REPOSITORY) private readonly cargoTypesRepository: ICargoTypesRepository, + private readonly shippingLineCompaniesService: ShippingLineCompaniesService, ) {} /** List rates — standard paginated envelope with server-side search. */ @@ -121,15 +137,16 @@ export class RatesService { } /** - * Rates sold per direction + route. Base freight always; customs clearance - * and empty-container return are the surcharges that are too — their fee - * depends on the lane (and, for returns, the container type). + * Rates sold per direction + route. Base freight always; customs clearance, + * empty-container return and fuel are the surcharges that are too — their + * fee depends on the lane (and, for returns, the container type). */ private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean { return ( this.isBaseFreight(appliesTo, trigger) || trigger === 'CUSTOMS_CLEARANCE' || - trigger === 'WITH_RETURN' + trigger === 'WITH_RETURN' || + trigger === 'FUEL' ); } @@ -148,7 +165,11 @@ export class RatesService { appliesTo: Rate['appliesTo'], trigger: Rate['trigger'], ): boolean { - return this.isRouteScoped(appliesTo, trigger) || trigger === 'LASHING'; + return ( + this.isRouteScoped(appliesTo, trigger) || + trigger === 'LASHING' || + trigger === 'CANCELLATION' + ); } /** @@ -160,7 +181,9 @@ export class RatesService { appliesTo: Rate['appliesTo'], tradeDirection: string | null, ): { origin: YardCountry; destination: YardCountry } { - if (appliesTo === 'INTERCITY') { + // DOMESTIC only reaches here on a FUEL rate's intercity lane — it stays + // inside Ethiopia exactly like intercity base freight. + if (appliesTo === 'INTERCITY' || tradeDirection === 'DOMESTIC') { return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA }; } return tradeDirection === 'EXPORT' @@ -238,10 +261,13 @@ export class RatesService { }): void { const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input; const { containerTypeId, cargoTypeId } = input; - if (trigger === 'CUSTOMS_CLEARANCE') { + if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'CANCELLATION') { + // Both fees are sold per direction + cargo kind + type: customs clearance + // per lane, the wagon cancellation fee per direction only. + const fee = trigger === 'CANCELLATION' ? 'cancellation fee' : 'customs clearance'; if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') { throw new BadRequestException( - 'A customs clearance rate must say whether it covers IMPORT or EXPORT.', + `A ${fee} rate must say whether it covers IMPORT or EXPORT.`, ); } // Sold per cargo kind: a container fee names the container type it covers @@ -249,29 +275,29 @@ export class RatesService { // that absence is what marks it as the bulk fee. if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') { throw new BadRequestException( - 'A customs clearance rate must say whether it covers containers or bulk.', + `A ${fee} rate must say whether it covers containers or bulk.`, ); } if (cargoKind === 'CONTAINER' && !containerTypeId) { throw new BadRequestException( - 'A container customs clearance rate must name the container type it covers.', + `A container ${fee} rate must name the container type it covers.`, ); } if (cargoKind === 'BULK' && containerTypeId) { throw new BadRequestException( - 'A bulk customs clearance rate cannot be scoped to a container type.', + `A bulk ${fee} rate cannot be scoped to a container type.`, ); } - // The bulk customs fee names the commodity it covers (sugar and - // fertilizer clear differently). + // The bulk fee names the commodity it covers (sugar and fertilizer + // clear — and cancel — differently). if (cargoKind === 'BULK' && !cargoTypeId) { throw new BadRequestException( - 'A bulk customs clearance rate must name the bulk cargo type it covers.', + `A bulk ${fee} rate must name the bulk cargo type it covers.`, ); } if (cargoKind === 'CONTAINER' && cargoTypeId) { throw new BadRequestException( - 'A container customs clearance rate cannot be scoped to a bulk cargo type.', + `A container ${fee} rate cannot be scoped to a bulk cargo type.`, ); } return; @@ -291,6 +317,31 @@ export class RatesService { } return; } + if (trigger === 'FUEL') { + // Fuel is sold per lane + commodity: the direction says which countries + // the leg spans (DOMESTIC = intercity, inside Ethiopia) and the cargo + // type names the commodity — different commodities price differently. + if ( + tradeDirection !== 'IMPORT' && + tradeDirection !== 'EXPORT' && + tradeDirection !== 'DOMESTIC' + ) { + throw new BadRequestException( + 'A fuel rate must say whether it covers IMPORT, EXPORT or DOMESTIC (intercity).', + ); + } + if (containerTypeId) { + throw new BadRequestException( + 'A fuel rate cannot be scoped to a container type.', + ); + } + if (!cargoTypeId) { + throw new BadRequestException( + 'A fuel rate must name the cargo type it covers.', + ); + } + return; + } if (trigger === 'WITH_RETURN') { // Returning the empty box only exists on imports (the box goes back to // the port) — export return rates are rejected until the business sells @@ -463,6 +514,7 @@ export class RatesService { rateType: string; /** Passed only for additive surcharges — see {@link resolvesSingleRate}. */ rateUnit?: string; + shippingLineCompanyId: string | null; containerTypeId: string | null; cargoTypeId: string | null; tradeDirection: string | null; @@ -480,37 +532,68 @@ export class RatesService { } } + /** + * Validate the shipping line a rate is scoped to, when any. + * + * A shipping line only ever ships import — the export leg is sold through the + * customer's contract — so a line rate carrying an EXPORT direction is + * rejected here as well as by `CK_rates_shipping_line_import_only`. + * Returns the owner id to store (null = the standard customer rate). + */ + private async resolveShippingLineScope( + shippingLineCompanyId: string | null | undefined, + tradeDirection: string | null, + ): Promise { + if (!shippingLineCompanyId) return null; + + // Throws NotFoundException when the line does not exist. + const line = await this.shippingLineCompaniesService.findById(shippingLineCompanyId); + if (line.status !== ShippingLineStatus.Active) { + throw new BadRequestException( + `${line.name} is ${line.status} — rates can only be configured for an active shipping line.`, + ); + } + if (tradeDirection && tradeDirection !== 'IMPORT') { + throw new BadRequestException( + 'Shipping line rates are import-only — the export leg is priced through the customer contract.', + ); + } + return shippingLineCompanyId; + } + /** Create a rate in DRAFT status. */ async create(dto: CreateRateDto, proposedByStaffId: string): Promise { const appliesTo = dto.appliesTo as Rate['appliesTo']; const trigger = dto.trigger as Rate['trigger']; // Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so // the engine never accidentally narrows a surcharge by container/direction. - // Exceptions: customs clearance and empty-container return keep direction + - // container type — both are sold per lane (and per container type). + // Exceptions: the directed surcharges (customs clearance, cancellation, + // empty-container return, lashing, fuel) keep direction + cargo scope. const isSurcharge = trigger !== 'ALWAYS'; - const cargoKind = - trigger === 'CUSTOMS_CLEARANCE' - ? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null) - : null; + const cargoKind = CARGO_KIND_TRIGGERS.includes(trigger) + ? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null) + : null; const containerTypeId = trigger === 'WITH_RETURN' || - (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER') + (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER') ? (dto.containerTypeId ?? null) : isSurcharge ? null : (dto.containerTypeId ?? null); const cargoTypeId = - (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') || - trigger === 'LASHING' + (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') || + trigger === 'LASHING' || + trigger === 'FUEL' ? (dto.cargoTypeId ?? null) : isSurcharge ? null : (dto.cargoTypeId ?? null); // Intercity never leaves Ethiopia, so it has no trade direction to store — - // its yard pair already says where it runs. + // its yard pair already says where it runs. (Fuel is the exception: its + // intercity lane is stored as DOMESTIC, since appliesTo = OTHER says + // nothing about the direction.) const tradeDirection = - trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING' + DIRECTED_SURCHARGE_TRIGGERS.includes(trigger) ? (dto.tradeDirection ?? null) : isSurcharge || appliesTo === 'INTERCITY' ? null @@ -534,6 +617,11 @@ export class RatesService { destinationYardId: dto.destinationYardId, }); + const shippingLineCompanyId = await this.resolveShippingLineScope( + dto.shippingLineCompanyId, + tradeDirection, + ); + const rateType = deriveRateType({ appliesTo, trigger, @@ -563,9 +651,12 @@ export class RatesService { await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm }); } + const baseLiters = this.resolveBaseLiters(rateUnit, dto.baseLiters); + await this.assertNoDuplicatePattern({ rateType, ...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }), + shippingLineCompanyId, containerTypeId, cargoTypeId, tradeDirection, @@ -578,6 +669,7 @@ export class RatesService { appliesTo, trigger, rateType, + shippingLineCompanyId, containerTypeId, cargoTypeId, tradeDirection, @@ -588,6 +680,7 @@ export class RatesService { currency: appliesTo === 'LAST_MILE' ? (dto.currency ?? 'ETB') : 'USD', rateValue: dto.rateValue, rateUnit, + baseLiters, minKm, maxKm, status: 'DRAFT', @@ -595,6 +688,25 @@ export class RatesService { }); } + /** + * The liters a PER_LITER fuel rate bills (price = baseLiters × rateValue, + * once per booking). Required there; cleared on every other rate shape — + * a PER_WAGON fuel rate bills wagons × rateValue and carries none. + */ + private resolveBaseLiters( + rateUnit: Rate['rateUnit'], + baseLiters?: number | null, + ): number | null { + if (rateUnit !== 'PER_LITER') return null; + const liters = Number(baseLiters); + if (!(liters > 0)) { + throw new BadRequestException( + 'A per-liter fuel rate needs a base liters amount — the price is base liters × rate value.', + ); + } + return liters; + } + /** * Update a DRAFT rate in place. Nothing prices off a draft, so a direct edit * is safe. A LIVE rate cannot take this path — see `applyApprovedUpdate`. @@ -662,16 +774,15 @@ export class RatesService { // A patch that leaves the cargo kind unsaid keeps the one the rate already // has — read back off its container scope (container fees carry the type). - const cargoKind = - trigger !== 'CUSTOMS_CLEARANCE' - ? null - : ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? - (existing.containerTypeId ? 'CONTAINER' : 'BULK')); + const cargoKind = !CARGO_KIND_TRIGGERS.includes(trigger) + ? null + : ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? + (existing.containerTypeId ? 'CONTAINER' : 'BULK')); const keepsContainerType = !isSurcharge || trigger === 'WITH_RETURN' || - (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER'); + (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER'); const containerTypeId = !keepsContainerType ? null : dto.containerTypeId !== undefined @@ -679,15 +790,16 @@ export class RatesService { : existing.containerTypeId; const keepsCargoType = !isSurcharge || - (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') || - trigger === 'LASHING'; + (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') || + trigger === 'LASHING' || + trigger === 'FUEL'; const cargoTypeId = !keepsCargoType ? null : dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId; const tradeDirection = - trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING' + DIRECTED_SURCHARGE_TRIGGERS.includes(trigger) ? dto.tradeDirection !== undefined ? dto.tradeDirection : existing.tradeDirection @@ -731,6 +843,17 @@ export class RatesService { updates.originYardId = yardScope.originYardId; updates.destinationYardId = yardScope.destinationYardId; + // The owning line is re-validated on every edit: a patch that flips the + // direction to EXPORT has to be refused for a line rate, and a patch that + // moves the rate to a suspended line too. + const shippingLineCompanyId = await this.resolveShippingLineScope( + dto.shippingLineCompanyId !== undefined + ? dto.shippingLineCompanyId + : existing.shippingLineCompanyId, + updates.tradeDirection, + ); + updates.shippingLineCompanyId = shippingLineCompanyId; + // Keep the derived rateType in sync with whatever changed. const rateType = deriveRateType({ appliesTo, @@ -779,6 +902,7 @@ export class RatesService { await this.assertNoDuplicatePattern({ rateType, ...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }), + shippingLineCompanyId, containerTypeId: updates.containerTypeId, cargoTypeId: updates.cargoTypeId, tradeDirection: updates.tradeDirection, @@ -788,6 +912,11 @@ export class RatesService { ignoreId: id, }); + updates.baseLiters = this.resolveBaseLiters( + rateUnit, + dto.baseLiters !== undefined ? dto.baseLiters : existing.baseLiters, + ); + updates.currency = appliesTo === 'LAST_MILE' ? (dto.currency ?? existing.currency ?? 'ETB') diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-positions.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-positions.service.ts new file mode 100644 index 000000000..93f9752f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-positions.service.ts @@ -0,0 +1,194 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, In, IsNull } from 'typeorm'; + +import { YardPosition } from '../entities/yard-position.entity'; +import { Yard } from '../entities/yard.entity'; + +/** A mapped desk, joined to its IAM position for display. */ +export interface YardPositionRow { + id: string; + yardId: string; + yardCode: string; + yardLabel: string; + positionId: string; + /** Localised name from `iam.positions.name` — null if the position is gone. */ + positionName: { am?: string; en?: string } | null; + positionTypeKey: string | null; +} + +/** + * The desk↔yard mapping behind yard access scoping. + * + * Reads always join `iam.positions` and drop soft-deleted rows: the mapping has + * no FK to IAM (see the migration), so a position deleted in the admin UI leaves + * an orphan row here. Dropping it on read means the orphan can never widen + * someone's scope — it just disappears. + */ +@Injectable() +export class YardPositionsService { + constructor(private readonly dataSource: DataSource) {} + + /** Mapping rows, optionally narrowed to one yard or one position. */ + async list(filter: { + yardId?: string; + positionId?: string; + }): Promise { + const params: unknown[] = []; + const where: string[] = ['yp.deleted_at IS NULL', 'y.deleted_at IS NULL']; + + if (filter.yardId) { + params.push(filter.yardId); + where.push(`yp.yard_id = $${params.length}`); + } + if (filter.positionId) { + params.push(filter.positionId); + where.push(`yp.position_id = $${params.length}`); + } + + return this.dataSource.query( + `SELECT yp.id AS "id", + yp.yard_id AS "yardId", + y.code AS "yardCode", + y.label AS "yardLabel", + yp.position_id AS "positionId", + p.name AS "positionName", + pt.key AS "positionTypeKey" + FROM freight.yard_positions yp + JOIN freight.yards y ON y.id = yp.yard_id + -- INNER join: a mapping whose position was deleted grants nothing and + -- is not shown. The row stays for audit until someone re-saves the set. + JOIN iam.positions p ON p.id = yp.position_id AND p.deleted_at IS NULL + LEFT JOIN iam.position_types pt ON pt.id = p.position_type_id + WHERE ${where.join(' AND ')} + ORDER BY y.display_order ASC, y.label ASC, p.name->>'en' ASC`, + params, + ); + } + + /** + * Replace the yard's entire position set. + * + * Replace, not append — the admin UI submits the full multi-select value, so a + * partial payload would silently keep desks the user just unticked. Callers + * sending a delta will remove everything they omit. + */ + async setPositionsForYard( + yardId: string, + positionIds: string[], + ): Promise { + await this.assertYardExists(yardId); + await this.assertPositionsExist(positionIds); + + await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(YardPosition); + await repo.delete({ yardId }); + if (positionIds.length) { + await repo.insert( + [...new Set(positionIds)].map((positionId) => ({ yardId, positionId })), + ); + } + }); + + return this.list({ yardId }); + } + + /** Replace the position's entire yard set. Same replace semantics. */ + async setYardsForPosition( + positionId: string, + yardIds: string[], + ): Promise { + await this.assertPositionsExist([positionId]); + await this.assertYardsExist(yardIds); + + await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(YardPosition); + await repo.delete({ positionId }); + if (yardIds.length) { + await repo.insert( + [...new Set(yardIds)].map((yardId) => ({ yardId, positionId })), + ); + } + }); + + return this.list({ positionId }); + } + + /** + * Positions offered by the mapping picker. + * + * Reads `iam.positions` directly rather than going through IAM's + * `/positions/list/{unitId}`: that endpoint needs the caller to resolve a unit + * first, and the picker wants every desk that could staff a yard regardless of + * which unit it hangs under. + */ + async listSelectablePositions(): Promise< + Array<{ + id: string; + name: { am?: string; en?: string } | null; + positionTypeKey: string | null; + unitKey: string | null; + }> + > { + return this.dataSource.query( + `SELECT p.id AS "id", + p.name AS "name", + pt.key AS "positionTypeKey", + u.key AS "unitKey" + FROM iam.positions p + LEFT JOIN iam.position_types pt ON pt.id = p.position_type_id + LEFT JOIN iam.units u ON u.id = p.unit_id + WHERE p.deleted_at IS NULL + ORDER BY p.name->>'en' ASC`, + ); + } + + /** Yard ids mapped to any of these positions — the scope resolver's read. */ + async yardIdsForPositions(positionIds: string[]): Promise { + if (!positionIds.length) return []; + const rows: { yardId: string }[] = await this.dataSource.query( + `SELECT DISTINCT yp.yard_id AS "yardId" + FROM freight.yard_positions yp + JOIN freight.yards y ON y.id = yp.yard_id AND y.deleted_at IS NULL + WHERE yp.deleted_at IS NULL + AND yp.position_id = ANY($1)`, + [positionIds], + ); + return rows.map((r) => r.yardId); + } + + private async assertYardExists(yardId: string): Promise { + const yard = await this.dataSource + .getRepository(Yard) + .findOne({ where: { id: yardId, deletedAt: IsNull() } }); + if (!yard) throw new NotFoundException(`Yard ${yardId} not found`); + } + + private async assertYardsExist(yardIds: string[]): Promise { + if (!yardIds.length) return; + const found = await this.dataSource + .getRepository(Yard) + .count({ where: { id: In(yardIds), deletedAt: IsNull() } }); + if (found !== new Set(yardIds).size) { + throw new BadRequestException('One or more yards do not exist'); + } + } + + /** + * Validated in the service because the database cannot: there is no FK to + * `iam.positions`, so an unchecked payload would happily store a typo'd uuid + * that silently grants nothing and reads as a configuration bug later. + */ + private async assertPositionsExist(positionIds: string[]): Promise { + if (!positionIds.length) return; + const unique = [...new Set(positionIds)]; + const rows: { count: string }[] = await this.dataSource.query( + `SELECT COUNT(*)::text AS count + FROM iam.positions + WHERE id = ANY($1) AND deleted_at IS NULL`, + [unique], + ); + if (Number(rows[0]?.count ?? 0) !== unique.length) { + throw new BadRequestException('One or more positions do not exist'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.spec.ts new file mode 100644 index 000000000..c9af80ad3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.spec.ts @@ -0,0 +1,139 @@ +import { ForbiddenException } from '@nestjs/common'; + +import { YardScopeService } from './yard-scope.service'; + +/** + * The resolver answers "which yards", never "may they act at all" — that stays + * with the permission guard. So a mapped desk is narrowed to its yards, and an + * unmapped one keeps the reach its permissions already gave it. + */ +describe('YardScopeService', () => { + const yardIdsForPositions = jest.fn(); + const service = () => + new YardScopeService({ yardIdsForPositions } as never); + + const staff = (positionId: string, permissions: string[] = []) => ({ + roles: [{ key: 'staff' }], + permissions: permissions.map((key) => ({ key })), + employee: { position: { id: positionId, permissions: [] } }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.YARD_SCOPE_ENFORCE; + }); + + it('resolves a mapped position to its yards', async () => { + yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']); + + const scope = await service().getScopedYardIds(staff('pos-officer')); + + expect(scope).toEqual(['yard-kality', 'yard-mojo']); + expect(yardIdsForPositions).toHaveBeenCalledWith(['pos-officer']); + }); + + it('leaves an unmapped position unrestricted — permissions still gate the action', async () => { + yardIdsForPositions.mockResolvedValue([]); + + expect(await service().getScopedYardIds(staff('pos-unmapped'))).toBeNull(); + }); + + it('leaves a caller with no resolvable position unrestricted', async () => { + const noPosition = { roles: [{ key: 'staff' }], employee: { position: {} } }; + + expect(await service().getScopedYardIds(noPosition)).toBeNull(); + expect(yardIdsForPositions).not.toHaveBeenCalled(); + }); + + it('narrows nothing for an anonymous caller but grants nothing either', async () => { + expect(await service().getScopedYardIds(null)).toEqual([]); + }); + + it('returns unrestricted only for super admins and view_all holders', async () => { + const superAdmin = { roles: [{ key: 'super_admin' }] }; + const hqDesk = staff('pos-occ', ['edr_freight_app:yards:view_all']); + + expect(await service().getScopedYardIds(superAdmin)).toBeNull(); + expect(await service().getScopedYardIds(hqDesk)).toBeNull(); + expect(yardIdsForPositions).not.toHaveBeenCalled(); + }); + + it('includes delegated positions — standing in must not lose the yard', async () => { + yardIdsForPositions.mockResolvedValue(['yard-kality']); + + await service().getScopedYardIds({ + roles: [{ key: 'staff' }], + employee: { + position: { id: 'pos-own' }, + delegatedPositions: [{ id: 'pos-gelan-director' }], + }, + }); + + expect(yardIdsForPositions).toHaveBeenCalledWith([ + 'pos-own', + 'pos-gelan-director', + ]); + }); + + describe('listFilterYardIds', () => { + it('narrows nothing while shadow-logging', async () => { + yardIdsForPositions.mockResolvedValue(['yard-kality']); + + expect( + await service().listFilterYardIds(staff('pos-officer'), undefined, 'list'), + ).toBeNull(); + }); + + it('narrows to the mapped yards once enforcing', async () => { + process.env.YARD_SCOPE_ENFORCE = 'true'; + yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']); + + expect( + await service().listFilterYardIds(staff('pos-officer'), undefined, 'list'), + ).toEqual(['yard-kality', 'yard-mojo']); + }); + + it('keeps an in-scope yard filter as the caller asked', async () => { + process.env.YARD_SCOPE_ENFORCE = 'true'; + yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']); + + expect( + await service().listFilterYardIds(staff('pos-officer'), 'yard-mojo', 'list'), + ).toEqual(['yard-mojo']); + }); + + it('returns an empty set — not everything — for an out-of-scope yard filter', async () => { + process.env.YARD_SCOPE_ENFORCE = 'true'; + yardIdsForPositions.mockResolvedValue(['yard-kality']); + + expect( + await service().listFilterYardIds(staff('pos-officer'), 'yard-djibouti', 'list'), + ).toEqual([]); + }); + + it('never narrows an unmapped desk', async () => { + process.env.YARD_SCOPE_ENFORCE = 'true'; + yardIdsForPositions.mockResolvedValue([]); + + expect( + await service().listFilterYardIds(staff('pos-unmapped'), undefined, 'list'), + ).toBeNull(); + }); + }); + + it('only logs an out-of-scope yard until YARD_SCOPE_ENFORCE is set', async () => { + yardIdsForPositions.mockResolvedValue(['yard-kality']); + const shadow = service(); + + await expect( + shadow.assertYardInScope(staff('pos-officer'), 'yard-mojo', 'test'), + ).resolves.toBeUndefined(); + + process.env.YARD_SCOPE_ENFORCE = 'true'; + const enforcing = service(); + + await expect( + enforcing.assertYardInScope(staff('pos-officer'), 'yard-mojo', 'test'), + ).rejects.toBeInstanceOf(ForbiddenException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.ts new file mode 100644 index 000000000..0a7fbe2e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-scope.service.ts @@ -0,0 +1,186 @@ +import { ForbiddenException, Injectable, Logger } from "@nestjs/common"; + +import { hasFreightPermission, isSuperAdmin } from "../../../common/freight-permission.util"; +import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry"; +import { YardPositionsService } from "./yard-positions.service"; + +/** + * Caller shape the resolver reads — the `/auth/me` user in either of its two + * shapes. Structurally compatible with what `freight-permission.util` accepts, + * so the same object serves both the permission checks and the position walk. + */ +type PositionLike = { + id?: string; + permissions?: { key?: string }[]; + positionType?: { key?: string } | null; +}; + +type ScopeUser = { + roles?: { key?: string }[]; + permissions?: { key?: string }[]; + employee?: + | { + position?: PositionLike; + delegatedPositions?: PositionLike[]; + } + | { positions?: PositionLike[] }[] + | null; +}; + +/** + * Which yards a caller may touch. + * + * Scope follows the caller's ACTIVE position, not a union of every position they + * have ever held: the frontends already send `x-current-position-id` and the + * token snapshots that one position, so switching desks switches yards — which + * is what staff covering two yards actually do. Delegated positions are added on + * top, otherwise standing in for the Gelan director silently loses Gelan. + * + * `null` means unrestricted, and an UNMAPPED caller gets it. Scoping narrows a + * desk that has been given yards; it does not hand out access. Whether the + * caller may perform the action at all is the permission guard's job — this + * resolver only answers "which yards", so a desk with the permission and no + * mapping keeps the reach it had before the mapping existed. + * + * The trade-off is deliberate and worth knowing: an accidentally-cleared + * mapping widens access rather than blocking work, so the mapping is not a + * containment barrier on its own — the permission keys still are. Super admins + * and holders of `yards:view_all` are unrestricted regardless of mapping. + * + * ENFORCEMENT IS OFF until `YARD_SCOPE_ENFORCE=true`. Until then + * {@link assertYardInScope} logs what it would have blocked and returns. Flip it + * only once the mapping table is populated and the log is quiet — on an empty + * table, enforcing locks out every staff member at once. + */ +@Injectable() +export class YardScopeService { + private readonly logger = new Logger(YardScopeService.name); + + // ponytail: 60s cache keyed by the position-id set, no invalidation hook. A + // mapping change takes up to a minute to reach the resolver. Call + // `invalidate()` from the mutation if that lag ever matters. + private static readonly CACHE_TTL_MS = 60_000; + private readonly cache = new Map(); + + constructor(private readonly yardPositions: YardPositionsService) {} + + /** True when the deny path is live; false while shadow-logging. */ + get enforced(): boolean { + return process.env.YARD_SCOPE_ENFORCE === "false"; + } + + /** Yard ids the caller is scoped to, or `null` for unrestricted. */ + async getScopedYardIds(user: ScopeUser | null | undefined): Promise { + // No user at all is an unauthenticated call the guards should already have + // rejected — narrow to nothing rather than trusting it. + if (!user) return []; + if (isSuperAdmin(user)) return null; + if (hasFreightPermission(user, FREIGHT_PERMS.yards.viewAll)) return null; + + const positionIds = this.effectivePositionIds(user); + // No resolvable position — nothing to narrow by, so nothing is narrowed. + if (!positionIds.length) return null; + + const key = positionIds.join(","); + const hit = this.cache.get(key); + if (hit && Date.now() - hit.at < YardScopeService.CACHE_TTL_MS) { + return hit.yardIds.length ? hit.yardIds : null; + } + + const yardIds = await this.yardPositions.yardIdsForPositions(positionIds); + this.cache.set(key, { yardIds, at: Date.now() }); + // Unmapped desk → unrestricted. Mapping narrows; absence of one does not. + return yardIds.length ? yardIds : null; + } + + async isYardInScope( + user: ScopeUser | null | undefined, + yardId: string | null | undefined, + ): Promise { + if (!yardId) return true; + const scope = await this.getScopedYardIds(user); + return scope === null || scope.includes(yardId); + } + + /** + * Gate an action on a yard. While `YARD_SCOPE_ENFORCE` is unset this only + * logs — wire it into write paths first and read filters second, so the + * shadow log shows what enforcement would break before it breaks it. + */ + async assertYardInScope( + user: ScopeUser | null | undefined, + yardId: string | null | undefined, + context: string, + ): Promise { + if (await this.isYardInScope(user, yardId)) return; + + const positions = this.effectivePositionIds(user).join(",") || "none"; + if (!this.enforced) { + this.logger.warn( + `[yard-scope shadow] would block ${context}: yard=${yardId} positions=${positions}`, + ); + return; + } + throw new ForbiddenException("This yard is outside your assigned yards"); + } + + /** + * Yard ids a list query should be narrowed to, or `null` for no narrowing. + * + * Returns an EMPTY array only when the caller explicitly asked for a yard + * outside their scope and enforcement is on — the caller should answer with an + * empty result rather than silently widening back to everything. + * + * While `YARD_SCOPE_ENFORCE` is unset this always returns `null` and logs what + * it would have narrowed, so the mapping can be populated against real traffic + * before it starts hiding rows. + */ + async listFilterYardIds( + user: ScopeUser | null | undefined, + requestedYardId: string | null | undefined, + context: string, + ): Promise { + const scope = await this.getScopedYardIds(user); + if (scope === null) return null; + + const outOfScope = !!requestedYardId && !scope.includes(requestedYardId); + + if (!this.enforced) { + this.logger.warn( + `[yard-scope shadow] would narrow ${context} to [${scope.join(", ")}]` + + (outOfScope ? ` and reject yard=${requestedYardId}` : ""), + ); + return null; + } + + if (outOfScope) return []; + return requestedYardId ? [requestedYardId] : scope; + } + + /** Drops the memoised scopes — call after editing the mapping. */ + invalidate(): void { + this.cache.clear(); + } + + /** Active position plus any delegated ones, across both `employee` shapes. */ + private effectivePositionIds(user: ScopeUser | null | undefined): string[] { + const ids = new Set(); + const employee = user?.employee; + if (!employee) return []; + + if (Array.isArray(employee)) { + for (const emp of employee) { + for (const position of emp.positions ?? []) { + if (position?.id) ids.add(position.id); + } + } + return [...ids]; + } + + if (employee.position?.id) ids.add(employee.position.id); + for (const delegated of employee.delegatedPositions ?? []) { + if (delegated?.id) ids.add(delegated.id); + } + return [...ids]; + } +} diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts index 4a5fc86bb..2d1bbf6f4 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts @@ -72,6 +72,8 @@ describe('SchedulingRescheduleService', () => { previewTrainSchedule: jest.fn(), unassignBooking: jest.fn(), assignBookingsToSchedule: jest.fn(), + windowFieldsForNewDeparture: jest.fn().mockResolvedValue({}), + emitWindowState: jest.fn().mockResolvedValue(undefined), }; schedulingRescheduleRepository = { createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }), @@ -213,6 +215,13 @@ describe('SchedulingRescheduleService', () => { }); trainSchedulesRepository.updateStatus.mockResolvedValue(undefined); trainSchedulingService.assignBookingsToSchedule.mockResolvedValue({ id: 'sched-1' }); + // An OPEN window's close must follow the new departure (this is the + // portal's "closes in" countdown) — the derived fields ride along with the + // date write. + const newCloses = new Date('2099-06-22T08:00:00.000Z'); + trainSchedulingService.windowFieldsForNewDeparture.mockResolvedValue({ + windowClosesAt: newCloses, + }); const result = await service.maintenanceReschedule( 'sched-1', @@ -228,9 +237,16 @@ describe('SchedulingRescheduleService', () => { expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith( 'sched-1', 'DRAFT', - { scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z') }, + { + scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z'), + windowClosesAt: newCloses, + }, txManager, ); + expect(trainSchedulingService.windowFieldsForNewDeparture).toHaveBeenCalledWith( + schedule, + new Date('2099-06-22T10:00:00.000Z'), + ); expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith( expect.objectContaining({ trigger: 'TRAIN_MAINTENANCE', diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts index 7283c874a..7c41ee43a 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -227,17 +227,25 @@ export class SchedulingRescheduleService { // through this manager without editing TrainSchedulingService. A failure // between those steps and this block can still leave partial state; a human // must finish the full cross-service transaction threading. + // The booking window must follow the new departure (an OPEN window's + // "closes in" countdown is capped at departure − close offset; PRE_WINDOW / + // DONE re-derive their open/close). Same math as maintenanceReschedule. + const windowFields = newDeparture + ? await this.trainSchedulingService.windowFieldsForNewDeparture( + schedule, + newDeparture, + ) + : {}; + await this.dataSource.transaction(async (manager) => { if (newDeparture) { - // M7: raw write of scheduledDepartureDate. We deliberately do NOT - // delegate to TrainSchedulingService.updateScheduleDate, which only - // permits a date change while windowPhase === 'PRE_WINDOW' and would - // reject reschedules of already-open (SCHEDULED) trains. Consequence: - // the booking-window fields are NOT re-derived for the new date here. + // Raw write of scheduledDepartureDate: updateScheduleDate only permits a + // date change while windowPhase === 'PRE_WINDOW' and would reject + // reschedules of already-open (SCHEDULED) trains. await this.trainSchedulesRepository.updateStatus( scheduleId, schedule.status as TrainScheduleStatus, - { scheduledDepartureDate: newDeparture }, + { scheduledDepartureDate: newDeparture, ...windowFields }, manager, ); } @@ -263,6 +271,7 @@ export class SchedulingRescheduleService { // `newDeparture` is null when the date was unchanged, so retained customers // are not falsely told the train was rescheduled. await this.notifyRescheduleOutcome(dto, newDeparture); + if (newDeparture) void this.trainSchedulingService.emitWindowState(scheduleId); return { plan, schedule: assignResult }; } diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/cancel-shipping-line-booking.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/cancel-shipping-line-booking.dto.ts new file mode 100644 index 000000000..1064cdeb4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/cancel-shipping-line-booking.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsOptional, IsString, MaxLength } from "class-validator"; + +/** Payload for a shipping line cancelling its own booking. */ +export class CancelShippingLineBookingDto { + @ApiProperty({ + required: false, + description: + "Why the booking is being cancelled. Recorded on the booking's review-note log.", + }) + @IsOptional() + @IsString() + @MaxLength(500) + reason?: string; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/complete-shipping-line-booking.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/complete-shipping-line-booking.dto.ts new file mode 100644 index 000000000..90e31e42d --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/complete-shipping-line-booking.dto.ts @@ -0,0 +1,193 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; +import { + IsArray, + IsBoolean, + IsDateString, + IsIn, + IsInt, + IsNumber, + IsOptional, + IsString, + IsUUID, + Min, + ValidateNested, +} from "class-validator"; + +import { PAYMENT_CURRENCIES } from "../../contracts/dto/create-contract.dto"; + +/** + * One physical container on a line — number, seal, VGM and its per-container + * handling switches. Same shape the customer shipment form submits. + */ +export class CompleteShippingLineContainerUnitDto { + @ApiProperty({ example: "MSCU1234567" }) + @IsString() + containerNumber!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + sealNumber?: string; + + @ApiProperty({ minimum: 0, description: "VGM of this container, tons." }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + vgmTons!: number; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + isHazardous?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + isReefer?: boolean; +} + +/** + * One container line the shipping line ships — container type + count, the + * same shape the customer one-time form collects. When `units` is sent (the + * full booking page), per-container numbers/seals/VGM and handling switches + * are persisted exactly like the customer shipment form; without it (legacy + * modal shape) the line-level counts stand alone. + */ +export class CompleteShippingLineContainerLineDto { + @ApiPropertyOptional({ + format: "uuid", + description: + "Container type being shipped. Optional when containerSize is sent — the server resolves the type from the size.", + }) + @IsOptional() + @IsUUID() + containerTypeId?: string; + + @ApiPropertyOptional({ + description: + 'Container size, e.g. "20ft" | "40ft". The server maps it to the configured container type (reefer variant when the line carries reefer boxes) — so the client never needs the type catalog.', + }) + @IsOptional() + @IsString() + containerSize?: string; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + @Transform(({ value }) => Number(value)) + quantity!: number; + + @ApiPropertyOptional({ minimum: 0, description: "VGM per container, tons." }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + vgmPerUnitTons?: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + hazardousQuantity?: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + reeferQuantity?: number; + + @ApiPropertyOptional({ + type: [CompleteShippingLineContainerUnitDto], + description: + "Per-container details. When present, the handling counts and VGM are derived from these rows.", + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CompleteShippingLineContainerUnitDto) + units?: CompleteShippingLineContainerUnitDto[]; +} + +/** + * Completion payload for a shipping-line booking whose documents Operations + * has approved (CLEARANCE_READY): the cargo and the binding shipment day — + * the two things `initiate` deliberately left empty. + */ +export class CompleteShippingLineBookingDto { + @ApiProperty({ + description: "Binding shipment day (train departure day).", + example: "2026-09-01", + }) + @IsDateString() + scheduledDate!: string; + + @ApiPropertyOptional({ + format: "uuid", + description: + "Which of the line's dedicated trains this booking rides. Required when more than one departs on the chosen day; implicit with a single departure.", + }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + + @ApiPropertyOptional({ enum: PAYMENT_CURRENCIES }) + @IsOptional() + @IsIn([...PAYMENT_CURRENCIES]) + paymentCurrency?: string; + + @ApiPropertyOptional({ + type: [CompleteShippingLineContainerLineDto], + description: "Container freight: what ships. Required for CONTAINER bookings.", + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CompleteShippingLineContainerLineDto) + containers?: CompleteShippingLineContainerLineDto[]; + + @ApiPropertyOptional({ + format: "uuid", + description: "Bulk freight: the cargo type. Required for BULK bookings.", + }) + @IsOptional() + @IsUUID() + cargoTypeId?: string; + + @ApiPropertyOptional({ + minimum: 0, + description: "Bulk freight: total weight in tons. Required for BULK bookings.", + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + cargoWeightTons?: number; + + @ApiPropertyOptional({ + minimum: 0, + description: "Bulk freight: hazardous portion of the cargo.", + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + bulkHazardousQuantity?: number; + + @ApiPropertyOptional({ + minimum: 0, + description: "Bulk freight: refrigerated portion of the cargo.", + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + bulkReeferQuantity?: number; + + @ApiPropertyOptional({ description: "What the containers carry." }) + @IsOptional() + @IsString() + cargoFreeText?: string; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/create-shipping-line.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/create-shipping-line.dto.ts new file mode 100644 index 000000000..5f89c916c --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/create-shipping-line.dto.ts @@ -0,0 +1,68 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, +} from "class-validator"; + +import { IsValidPhone } from "../../../common/validators/is-phone-number.validator"; + +export class CreateShippingLineDto { + @ApiProperty({ example: "Ethiopian Shipping Lines" }) + @IsString() + @IsNotEmpty() + @MaxLength(200) + name!: string; + + /** + * Becomes the IAM account's email — the activation link is sent here, so it + * is required even though the customer equivalent is optional. + */ + @ApiProperty({ example: "ops@esl.com.et" }) + @IsEmail() + @MaxLength(150) + email!: string; + + @ApiPropertyOptional({ example: "+251911223344" }) + @IsOptional() + @IsString() + @MaxLength(30) + @IsValidPhone() + phoneNumber?: string; + + @ApiPropertyOptional({ + example: "ESLK", + description: "Standard Carrier Alpha Code — 2-4 letters", + }) + @IsOptional() + @IsString() + @Matches(/^[A-Za-z]{2,4}$/, { + message: "SCAC must be 2-4 letters", + }) + scacCode?: string; + + @ApiPropertyOptional({ example: "IMO9074729" }) + @IsOptional() + @IsString() + @MaxLength(20) + imoNumber?: string; + + @ApiPropertyOptional({ example: "ESLU" }) + @IsOptional() + @IsString() + @MaxLength(20) + bicCode?: string; + + /** + * Login name. Optional — defaults to the email, which is what the line will + * naturally try first. + */ + @ApiPropertyOptional({ example: "esl-ops" }) + @IsOptional() + @IsString() + @MaxLength(100) + username?: string; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/initiate-shipping-line-booking.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/initiate-shipping-line-booking.dto.ts new file mode 100644 index 000000000..3b0615fe4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/initiate-shipping-line-booking.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsDateString, IsIn, IsOptional, IsUUID } from "class-validator"; + +import { FREIGHT_TYPES } from "../../bookings/entities/booking.entity"; + +/** + * Payload for initiating a bare shipping-line booking. + * + * A customer's bare booking inherits its lane from the contract it is initiated + * under. Shipping lines have no contract, so the lane comes from a route the + * caller picks — one choice that yields origin, destination and trade direction + * together, rather than three fields that can contradict each other. + */ +export class InitiateShippingLineBookingDto { + @ApiProperty({ + description: + "The lane being booked. Supplies the booking's origin yard, destination yard and trade direction.", + }) + @IsUUID() + routeId!: string; + + @ApiProperty({ + required: false, + description: "Service type being booked.", + }) + @IsOptional() + @IsUUID() + serviceTypeId?: string; + + @ApiProperty({ + required: false, + enum: FREIGHT_TYPES, + description: "Freight type. Defaults to CONTAINER.", + }) + @IsOptional() + @IsIn(FREIGHT_TYPES) + freightType?: string; + + @ApiProperty({ + required: false, + description: + "Intended shipment day (YYYY-MM-DD). Unlike the customer flow it is picked up front — a shipping line has no later operation-request step to choose it at.", + }) + @IsOptional() + @IsDateString() + scheduledDate?: string; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-credit.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-credit.dto.ts new file mode 100644 index 000000000..7ad524f01 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-credit.dto.ts @@ -0,0 +1,85 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + ArrayNotEmpty, + IsArray, + IsInt, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, + MinLength, +} from "class-validator"; + +/** Finance's request to bill a batch of unbilled credits as one invoice. */ +export class GenerateCreditInvoiceDto { + @ApiProperty({ + description: + "The unbilled credits to bill. All must belong to the same shipping line and share one currency.", + type: [String], + format: "uuid", + }) + @IsArray() + @ArrayNotEmpty() + @IsUUID("4", { each: true }) + creditIds!: string[]; + + @ApiPropertyOptional({ + description: + "Pay window in days from issue. Defaults to the standard invoice term.", + minimum: 1, + example: 14, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + dueInDays?: number; +} + +/** Finance's request for a manual action on a credit invoice (maker step). */ +export class RequestInvoiceActionDto { + @ApiProperty({ + description: + "Why the action is needed. Shown to the approver and kept for audit.", + example: "Paid by bank transfer, slip #TT-4491", + }) + @IsString() + @MinLength(3) + @MaxLength(500) + reason!: string; + + @ApiPropertyOptional({ + description: + "Offline payment reference (bank slip / transfer number). MARK_PAID requests only.", + example: "TT-4491", + }) + @IsOptional() + @IsString() + @MaxLength(255) + paymentReference?: string; +} + +/** The decision on a pending request (approve and reject routes). */ +export class DecideInvoiceActionDto { + @ApiPropertyOptional({ + description: "Decision note. Required when rejecting.", + }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} + +/** Write-off of a single unbilled credit. */ +export class CancelCreditDto { + @ApiProperty({ + description: "Why the credit is being written off. Recorded on the row.", + example: "Booking voided before departure", + }) + @IsString() + @MinLength(3) + @MaxLength(255) + reason!: string; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-response.dto.ts b/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-response.dto.ts new file mode 100644 index 000000000..051c3af28 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/dto/shipping-line-response.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; + +import { + ShippingLineCompany, + ShippingLineStatus, +} from "../entities/shipping-line-company.entity"; + +export class ShippingLineResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiProperty() + email: string; + + @ApiPropertyOptional() + phoneNumber?: string | null; + + @ApiPropertyOptional() + scacCode?: string | null; + + @ApiPropertyOptional() + imoNumber?: string | null; + + @ApiPropertyOptional() + bicCode?: string | null; + + @ApiProperty({ enum: ShippingLineStatus }) + status: ShippingLineStatus; + + @ApiProperty() + createdAt: Date; + + constructor(entity: ShippingLineCompany) { + this.id = entity.id; + this.name = entity.name; + this.email = entity.email; + this.phoneNumber = entity.phoneNumber ?? null; + this.scacCode = entity.scacCode ?? null; + this.imoNumber = entity.imoNumber ?? null; + this.bicCode = entity.bicCode ?? null; + this.status = entity.status; + this.createdAt = entity.createdAt; + } +} + +export class RegisterShippingLineResponseDto { + @ApiProperty({ type: ShippingLineResponseDto }) + shippingLine: ShippingLineResponseDto; + + @ApiPropertyOptional({ + description: + "Masked destination the activation link was sent to, or null if delivery failed.", + example: "o**@esl.com.et", + }) + activationSentTo: string | null; + + constructor(shippingLine: ShippingLineCompany, activationSentTo: string | null) { + this.shippingLine = new ShippingLineResponseDto(shippingLine); + this.activationSentTo = activationSentTo; + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-company.entity.ts b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-company.entity.ts new file mode 100644 index 000000000..fac8c00a6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-company.entity.ts @@ -0,0 +1,64 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index } from "typeorm"; + +export enum ShippingLineStatus { + Active = "active", + Suspended = "suspended", +} + +/** + * A shipping line — a carrier that books rail capacity directly, registered by + * backoffice staff rather than self-signing up. + * + * Deliberately NOT a {@link Company} of a new {@link CompanyType}: a shipping + * line carries none of what `companies` exists to hold — no TIN, no business + * licence, no eTrade authenticity lookup, no operational `company_profiles`, no + * onboarding wizard state. Modelling it there would mean making all of that + * nullable for one row shape that never uses it. + * + * The company IS the account: there is no contact-person row (customers get one + * via `external_profiles`), so `user_id` lives here and the login credentials + * are the company's own. That is also why the password-reset flow resolves a + * shipping line straight off this table instead of through a primary contact. + */ +@Entity({ schema: "freight", name: "shipping_line_companies" }) +@Index(["status"]) +export class ShippingLineCompany extends BaseEntity { + /** + * The IAM account (`iam.users`, userType `individual`) that signs in as this + * shipping line. No FK: `iam` is a separate schema owned by the IAM service, + * and the rest of the codebase reaches it by query rather than by relation. + */ + @Column({ name: "user_id", type: "uuid", unique: true }) + userId!: string; + + @Column({ name: "name", type: "varchar", length: 200 }) + name!: string; + + /** Standard Carrier Alpha Code — 2-4 letters identifying the carrier. */ + @Column({ name: "scac_code", type: "varchar", length: 4, nullable: true }) + scacCode?: string | null; + + /** IMO number of the vessel operator. */ + @Column({ name: "imo_number", type: "varchar", length: 20, nullable: true }) + imoNumber?: string | null; + + /** BIC code — the container prefix the line's equipment is registered under. */ + @Column({ name: "bic_code", type: "varchar", length: 20, nullable: true }) + bicCode?: string | null; + + /** Mirrors the IAM account's email; the activation link is sent here. */ + @Column({ name: "email", type: "varchar", length: 150 }) + email!: string; + + @Column({ name: "phone_number", type: "varchar", length: 30, nullable: true }) + phoneNumber?: string | null; + + @Column({ + name: "status", + type: "enum", + enum: ShippingLineStatus, + default: ShippingLineStatus.Active, + }) + status!: ShippingLineStatus; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-credit.entity.ts b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-credit.entity.ts new file mode 100644 index 000000000..3c81968f6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-credit.entity.ts @@ -0,0 +1,110 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; + +import { Booking } from "../../bookings/entities/booking.entity"; +import { Invoice } from "../../billing/entities/invoice.entity"; +import { ShippingLineCompany } from "./shipping-line-company.entity"; + +/** Where a credit sits between "service used" and "money received". */ +export enum ShippingLineCreditStatus { + /** Service used, priced, not yet on any invoice. Counts as debt. */ + Unbilled = "UNBILLED", + /** Finance put it on an invoice; awaiting payment. Still counts as debt. */ + Billed = "BILLED", + /** The invoice settled. Terminal — no longer debt, and never re-billed. */ + Paid = "PAID", + /** Written off / booking voided. Terminal, excluded from every total. */ + Cancelled = "CANCELLED", +} + +/** Statuses a shipping line still owes money for. */ +export const OUTSTANDING_CREDIT_STATUSES = [ + ShippingLineCreditStatus.Unbilled, + ShippingLineCreditStatus.Billed, +] as const; + +/** + * What a shipping line owes for one booking. + * + * Shipping lines get the service first and pay later, so a booking of theirs + * raises no invoice and passes no payment gate — it raises one of these. The + * amount is frozen when the booking is priced and is never recalculated, so a + * later rate change cannot silently alter a debt already incurred. + * + * Finance batches unbilled credits into one invoice (see + * `ShippingLineCreditsService.generateInvoice`); the line pays that invoice + * through the ordinary CBE flow; settlement flips the batch to PAID and the + * debt disappears. The outstanding figure is always derived by summing + * {@link OUTSTANDING_CREDIT_STATUSES} rows — there is no balance column, + * because a stored balance is one missed UPDATE away from being a lie. + */ +@Entity({ schema: "freight", name: "shipping_line_credits" }) +@Index(["shippingLineCompanyId", "status"]) +@Index(["invoiceId"]) +export class ShippingLineCredit extends BaseEntity { + /** The line that owes this. */ + @Column({ name: "shipping_line_company_id", type: "uuid" }) + shippingLineCompanyId!: string; + + @ManyToOne(() => ShippingLineCompany) + @JoinColumn({ name: "shipping_line_company_id" }) + shippingLineCompany?: ShippingLineCompany; + + /** + * The booking that incurred the charge. Unique among live rows (partial + * index excludes soft-deleted and CANCELLED), so one booking can never be + * billed twice. + */ + @Column({ name: "booking_id", type: "uuid" }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: "booking_id" }) + booking?: Booking; + + /** Frozen at pricing time. Never recalculated. */ + @Column({ name: "amount", type: "numeric", precision: 14, scale: 2 }) + amount!: number; + + @Column({ name: "currency", type: "varchar", length: 8, default: "ETB" }) + currency!: string; + + @Column({ + name: "status", + type: "enum", + enum: ShippingLineCreditStatus, + default: ShippingLineCreditStatus.Unbilled, + }) + status!: ShippingLineCreditStatus; + + /** What the charge is for; becomes the invoice line description. */ + @Column({ name: "description", type: "varchar", length: 255, nullable: true }) + description?: string | null; + + /** The invoice this credit was billed on; null while UNBILLED. */ + @Column({ name: "invoice_id", type: "uuid", nullable: true }) + invoiceId?: string | null; + + @ManyToOne(() => Invoice) + @JoinColumn({ name: "invoice_id" }) + invoice?: Invoice; + + /** When finance put it on an invoice. */ + @Column({ name: "billed_at", type: "timestamptz", nullable: true }) + billedAt?: Date | null; + + /** When that invoice settled. */ + @Column({ name: "paid_at", type: "timestamptz", nullable: true }) + paidAt?: Date | null; + + @Column({ name: "cancelled_at", type: "timestamptz", nullable: true }) + cancelledAt?: Date | null; + + @Column({ + name: "cancellation_reason", + type: "varchar", + length: 255, + nullable: true, + }) + cancellationReason?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-invoice-approval.entity.ts b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-invoice-approval.entity.ts new file mode 100644 index 000000000..54ef7338d --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/entities/shipping-line-invoice-approval.entity.ts @@ -0,0 +1,82 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; + +import { Invoice } from "../../billing/entities/invoice.entity"; + +/** What finance asked to do to a shipping-line credit invoice. */ +export enum ShippingLineInvoiceActionType { + /** Record a full offline settlement (paid outside the gateway). */ + MarkPaid = "MARK_PAID", + /** Void the invoice; its credits return to the unbilled pool. */ + Cancel = "CANCEL", +} + +export enum ShippingLineInvoiceActionStatus { + Pending = "PENDING", + Approved = "APPROVED", + Rejected = "REJECTED", +} + +/** + * Maker–checker for manual actions on shipping-line credit invoices. + * + * Marking an invoice paid by hand erases real debt, and cancelling one + * releases its credits back to the unbilled pool — either done unilaterally is + * a one-person fraud path. So finance REQUESTS the action (one permission) + * and a chief APPROVES or REJECTS it (a separate permission, different + * person). Every request is kept, decided or not: the table is the audit + * trail of who asked, who decided, and why. + */ +@Entity({ schema: "freight", name: "shipping_line_invoice_approvals" }) +@Index(["invoiceId", "status"]) +export class ShippingLineInvoiceApproval extends BaseEntity { + @Column({ name: "invoice_id", type: "uuid" }) + invoiceId!: string; + + @ManyToOne(() => Invoice) + @JoinColumn({ name: "invoice_id" }) + invoice?: Invoice; + + @Column({ name: "action", type: "enum", enum: ShippingLineInvoiceActionType }) + action!: ShippingLineInvoiceActionType; + + @Column({ + name: "status", + type: "enum", + enum: ShippingLineInvoiceActionStatus, + default: ShippingLineInvoiceActionStatus.Pending, + }) + status!: ShippingLineInvoiceActionStatus; + + /** IAM user id of the finance staff who raised the request. */ + @Column({ name: "requested_by", type: "uuid" }) + requestedBy!: string; + + /** Why the action is needed; shown to the approver, kept for audit. */ + @Column({ name: "reason", type: "varchar", length: 500 }) + reason!: string; + + /** Offline payment reference (bank slip no. etc.) for MARK_PAID requests. */ + @Column({ + name: "payment_reference", + type: "varchar", + length: 255, + nullable: true, + }) + paymentReference?: string | null; + + /** IAM user id of the chief who approved/rejected; null while pending. */ + @Column({ name: "decided_by", type: "uuid", nullable: true }) + decidedBy?: string | null; + + @Column({ name: "decided_at", type: "timestamptz", nullable: true }) + decidedAt?: Date | null; + + @Column({ + name: "decision_note", + type: "varchar", + length: 500, + nullable: true, + }) + decisionNote?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.controller.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.controller.ts new file mode 100644 index 000000000..b2417a08f --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.controller.ts @@ -0,0 +1,109 @@ +import { CurrentUser } from "@edr/api-common"; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { PortalCustomer } from "../../common/booking-guards"; +import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto"; +import { ShippingLineBookingCompletionService } from "./shipping-line-booking-completion.service"; + +interface CurrentIamUser { + id: string; +} + +/** + * The completion half of shipping-line bookings, sharing the + * `/shipping-line-bookings` prefix with {@link ShippingLineBookingsController}. + * Separate controller because it lives in its own module — see + * {@link ShippingLineBookingCompletionService} for why the module split exists. + */ +@ApiTags("shipping-line-bookings") +@Controller("shipping-line-bookings") +@ApiBearerAuth() +export class ShippingLineBookingCompletionController { + constructor( + private readonly completionService: ShippingLineBookingCompletionService, + ) {} + + @Get(":id/available-days") + @PortalCustomer() + @ApiOperation({ + summary: + "Days with an open departure that can carry this booking's cargo — for the completion form's day picker.", + }) + async availableDays( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + ) { + return this.completionService.availableDaysMine(user.id, id); + } + + @Get(":id/trains") + @PortalCustomer() + @ApiOperation({ + summary: + "The line's dedicated trains on the booking's lane for a shipment day, each with per-wagon-type free space — for the completion form's train picker. Cargo context (sizes/cargoTypeId/wagons) refines the availability.", + }) + async trainsForDay( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + @Query("date") date?: string, + @Query("sizes") sizes?: string, + @Query("cargoTypeId") cargoTypeId?: string, + @Query("wagons") wagons?: string, + ) { + return this.completionService.trainsForDayMine(user.id, id, date, { + containerSizes: sizes ? sizes.split(",").filter(Boolean) : undefined, + cargoTypeId: cargoTypeId || undefined, + wagons: wagons ? Number(wagons) : undefined, + }); + } + + @Post(":id/price-preview") + @PortalCustomer() + @ApiOperation({ + summary: + "Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", + }) + async pricePreview( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CompleteShippingLineBookingDto, + ) { + return this.completionService.previewPriceMine(user.id, id, dto); + } + + @Get(":id/operations") + @PortalCustomer() + @ApiOperation({ + summary: + "Operations view of the booking: the train it rides (assigned or requested) and the wagons allocated to it.", + }) + async operations( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + ) { + return this.completionService.operationsMine(user.id, id); + } + + @Post(":id/complete") + @PortalCustomer() + @ApiOperation({ + summary: + "Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day. Prices off the line's rates, records the charge on the credit ledger and requests operation.", + }) + async completeMine( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CompleteShippingLineBookingDto, + ) { + return this.completionService.completeMine(user.id, id, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.module.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.module.ts new file mode 100644 index 000000000..470984c70 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.module.ts @@ -0,0 +1,29 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { BookingsModule } from "../bookings/bookings.module"; +import { Booking } from "../bookings/entities/booking.entity"; +import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; +import { ShippingLineBookingCompletionController } from "./shipping-line-booking-completion.controller"; +import { ShippingLineBookingCompletionService } from "./shipping-line-booking-completion.service"; +import { ShippingLineCompaniesModule } from "./shipping-line-companies.module"; + +/** + * Deliberately a LEAF module — registered in AppModule and imported by + * nothing. Completion needs BookingsModule (pricing + the operation-request + * transition), but ShippingLineCompaniesModule sits under rule-engine and + * companies, which sit under BookingsModule; importing bookings from there + * closes a module cycle Nest cannot construct. Keeping the completion flow + * here keeps the graph acyclic with no forwardRef chains. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([Booking]), + BookingsModule, + TrainSchedulingModule, + ShippingLineCompaniesModule, + ], + controllers: [ShippingLineBookingCompletionController], + providers: [ShippingLineBookingCompletionService], +}) +export class ShippingLineBookingCompletionModule {} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts new file mode 100644 index 000000000..ce5a7fad8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts @@ -0,0 +1,816 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { In, Repository } from "typeorm"; + +import { BookingPricingService } from "../bookings/booking-pricing.service"; +import { BookingTransitionService } from "../bookings/booking-transition.service"; +import { BookingsService } from "../bookings/bookings.service"; +import type { Container20ftUnit } from "../bookings/container-pairing.util"; +import { ContainerValidationService } from "../bookings/container-validation.service"; +import { BookingContainer } from "../bookings/entities/booking-container.entity"; +import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity"; +import { Booking } from "../bookings/entities/booking.entity"; +import { wagonsPerUnitForSize } from "../rule-engine/container-type.util"; +import { CargoType } from "../rule-engine/entities/cargo-type.entity"; +import { ContainerType } from "../rule-engine/entities/container-type.entity"; +import { eatDay } from "../train-scheduling/batch-window.util"; +import { + BookingBatchService, + type TrainOptionCargoOverrides, +} from "../train-scheduling/booking-batch.service"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; +import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity"; +import { TrainSchedulingService } from "../train-scheduling/services/train-scheduling.service"; +import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto"; +import { + ShippingLineCredit, + ShippingLineCreditStatus, +} from "./entities/shipping-line-credit.entity"; +import { ShippingLineCompaniesService } from "./shipping-line-companies.service"; +import { ShippingLineCreditsService } from "./shipping-line-credits.service"; + +/** + * Completion of a shipping-line booking — the step after Operations approves + * its documents, mirroring what a customer does at that point: cargo + binding + * shipment day go in, the booking prices off the line's negotiated rates and + * the request lands with Operations. + * + * Its own module (not part of {@link ShippingLineBookingsService}) because it + * needs BookingsModule (pricing, the operation-request transition) and + * TrainSchedulingModule — and ShippingLineCompaniesModule is imported by + * rule-engine/companies, which sit UNDER BookingsModule. Importing bookings + * from there closes a module cycle Nest cannot construct; a leaf module that + * nothing imports keeps the graph acyclic. + */ +@Injectable() +export class ShippingLineBookingCompletionService { + constructor( + @InjectRepository(Booking) + private readonly bookingsRepository: Repository, + private readonly shippingLineCompaniesService: ShippingLineCompaniesService, + private readonly bookingsService: BookingsService, + private readonly bookingPricingService: BookingPricingService, + private readonly bookingTransitionService: BookingTransitionService, + private readonly trainSchedulingService: TrainSchedulingService, + private readonly bookingBatchService: BookingBatchService, + private readonly creditsService: ShippingLineCreditsService, + private readonly containerValidationService: ContainerValidationService, + ) {} + + /** + * 20ft weight-pairing check over the completion payload — the same rule the + * customer shipment form enforces (`max20ftPairWeightDiffTons`, default 10t): + * two 20ft sharing a wagon must be within the cap. Preview surfaces the + * messages; completion hard-blocks on them. Runs off the DTO so nothing is + * persisted before the check passes. + */ + private async pairingViolationMessages( + dto: CompleteShippingLineBookingDto, + ): Promise { + const units: Container20ftUnit[] = []; + for (const line of dto.containers ?? []) { + const containerType = await this.resolveContainerType(line); + if (containerType.sizeFt !== 20) continue; + (line.units ?? []).forEach((u, idx) => + units.push({ + label: u.containerNumber || `20ft-${idx + 1}`, + grossWeightTons: Number(u.vgmTons ?? 0), + }), + ); + } + const violations = + await this.containerValidationService.validate20ftPairingUnits(units); + return violations.map((v) => v.message); + } + + /** Same session→owner resolution every shipping-line entry point uses. */ + private async requireShippingLine(userId: string) { + const shippingLine = + await this.shippingLineCompaniesService.findByUserId(userId); + if (!shippingLine) { + throw new ForbiddenException("This account is not a shipping line."); + } + if (shippingLine.status !== "active") { + throw new ForbiddenException( + "This shipping-line account is suspended and cannot create bookings.", + ); + } + return shippingLine; + } + + private async requireOwnBooking( + userId: string, + bookingId: string, + relations?: { bookingContainers?: boolean }, + ) { + const shippingLine = await this.requireShippingLine(userId); + const booking = await this.bookingsRepository.findOne({ + where: { id: bookingId, shippingLineCompanyId: shippingLine.id }, + relations, + }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + return booking; + } + + /** + * The line's dedicated departures on the booking's lane (DRAFT/SCHEDULED, + * soonest first). These trains run NO booking-window cycle — the line books + * whenever it wants until the close offset stamped in `windowClosesAt` — and + * they are excluded from every customer pool, so this is the only source + * that can offer them. + */ + private async dedicatedTrainsForBooking(booking: Booking) { + if (!booking.shippingLineCompanyId) return []; + return this.bookingsRepository.manager.getRepository(TrainSchedule).find({ + where: { + shippingLineCompanyId: booking.shippingLineCompanyId, + originStationId: booking.originYardId ?? undefined, + destinationStationId: booking.destinationYardId ?? undefined, + status: In(["DRAFT", "SCHEDULED"]), + }, + order: { scheduledDepartureDate: "ASC" }, + }); + } + + /** Still bookable: the close offset before departure has not passed yet. */ + private isStillOpen(schedule: TrainSchedule): boolean { + const closesAt = + schedule.windowClosesAt ?? schedule.scheduledDepartureDate; + return closesAt.getTime() > Date.now(); + } + + /** + * The line's dedicated trains on the booking's lane for one shipment day, + * each with per-wagon-type free space — the completion form's train picker. + * A booking rides ONE schedule, so with several departures that day the + * line picks which; the pick is validated again at complete time. + */ + async trainsForDayMine( + userId: string, + bookingId: string, + date: string | undefined, + overrides?: TrainOptionCargoOverrides, + ) { + const booking = await this.requireOwnBooking(userId, bookingId); + if (!booking.shippingLineCompanyId) return []; + return this.bookingBatchService.dedicatedTrainOptionsForDay( + booking, + date ? eatDay(new Date(date)) : null, + booking.shippingLineCompanyId, + overrides, + ); + } + + /** + * Days the shipping line may pick as the shipment day. + * + * Lanes with trains DEDICATED to this line offer exactly those trains' days, + * open until each train's close offset — no window cycle. Lanes without a + * dedicated train fall back to the shared customer day pool, exactly as + * before. Ownership is checked first so one line cannot probe another's + * booking. + */ + async availableDaysMine(userId: string, bookingId: string) { + const booking = await this.requireOwnBooking(userId, bookingId); + const dedicated = await this.dedicatedTrainsForBooking(booking); + if (dedicated.length === 0) { + return this.bookingsService.availableDaysForBooking(bookingId); + } + const days = [ + ...new Set( + dedicated + .filter((s) => this.isStillOpen(s)) + .map((s) => eatDay(s.scheduledDepartureDate)), + ), + ]; + return { days }; + } + + /** + * Complete a bare shipping-line booking once Operations has approved its + * documents (CLEARANCE_READY), or after Operations returned the request + * (OPERATION_CHANGES_REQUESTED). The cargo and the binding shipment day go + * in, the booking is priced off the line's negotiated rates, and the request + * lands with Operations (OPERATION_REQUEST_PENDING) through the same + * transition customers use. + * + * Payment differs from customers by design: no invoice is issued here. + * Shipping lines run on the credit ledger — the priced amount is recorded as + * an UNBILLED credit and Finance bills a batch later, so the booking + * proceeds without a payment gate. + */ + async completeMine( + userId: string, + bookingId: string, + dto: CompleteShippingLineBookingDto, + ) { + const booking = await this.requireOwnBooking(userId, bookingId, { + bookingContainers: true, + }); + if ( + !["CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED"].includes( + booking.status, + ) + ) { + throw new BadRequestException( + "Your documents must be approved before the booking can be completed.", + ); + } + + // Unbalanced 20ft pairs can never be planned onto wagons — refuse before + // any cargo/credit write below. Same block the contract path applies. + if (booking.freightType === "CONTAINER") { + const pairing = await this.pairingViolationMessages(dto); + if (pairing.length) { + throw new BadRequestException( + `Cannot complete booking — 20ft containers cannot be paired on wagons: ${pairing.join(" ")}`, + ); + } + } + + // Completion is booking time. A lane with trains DEDICATED to this line + // has no window concept at all: the line books whenever it wants until the + // train's close offset. Only a lane with no dedicated train falls back to + // the customer window gate, unchanged. + const dedicated = await this.dedicatedTrainsForBooking(booking); + const pickedDay = eatDay(new Date(dto.scheduledDate)); + const dedicatedOnDay = dedicated.filter( + (s) => eatDay(s.scheduledDepartureDate) === pickedDay, + ); + let bypassDayPool = false; + let requestedTrainScheduleId: string | null = null; + if (dedicatedOnDay.length > 0) { + const openOnDay = dedicatedOnDay.filter((s) => this.isStillOpen(s)); + if (openOnDay.length === 0) { + throw new BadRequestException( + "Booking for your train on this day has closed — the cut-off before departure has passed.", + ); + } + // A booking rides ONE schedule. Several departures that day → the line + // must say which; a single one is picked implicitly. The id comes from + // the request, so it is validated against the day's own trains. + if (dto.trainScheduleId) { + const picked = openOnDay.find((s) => s.id === dto.trainScheduleId); + if (!picked) { + throw new BadRequestException( + "The selected train does not run your route on that day (or its booking cut-off has passed) — pick another train.", + ); + } + requestedTrainScheduleId = picked.id; + } else if (openOnDay.length === 1) { + requestedTrainScheduleId = openOnDay[0].id; + } else { + throw new BadRequestException( + "More than one of your trains departs that day — select which train this booking rides.", + ); + } + // The day is backed by the line's own train, which every customer pool + // deliberately excludes — so the day-pool gate downstream must not run. + bypassDayPool = true; + } else if (dedicated.length > 0) { + throw new BadRequestException( + "Pick one of your assigned train days for this route.", + ); + } else { + await this.trainSchedulingService.assertBookingWindowOpen({ + originYardId: booking.originYardId ?? null, + destinationYardId: booking.destinationYardId ?? null, + scheduledDate: dto.scheduledDate, + direction: booking.tradeDirection ?? null, + }); + } + + let hasCargo = + (booking.bookingContainers?.length ?? 0) > 0 || + Number(booking.cargoTotalWeightVgm) > 0; + const restatesCargo = Boolean( + dto.containers?.length || dto.cargoTypeId || dto.cargoWeightTons, + ); + + // Operations may return the request asking for the CARGO to change, not + // just the day. A resubmit that restates cargo starts completion over: + // the recorded (unbilled) credit is written off and the persisted cargo + // wiped, so the fresh path below re-persists, re-prices and re-records. + // Once the credit is on an issued invoice the cargo is frozen — the + // invoice total must keep matching what it bills. + if (hasCargo && restatesCargo) { + const credit = await this.bookingsRepository.manager + .getRepository(ShippingLineCredit) + .findOne({ where: { bookingId } }); + if (credit && credit.status === ShippingLineCreditStatus.Unbilled) { + await this.creditsService.cancelCredit( + credit.id, + "Cargo changed before billing — booking re-priced on completion.", + ); + } else if ( + credit && + credit.status !== ShippingLineCreditStatus.Cancelled + ) { + throw new BadRequestException( + "This booking's charge has already been invoiced — contact Operations to change its cargo.", + ); + } + await this.wipeCargo(bookingId); + hasCargo = false; + } + + // First completion persists cargo and prices the booking; a day-only + // resubmit after OPERATION_CHANGES_REQUESTED skips straight to the + // operation request with the cargo (and price) it already carries. + if (!hasCargo) { + if (booking.freightType === "CONTAINER") { + await this.persistContainerLines(booking, dto); + } else { + if (!dto.cargoTypeId || !(Number(dto.cargoWeightTons) > 0)) { + throw new BadRequestException( + "Bulk bookings need a cargo type and a total weight in tons.", + ); + } + const cargoType = await this.bookingsRepository.manager + .getRepository(CargoType) + .findOne({ where: { id: dto.cargoTypeId, isActive: true } }); + if (!cargoType) { + throw new NotFoundException( + `Cargo type ${dto.cargoTypeId} not found`, + ); + } + } + + await this.bookingsRepository.update(bookingId, { + cargoTypeId: + booking.freightType === "BULK" ? (dto.cargoTypeId ?? null) : null, + cargoFreeText: dto.cargoFreeText?.trim() || null, + cargoTotalWeightVgm: + booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : 0, + bulkTotalWeightTons: + booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : null, + // Bulk handling portions — sized against the cargo, billed by pricing. + ...(booking.freightType === "BULK" + ? { + bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0), + bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0), + } + : {}), + // Hazard is per-line for containers; the booking-level flag is what + // pricing bills the surcharge from. + isHazardous: + (dto.containers ?? []).some( + (line) => + Number(line.hazardousQuantity ?? 0) > 0 || + (line.units ?? []).some((u) => u.isHazardous), + ) || Number(dto.bulkHazardousQuantity ?? 0) > 0, + // Same for reefer: the rule engine's REEFER trigger fires on the + // booking-level flag (or a reefer container TYPE) — a ticked reefer + // switch on a standard box only sets the per-line count, so without + // this flag the surcharge silently never bills. + isReefer: + (dto.containers ?? []).some( + (line) => + Number(line.reeferQuantity ?? 0) > 0 || + (line.units ?? []).some((u) => u.isReefer), + ) || Number(dto.bulkReeferQuantity ?? 0) > 0, + // Shipping lines are always billed in ETB: the charge lands on the + // ETB credit ledger, so the currency is enforced here rather than + // trusted from the payload. + paymentCurrency: "ETB", + } as never); + + const loaded = await this.bookingsRepository.findOne({ + where: { id: bookingId }, + relations: { bookingContainers: true, serviceType: true }, + }); + const computed = await this.bookingPricingService.computePriceForBooking( + loaded ?? booking, + ); + // A zero price or hard block means no rate is configured for this line + // on this lane. Roll the cargo back so the booking stays completable — + // the approved clearance is not lost — and surface why. + if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) { + await this.wipeCargo(bookingId); + throw new BadRequestException( + computed.hardBlocked.length > 0 + ? computed.hardBlocked.join("; ") + : "No rate is configured for your shipping line on this route/cargo — please contact Operations.", + ); + } + + await this.bookingsRepository.update(bookingId, { + 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( + bookingId, + computed.usedRates, + computed.appliedModifiers, + ); + + // No credit is recorded here: completion only REQUESTS the operation. + // The charge lands on the line's ledger when Operations accepts — + // `shipping_line_booking.accepted` → ShippingLineCreditsService — so a + // request that is returned or never accepted creates no debt. + } + + // Binding day, OPERATION_REQUEST_PENDING and the staff notification — the + // machine a customer booking uses. When the day is backed by a dedicated + // train, the customer day-pool gate is skipped (validated above instead). + return this.bookingTransitionService.requestOperation( + bookingId, + dto.scheduledDate, + requestedTrainScheduleId, + bypassDayPool ? { bypassDayPool: true } : undefined, + ); + } + + /** + * Authoritative price preview for the completion form's confirm step: the + * SAME compute the completion itself runs, over an in-memory probe shaped + * exactly like completeMine would persist the booking — so the figure the + * shipping line confirms is line-for-line what it will owe. + * + * The result is not advisory-only: the breakdown is saved on the booking and + * the rate snapshots are (re)written, so every re-preview refreshes them. + * Nothing else is persisted — no cargo rows, no credit, no transition. + */ + async previewPriceMine( + userId: string, + bookingId: string, + dto: CompleteShippingLineBookingDto, + ) { + const booking = await this.requireOwnBooking(userId, bookingId, { + bookingContainers: true, + }); + if ( + !["CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED"].includes( + booking.status, + ) + ) { + throw new BadRequestException( + "Your documents must be approved before the booking can be priced.", + ); + } + + // In-memory cargo, mirroring what completeMine persists. + let probeContainers: Partial[] = []; + let bulkFields: Record = {}; + if (booking.freightType === "CONTAINER") { + const lines = dto.containers ?? []; + if (!lines.length) { + throw new BadRequestException( + "At least one container line is required.", + ); + } + for (const line of lines) { + const containerType = await this.resolveContainerType(line); + const figures = this.lineFigures(line); + probeContainers.push({ + containerTypeId: containerType.id, + containerSize: containerType.sizeFt + ? `${containerType.sizeFt}ft` + : null, + quantity: line.quantity, + hazardousQuantity: figures.hazardous, + reeferQuantity: figures.reefer, + returnQuantity: 0, + vgmPerUnitTons: figures.vgmPerUnit, + totalVgmTons: figures.totalVgm, + wagonsRequired: Math.ceil( + line.quantity * wagonsPerUnitForSize(containerType.sizeFt), + ), + }); + } + } else { + if (!dto.cargoTypeId || !(Number(dto.cargoWeightTons) > 0)) { + throw new BadRequestException( + "Bulk bookings need a cargo type and a total weight in tons.", + ); + } + const cargoType = await this.bookingsRepository.manager + .getRepository(CargoType) + .findOne({ where: { id: dto.cargoTypeId, isActive: true } }); + if (!cargoType) { + throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`); + } + bulkFields = { + cargoTypeId: dto.cargoTypeId, + cargoTotalWeightVgm: Number(dto.cargoWeightTons), + bulkTotalWeightTons: Number(dto.cargoWeightTons), + bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0), + bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0), + }; + probeContainers = []; + } + + // Prototype-preserving clone so entity getters keep working — the same + // probe trick the contract preview uses. + const probe = Object.assign( + Object.create(Object.getPrototypeOf(booking)), + booking, + { + bookingContainers: probeContainers, + paymentCurrency: "ETB", + isHazardous: + (dto.containers ?? []).some( + (line) => + Number(line.hazardousQuantity ?? 0) > 0 || + (line.units ?? []).some((u) => u.isHazardous), + ) || Number(dto.bulkHazardousQuantity ?? 0) > 0, + // Mirrors completeMine: without the booking-level flag the engine's + // REEFER trigger never fires for reefer opt-ins on standard boxes, + // and the quote would show base freight only. + isReefer: + (dto.containers ?? []).some( + (line) => + Number(line.reeferQuantity ?? 0) > 0 || + (line.units ?? []).some((u) => u.isReefer), + ) || Number(dto.bulkReeferQuantity ?? 0) > 0, + ...bulkFields, + }, + ) as Booking; + + const computed = + await this.bookingPricingService.computePriceForBooking(probe); + if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) { + throw new BadRequestException( + computed.hardBlocked.length > 0 + ? computed.hardBlocked.join("; ") + : "No rate is configured for your shipping line on this route/cargo — please contact Operations.", + ); + } + + // Persist the quoted figure: breakdown on the booking, snapshots of the + // rates it was built from. createPricingSnapshots clears the previous + // artifacts first, so a re-preview replaces the old quote rather than + // stacking a second one. + await this.bookingsRepository.update(bookingId, { + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + } as never); + await this.bookingPricingService.createPricingSnapshots( + bookingId, + computed.usedRates, + computed.appliedModifiers, + ); + + // Pairing is reported, not thrown: the confirm modal shows it next to the + // price (as the customer form does) and disables confirm; /complete + // hard-blocks the same payload. + const pairingErrors = + booking.freightType === "CONTAINER" + ? await this.pairingViolationMessages(dto) + : []; + + return { + totalAmount: computed.totalAmount, + currency: computed.currency, + lineItems: computed.lineItems, + warnings: computed.warnings, + overweightLines: computed.overweightLines, + pairingErrors, + }; + } + + /** + * What operations has done with the booking so far: the train it rides + * (assigned, or the requested one before assignment) and the wagons the + * batch engine allocated to it, with any container numbers loaded per wagon. + * Read-only, owner-scoped — feeds the detail page's Wagons & Train tab. + */ + async operationsMine(userId: string, bookingId: string) { + const booking = await this.requireOwnBooking(userId, bookingId); + const manager = this.bookingsRepository.manager; + + const scheduleId = + booking.trainScheduleId ?? booking.requestedTrainScheduleId ?? null; + let train: Record | null = null; + if (scheduleId) { + const schedule = await manager.getRepository(TrainSchedule).findOne({ + where: { id: scheduleId }, + relations: { originStation: true, destinationStation: true }, + }); + if (schedule) { + train = { + id: schedule.id, + reference: schedule.reference, + trainNumber: schedule.trainNumber, + status: schedule.status, + direction: schedule.direction, + scheduledDepartureDate: schedule.scheduledDepartureDate, + scheduledArrivalDate: schedule.scheduledArrivalDate, + originLabel: + schedule.originStation?.label ?? + schedule.originStation?.code ?? + "Origin", + destinationLabel: + schedule.destinationStation?.label ?? + schedule.destinationStation?.code ?? + "Destination", + // Whether this is the confirmed assignment or still the request. + assigned: Boolean(booking.trainScheduleId), + }; + } + } + + const allocations = await manager + .getRepository(WagonBookingAllocation) + .find({ + where: { bookingId }, + relations: { + trainSetWagon: { wagonType: true, physicalWagon: true }, + containerItems: true, + }, + order: { createdAt: "ASC" }, + }); + + const wagons = allocations.map((allocation) => ({ + id: allocation.id, + status: allocation.status, + loadType: allocation.loadType, + allocatedWeightTons: Number(allocation.allocatedWeightTons), + sequenceNo: allocation.trainSetWagon?.sequenceNo ?? null, + wagonNumber: allocation.trainSetWagon?.physicalWagon?.wagonNumber ?? null, + wagonType: + allocation.trainSetWagon?.wagonType?.name ?? + allocation.trainSetWagon?.wagonType?.code ?? + null, + capacityTons: Number(allocation.trainSetWagon?.capacityTons ?? 0), + containerNumbers: (allocation.containerItems ?? []) + .map((item) => item.containerNumber) + .filter((n): n is string => Boolean(n)), + })); + + return { train, wagons }; + } + + /** + * Resolve a line's container type: by id when the payload carries one, else + * from the size string ("40ft" → the active 40ft type, preferring the reefer + * variant when the line ships reefer boxes). Resolution lives HERE, not in + * the portal, so a slow or failed catalog fetch can never block a booking + * with a phantom "type not configured" error — mirrors the customer flow's + * server-side size→type mapping. + */ + private async resolveContainerType(line: { + containerTypeId?: string; + containerSize?: string; + reeferQuantity?: number; + units?: { isReefer?: boolean }[]; + }): Promise { + const containerTypeRepo = + this.bookingsRepository.manager.getRepository(ContainerType); + + if (line.containerTypeId) { + const byId = await containerTypeRepo.findOne({ + where: { id: line.containerTypeId, isActive: true }, + }); + if (!byId) { + throw new NotFoundException( + `Container type ${line.containerTypeId} not found`, + ); + } + return byId; + } + + const sizeFt = parseInt(line.containerSize ?? "", 10); + if (!Number.isFinite(sizeFt)) { + throw new BadRequestException( + "Each container line needs a containerTypeId or a containerSize.", + ); + } + const candidates = await containerTypeRepo.find({ + where: { isActive: true }, + }); + const ofSize = candidates.filter((ct) => Number(ct.sizeFt) === sizeFt); + if (!ofSize.length) { + throw new BadRequestException( + `No ${sizeFt}ft container type is configured — please contact Operations.`, + ); + } + const wantsReefer = + Number(line.reeferQuantity ?? 0) > 0 || + (line.units ?? []).some((u) => u.isReefer); + if (wantsReefer) { + const reefer = ofSize.find((ct) => ct.isReefer); + if (reefer) return reefer; + } + return ofSize.find((ct) => !ct.isReefer) ?? ofSize[0]; + } + + /** + * A line's derived figures. With per-container rows (the full booking page), + * counts and VGM come FROM the rows — each container's switches are the + * source of truth. Without them, the line-level figures stand alone. + */ + private lineFigures(line: { + quantity: number; + vgmPerUnitTons?: number; + hazardousQuantity?: number; + reeferQuantity?: number; + units?: { vgmTons?: number; isHazardous?: boolean; isReefer?: boolean }[]; + }) { + const units = line.units ?? []; + const hazardous = units.length + ? units.filter((u) => u.isHazardous).length + : Math.min(Number(line.hazardousQuantity ?? 0), line.quantity); + const reefer = units.length + ? units.filter((u) => u.isReefer).length + : Math.min(Number(line.reeferQuantity ?? 0), line.quantity); + const totalVgm = units.length + ? units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0) + : Number(line.vgmPerUnitTons ?? 0) * line.quantity; + const vgmPerUnit = units.length + ? totalVgm / units.length + : Number(line.vgmPerUnitTons ?? 0); + return { hazardous, reefer, totalVgm, vgmPerUnit }; + } + + /** + * Persist the container lines of a CONTAINER completion. Same row shape the + * customer paths write (quantity per type, VGM totals, wagon share) — the + * per-unit ISO numbers customers also skip at booking time arrive later at + * yard operations. + */ + private async persistContainerLines( + booking: Booking, + dto: CompleteShippingLineBookingDto, + ): Promise { + const lines = dto.containers ?? []; + if (!lines.length) { + throw new BadRequestException("At least one container line is required."); + } + + const containerRepo = + this.bookingsRepository.manager.getRepository(BookingContainer); + const unitRepo = + this.bookingsRepository.manager.getRepository(BookingContainerUnit); + + for (const line of lines) { + const containerType = await this.resolveContainerType(line); + // Counts and VGM derived by lineFigures — the same math the price + // preview runs, so the persisted cargo always matches the quote. + const units = line.units ?? []; + const figures = this.lineFigures(line); + const containerRow = await containerRepo.save( + containerRepo.create({ + bookingId: booking.id, + containerTypeId: containerType.id, + containerSize: containerType.sizeFt + ? `${containerType.sizeFt}ft` + : null, + quantity: line.quantity, + hazardousQuantity: figures.hazardous, + reeferQuantity: figures.reefer, + returnQuantity: 0, + vgmPerUnitTons: figures.vgmPerUnit, + totalVgmTons: figures.totalVgm, + wagonsRequired: Math.ceil( + line.quantity * wagonsPerUnitForSize(containerType.sizeFt), + ), + }), + ); + let sortOrder = 0; + for (const unit of units) { + await unitRepo.save( + unitRepo.create({ + bookingContainerId: containerRow.id, + containerNumber: unit.containerNumber.trim().toUpperCase(), + sealNumber: unit.sealNumber?.trim() || null, + vgmTons: Number(unit.vgmTons ?? 0), + isHazardous: unit.isHazardous ?? false, + isReefer: unit.isReefer ?? false, + isReturn: false, + sortOrder: sortOrder++, + }), + ); + } + } + } + + /** Roll a failed/superseded completion back to the bare-booking shape. */ + private async wipeCargo(bookingId: string): Promise { + await this.bookingsRepository.manager + .getRepository(BookingContainer) + .softDelete({ bookingId }); + await this.bookingsRepository.update(bookingId, { + cargoTypeId: null, + cargoTotalWeightVgm: 0, + bulkTotalWeightTons: null, + totalAmount: 0, + pricingBreakdown: null, + } as never); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.controller.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.controller.ts new file mode 100644 index 000000000..8a8e8ef2b --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.controller.ts @@ -0,0 +1,107 @@ +import { CurrentUser } from "@edr/api-common"; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { PortalCustomer } from "../../common/booking-guards"; +import { CancelShippingLineBookingDto } from "./dto/cancel-shipping-line-booking.dto"; +import { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto"; +import { ShippingLineBookingsService } from "./shipping-line-bookings.service"; + +interface CurrentIamUser { + id: string; +} + +/** + * Bookings a shipping line makes for itself, from the portal. + * + * Separate from `/bookings` (customers) on purpose — see + * {@link ShippingLineBookingsService} for why the two flows are not merged. + * `PortalCustomer` only proves a valid portal session; the service resolves the + * shipping-line account from that session and rejects anyone else, so the owner + * is never taken from the request body. + */ +@ApiTags("shipping-line-bookings") +@Controller("shipping-line-bookings") +@ApiBearerAuth() +export class ShippingLineBookingsController { + constructor( + private readonly shippingLineBookingsService: ShippingLineBookingsService, + ) {} + + @Post("initiate") + @PortalCustomer() + @ApiOperation({ + summary: + "Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.", + }) + async initiate( + @CurrentUser() user: CurrentIamUser, + @Body() dto: InitiateShippingLineBookingDto, + ) { + return this.shippingLineBookingsService.initiate(user.id, dto); + } + + // Declared before @Get(":id") so the path isn't captured as a booking id. + @Get("reference-data") + @PortalCustomer() + @ApiOperation({ + summary: + "Catalog for the initiate form: bookable routes (each carrying its trade direction) and service types.", + }) + async referenceData(@CurrentUser() user: CurrentIamUser) { + return this.shippingLineBookingsService.referenceData(user.id); + } + + @Get("my") + @PortalCustomer() + @ApiOperation({ summary: "List the signed-in shipping line's bookings." }) + async listMine(@CurrentUser() user: CurrentIamUser) { + return this.shippingLineBookingsService.listMine(user.id); + } + + // Declared before @Get(":id") so the path isn't captured as a booking id. + @Get("my-trains") + @PortalCustomer() + @ApiOperation({ + summary: + "Train departures dedicated to the signed-in shipping line. These trains are hidden from customers; this is the only portal read that surfaces them.", + }) + async listMyTrains(@CurrentUser() user: CurrentIamUser) { + return this.shippingLineBookingsService.listMyTrains(user.id); + } + + @Get(":id") + @PortalCustomer() + @ApiOperation({ summary: "Get one of the signed-in shipping line's bookings." }) + async findMine( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + ) { + return this.shippingLineBookingsService.findMine(user.id, id); + } + + @Post(":id/cancel") + @PortalCustomer() + @ApiOperation({ + summary: + "Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.", + }) + async cancelMine( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CancelShippingLineBookingDto, + ) { + return this.shippingLineBookingsService.cancelMine( + user.id, + id, + dto.reason, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.service.ts new file mode 100644 index 000000000..da1b90129 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-bookings.service.ts @@ -0,0 +1,473 @@ +import { insertWithGeneratedReference } from "@edr/api-common"; +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { In, MoreThanOrEqual, Repository } from "typeorm"; + +import { BookingDocumentReview } from "../bookings/entities/booking-document-review.entity"; +import { BookingReviewNote } from "../bookings/entities/booking-review-note.entity"; +import { Booking } from "../bookings/entities/booking.entity"; +import { formatRouteLabel, Route } from "../routes/entities/route.entity"; +import { CargoType } from "../rule-engine/entities/cargo-type.entity"; +import { ContainerType } from "../rule-engine/entities/container-type.entity"; +import { ServiceType } from "../rule-engine/entities/service-type.entity"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; +import { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto"; +import { ShippingLineCompaniesService } from "./shipping-line-companies.service"; + +/** + * The only trade direction a shipping line books. + * + * Their cargo arrives by sea at Djibouti and moves inland to Ethiopia, which is + * IMPORT by the rule routes are stamped with (DJ→ET = IMPORT, ET→DJ = EXPORT, + * same country = DOMESTIC). Export and intercity lanes are therefore neither + * offered nor accepted. + */ +const SHIPPING_LINE_DIRECTION = "IMPORT"; + +/** + * Statuses a shipping line may cancel its own booking from — everything before + * the booking is priced. Past this point cancelling has billing consequences + * (fees, credit notes) and belongs with Operations. + */ +const SHIPPING_LINE_CANCELLABLE_STATUSES: string[] = [ + "AWAITING_DOCUMENTS", + "DOCUMENTS_UNDER_REVIEW", + "CLEARANCE_READY", + "CHANGES_REQUESTED", +]; + +/** + * Booking creation for shipping lines. + * + * Deliberately separate from `BookingsService` / `ContractBookingService` + * rather than a branch inside them. Those are built end to end around a + * customer: a `companies` row, an approved operational `company_profile`, a + * contract supplying route/quantities, and contract-capacity accounting. A + * shipping line has none of that — it books directly, without a contract — so + * branching there would mean threading "no company, no profile, no contract" + * through every method a customer booking passes through. Keeping it here means + * the customer paths are not touched at all. + * + * What IS shared is the table and the downstream lifecycle: the row lands in + * `freight.bookings` at `AWAITING_DOCUMENTS`, the shipping line uploads its + * documents against the `shipping_line_booking_documents` file-upload setting, + * and Operations reviews and finalizes them through the same clearance flow + * customers already use. + */ +@Injectable() +export class ShippingLineBookingsService { + constructor( + @InjectRepository(Booking) + private readonly bookingsRepository: Repository, + private readonly shippingLineCompaniesService: ShippingLineCompaniesService, + ) {} + + /** + * Resolve the shipping-line account for a signed-in user, or reject. Every + * entry point goes through this: the owner is taken from the session, never + * from the request body, so one shipping line cannot book as another. + */ + private async requireShippingLine(userId: string) { + const shippingLine = + await this.shippingLineCompaniesService.findByUserId(userId); + if (!shippingLine) { + throw new ForbiddenException("This account is not a shipping line."); + } + if (shippingLine.status !== "active") { + throw new ForbiddenException( + "This shipping-line account is suspended and cannot create bookings.", + ); + } + return shippingLine; + } + + /** + * The catalog the initiate form needs: the lanes EDR actually runs, and the + * services that can be booked on their own. + * + * Routes are offered instead of two loose yard pickers so a shipping line + * cannot invent a lane that does not exist — and because the route already + * carries its trade direction, which is otherwise guesswork. + * + * Read-only and scoped to bookable rows, which is why it lives here rather + * than reusing the staff `/routes` controller (gated behind fleet + * permissions a shipping line does not and should not hold). + */ + async referenceData(userId: string) { + await this.requireShippingLine(userId); + + const [routes, serviceTypes, containerTypes, cargoTypes] = + await Promise.all([ + this.bookingsRepository.manager.getRepository(Route).find({ + // Shipping lines only move inbound cargo: it lands at the Djibouti port + // and runs inland to Ethiopia. Filtering here rather than in the portal + // means an export or intercity lane is never offered AND never + // accepted — `initiate` re-checks the same rule below. + where: { status: "AVAILABLE", direction: SHIPPING_LINE_DIRECTION }, + relations: { originYard: true, destinationYard: true }, + }), + // Customs-bundled services are excluded: those run the phased ET/DJ + // customs workflow, which is a contract-backed flow a shipping line has + // no part in. Their clearance is the single document set Operations + // reviews on the booking itself. + this.bookingsRepository.manager.getRepository(ServiceType).find({ + where: { + canBeBookedAlone: true, + includesCustoms: false, + isActive: true, + }, + order: { displayOrder: "ASC" }, + }), + // For the completion form: what ships. Container types for CONTAINER + // bookings, cargo types for BULK ones. + this.bookingsRepository.manager.getRepository(ContainerType).find({ + where: { isActive: true }, + }), + this.bookingsRepository.manager.getRepository(CargoType).find({ + where: { isActive: true }, + order: { displayOrder: "ASC" }, + }), + ]); + + return { + routes: routes.map((route) => ({ + id: route.id, + label: formatRouteLabel(route), + direction: route.direction, + originYardId: route.originYardId, + // Per-yard labels so the portal can offer origin and destination as two + // separate pickers (the shape the customer form uses) while still + // resolving the pair back to one of these routes. + originLabel: + route.originYard?.label ?? route.originYard?.code ?? "Origin", + destinationYardId: route.destinationYardId, + destinationLabel: + route.destinationYard?.label ?? + route.destinationYard?.code ?? + "Destination", + })), + serviceTypes: serviceTypes.map((service) => ({ + id: service.id, + name: service.serviceName, + })), + containerTypes: containerTypes.map((ct) => ({ + id: ct.id, + label: ct.label ?? ct.code, + sizeFt: ct.sizeFt, + isReefer: ct.isReefer, + })), + // parentGroupId lets the portal tell leaf types from grouping rows. + cargoTypes: cargoTypes.map((cargo) => ({ + id: cargo.id, + name: cargo.cargoTypeName, + parentGroupId: cargo.parentGroupId ?? null, + unitOfMeasure: cargo.unitOfMeasure ?? null, + })), + }; + } + + /** + * Create a BARE booking for a shipping line — no contract, no cargo, no date + * and no price. It exists so documents have something to hang off: the + * shipping line uploads them next, Operations approves, and only then is the + * booking completed with its cargo and shipment day. + */ + async initiate(userId: string, dto: InitiateShippingLineBookingDto) { + const shippingLine = await this.requireShippingLine(userId); + + // The route is the single source of origin, destination AND direction — + // resolved server-side so the three can never disagree, and so a caller + // cannot post a lane EDR does not run. + const route = await this.bookingsRepository.manager + .getRepository(Route) + .findOne({ where: { id: dto.routeId } }); + if (!route) { + throw new NotFoundException(`Route ${dto.routeId} not found`); + } + if (route.status !== "AVAILABLE") { + throw new BadRequestException( + "This route is not currently available for booking.", + ); + } + // Enforced here too, not just by filtering the picker: the route id comes + // from the request, so an export or intercity lane could otherwise be + // posted directly. + if (route.direction !== SHIPPING_LINE_DIRECTION) { + throw new BadRequestException( + "Shipping lines can only book inbound (Djibouti to Ethiopia) routes.", + ); + } + + // Only a forward-looking day makes sense; train validation happens later + // when Operations schedules it, so only the past is rejected here. + let scheduledDate: Date | null = null; + if (dto.scheduledDate) { + scheduledDate = new Date(dto.scheduledDate); + const today = new Date(); + today.setHours(0, 0, 0, 0); + if (scheduledDate < today) { + throw new BadRequestException( + "The scheduled date cannot be in the past.", + ); + } + } + + // Same reasoning as the picker filter: a customs-bundled service would put + // the booking into the phased customs workflow, which has no contract to + // hang off here. Checked server-side because the id comes from the request. + if (dto.serviceTypeId) { + const serviceType = await this.bookingsRepository.manager + .getRepository(ServiceType) + .findOne({ where: { id: dto.serviceTypeId } }); + if (!serviceType) { + throw new NotFoundException( + `Service type ${dto.serviceTypeId} not found`, + ); + } + if (serviceType.includesCustoms) { + throw new BadRequestException( + "Shipping lines cannot book a service that bundles customs clearance.", + ); + } + } + + return insertWithGeneratedReference( + () => this.generateReference(), + (reference) => + this.bookingsRepository.save({ + reference, + // The owner columns: a shipping-line booking has no company and no + // operational profile, which is exactly what the `chk_bookings_ + // single_owner` CHECK expects alongside a set shippingLineCompanyId. + companyId: null, + companyProfileId: null, + shippingLineCompanyId: shippingLine.id, + status: "AWAITING_DOCUMENTS", + bookingType: "ONE_TIME", + contractId: null, + contractType: "NEW", + createdByRole: "SHIPPING_LINE", + createdByUserId: userId, + // Taken from the chosen route, never from the request body: the + // direction is frozen on the route from its yard countries, so + // deriving it here keeps it consistent with scheduling and booking + // windows, which read the same field. + originYardId: route.originYardId, + destinationYardId: route.destinationYardId, + tradeDirection: route.direction, + serviceTypeId: dto.serviceTypeId ?? null, + freightType: dto.freightType ?? "CONTAINER", + // The shipping line picks its shipment day up front (no later + // operation-request step exists for them); cargo is still filled in + // when the booking is completed. + scheduledDate, + cargoTypeId: null, + cargoTotalWeightVgm: 0, + } as never), + ); + } + + /** + * List the bookings belonging to the signed-in shipping line, newest first. + * + * Each row carries `hasQueriedDocuments`: a reviewer querying a document sets + * that document's review status but leaves the BOOKING on + * DOCUMENTS_UNDER_REVIEW, so status alone cannot tell the list which bookings + * need the shipping line to act. Resolved in one grouped query rather than a + * clearance call per row. + */ + async listMine(userId: string) { + const shippingLine = await this.requireShippingLine(userId); + + const bookings = await this.bookingsRepository.find({ + where: { shippingLineCompanyId: shippingLine.id }, + relations: { originYard: true, destinationYard: true }, + order: { createdAt: "DESC" }, + }); + if (bookings.length === 0) return []; + + const queried = await this.bookingsRepository.manager + .getRepository(BookingDocumentReview) + .find({ + where: { + bookingId: In(bookings.map((b) => b.id)), + status: "QUERIED", + }, + select: { bookingId: true }, + }); + + const queriedIds = new Set(queried.map((row) => row.bookingId)); + + return bookings.map((booking) => ({ + ...booking, + hasQueriedDocuments: queriedIds.has(booking.id), + })); + } + + /** + * Fetch one of the signed-in shipping line's own bookings. Scoped by owner so + * an id belonging to a customer (or another shipping line) reads as missing. + */ + async findMine(userId: string, bookingId: string) { + const shippingLine = await this.requireShippingLine(userId); + + const booking = await this.bookingsRepository.findOne({ + where: { id: bookingId, shippingLineCompanyId: shippingLine.id }, + // Yards are loaded so the portal can render the lane without a second + // lookup — they are set at initiate time from the chosen route. Cargo + // (container lines + units, bulk cargo type) rides along for the detail + // page's cargo tab once the booking is completed. + relations: { + originYard: true, + destinationYard: true, + serviceType: true, + cargoType: true, + bookingContainers: { containerType: true, units: true }, + }, + }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + + // Same flag as the list — see listMine for why booking status alone is not + // enough to tell whether the shipping line has something to fix. + const queriedCount = await this.bookingsRepository.manager + .getRepository(BookingDocumentReview) + .count({ where: { bookingId, status: "QUERIED" } }); + + // The note Operations wrote when returning the request — the line has to + // read it to know what to fix. Only the latest CHANGES_REQUESTED note is + // exposed; the other review-note types are staff-internal. + const changeNote = + booking.status === "OPERATION_CHANGES_REQUESTED" + ? await this.bookingsRepository.manager + .getRepository(BookingReviewNote) + .findOne({ + where: { bookingId, type: "CHANGES_REQUESTED" }, + order: { createdAt: "DESC" }, + }) + : null; + + return { + ...booking, + hasQueriedDocuments: queriedCount > 0, + operationChangeNote: changeNote?.note ?? null, + }; + } + + /** + * Train departures dedicated to the signed-in shipping line: schedules whose + * `shippingLineCompanyId` is this line's. These trains are hidden from every + * customer-facing read, so this endpoint is the ONLY place they surface in + * the portal — the home page lists them and the booking detail matches them + * to a booking by lane + day. + */ + async listMyTrains(userId: string) { + const shippingLine = await this.requireShippingLine(userId); + + // Recent past kept (48h) so a just-departed train is still visible while + // its cargo is on the rails; CANCELLED never shows. + const horizon = new Date(Date.now() - 48 * 60 * 60 * 1000); + const schedules = await this.bookingsRepository.manager + .getRepository(TrainSchedule) + .find({ + where: { + shippingLineCompanyId: shippingLine.id, + status: In(["DRAFT", "SCHEDULED", "DISPATCHED"]), + scheduledDepartureDate: MoreThanOrEqual(horizon), + }, + relations: { originStation: true, destinationStation: true }, + order: { scheduledDepartureDate: "ASC" }, + }); + + return schedules.map((s) => ({ + id: s.id, + reference: s.reference, + trainNumber: s.trainNumber, + status: s.status, + direction: s.direction, + scheduledDepartureDate: s.scheduledDepartureDate, + scheduledArrivalDate: s.scheduledArrivalDate, + originYardId: s.originStationId, + originLabel: s.originStation?.label ?? s.originStation?.code ?? "Origin", + destinationYardId: s.destinationStationId, + destinationLabel: + s.destinationStation?.label ?? + s.destinationStation?.code ?? + "Destination", + })); + } + + /** + * Cancel one of the signed-in shipping line's own bookings. + * + * Its own method rather than the customer `customerCancel`: that path routes + * into `BookingTransitionService.cancel`, whose status whitelist covers the + * contract-backed lifecycle (DRAFT, SUBMITTED, PENDING_APPROVAL…) and does + * not include the document-clearance statuses a shipping-line booking lives + * in — so it would reject every one of them. + * + * Only allowed before the booking is priced and paid. Once it carries a + * charge, cancelling is a billing decision (fees, credit notes) that belongs + * with Operations, not a self-service button. + */ + async cancelMine(userId: string, bookingId: string, reason?: string) { + const shippingLine = await this.requireShippingLine(userId); + + const booking = await this.bookingsRepository.findOne({ + where: { id: bookingId, shippingLineCompanyId: shippingLine.id }, + }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + + if (booking.status === "CANCELLED") { + throw new BadRequestException("This booking is already cancelled."); + } + if (!SHIPPING_LINE_CANCELLABLE_STATUSES.includes(booking.status)) { + throw new BadRequestException( + "This booking can no longer be cancelled — please contact Operations.", + ); + } + // Belt and braces: the statuses above are all pre-pricing, so a charge here + // would mean the booking moved on in a way this guard did not anticipate. + if (Number(booking.totalAmount ?? 0) > 0) { + throw new BadRequestException( + "This booking has already been priced — please contact Operations to cancel it.", + ); + } + + // The reason lives on the booking's review-note log, the same place the + // customer cancel path records it — there is no column for it. + await this.bookingsRepository.manager + .getRepository(BookingReviewNote) + .save({ + bookingId, + note: reason?.trim() || "Cancelled by the shipping line", + type: "REJECTION", + authorId: userId, + } as never); + + await this.bookingsRepository.update(bookingId, { + status: "CANCELLED", + } as never); + + return this.findMine(userId, bookingId); + } + + /** Mirrors the customer reference format — one booking sequence per year. */ + private async generateReference(): Promise { + const year = new Date().getFullYear(); + const { max } = (await this.bookingsRepository + .createQueryBuilder("b") + .select( + `COALESCE(MAX(NULLIF(regexp_replace(b.reference, '^BK-${year}-', ''), b.reference)::int), 0)`, + "max", + ) + .where("b.reference LIKE :prefix", { prefix: `BK-${year}-%` }) + .getRawOne<{ max: number }>()) ?? { max: 0 }; + + return `BK-${year}-${String(Number(max) + 1).padStart(6, "0")}`; + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.controller.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.controller.ts new file mode 100644 index 000000000..3d11e2a29 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.controller.ts @@ -0,0 +1,100 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} 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 "../auth/dto/forgot-password.dto"; +import { CreateShippingLineDto } from "./dto/create-shipping-line.dto"; +import { + RegisterShippingLineResponseDto, + ShippingLineResponseDto, +} from "./dto/shipping-line-response.dto"; +import { ShippingLineCompaniesService } from "./shipping-line-companies.service"; + +/** + * Shipping line *companies* — carriers with a portal login, registered by staff + * (there is no self-signup). The line receives a single-use activation link and + * sets its own password, so staff never see or handle a credential. + * + * Distinct from `freight.shipping_lines` behind `/shipping-lines` + * (rule-engine): that is a pricing lookup list — a code/label a booking points + * at via `shipping_line_id` — with no account, no user and no login. Same words, + * different concept, hence the separate route. + */ +@ApiTags("shipping-line-companies") +@Controller("shipping-line-companies") +@ApiBearerAuth() +export class ShippingLineCompaniesController { + constructor(private readonly shippingLineCompaniesService: ShippingLineCompaniesService) {} + + @Post() + @BookingStaff(FREIGHT_PERMS.shippingLines.create) + @ApiOperation({ + summary: "Register a shipping line and send its activation link", + }) + async register( + @Body() dto: CreateShippingLineDto, + ): Promise { + const { shippingLine, activationSentTo } = + await this.shippingLineCompaniesService.register(dto); + return new RegisterShippingLineResponseDto(shippingLine, activationSentTo); + } + + @Get() + // OR'd: the credits view needs this list as its line picker, so holding + // shipping_line_credits:view alone is enough to read it. + @BookingStaff([ + FREIGHT_PERMS.shippingLines.view, + FREIGHT_PERMS.shippingLineCredits.view, + ]) + @ApiOperation({ summary: "List shipping lines (paginated)" }) + async list( + @Query("page") page?: string, + @Query("limit") limit?: string, + ): Promise<{ + items: ShippingLineResponseDto[]; + total: number; + page: number; + limit: number; + }> { + const result = await this.shippingLineCompaniesService.list( + page ? Number(page) : undefined, + limit ? Number(limit) : undefined, + ); + return { + ...result, + items: result.items.map((item) => new ShippingLineResponseDto(item)), + }; + } + + @Get(":id") + @BookingStaff(FREIGHT_PERMS.shippingLines.view) + @ApiOperation({ summary: "Get a shipping line by id" }) + async findOne( + @Param("id", ParseUUIDPipe) id: string, + ): Promise { + return new ShippingLineResponseDto( + await this.shippingLineCompaniesService.findById(id), + ); + } + + @Post(":id/resend-activation") + @BookingStaff(FREIGHT_PERMS.shippingLines.resetPassword) + @ApiOperation({ + summary: "Resend a shipping line's activation / password-reset link", + }) + async resendActivation( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: BackofficeResetPasswordDto, + ) { + return this.shippingLineCompaniesService.resendActivation(id, dto.channel); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts new file mode 100644 index 000000000..888a996a6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.module.ts @@ -0,0 +1,59 @@ +import { Module, forwardRef } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { FreightAuthModule } from "../auth/freight-auth.module"; +import { BillingModule } from "../billing/billing.module"; +import { Booking } from "../bookings/entities/booking.entity"; +import { OtpModule } from "../otp/otp.module"; +import { ShippingLineCompany } from "./entities/shipping-line-company.entity"; +import { ShippingLineCredit } from "./entities/shipping-line-credit.entity"; +import { ShippingLineInvoiceApproval } from "./entities/shipping-line-invoice-approval.entity"; +import { ShippingLineInvoiceApprovalsRepository } from "./shipping-line-invoice-approvals.repository"; +import { ShippingLineBookingsController } from "./shipping-line-bookings.controller"; +import { ShippingLineBookingsService } from "./shipping-line-bookings.service"; +import { ShippingLineCompaniesController } from "./shipping-line-companies.controller"; +import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository"; +import { ShippingLineCompaniesService } from "./shipping-line-companies.service"; +import { ShippingLineCreditsController } from "./shipping-line-credits.controller"; +import { ShippingLineCreditsRepository } from "./shipping-line-credits.repository"; +import { ShippingLineCreditsService } from "./shipping-line-credits.service"; + +@Module({ + imports: [ + // Booking is registered here only so this module can create shipping-line + // rows in `freight.bookings`; the customer BookingsModule is untouched. + TypeOrmModule.forFeature([ + ShippingLineCompany, + ShippingLineCredit, + ShippingLineInvoiceApproval, + User, + Booking, + ]), + // CustomerResetService — activation links reuse the staff-triggered reset path. + FreightAuthModule, + OtpModule, + // Credits are billed by generating an ordinary invoice. Billing still knows + // nothing about credits and hears about settlement only by emitting its own + // `shipping_line_credit.invoice.paid` event, but the module graph now cycles + // (billing -> companies -> here -> billing), so this edge needs forwardRef. + forwardRef(() => BillingModule), + ], + controllers: [ + ShippingLineCompaniesController, + ShippingLineBookingsController, + ShippingLineCreditsController, + ], + providers: [ + ShippingLineCompaniesService, + ShippingLineCompaniesRepository, + ShippingLineBookingsService, + ShippingLineCreditsService, + ShippingLineCreditsRepository, + ShippingLineInvoiceApprovalsRepository, + ], + // Exported so whatever prices a shipping-line booking can record the charge. + exports: [ShippingLineCompaniesService, ShippingLineCreditsService], +}) +export class ShippingLineCompaniesModule {} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.repository.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.repository.ts new file mode 100644 index 000000000..997550172 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.repository.ts @@ -0,0 +1,61 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { EntityManager, Repository } from "typeorm"; + +import { ShippingLineCompany } from "./entities/shipping-line-company.entity"; + +@Injectable() +export class ShippingLineCompaniesRepository extends BaseRepository { + constructor( + @InjectRepository(ShippingLineCompany) + private readonly shippingLineRepo: Repository, + ) { + super(shippingLineRepo); + } + + findByUserId(userId: string): Promise { + return this.shippingLineRepo.findOne({ where: { userId } }); + } + + /** Case-insensitive, matching the `lower(email)` unique index. */ + async existsByEmail(email: string): Promise { + const count = await this.shippingLineRepo + .createQueryBuilder("sl") + .where("lower(sl.email) = lower(:email)", { email }) + .getCount(); + return count > 0; + } + + async existsByScac(scacCode: string): Promise { + const count = await this.shippingLineRepo + .createQueryBuilder("sl") + .where("upper(sl.scacCode) = upper(:scacCode)", { scacCode }) + .getCount(); + return count > 0; + } + + findAllPaginated( + skip: number, + take: number, + ): Promise<[ShippingLineCompany[], number]> { + return this.shippingLineRepo.findAndCount({ + order: { createdAt: "DESC" }, + skip, + take, + }); + } + + /** + * Insert inside a caller-supplied transaction, so the shipping-line row and + * the IAM user it points at commit together — a row referencing a user that + * was rolled back (or vice versa) is an account nobody can sign in to. + */ + createInTransaction( + manager: EntityManager, + data: Partial, + ): Promise { + const repo = manager.getRepository(ShippingLineCompany); + return repo.save(repo.create(data)); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.spec.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.spec.ts new file mode 100644 index 000000000..d9d429dc9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.spec.ts @@ -0,0 +1,233 @@ +import { ConflictException } from "@nestjs/common"; +import { + EUserStatus, + EUserType, +} from "@tria-plc/api-common/utils/enums/user.enum"; + +import { ResetChannel } from "../auth/dto/forgot-password.dto"; +import { ShippingLineCompaniesService } from "./shipping-line-companies.service"; + +/** + * Registration is the whole feature: an IAM account and a carrier record + * created together, then an activation link the line uses to set its own + * password. These lock the parts that would silently break the login. + */ +describe("ShippingLineCompaniesService.register", () => { + const savedUser = { id: "user-1" }; + + let shippingLinesRepo: { + existsByEmail: jest.Mock; + existsByScac: jest.Mock; + createInTransaction: jest.Mock; + findById: jest.Mock; + findByUserId: jest.Mock; + }; + let userRepository: { findOne: jest.Mock }; + let customerResetService: { sendResetLinkToUser: jest.Mock }; + let dataSource: { transaction: jest.Mock }; + let userRepoInTx: { create: jest.Mock; save: jest.Mock }; + let service: ShippingLineCompaniesService; + + const dto = { + name: "Ethiopian Shipping Lines", + email: "Ops@ESL.com.et", + phoneNumber: "+251911223344", + scacCode: "eslk", + }; + + beforeEach(() => { + userRepoInTx = { + create: jest.fn((v) => v), + save: jest.fn().mockResolvedValue(savedUser), + }; + + shippingLinesRepo = { + existsByEmail: jest.fn().mockResolvedValue(false), + existsByScac: jest.fn().mockResolvedValue(false), + createInTransaction: jest + .fn() + .mockImplementation((_m, data) => ({ id: "sl-1", ...data })), + findById: jest.fn(), + findByUserId: jest.fn(), + }; + userRepository = { findOne: jest.fn().mockResolvedValue(null) }; + customerResetService = { + sendResetLinkToUser: jest + .fn() + .mockResolvedValue({ maskedTarget: "o**@esl.com.et", channel: "email" }), + }; + dataSource = { + transaction: jest.fn(async (cb) => + cb({ getRepository: () => userRepoInTx }), + ), + }; + + service = new ShippingLineCompaniesService( + shippingLinesRepo as never, + userRepository as never, + customerResetService as never, + dataSource as never, + ); + }); + + it("creates the IAM account with no password set", async () => { + await service.register(dto as never); + + const created = userRepoInTx.create.mock.calls[0][0]; + expect(created).toMatchObject({ + userType: EUserType.INDIVIDUAL, + isActive: true, + status: EUserStatus.ACCEPTED, + // The line sets its own password from the activation link. Employee + // creation seeds a shared default here; a shipping line must not get one. + hasSetPassword: false, + }); + }); + + it("never writes a credential row", async () => { + await service.register(dto as never); + + // Only the User repository is touched inside the transaction — a + // UserCredential insert would mean the account has a password nobody chose. + for (const call of userRepoInTx.save.mock.calls) { + expect(call[0]).not.toHaveProperty("password"); + } + }); + + it("normalises email and SCAC before storing", async () => { + const result = await service.register(dto as never); + + expect(result.shippingLine).toMatchObject({ + email: "ops@esl.com.et", + scacCode: "ESLK", + }); + }); + + it("creates the account and the record in one transaction", async () => { + await service.register(dto as never); + + expect(dataSource.transaction).toHaveBeenCalledTimes(1); + expect(shippingLinesRepo.createInTransaction).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ userId: "user-1" }), + ); + }); + + it("sends the activation link outside the transaction, after commit", async () => { + const order: string[] = []; + dataSource.transaction.mockImplementation(async (cb: never) => { + order.push("tx"); + return (cb as unknown as (m: unknown) => Promise)({ + getRepository: () => userRepoInTx, + }); + }); + customerResetService.sendResetLinkToUser.mockImplementation(async () => { + order.push("send"); + return { maskedTarget: "o**@esl.com.et", channel: "email" }; + }); + + await service.register(dto as never); + + expect(order[0]).toBe("tx"); + expect(order).toContain("send"); + }); + + it("emails the link, and also texts it when the number is domestic", async () => { + await service.register(dto as never); + + const channels = customerResetService.sendResetLinkToUser.mock.calls.map( + (c) => c[1], + ); + expect(channels).toContain(ResetChannel.Email); + expect(channels).toContain(ResetChannel.Phone); + }); + + it("emails only when the number is foreign — the SMS gateway is domestic-only", async () => { + await service.register({ ...dto, phoneNumber: "+441234567890" } as never); + + const channels = customerResetService.sendResetLinkToUser.mock.calls.map( + (c) => c[1], + ); + expect(channels).toEqual([ResetChannel.Email]); + }); + + it("keeps the registration when the activation link fails to send", async () => { + customerResetService.sendResetLinkToUser.mockResolvedValue(null); + + const result = await service.register(dto as never); + + // The account is valid without the link and the link is resendable — + // a delivery failure must not roll back the registration. + expect(result.shippingLine).toMatchObject({ id: "sl-1" }); + expect(result.activationSentTo).toBeNull(); + }); + + it("refuses a duplicate email", async () => { + shippingLinesRepo.existsByEmail.mockResolvedValue(true); + + await expect(service.register(dto as never)).rejects.toBeInstanceOf( + ConflictException, + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("refuses a duplicate SCAC", async () => { + shippingLinesRepo.existsByScac.mockResolvedValue(true); + + await expect(service.register(dto as never)).rejects.toBeInstanceOf( + ConflictException, + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("refuses credentials already belonging to another account", async () => { + // Reusing an existing IAM user would let one login resolve to both a + // customer and a shipping line. + userRepository.findOne.mockResolvedValue({ id: "existing" }); + + await expect(service.register(dto as never)).rejects.toBeInstanceOf( + ConflictException, + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("defaults the username to the email", async () => { + await service.register(dto as never); + + expect(userRepoInTx.create.mock.calls[0][0]).toMatchObject({ + username: "ops@esl.com.et", + }); + }); + + /** + * The default reset lookup inner-joins an active `user_credentials` row so a + * reset cannot revive a suspended account. A shipping line has no credential + * until it uses the activation link, so without this flag the account is + * excluded from its own activation — the link is never minted, never logged, + * and resend answers 404. + */ + it("requests the credential-less lookup for every activation send", async () => { + await service.register(dto as never); + + expect(customerResetService.sendResetLinkToUser).toHaveBeenCalled(); + for (const call of customerResetService.sendResetLinkToUser.mock.calls) { + expect(call[2]).toMatchObject({ allowWithoutCredential: true }); + } + }); + + it("requests the credential-less lookup when resending", async () => { + shippingLinesRepo.findById.mockResolvedValue({ + id: "sl-1", + userId: "user-1", + phoneNumber: "+251911223344", + }); + + await service.resendActivation("sl-1", ResetChannel.Email); + + expect(customerResetService.sendResetLinkToUser).toHaveBeenCalledWith( + "user-1", + ResetChannel.Email, + expect.objectContaining({ allowWithoutCredential: true }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts new file mode 100644 index 000000000..ad290def8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-companies.service.ts @@ -0,0 +1,219 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { + EUserStatus, + EUserType, +} from "@tria-plc/api-common/utils/enums/user.enum"; +// Subpath import (not the package root) so ts-jest can resolve it when this +// file lands in a spec's compile graph — same reason as backoffice.service.ts. +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { DataSource, Repository } from "typeorm"; + +import { CustomerResetService } from "../auth/customer-reset.service"; +import { ResetChannel } from "../auth/dto/forgot-password.dto"; +import { isDomesticPhone } from "../otp/otp.service"; +import { CreateShippingLineDto } from "./dto/create-shipping-line.dto"; +import { ShippingLineCompany } from "./entities/shipping-line-company.entity"; +import { ShippingLineCompaniesRepository } from "./shipping-line-companies.repository"; + +export interface RegisteredShippingLine { + shippingLine: ShippingLineCompany; + /** Masked destination of the activation link, or null if none was sent. */ + activationSentTo: string | null; + activationChannel: ResetChannel | null; +} + +@Injectable() +export class ShippingLineCompaniesService { + private readonly logger = new Logger(ShippingLineCompaniesService.name); + + constructor( + private readonly shippingLineCompaniesRepo: ShippingLineCompaniesRepository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly customerResetService: CustomerResetService, + private readonly dataSource: DataSource, + ) {} + + /** + * Register a shipping line: create its IAM account and its record together, + * then send an activation link so the line sets its own password. + * + * The IAM mechanics follow `BackofficeService.createOrganizationUser` — same + * entities, same transaction shape — with one deliberate difference: no + * `UserCredential` row is written and `hasSetPassword` stays false. Staff + * creating an employee seed a shared default password; a shipping line must + * come through the activation link instead, so no credential exists until the + * line sets one. + */ + async register(dto: CreateShippingLineDto): Promise { + const email = dto.email.trim().toLowerCase(); + const username = (dto.username?.trim() || email).toLowerCase(); + const phoneNumber = dto.phoneNumber?.trim() || undefined; + const scacCode = dto.scacCode?.trim().toUpperCase(); + + if (await this.shippingLineCompaniesRepo.existsByEmail(email)) { + throw new ConflictException( + `A shipping line with email ${email} already exists`, + ); + } + + if (scacCode && (await this.shippingLineCompaniesRepo.existsByScac(scacCode))) { + throw new ConflictException( + `A shipping line with SCAC ${scacCode} already exists`, + ); + } + + // An existing IAM account means these credentials already belong to a + // customer or an employee. Reusing it would let one login resolve to two + // different account kinds, so this is refused rather than merged — unlike + // employee creation, which legitimately re-uses a person's existing user. + const existingUser = await this.userRepository.findOne({ + where: [{ email }, { username }], + select: { id: true }, + }); + if (existingUser) { + throw new ConflictException( + "email_or_username_already_in_use", + ); + } + + const shippingLine = await this.dataSource.transaction(async (manager) => { + const userRepo = manager.getRepository(User); + const user = await userRepo.save( + userRepo.create({ + email, + username, + phoneNumber, + name: { en: dto.name.trim() }, + userType: EUserType.INDIVIDUAL, + isActive: true, + // No credential row is written: the account has no password until the + // activation link is used. `hasSetPassword` must stay false or the + // portal treats the account as ready to sign in with a password that + // does not exist. + hasSetPassword: false, + status: EUserStatus.ACCEPTED, + }), + ); + + return this.shippingLineCompaniesRepo.createInTransaction(manager, { + userId: user.id as string, + name: dto.name.trim(), + email, + phoneNumber: phoneNumber ?? null, + scacCode: scacCode ?? null, + imoNumber: dto.imoNumber?.trim() || null, + bicCode: dto.bicCode?.trim() || null, + }); + }); + + // Outside the transaction on purpose: a delivery failure must not roll back + // a registered line. The link is resendable, and the account is already + // valid without it. + const activation = await this.sendActivationLink(shippingLine); + + return { + shippingLine, + activationSentTo: activation?.maskedTarget ?? null, + activationChannel: activation?.channel ?? null, + }; + } + + /** + * Send the activation link on registration. + * + * Email always goes out — it is required at registration and is the only + * channel guaranteed to reach a foreign-registered line. SMS is sent in + * addition when the number is domestic, since the gateway silently drops + * anything else (see `CustomerResetService`). Both carry the SAME single-use + * ticket: minting retires earlier tickets, so two mints would kill the email + * link the moment the SMS went out. + * + * Reports the email send, as that is the one that is always attempted. + */ + async sendActivationLink(shippingLine: ShippingLineCompany) { + const scope = `shipping line ${shippingLine.id}`; + const channels = [ResetChannel.Email]; + if (shippingLine.phoneNumber && isDomesticPhone(shippingLine.phoneNumber)) { + channels.push(ResetChannel.Phone); + } + + const sent = await this.customerResetService.sendResetLinkToUserOnChannels( + shippingLine.userId, + channels, + { scope, allowWithoutCredential: true }, + ); + const emailed = sent.find((s) => s.channel === ResetChannel.Email) ?? null; + + if (!emailed) { + this.logger.error( + `Activation email not sent for shipping line ${shippingLine.id} — no reachable address`, + ); + } + if (channels.includes(ResetChannel.Phone) && !sent.some((s) => s.channel === ResetChannel.Phone)) { + this.logger.warn(`Activation SMS not sent for shipping line ${shippingLine.id}`); + } + + return emailed; + } + + async resendActivation(id: string, channel: ResetChannel) { + const shippingLine = await this.shippingLineCompaniesRepo.findById(id); + if (!shippingLine) { + throw new NotFoundException("Shipping line not found"); + } + + if ( + channel === ResetChannel.Phone && + (!shippingLine.phoneNumber || !isDomesticPhone(shippingLine.phoneNumber)) + ) { + throw new BadRequestException( + "This shipping line has no domestic phone number — the SMS gateway cannot reach it", + ); + } + + const sent = await this.customerResetService.sendResetLinkToUser( + shippingLine.userId, + channel, + { scope: `shipping line ${shippingLine.id}`, allowWithoutCredential: true }, + ); + + if (!sent) { + throw new NotFoundException( + `No active account with ${ + channel === ResetChannel.Email ? "an email address" : "a phone number" + } for this shipping line`, + ); + } + + return sent; + } + + async findById(id: string): Promise { + const shippingLine = await this.shippingLineCompaniesRepo.findById(id); + if (!shippingLine) { + throw new NotFoundException("Shipping line not found"); + } + return shippingLine; + } + + /** The shipping line signed in as `userId`, or null for any other account. */ + findByUserId(userId: string): Promise { + return this.shippingLineCompaniesRepo.findByUserId(userId); + } + + async list(page = 1, limit = 20) { + const [items, total] = await this.shippingLineCompaniesRepo.findAllPaginated( + (page - 1) * limit, + limit, + ); + return { items, total, page, limit }; + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.controller.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.controller.ts new file mode 100644 index 000000000..7a2c8418f --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.controller.ts @@ -0,0 +1,284 @@ +import { CurrentUser } from "@edr/api-common"; +import { Freight } from "@edr/types"; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { BookingStaff, PortalCustomer } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { + CancelCreditDto, + DecideInvoiceActionDto, + GenerateCreditInvoiceDto, + RequestInvoiceActionDto, +} from "./dto/shipping-line-credit.dto"; +import { ShippingLineCreditStatus } from "./entities/shipping-line-credit.entity"; +import { ShippingLineInvoiceActionType } from "./entities/shipping-line-invoice-approval.entity"; +import { ShippingLineCreditsService } from "./shipping-line-credits.service"; + +interface CurrentIamUser { + id: string; +} + +/** + * Finance's view of what shipping lines owe. + * + * A shipping line books and ships without paying — the charge is recorded as a + * credit instead. Finance reads the unbilled list here, batches it into an + * invoice, and the line then pays that invoice through the ordinary + * `/billing` + CBE routes; nothing in this controller touches money directly. + */ +@ApiTags("shipping-line-credits") +@Controller("shipping-line-credits") +@ApiBearerAuth() +export class ShippingLineCreditsController { + constructor(private readonly credits: ShippingLineCreditsService) {} + + @Get() + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: + "The whole credit ledger across every shipping line (paginated), optionally filtered by line and/or status.", + }) + async listAll( + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, + @Query("status") status?: ShippingLineCreditStatus, + @Query("shippingLineId", new ParseUUIDPipe({ optional: true })) + shippingLineId?: string, + ) { + return this.credits.listAll( + page ? Number(page) : 1, + pageSize ? Number(pageSize) : 20, + status, + shippingLineId, + ); + } + + // Declared before the parameterised staff routes so "summary" is never + // captured as a shipping-line id. + @Get("summary") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: + "Outstanding totals across every shipping line, or one line when shippingLineId is given.", + }) + async summaryAll( + @Query("shippingLineId", new ParseUUIDPipe({ optional: true })) + shippingLineId?: string, + ) { + return this.credits.summary(shippingLineId); + } + + // Declared before ":shippingLineId" so "invoices" is never captured as an id. + @Get("invoices") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: + "Credit invoices across every shipping line (paginated), each with any pending manual-action request.", + }) + async listInvoices( + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, + @Query("status") status?: string, + @Query("shippingLineId", new ParseUUIDPipe({ optional: true })) + shippingLineId?: string, + ) { + return this.credits.listCreditInvoices( + page ? Number(page) : 1, + pageSize ? Number(pageSize) : 20, + status as Freight.InvoiceStatus | undefined, + shippingLineId, + ); + } + + @Get("invoice-actions/pending") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: + "Undecided manual-action requests for a batch of invoices (one lookup for a list page).", + }) + async pendingInvoiceActions(@Query("invoiceIds") invoiceIds?: string) { + const ids = (invoiceIds ?? "") + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + return this.credits.pendingInvoiceActions(ids); + } + + // ── Maker–checker on credit invoices ────────────────────────────────────── + // Request and approve are DIFFERENT permissions, and the service refuses a + // decision by the requester — marking debt paid or voiding an invoice is + // never a one-person action. + + @Post("invoices/:invoiceId/mark-paid-request") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid) + @ApiOperation({ + summary: + "Request recording a full offline payment against a credit invoice (awaits chief approval).", + }) + async requestMarkPaid( + @Param("invoiceId", ParseUUIDPipe) invoiceId: string, + @Body() dto: RequestInvoiceActionDto, + @CurrentUser() user: CurrentIamUser, + ) { + return this.credits.requestInvoiceAction( + invoiceId, + ShippingLineInvoiceActionType.MarkPaid, + user.id, + dto.reason, + dto.paymentReference, + ); + } + + @Post("invoices/:invoiceId/cancel-request") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceCancel) + @ApiOperation({ + summary: + "Request voiding a credit invoice — its credits return to the unbilled pool (awaits chief approval).", + }) + async requestCancel( + @Param("invoiceId", ParseUUIDPipe) invoiceId: string, + @Body() dto: RequestInvoiceActionDto, + @CurrentUser() user: CurrentIamUser, + ) { + return this.credits.requestInvoiceAction( + invoiceId, + ShippingLineInvoiceActionType.Cancel, + user.id, + dto.reason, + ); + } + + @Post("invoice-actions/:approvalId/approve") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceApprove) + @ApiOperation({ + summary: + "Approve a pending invoice request — executes the offline settlement or the cancellation.", + }) + async approveInvoiceAction( + @Param("approvalId", ParseUUIDPipe) approvalId: string, + @Body() dto: DecideInvoiceActionDto, + @CurrentUser() user: CurrentIamUser, + ) { + return this.credits.decideInvoiceAction( + approvalId, + user.id, + true, + dto.note, + ); + } + + @Post("invoice-actions/:approvalId/reject") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceReject) + @ApiOperation({ + summary: "Reject a pending invoice request — nothing is changed.", + }) + async rejectInvoiceAction( + @Param("approvalId", ParseUUIDPipe) approvalId: string, + @Body() dto: DecideInvoiceActionDto, + @CurrentUser() user: CurrentIamUser, + ) { + return this.credits.decideInvoiceAction( + approvalId, + user.id, + false, + dto.note, + ); + } + + // Declared before the parameterised staff routes so "me" is never captured + // as a shipping-line id. + @Get("me") + @PortalCustomer() + @ApiOperation({ + summary: + "The signed-in shipping line's own statement: outstanding balance plus its credit ledger.", + }) + async myStatement( + @CurrentUser() user: CurrentIamUser, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, + ) { + return this.credits.myStatement( + user.id, + page ? Number(page) : 1, + pageSize ? Number(pageSize) : 20, + ); + } + + @Get(":shippingLineId/outstanding") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: + "What one shipping line owes: unbilled + billed totals, derived from the ledger.", + }) + async outstanding( + @Param("shippingLineId", ParseUUIDPipe) shippingLineId: string, + ) { + return this.credits.outstanding(shippingLineId); + } + + @Get(":shippingLineId/unbilled") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: + "Credits that can go on an invoice for this line, oldest first. This is the selection list.", + }) + async listUnbilled( + @Param("shippingLineId", ParseUUIDPipe) shippingLineId: string, + ) { + return this.credits.listUnbilled(shippingLineId); + } + + @Get(":shippingLineId") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.view) + @ApiOperation({ + summary: "Full credit ledger for one shipping line (paginated).", + }) + async listCredits( + @Param("shippingLineId", ParseUUIDPipe) shippingLineId: string, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, + @Query("status") status?: ShippingLineCreditStatus, + ) { + return this.credits.listCredits( + shippingLineId, + page ? Number(page) : 1, + pageSize ? Number(pageSize) : 20, + status, + ); + } + + @Post("invoice") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoice) + @ApiOperation({ + summary: + "Bill a batch of unbilled credits as one invoice. All credits must belong to the same shipping line.", + }) + async generateInvoice(@Body() dto: GenerateCreditInvoiceDto) { + return this.credits.generateInvoice(dto.creditIds, { + dueInDays: dto.dueInDays, + }); + } + + @Post(":creditId/cancel") + @BookingStaff(FREIGHT_PERMS.shippingLineCredits.cancel) + @ApiOperation({ + summary: + "Write off an unbilled credit. Once billed, cancel the invoice instead.", + }) + async cancel( + @Param("creditId", ParseUUIDPipe) creditId: string, + @Body() dto: CancelCreditDto, + ) { + return this.credits.cancelCredit(creditId, dto.reason); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.repository.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.repository.ts new file mode 100644 index 000000000..f1413cbaf --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.repository.ts @@ -0,0 +1,148 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { EntityManager, In, Repository } from "typeorm"; + +import { + OUTSTANDING_CREDIT_STATUSES, + ShippingLineCredit, + ShippingLineCreditStatus, +} from "./entities/shipping-line-credit.entity"; + +/** What one shipping line currently owes, split by billing stage. */ +export interface OutstandingTotals { + /** Priced but not yet on an invoice. */ + unbilledAmount: number; + /** On an issued invoice, awaiting payment. */ + billedAmount: number; + /** `unbilledAmount + billedAmount` — the full debt. */ + totalOutstanding: number; + unbilledCount: number; + billedCount: number; + currency: string; +} + +@Injectable() +export class ShippingLineCreditsRepository extends BaseRepository { + constructor( + @InjectRepository(ShippingLineCredit) + private readonly credits: Repository, + ) { + super(credits); + } + + findByBookingId(bookingId: string): Promise { + return this.credits.findOne({ where: { bookingId } }); + } + + /** + * Finance's worklist: everything for one line that can go on an invoice, + * oldest first so the longest-standing debt is billed before newer charges. + */ + findUnbilled(shippingLineCompanyId: string): Promise { + return this.credits.find({ + where: { + shippingLineCompanyId, + status: ShippingLineCreditStatus.Unbilled, + }, + relations: { booking: true }, + order: { createdAt: "ASC" }, + }); + } + + findByInvoiceId( + invoiceId: string, + manager?: EntityManager, + ): Promise { + const repo = manager + ? manager.getRepository(ShippingLineCredit) + : this.credits; + return repo.find({ where: { invoiceId } }); + } + + /** + * Load a specific batch inside the caller's transaction and lock it, so two + * concurrent invoice generations cannot both claim the same credits. + */ + findByIdsForUpdate( + manager: EntityManager, + ids: string[], + ): Promise { + return manager.getRepository(ShippingLineCredit).find({ + where: { id: In(ids) }, + lock: { mode: "pessimistic_write" }, + }); + } + + /** + * Derived debt — never a stored column. Grouped in one query so the detail + * page does not fan out per status. Without a line id it totals every line — + * the back-office overview figure. + */ + async outstandingFor( + shippingLineCompanyId?: string, + ): Promise { + const qb = this.credits + .createQueryBuilder("credit") + .select("credit.status", "status") + .addSelect("COALESCE(SUM(credit.amount), 0)", "amount") + .addSelect("COUNT(*)", "count") + .where("credit.status IN (:...statuses)", { + statuses: [...OUTSTANDING_CREDIT_STATUSES], + }) + .andWhere("credit.deletedAt IS NULL") + .groupBy("credit.status"); + if (shippingLineCompanyId) { + qb.andWhere("credit.shippingLineCompanyId = :shippingLineCompanyId", { + shippingLineCompanyId, + }); + } + const rows = await qb.getRawMany<{ + status: string; + amount: string; + count: string; + }>(); + + const totals = (status: ShippingLineCreditStatus) => { + const row = rows.find((r) => r.status === status); + return { + amount: row ? Number(row.amount) : 0, + count: row ? Number(row.count) : 0, + }; + }; + + const unbilled = totals(ShippingLineCreditStatus.Unbilled); + const billed = totals(ShippingLineCreditStatus.Billed); + + return { + unbilledAmount: unbilled.amount, + billedAmount: billed.amount, + totalOutstanding: unbilled.amount + billed.amount, + unbilledCount: unbilled.count, + billedCount: billed.count, + currency: "ETB", + }; + } + + /** + * Paginated ledger — every credit, whatever its status. Scoped to one line + * when an id is given, across all lines otherwise. + */ + findAllPaginated( + shippingLineCompanyId: string | undefined, + skip: number, + take: number, + status?: ShippingLineCreditStatus, + ): Promise<[ShippingLineCredit[], number]> { + return this.credits.findAndCount({ + where: { + ...(shippingLineCompanyId ? { shippingLineCompanyId } : {}), + ...(status ? { status } : {}), + }, + relations: { booking: true, invoice: true, shippingLineCompany: true }, + order: { createdAt: "DESC" }, + skip, + take, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.spec.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.spec.ts new file mode 100644 index 000000000..43e0b5c40 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.spec.ts @@ -0,0 +1,304 @@ +import { BadRequestException, NotFoundException } from "@nestjs/common"; +import { Freight } from "@edr/types"; + +import { Booking } from "../bookings/entities/booking.entity"; +import { + ShippingLineCredit, + ShippingLineCreditStatus, +} from "./entities/shipping-line-credit.entity"; +import { ShippingLineCreditsService } from "./shipping-line-credits.service"; + +/** + * The money path: a shipping line ships without paying, so the debt lives + * entirely in these three transitions. Each test below locks one way the debt + * could be lost or double-counted. + */ +describe("ShippingLineCreditsService", () => { + let creditsRepo: { + findByIdsForUpdate: jest.Mock; + findUnbilled: jest.Mock; + outstandingFor: jest.Mock; + findAllPaginated: jest.Mock; + }; + let billing: { generateInvoice: jest.Mock }; + let shippingLines: { findById: jest.Mock; findByUserId: jest.Mock }; + let dataSource: { transaction: jest.Mock; getRepository: jest.Mock }; + let mg: { + findOne: jest.Mock; + getRepository: jest.Mock; + update: jest.Mock; + }; + let txRepo: { findOne: jest.Mock; save: jest.Mock; create: jest.Mock }; + let updateResult: { affected: number }; + let service: ShippingLineCreditsService; + + const booking = { + id: "booking-1", + reference: "BK-2026-000001", + shippingLineCompanyId: "sl-1", + } as Booking; + + beforeEach(() => { + txRepo = { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn((v) => v), + save: jest.fn(async (v) => ({ id: "credit-1", ...v })), + }; + mg = { + findOne: jest.fn().mockResolvedValue(booking), + getRepository: jest.fn(() => txRepo), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; + updateResult = { affected: 2 }; + dataSource = { + transaction: jest.fn(async (cb) => cb(mg)), + getRepository: jest.fn(() => ({ + update: jest.fn().mockResolvedValue(updateResult), + })), + }; + creditsRepo = { + findByIdsForUpdate: jest.fn(), + findUnbilled: jest.fn(), + outstandingFor: jest.fn(), + findAllPaginated: jest.fn(), + }; + billing = { + generateInvoice: jest.fn().mockResolvedValue({ + id: "inv-1", + invoiceNumber: "INV-20260813-00001", + totalAmount: 50000, + }), + }; + shippingLines = { + findById: jest.fn().mockResolvedValue({ id: "sl-1", name: "ESL" }), + findByUserId: jest.fn(), + }; + + service = new ShippingLineCreditsService( + dataSource as never, + creditsRepo as never, + // Approvals repo — only the invoice maker–checker paths touch it. + { + findPendingByInvoice: jest.fn(), + findPendingByInvoiceIds: jest.fn().mockResolvedValue([]), + findByIdForUpdate: jest.fn(), + create: jest.fn(), + update: jest.fn(), + } as never, + billing as never, + shippingLines as never, + ); + }); + + describe("recordCredit", () => { + it("records the charge against the booking's own shipping line", async () => { + const credit = await service.recordCredit({ + bookingId: "booking-1", + amount: 20000, + }); + + expect(txRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + // Taken from the booking, never from the caller. + shippingLineCompanyId: "sl-1", + bookingId: "booking-1", + amount: 20000, + status: ShippingLineCreditStatus.Unbilled, + }), + ); + expect(credit.id).toBe("credit-1"); + }); + + it("is idempotent per booking — a retried pricing step cannot double the debt", async () => { + const existing = { + id: "credit-existing", + status: ShippingLineCreditStatus.Unbilled, + amount: 20000, + currency: "ETB", + }; + txRepo.findOne.mockResolvedValue(existing); + + const credit = await service.recordCredit({ + bookingId: "booking-1", + amount: 20000, + }); + + expect(credit).toBe(existing); + expect(txRepo.save).not.toHaveBeenCalled(); + }); + + it("refuses a customer booking — those are paid up front, not on credit", async () => { + mg.findOne.mockResolvedValue({ + ...booking, + shippingLineCompanyId: null, + }); + + await expect( + service.recordCredit({ bookingId: "booking-1", amount: 100 }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("rejects a negative amount", async () => { + await expect( + service.recordCredit({ bookingId: "booking-1", amount: -1 }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe("generateInvoice", () => { + const unbilled = (id: string, amount: number) => ({ + id, + shippingLineCompanyId: "sl-1", + bookingId: `booking-${id}`, + amount, + currency: "ETB", + status: ShippingLineCreditStatus.Unbilled, + description: `Freight service — ${id}`, + }); + + it("bills the batch as one invoice and flips the credits to BILLED", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([ + unbilled("c1", 20000), + unbilled("c2", 30000), + ]); + + const invoice = await service.generateInvoice(["c1", "c2"]); + + expect(billing.generateInvoice).toHaveBeenCalledWith( + expect.objectContaining({ + source: Freight.InvoiceSource.ShippingLineCredit, + // The payer, not a customer — invoices.company_id stays null. + shippingLineCompanyId: "sl-1", + sourceId: "sl-1", + status: Freight.InvoiceStatus.Issued, + lines: [ + expect.objectContaining({ amount: 20000 }), + expect.objectContaining({ amount: 30000 }), + ], + }), + mg, + ); + expect(mg.update).toHaveBeenCalledWith( + ShippingLineCredit, + expect.anything(), + expect.objectContaining({ + status: ShippingLineCreditStatus.Billed, + invoiceId: "inv-1", + }), + ); + expect(invoice.id).toBe("inv-1"); + }); + + it("refuses to bill a credit that is already on an invoice", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([ + { ...unbilled("c1", 20000), status: ShippingLineCreditStatus.Billed }, + ]); + + await expect(service.generateInvoice(["c1"])).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(billing.generateInvoice).not.toHaveBeenCalled(); + }); + + it("refuses to mix two shipping lines on one invoice", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([ + unbilled("c1", 20000), + { ...unbilled("c2", 30000), shippingLineCompanyId: "sl-2" }, + ]); + + await expect( + service.generateInvoice(["c1", "c2"]), + ).rejects.toBeInstanceOf(BadRequestException); + expect(billing.generateInvoice).not.toHaveBeenCalled(); + }); + + it("refuses to mix currencies", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([ + unbilled("c1", 20000), + { ...unbilled("c2", 300), currency: "USD" }, + ]); + + await expect( + service.generateInvoice(["c1", "c2"]), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("reports ids that do not exist rather than silently billing the rest", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([unbilled("c1", 20000)]); + + await expect( + service.generateInvoice(["c1", "missing"]), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it("rejects an empty selection", async () => { + await expect(service.generateInvoice([])).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + }); + + describe("onInvoicePaid", () => { + it("clears every billed credit on the settled invoice", async () => { + const update = jest.fn().mockResolvedValue({ affected: 2 }); + dataSource.getRepository = jest.fn(() => ({ update })); + + await service.onInvoicePaid({ + invoiceId: "inv-1", + invoiceNumber: "INV-20260813-00001", + } as never); + + expect(update).toHaveBeenCalledWith( + // Scoped to BILLED so a redelivered webhook cannot re-stamp paidAt. + { invoiceId: "inv-1", status: ShippingLineCreditStatus.Billed }, + expect.objectContaining({ status: ShippingLineCreditStatus.Paid }), + ); + }); + + it("is a no-op on webhook redelivery", async () => { + const update = jest.fn().mockResolvedValue({ affected: 0 }); + dataSource.getRepository = jest.fn(() => ({ update })); + + await expect( + service.onInvoicePaid({ + invoiceId: "inv-1", + invoiceNumber: "INV-1", + } as never), + ).resolves.toBeUndefined(); + }); + }); + + describe("cancelCredit", () => { + it("writes off an unbilled credit", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([ + { id: "c1", status: ShippingLineCreditStatus.Unbilled }, + ]); + + const result = await service.cancelCredit("c1", "Booking voided"); + + expect(result.status).toBe(ShippingLineCreditStatus.Cancelled); + expect(mg.update).toHaveBeenCalledWith( + ShippingLineCredit, + { id: "c1" }, + expect.objectContaining({ + status: ShippingLineCreditStatus.Cancelled, + cancellationReason: "Booking voided", + }), + ); + }); + + it("refuses to write off a credit already on an invoice", async () => { + creditsRepo.findByIdsForUpdate.mockResolvedValue([ + { + id: "c1", + status: ShippingLineCreditStatus.Billed, + invoiceId: "inv-1", + }, + ]); + + await expect( + service.cancelCredit("c1", "oops"), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.ts new file mode 100644 index 000000000..2fffc4c2e --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-credits.service.ts @@ -0,0 +1,780 @@ +import { logCtx } from "@edr/api-common"; +import { Freight } from "@edr/types"; +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; +import { DataSource, EntityManager, In } from "typeorm"; + +import { + BillingService, + InvoiceEventPayload, + InvoiceLineInput, +} from "../billing/billing.service"; +import { Invoice } from "../billing/entities/invoice.entity"; +import { Booking } from "../bookings/entities/booking.entity"; +import { + ShippingLineCredit, + ShippingLineCreditStatus, +} from "./entities/shipping-line-credit.entity"; +import { + ShippingLineInvoiceApproval, + ShippingLineInvoiceActionStatus, + ShippingLineInvoiceActionType, +} from "./entities/shipping-line-invoice-approval.entity"; +import { ShippingLineCreditsRepository } from "./shipping-line-credits.repository"; +import { ShippingLineInvoiceApprovalsRepository } from "./shipping-line-invoice-approvals.repository"; +import { ShippingLineCompany } from "./entities/shipping-line-company.entity"; +import { ShippingLineCompaniesService } from "./shipping-line-companies.service"; + +/** A charge to record against a shipping line's booking. */ +export interface RecordCreditInput { + bookingId: string; + /** Frozen at this value; never recalculated afterwards. */ + amount: number; + currency?: string; + description?: string; +} + +/** Payment terms for a generated shipping-line invoice. */ +export interface GenerateCreditInvoiceOptions { + /** Pay window in days; defaults to the billing module's own default. */ + dueInDays?: number; +} + +/** + * Emitted by the booking-transition accept path for shipping-line bookings. + * An event rather than a service call: BookingsModule cannot import the + * shipping-line modules without closing a module cycle. + */ +export interface ShippingLineBookingAcceptedPayload { + bookingId: string; + reference: string; + /** The booking's priced total, frozen at completion time. */ + amount: number; +} + +/** + * The credit ledger for shipping lines — "use the service now, pay later". + * + * Three moments, in order: + * + * 1. **Charge.** A shipping line's booking is priced, and + * {@link recordCredit} writes an UNBILLED credit. No invoice, no payment + * intent, no gate on the booking — it proceeds regardless. + * 2. **Bill.** Finance picks a batch of unbilled credits for ONE line and + * {@link generateInvoice} turns them into a single invoice, one line per + * credit. The credits become BILLED. + * 3. **Settle.** The line pays that invoice through the ordinary CBE flow. + * Billing emits `shipping_line_credit.invoice.paid`, {@link onInvoicePaid} + * marks the batch PAID, and the debt disappears. + * + * Nothing here decrements a balance: the amount owed is always + * `SUM(amount)` over non-terminal credits. Payment is settled by the gateway + * webhook alone — no manual approval step — so a credit only ever leaves debt + * because real money arrived. + */ +@Injectable() +export class ShippingLineCreditsService { + private readonly logger = new Logger(ShippingLineCreditsService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly credits: ShippingLineCreditsRepository, + private readonly approvals: ShippingLineInvoiceApprovalsRepository, + private readonly billing: BillingService, + private readonly shippingLines: ShippingLineCompaniesService, + ) {} + + // ── 1. Charge ────────────────────────────────────────────────────────────── + + /** + * Record what a shipping line owes for one booking. + * + * Called when the booking is priced. The owner is read off the booking + * itself rather than passed in, so a credit can never be filed against the + * wrong line. Idempotent per booking: a second call returns the existing + * credit untouched rather than doubling the debt — safe against a retried + * pricing step, and the partial unique index backs it at the DB level. + * + * Pass `manager` to enlist in the caller's transaction, so the credit and + * whatever priced the booking commit together. + */ + async recordCredit( + input: RecordCreditInput, + manager?: EntityManager, + ): Promise { + if (!(input.amount >= 0)) { + throw new BadRequestException("Credit amount cannot be negative."); + } + + const run = async (mg: EntityManager): Promise => { + const booking = await mg.findOne(Booking, { + where: { id: input.bookingId }, + }); + if (!booking) { + throw new NotFoundException(`Booking ${input.bookingId} not found`); + } + if (!booking.shippingLineCompanyId) { + throw new BadRequestException( + `Booking ${booking.reference} is not a shipping-line booking — customer bookings are billed up front, not on credit.`, + ); + } + + const repo = mg.getRepository(ShippingLineCredit); + const existing = await repo.findOne({ + where: { bookingId: input.bookingId }, + }); + if (existing && existing.status !== ShippingLineCreditStatus.Cancelled) { + this.logger.warn( + `Credit already exists for booking ${booking.reference} (${existing.status}, ${existing.amount} ${existing.currency}) — leaving it unchanged.`, + ); + return existing; + } + + const credit = await repo.save( + repo.create({ + shippingLineCompanyId: booking.shippingLineCompanyId, + bookingId: input.bookingId, + amount: input.amount, + currency: input.currency ?? "ETB", + description: + input.description ?? `Freight service — booking ${booking.reference}`, + status: ShippingLineCreditStatus.Unbilled, + }), + ); + + logCtx( + { + creditId: credit.id, + bookingId: credit.bookingId, + shippingLineCompanyId: credit.shippingLineCompanyId, + amount: credit.amount, + }, + { path: "shippingLineCredit.recorded" }, + ); + + return credit; + }; + + return manager ? run(manager) : this.dataSource.transaction(run); + } + + /** + * The moment a shipping-line booking becomes debt: Operations accepted it. + * Swallows its own failures with a loud log instead of throwing — the accept + * has already committed, and failing the staff response for a ledger write + * would present a succeeded accept as an error. `recordCredit` is idempotent + * per booking, so a re-accepted (previously reverted) booking cannot double + * the debt. + */ + @OnEvent("shipping_line_booking.accepted") + async onBookingAccepted( + payload: ShippingLineBookingAcceptedPayload, + ): Promise { + try { + await this.recordCredit({ + bookingId: payload.bookingId, + amount: payload.amount, + // Shipping lines are always billed in ETB (enforced at completion). + currency: "ETB", + }); + } catch (err) { + this.logger.error( + `Failed to record credit for accepted shipping-line booking ${payload.reference} (${payload.bookingId}): ${(err as Error).message} — the debt is NOT on the ledger; record it manually or re-trigger.`, + ); + } + } + + // ── 2. Bill ──────────────────────────────────────────────────────────────── + + /** + * Turn a batch of unbilled credits into one invoice. + * + * Every credit must belong to the SAME shipping line — one invoice has one + * payer, so a mixed batch is rejected rather than silently split. The whole + * thing runs in one transaction with the credits locked FOR UPDATE, so two + * finance users clicking at once cannot bill the same credit twice: the + * second transaction blocks, then finds the rows already BILLED and fails. + */ + async generateInvoice( + creditIds: string[], + options: GenerateCreditInvoiceOptions = {}, + ): Promise { + if (creditIds.length === 0) { + throw new BadRequestException( + "Select at least one credit to invoice.", + ); + } + const uniqueIds = [...new Set(creditIds)]; + + return this.dataSource.transaction(async (mg) => { + const credits = await this.credits.findByIdsForUpdate(mg, uniqueIds); + + const missing = uniqueIds.filter( + (id) => !credits.some((c) => c.id === id), + ); + if (missing.length > 0) { + throw new NotFoundException( + `Credit(s) not found: ${missing.join(", ")}`, + ); + } + + const alreadyBilled = credits.filter( + (c) => c.status !== ShippingLineCreditStatus.Unbilled, + ); + if (alreadyBilled.length > 0) { + throw new BadRequestException( + `These credits are no longer unbilled and cannot be invoiced: ${alreadyBilled + .map((c) => `${c.id} (${c.status})`) + .join(", ")}`, + ); + } + + const lineIds = new Set(credits.map((c) => c.shippingLineCompanyId)); + if (lineIds.size > 1) { + throw new BadRequestException( + "All selected credits must belong to the same shipping line — one invoice has one payer.", + ); + } + const shippingLineCompanyId = credits[0].shippingLineCompanyId; + + const currencies = new Set(credits.map((c) => c.currency)); + if (currencies.size > 1) { + throw new BadRequestException( + `Cannot mix currencies on one invoice: ${[...currencies].join(", ")}.`, + ); + } + const currency = credits[0].currency; + + const shippingLine = await this.shippingLines.findById( + shippingLineCompanyId, + ); + if (!shippingLine) { + throw new NotFoundException( + `Shipping line ${shippingLineCompanyId} not found`, + ); + } + + const lines: InvoiceLineInput[] = credits.map((credit) => ({ + chargeType: "SHIPPING_LINE_SERVICE", + description: credit.description ?? undefined, + quantity: 1, + unitRate: Number(credit.amount), + amount: Number(credit.amount), + currency: credit.currency, + metadata: { creditId: credit.id, bookingId: credit.bookingId }, + })); + + const invoice = await this.billing.generateInvoice( + { + source: Freight.InvoiceSource.ShippingLineCredit, + // Unlike other sources this is the payer, not a single billed + // record: the invoice spans many bookings, and each credit keeps its + // own booking link. + sourceId: shippingLineCompanyId, + type: "SHIPPING_LINE_CREDIT", + shippingLineCompanyId, + currency, + lines, + dueInDays: options.dueInDays, + status: Freight.InvoiceStatus.Issued, + }, + mg, + ); + + const billedAt = new Date(); + await mg.update( + ShippingLineCredit, + { id: In(credits.map((c) => c.id)) }, + { + status: ShippingLineCreditStatus.Billed, + invoiceId: invoice.id, + billedAt, + }, + ); + + logCtx( + { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + shippingLineCompanyId, + creditCount: credits.length, + totalAmount: invoice.totalAmount, + }, + { path: "shippingLineCredit.invoiced" }, + ); + + return invoice; + }); + } + + // ── 3. Settle ────────────────────────────────────────────────────────────── + + /** + * Clear the batch once its invoice is paid. + * + * Driven by the billing event rather than a call inside the payment path, so + * the CBE webhook flow needs no knowledge of credits: whatever settles the + * invoice — gateway webhook, or a finance-recorded offline payment — this + * fires. Idempotent, because a redelivered webhook re-emits the event. + */ + @OnEvent("shipping_line_credit.invoice.paid") + async onInvoicePaid(payload: InvoiceEventPayload): Promise { + const result = await this.dataSource + .getRepository(ShippingLineCredit) + .update( + { + invoiceId: payload.invoiceId, + status: ShippingLineCreditStatus.Billed, + }, + { status: ShippingLineCreditStatus.Paid, paidAt: new Date() }, + ); + + logCtx( + { + invoiceId: payload.invoiceId, + invoiceNumber: payload.invoiceNumber, + creditsCleared: result.affected ?? 0, + }, + { path: "shippingLineCredit.settled" }, + ); + + // Zero is the ordinary idempotent no-op on webhook redelivery. It is only + // worth a line in the log, not an error: the invoice is paid either way. + if (!result.affected) { + this.logger.log( + `Invoice ${payload.invoiceNumber} paid — no BILLED credits left to clear (already settled).`, + ); + } + } + + // ── Reads ────────────────────────────────────────────────────────────────── + + /** Finance's worklist: what can go on an invoice for this line right now. */ + async listUnbilled(shippingLineCompanyId: string) { + await this.requireShippingLine(shippingLineCompanyId); + const credits = await this.credits.findUnbilled(shippingLineCompanyId); + return { + items: credits, + totalAmount: credits.reduce((sum, c) => sum + Number(c.amount), 0), + currency: credits[0]?.currency ?? "ETB", + }; + } + + /** The debt figure shown on the shipping-line detail page. */ + async outstanding(shippingLineCompanyId: string) { + await this.requireShippingLine(shippingLineCompanyId); + return this.credits.outstandingFor(shippingLineCompanyId); + } + + /** + * Back-office overview: outstanding totals across every line, or one line + * when an id is given. + */ + async summary(shippingLineCompanyId?: string) { + if (shippingLineCompanyId) { + await this.requireShippingLine(shippingLineCompanyId); + } + return this.credits.outstandingFor(shippingLineCompanyId); + } + + /** + * The whole ledger across every shipping line, newest first — finance's + * landing list. Optionally narrowed to one line and/or one status. + */ + async listAll( + page = 1, + pageSize = 20, + status?: ShippingLineCreditStatus, + shippingLineCompanyId?: string, + ) { + if (shippingLineCompanyId) { + await this.requireShippingLine(shippingLineCompanyId); + } + const [items, total] = await this.credits.findAllPaginated( + shippingLineCompanyId, + (page - 1) * pageSize, + pageSize, + status, + ); + return { items, total, page, pageSize }; + } + + /** Full ledger for one line, newest first. */ + async listCredits( + shippingLineCompanyId: string, + page = 1, + pageSize = 20, + status?: ShippingLineCreditStatus, + ) { + await this.requireShippingLine(shippingLineCompanyId); + const [items, total] = await this.credits.findAllPaginated( + shippingLineCompanyId, + (page - 1) * pageSize, + pageSize, + status, + ); + return { items, total, page, pageSize }; + } + + /** + * The signed-in shipping line's own statement: what it owes and why. + * Resolves the line from the session, so one line can never read another's. + */ + async myStatement(userId: string, page = 1, pageSize = 20) { + const shippingLine = await this.shippingLines.findByUserId(userId); + if (!shippingLine) { + throw new ForbiddenException("This account is not a shipping line."); + } + const [outstanding, ledger] = await Promise.all([ + this.credits.outstandingFor(shippingLine.id), + this.credits.findAllPaginated( + shippingLine.id, + (page - 1) * pageSize, + pageSize, + ), + ]); + return { + outstanding, + items: ledger[0], + total: ledger[1], + page, + pageSize, + }; + } + + // ── Credit invoices: list + maker–checker manual actions ───────────────── + + /** + * Staff list of the invoices minted from credit batches, each with its line + * name and any undecided manual-action request attached — the data the + * back-office actions column renders from. + */ + async listCreditInvoices( + page = 1, + pageSize = 20, + status?: Freight.InvoiceStatus, + shippingLineCompanyId?: string, + ) { + const [invoices, total] = await this.dataSource + .getRepository(Invoice) + .findAndCount({ + where: { + source: Freight.InvoiceSource.ShippingLineCredit, + ...(status ? { status } : {}), + ...(shippingLineCompanyId ? { shippingLineCompanyId } : {}), + }, + order: { createdAt: "DESC" }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + const lineIds = [ + ...new Set( + invoices + .map((inv) => inv.shippingLineCompanyId) + .filter((id): id is string => !!id), + ), + ]; + const lines = lineIds.length + ? await this.dataSource + .getRepository(ShippingLineCompany) + .find({ where: { id: In(lineIds) } }) + : []; + const nameById = new Map(lines.map((l) => [l.id, l.name])); + + const pending = await this.approvals.findPendingByInvoiceIds( + invoices.map((inv) => inv.id), + ); + const pendingByInvoice = new Map(pending.map((p) => [p.invoiceId, p])); + + return { + items: invoices.map((inv) => ({ + ...inv, + shippingLineName: inv.shippingLineCompanyId + ? (nameById.get(inv.shippingLineCompanyId) ?? null) + : null, + pendingAction: pendingByInvoice.get(inv.id) ?? null, + })), + total, + page, + pageSize, + }; + } + + /** Undecided requests for a batch of invoices — feeds any invoice list. */ + async pendingInvoiceActions( + invoiceIds: string[], + ): Promise { + // Bounded to a list page's worth of ids; anything larger is a misuse. + return this.approvals.findPendingByInvoiceIds(invoiceIds.slice(0, 100)); + } + + /** + * Finance raises a manual action on a credit invoice: record an offline + * payment (MARK_PAID) or void it (CANCEL). Nothing happens to the invoice + * yet — a chief with the matching approve permission decides it. One + * undecided request per invoice (backed by a partial unique index). + */ + async requestInvoiceAction( + invoiceId: string, + action: ShippingLineInvoiceActionType, + requestedBy: string, + reason: string, + paymentReference?: string, + ): Promise { + const invoice = await this.dataSource + .getRepository(Invoice) + .findOne({ where: { id: invoiceId } }); + if (!invoice) { + throw new NotFoundException(`Invoice ${invoiceId} not found`); + } + if (invoice.source !== Freight.InvoiceSource.ShippingLineCredit) { + throw new BadRequestException( + "Manual actions here apply only to shipping-line credit invoices.", + ); + } + // Fast feedback only — the billing service re-validates authoritatively + // (under lock) when the request is approved. + if ( + action === ShippingLineInvoiceActionType.MarkPaid && + invoice.status === Freight.InvoiceStatus.Paid + ) { + throw new BadRequestException("Invoice is already paid."); + } + if (invoice.status === Freight.InvoiceStatus.Cancelled) { + throw new BadRequestException("Invoice is already cancelled."); + } + if ( + action === ShippingLineInvoiceActionType.Cancel && + Number(invoice.paidAmount) > 0 + ) { + throw new BadRequestException( + "Cannot cancel an invoice that has payments recorded against it.", + ); + } + const existing = await this.approvals.findPendingByInvoice(invoiceId); + if (existing) { + throw new BadRequestException( + `A ${existing.action} request is already awaiting decision on this invoice.`, + ); + } + + const approval = await this.approvals.create({ + invoiceId, + action, + status: ShippingLineInvoiceActionStatus.Pending, + requestedBy, + reason, + paymentReference: paymentReference ?? null, + }); + + logCtx( + { + approvalId: approval.id, + invoiceId, + invoiceNumber: invoice.invoiceNumber, + action, + requestedBy, + }, + { path: "shippingLineCredit.invoiceAction.requested" }, + ); + + return approval; + } + + /** + * Decide a pending request. Gated purely by permission (the approve/reject + * grants on the controller routes) — a decider holding the grant may decide + * ANY pending request, their own included; that trade-off is deliberate. + * + * Approval executes the real action through the billing service AFTER the + * decision row commits — its settlement/cancellation events must fire from + * billing's own committed transaction (the credits listeners react to + * them). If billing then rejects the action, the decision is compensated + * back to PENDING so the request is not silently lost. + */ + async decideInvoiceAction( + approvalId: string, + decidedBy: string, + approve: boolean, + note?: string, + ): Promise { + if (!approve && !note?.trim()) { + throw new BadRequestException( + "A note is required when rejecting a request.", + ); + } + + const decided = await this.dataSource.transaction(async (mg) => { + const approval = await this.approvals.findByIdForUpdate(mg, approvalId); + if (!approval) { + throw new NotFoundException(`Request ${approvalId} not found`); + } + if (approval.status !== ShippingLineInvoiceActionStatus.Pending) { + throw new BadRequestException( + `This request was already ${approval.status.toLowerCase()}.`, + ); + } + + const status = approve + ? ShippingLineInvoiceActionStatus.Approved + : ShippingLineInvoiceActionStatus.Rejected; + await mg.update( + ShippingLineInvoiceApproval, + { id: approvalId }, + { + status, + decidedBy, + decidedAt: new Date(), + decisionNote: note ?? null, + }, + ); + return { ...approval, status, decidedBy, decisionNote: note ?? null }; + }); + + if (!approve) { + logCtx( + { approvalId, invoiceId: decided.invoiceId, decidedBy }, + { path: "shippingLineCredit.invoiceAction.rejected" }, + ); + return decided; + } + + try { + if (decided.action === ShippingLineInvoiceActionType.MarkPaid) { + const invoice = await this.dataSource + .getRepository(Invoice) + .findOne({ where: { id: decided.invoiceId } }); + if (!invoice) { + throw new NotFoundException(`Invoice ${decided.invoiceId} not found`); + } + // Full settlement of the outstanding balance; billing emits + // `shipping_line_credit.invoice.paid`, which marks the credits PAID. + await this.billing.recordPayment(decided.invoiceId, { + amount: Number(invoice.balanceAmount ?? invoice.totalAmount), + method: "OFFLINE", + reference: decided.paymentReference ?? undefined, + metadata: { + approvalId: decided.id, + requestedBy: decided.requestedBy, + approvedBy: decidedBy, + }, + }); + } else { + // Billing emits `shipping_line_credit.invoice.cancelled`; + // onInvoiceCancelled releases the credits back to the unbilled pool. + await this.billing.cancelInvoice(decided.invoiceId); + } + } catch (err) { + // The action was refused (state changed since the request — e.g. the + // line paid through CBE in the meantime). Put the request back so it is + // not recorded as approved-but-unexecuted. + await this.approvals.update(approvalId, { + status: ShippingLineInvoiceActionStatus.Pending, + decidedBy: null, + decidedAt: null, + decisionNote: null, + }); + throw err; + } + + logCtx( + { + approvalId, + invoiceId: decided.invoiceId, + action: decided.action, + decidedBy, + }, + { path: "shippingLineCredit.invoiceAction.approved" }, + ); + + return decided; + } + + /** + * When a credit invoice is cancelled — through the approval flow or any + * other billing path — its BILLED credits return to the unbilled pool so + * the debt can be re-billed. The debt itself never disappears on invoice + * cancellation; only {@link cancelCredit} writes debt off. + */ + @OnEvent("shipping_line_credit.invoice.cancelled") + async onInvoiceCancelled(payload: InvoiceEventPayload): Promise { + const result = await this.dataSource + .getRepository(ShippingLineCredit) + .update( + { + invoiceId: payload.invoiceId, + status: ShippingLineCreditStatus.Billed, + }, + { + status: ShippingLineCreditStatus.Unbilled, + invoiceId: null, + billedAt: null, + }, + ); + + logCtx( + { + invoiceId: payload.invoiceId, + invoiceNumber: payload.invoiceNumber, + creditsReleased: result.affected ?? 0, + }, + { path: "shippingLineCredit.invoiceCancelled.released" }, + ); + } + + // ── Cancellation ─────────────────────────────────────────────────────────── + + /** + * Write off an unbilled credit (booking voided, charge raised in error). + * Only UNBILLED credits can be cancelled — once a credit is on an issued + * invoice, the invoice is what has to be cancelled or credited, otherwise + * the invoice total would stop matching the sum of its lines. + */ + async cancelCredit( + creditId: string, + reason: string, + ): Promise { + return this.dataSource.transaction(async (mg) => { + const [credit] = await this.credits.findByIdsForUpdate(mg, [creditId]); + if (!credit) { + throw new NotFoundException(`Credit ${creditId} not found`); + } + if (credit.status !== ShippingLineCreditStatus.Unbilled) { + throw new BadRequestException( + `Only an unbilled credit can be cancelled; this one is ${credit.status}. Cancel or credit invoice ${credit.invoiceId} instead.`, + ); + } + + await mg.update( + ShippingLineCredit, + { id: creditId }, + { + status: ShippingLineCreditStatus.Cancelled, + cancelledAt: new Date(), + cancellationReason: reason, + }, + ); + + return { ...credit, status: ShippingLineCreditStatus.Cancelled }; + }); + } + + private async requireShippingLine(shippingLineCompanyId: string) { + const shippingLine = await this.shippingLines.findById( + shippingLineCompanyId, + ); + if (!shippingLine) { + throw new NotFoundException( + `Shipping line ${shippingLineCompanyId} not found`, + ); + } + return shippingLine; + } +} diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-invoice-approvals.repository.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-invoice-approvals.repository.ts new file mode 100644 index 000000000..dcd904fa2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-invoice-approvals.repository.ts @@ -0,0 +1,51 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { EntityManager, In, Repository } from "typeorm"; + +import { + ShippingLineInvoiceApproval, + ShippingLineInvoiceActionStatus, +} from "./entities/shipping-line-invoice-approval.entity"; + +@Injectable() +export class ShippingLineInvoiceApprovalsRepository extends BaseRepository { + constructor( + @InjectRepository(ShippingLineInvoiceApproval) + private readonly approvals: Repository, + ) { + super(approvals); + } + + findPendingByInvoice( + invoiceId: string, + ): Promise { + return this.approvals.findOne({ + where: { invoiceId, status: ShippingLineInvoiceActionStatus.Pending }, + }); + } + + /** Pending requests for a page of invoices — one query, no N+1. */ + findPendingByInvoiceIds( + invoiceIds: string[], + ): Promise { + if (!invoiceIds.length) return Promise.resolve([]); + return this.approvals.find({ + where: { + invoiceId: In(invoiceIds), + status: ShippingLineInvoiceActionStatus.Pending, + }, + }); + } + + /** Load one request inside the caller's transaction, locked for decision. */ + findByIdForUpdate( + manager: EntityManager, + id: string, + ): Promise { + return manager.getRepository(ShippingLineInvoiceApproval).findOne({ + where: { id }, + lock: { mode: "pessimistic_write" }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/dto/update-stamp-setting.dto.ts b/apps/edr-freight-api/src/modules/stamp-settings/dto/update-stamp-setting.dto.ts new file mode 100644 index 000000000..96f86bf61 --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/dto/update-stamp-setting.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, MinLength } from "class-validator"; + +export class UpdateStampSettingDto { + @ApiProperty({ description: "Stamp image as a base64 data URL (PNG/JPG)." }) + @IsString() + @MinLength(1) + stampImageBase64!: string; +} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/entities/stamp-setting.entity.ts b/apps/edr-freight-api/src/modules/stamp-settings/entities/stamp-setting.entity.ts new file mode 100644 index 000000000..7ab7a0e2c --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/entities/stamp-setting.entity.ts @@ -0,0 +1,24 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, JoinColumn, ManyToOne } from "typeorm"; + +import { FileRecord } from "../../files/entities/file.entity"; + +/** + * Single-row table holding the one company stamp/seal image stamped onto + * generated invoice/receipt PDFs (see InvoiceDocumentService). Mirrors the + * exchange_settings single-row pattern — `get()` lazily creates the row, and + * there is never more than one. + */ +@Entity({ schema: "freight", name: "stamp_settings" }) +export class StampSetting extends BaseEntity { + @Column({ name: "stamp_file_id", type: "uuid", nullable: true }) + stampFileId?: string | null; + + @ManyToOne(() => FileRecord, { nullable: true }) + @JoinColumn({ name: "stamp_file_id" }) + stampFile?: FileRecord | null; + + /** IAM user id of the last operator to set/clear the stamp. */ + @Column({ name: "updated_by_id", type: "uuid", nullable: true }) + updatedById?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.controller.ts b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.controller.ts new file mode 100644 index 000000000..1ba60139e --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.controller.ts @@ -0,0 +1,39 @@ +import { Body, Controller, Delete, Get, Put } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { CurrentUser } from "@edr/api-common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { UpdateStampSettingDto } from "./dto/update-stamp-setting.dto"; +import { StampSettingsService } from "./stamp-settings.service"; + +@ApiTags("stamp-settings") +@ApiBearerAuth() +@Controller("stamp-settings") +export class StampSettingsController { + constructor(private readonly service: StampSettingsService) {} + + @Get() + @BookingStaff([FREIGHT_PERMS.settings.stamp.view, FREIGHT_PERMS.admin]) + @ApiOperation({ summary: "Current company stamp used on invoice/receipt PDFs" }) + get() { + return this.service.getView(); + } + + @Put() + @BookingStaff([FREIGHT_PERMS.settings.stamp.manage, FREIGHT_PERMS.admin]) + @ApiOperation({ summary: "Replace the company stamp" }) + update(@Body() dto: UpdateStampSettingDto, @CurrentUser() user: TCurrentUser) { + return this.service.setStamp(dto.stampImageBase64, user?.id ?? null); + } + + @Delete() + @BookingStaff([FREIGHT_PERMS.settings.stamp.manage, FREIGHT_PERMS.admin]) + @ApiOperation({ + summary: "Clear the company stamp (invoices fall back to the plain seal)", + }) + clear(@CurrentUser() user: TCurrentUser) { + return this.service.clearStamp(user?.id ?? null); + } +} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.module.ts b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.module.ts new file mode 100644 index 000000000..6c9fc3a36 --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.module.ts @@ -0,0 +1,23 @@ +import { Global, Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { FilesModule } from "../files/files.module"; +import { MinioModule } from "../minio/minio.module"; +import { StampSetting } from "./entities/stamp-setting.entity"; +import { StampSettingsController } from "./stamp-settings.controller"; +import { StampSettingsRepository } from "./stamp-settings.repository"; +import { StampSettingsService } from "./stamp-settings.service"; + +/** + * Global so DocumentsModule (invoice PDF rendering) can inject + * {@link StampSettingsService} without pulling in a circular billing/warehouse + * dependency — same reasoning as ExchangeSettingsModule. + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([StampSetting]), FilesModule, MinioModule], + controllers: [StampSettingsController], + providers: [StampSettingsRepository, StampSettingsService], + exports: [StampSettingsService], +}) +export class StampSettingsModule {} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.repository.ts b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.repository.ts new file mode 100644 index 000000000..0ca4cfb68 --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.repository.ts @@ -0,0 +1,21 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; + +import { StampSetting } from "./entities/stamp-setting.entity"; + +@Injectable() +export class StampSettingsRepository extends BaseRepository { + constructor( + @InjectRepository(StampSetting) + repo: Repository, + ) { + super(repo); + } + + /** The single settings row, with its stamp file joined, or null before first upload. */ + findSingleton(): Promise { + return this.repository.findOne({ where: {}, relations: ["stampFile"] }); + } +} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.spec.ts b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.spec.ts new file mode 100644 index 000000000..ef55e9ecc --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.spec.ts @@ -0,0 +1,110 @@ +import { Readable } from "stream"; + +import { StampSettingsService } from "./stamp-settings.service"; + +/** + * The one global company stamp feeds three document paths (invoices, warehouse + * papers, contract signature blocks). All three treat the returned value as a + * `data:` URL — so the data-URL-or-null contract of getStampImageUrl is what + * these specs pin down, especially its behaviour when MinIO cannot be reached. + */ +describe("StampSettingsService.getStampImageUrl", () => { + const PNG = Buffer.from("fake-png-bytes"); + const OBJECT_URL = "https://minio.local:9000/edr-freight/stamp/company.png"; + + const build = ( + overrides: { + stampUrl?: string | null; + getFileStream?: jest.Mock; + findSingleton?: jest.Mock; + } = {}, + ) => { + const service = Object.create( + StampSettingsService.prototype, + ) as StampSettingsService; + const warn = jest.fn(); + Object.assign(service, { + logger: { warn, log: jest.fn() }, + repository: { + findSingleton: + overrides.findSingleton ?? + jest.fn().mockResolvedValue({ + id: "s-1", + stampFileId: overrides.stampUrl ? "f-1" : null, + stampFile: overrides.stampUrl ? { url: overrides.stampUrl } : null, + updatedById: null, + updatedAt: null, + }), + create: jest.fn(), + update: jest.fn(), + }, + minioService: { + getObjectNameFromUrl: jest.fn().mockReturnValue("stamp/company.png"), + getFileStream: + overrides.getFileStream ?? + jest.fn().mockResolvedValue(Readable.from(PNG)), + }, + filesService: { upload: jest.fn() }, + }); + return { service, warn }; + }; + + it("inlines the stored stamp as a data URL", async () => { + const { service } = build({ stampUrl: OBJECT_URL }); + + await expect(service.getStampImageUrl()).resolves.toBe( + `data:image/png;base64,${PNG.toString("base64")}`, + ); + }); + + it("returns null when no stamp is configured", async () => { + const { service } = build({ stampUrl: null }); + + await expect(service.getStampImageUrl()).resolves.toBeNull(); + }); + + it("passes an already-inlined data URL straight through", async () => { + const dataUrl = "data:image/png;base64,QUJD"; + const { service } = build({ stampUrl: dataUrl }); + + await expect(service.getStampImageUrl()).resolves.toBe(dataUrl); + }); + + /** + * Regression: inlineImageUrl falls back to the raw object URL when MinIO is + * unreachable, which is right for getView (a browser can fetch it) but wrong + * here. ContractTransitionService base64-decodes this value to snapshot the + * seal — and a URL decodes to garbage bytes WITHOUT throwing, so a transient + * MinIO failure used to seal an executed contract with a corrupt image file. + * Degrade to null instead so callers draw their text/vector seal. + */ + it("returns null rather than a raw object URL when MinIO inlining fails", async () => { + const { service, warn } = build({ + stampUrl: OBJECT_URL, + getFileStream: jest.fn().mockRejectedValue(new Error("connect ECONNREFUSED")), + }); + + await expect(service.getStampImageUrl()).resolves.toBeNull(); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/plain seal/i)); + }); + + it("never throws when the settings lookup itself fails", async () => { + const { service, warn } = build({ + findSingleton: jest.fn().mockRejectedValue(new Error("db is down")), + }); + + await expect(service.getStampImageUrl()).resolves.toBeNull(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("db is down")); + }); + + it("still exposes the raw URL through getView, which a browser can load", async () => { + const { service } = build({ + stampUrl: OBJECT_URL, + getFileStream: jest.fn().mockRejectedValue(new Error("connect ECONNREFUSED")), + }); + + await expect(service.getView()).resolves.toEqual( + expect.objectContaining({ stampImageUrl: OBJECT_URL }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.ts b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.ts new file mode 100644 index 000000000..bfc544f88 --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.ts @@ -0,0 +1,166 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { Readable } from "stream"; + +import { FilesService } from "../files/files.service"; +import { MinioService } from "../minio/minio.service"; +import { StampSettingsRepository } from "./stamp-settings.repository"; +import { StampSetting } from "./entities/stamp-setting.entity"; + +export interface StampSettingView { + stampImageUrl: string | null; + updatedById: string | null; + updatedAt: Date | null; +} + +/** + * Owns the single `stamp_settings` row: the one company stamp/seal image used + * on generated invoice/receipt PDFs (see InvoiceDocumentService). Same + * single-row shape as ExchangeSettingsService, but the value is an uploaded + * image (via FilesService) rather than a scalar. + */ +@Injectable() +export class StampSettingsService { + private readonly logger = new Logger(StampSettingsService.name); + + constructor( + private readonly repository: StampSettingsRepository, + private readonly filesService: FilesService, + private readonly minioService: MinioService, + ) {} + + /** The settings row, created empty on first access. */ + async get(): Promise { + const existing = await this.repository.findSingleton(); + if (existing) return existing; + return this.repository.create({ stampFileId: null, updatedById: null }); + } + + /** Current stamp, with the image inlined as a data URL (or null if unset). */ + async getView(): Promise { + const setting = await this.get(); + return { + stampImageUrl: await this.inlineImageUrl(setting.stampFile?.url), + updatedById: setting.updatedById ?? null, + updatedAt: setting.updatedAt ?? null, + }; + } + + /** + * The stamp image for embedding into generated documents, ALWAYS as a + * `data:` URL or null. Never throws — document generation must succeed even + * if the stamp lookup fails; callers fall back to their own seal on null. + * + * The data-URL-or-null guarantee is load-bearing, not cosmetic: the HTML + * render paths inline this value into an that headless Chromium + * cannot fetch over the network. So where getView() may hand a raw URL to + * a browser that can load it, this degrades to null and lets the caller + * draw its text/vector seal instead. (Contract signing no longer consumes + * this — staff signatures reference the stampFileId directly.) + */ + async getStampImageUrl(): Promise { + try { + const setting = await this.get(); + const inlined = await this.inlineImageUrl(setting.stampFile?.url); + if (inlined && !inlined.startsWith("data:")) { + this.logger.warn( + `Company stamp could not be inlined for document rendering (falling back to the plain seal): ${inlined}`, + ); + return null; + } + return inlined; + } catch (err) { + this.logger.warn( + `Could not load company stamp for PDF rendering: ${(err as Error).message}`, + ); + return null; + } + } + + /** + * Replace the stamp image, storing it in MinIO via FilesService. + * + * The replaced file is NEVER deleted: contract signatures reference stamp + * files by id (ContractTransitionService points staff signatures at the + * current stampFileId instead of copying the image), so each retired file + * is the immutable record of which seal executed the contracts signed while + * it was current. Deleting it would strip the seal off those contracts. + */ + async setStamp( + stampImageBase64: string, + updatedById?: string | null, + ): Promise { + const current = await this.get(); + + const fileRecord = await this.filesService.upload({ + resourceId: current.id, + resource: "stamp_settings", + code: "stamp", + file: this.toUploadFile(stampImageBase64), + uploadedByUserId: updatedById ?? null, + }); + + await this.repository.update(current.id, { + stampFileId: fileRecord.id, + updatedById: updatedById ?? null, + }); + + this.logger.log(`Company stamp updated by ${updatedById ?? "unknown user"}`); + return this.getView(); + } + + /** + * Clear the stamp (invoices fall back to the programmatic seal). The file + * is kept for the same reason as in {@link setStamp}. + */ + async clearStamp(updatedById?: string | null): Promise { + const current = await this.get(); + + await this.repository.update(current.id, { + stampFileId: null, + updatedById: updatedById ?? null, + }); + + return this.getView(); + } + + private toUploadFile(base64: string): Express.Multer.File { + const raw = base64.includes(",") ? base64.split(",")[1]! : base64; + const buffer = Buffer.from(raw, "base64"); + return { + fieldname: "stamp", + originalname: "company-stamp.png", + encoding: "7bit", + mimetype: "image/png", + size: buffer.length, + buffer, + stream: Readable.from(buffer), + destination: "", + filename: "", + path: "", + }; + } + + private async inlineImageUrl(url?: string | null): Promise { + if (!url) return null; + if (url.startsWith("data:")) return url; + try { + const objectName = this.minioService.getObjectNameFromUrl(url); + const stream = await this.minioService.getFileStream(objectName); + const buffer = await this.streamToBuffer(stream); + return `data:image/png;base64,${buffer.toString("base64")}`; + } catch { + return url; + } + } + + private streamToBuffer(stream: Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + stream.on("error", reject); + stream.on("end", () => resolve(Buffer.concat(chunks))); + }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index 5730120e9..393378740 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -7,6 +7,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } fro import { Yard } from '../../rule-engine/entities/yard.entity'; import { Route } from '../../routes/entities/route.entity'; +import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; import { TrainSet } from '../../train-sets/entities/train-set.entity'; import { TrainScheduleBooking } from './train-schedule-booking.entity'; @@ -64,6 +65,14 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true }) trainNumber?: string | null; + /** + * Voyage (sailing) number for this departure — the identifier yards and + * customs quote alongside the train number. Per-departure, so it lives here + * rather than on the built train. + */ + @Column({ name: 'voyage_number', type: 'varchar', length: 20, nullable: true }) + voyageNumber?: string | null; + // Human-facing unique schedule reference (S-YYYY-NNNNN). Shown on the schedule // list, booking windows, and load lists. Assigned at creation from the highest // sequence issued this year (see TrainSchedulesRepository.maxReferenceSequence). @@ -73,6 +82,19 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'direction', type: 'varchar', length: 10, nullable: true }) direction?: string | null; + /** + * Dedicates this departure to one shipping line. NULL = a normal train, + * visible to customers as today. Set = the train is HIDDEN from every + * customer-facing read (windows, day pools, home cards) and shown only to + * this shipping line in its portal. + */ + @Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true }) + shippingLineCompanyId?: string | null; + + @ManyToOne(() => ShippingLineCompany) + @JoinColumn({ name: 'shipping_line_company_id' }) + shippingLineCompany?: ShippingLineCompany | null; + /** * Reverse the wagon ORDER on this train: when true, the built wagon plan is * flipped at build so the physically-last wagon sits at position 1. Only the 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 23024bfdf..cb1958582 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 @@ -17,6 +17,7 @@ import { FindOptionsWhere, ILike, In, + IsNull, LessThanOrEqual, MoreThanOrEqual, } from 'typeorm'; @@ -25,6 +26,7 @@ import { Booking } from '../bookings/entities/booking.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; import { formatRouteLabel } from '../routes/entities/route.entity'; +import { isRoadService } from '../bookings/road.util'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; import { CargoType } from '../rule-engine/entities/cargo-type.entity'; @@ -153,6 +155,19 @@ export interface ExportTrainOption { }>; } +/** Form-entered cargo for a train-options probe (nothing persisted yet). */ +export interface TrainOptionCargoOverrides { + /** Container types drive the per-type space. */ + containerTypeIds?: string[]; + /** Size labels ("20ft"/"40ft") when the form has no type ids. */ + containerSizes?: string[]; + /** Bulk counterparts of the container inputs. */ + cargoTypeId?: string; + cargoTypeCode?: string; + /** Needed wagons estimate from the form (drives the `fits` flag). */ + wagons?: number; +} + /** A train a paid-unallocated booking can board (route + capacity verified). */ export interface AllocationCandidate { id: string; @@ -820,8 +835,9 @@ export class BookingBatchService implements OnModuleInit { // stop order, so we fetch the day's open trains without endpoint filters. const corridor = await this.trainSchedulesRepository.findAll({ where: [ - { status: TrainScheduleStatusEnum.Draft }, - { status: TrainScheduleStatusEnum.Scheduled }, + // Dedicated shipping-line trains are never customer-booking targets. + { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() }, + { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() }, ], }); // A customer-picked train narrows the scan to that ONE schedule: export @@ -983,8 +999,9 @@ export class BookingBatchService implements OnModuleInit { ): Promise> { const corridor = await this.trainSchedulesRepository.findAll({ where: [ - { status: TrainScheduleStatusEnum.Draft }, - { status: TrainScheduleStatusEnum.Scheduled }, + // Dedicated shipping-line trains are never customer-booking targets. + { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() }, + { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() }, ], }); const candidates = corridor @@ -1048,19 +1065,84 @@ export class BookingBatchService implements OnModuleInit { async exportTrainOptionsForDay( booking: Booking, day: string, - overrides?: { - /** Cargo the customer is entering on a form (bare contract instance — - * nothing persisted yet): container types drive the per-type space. */ - containerTypeIds?: string[]; - /** Size labels ("20ft"/"40ft") when the form has no type ids. */ - containerSizes?: string[]; - /** Bulk counterparts of the container inputs. */ - cargoTypeId?: string; - cargoTypeCode?: string; - /** Needed wagons estimate from the form (drives the `fits` flag). */ - wagons?: number; - }, + overrides?: TrainOptionCargoOverrides, ): Promise { + booking = await this.withCargoOverrides(booking, overrides); + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + // Dedicated shipping-line trains are never customer-booking targets. + { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() }, + { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() }, + ], + }); + const candidates = corridor + .filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + s.direction === 'EXPORT', + ) + .sort( + (a, b) => + a.scheduledDepartureDate!.getTime() - + b.scheduledDepartureDate!.getTime(), + ); + return this.buildTrainOptions(booking, candidates); + } + + /** + * The same per-train wagon-availability cards, but for the trains DEDICATED + * to a shipping line on the booking's lane + day. Same option shape as the + * export picker so the portal reuses the same component; `isOpen` + * additionally respects the dedicated close offset (windowClosesAt), since + * these trains run no window cycle. + */ + async dedicatedTrainOptionsForDay( + booking: Booking, + day: string | null, + shippingLineCompanyId: string, + overrides?: TrainOptionCargoOverrides, + ): Promise { + booking = await this.withCargoOverrides(booking, overrides); + const dedicated = await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId }, + { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId }, + ], + }); + const candidates = dedicated + .filter( + (s) => + s.scheduledDepartureDate != null && + // A day narrows to that departure day; without one, every upcoming + // departure on the lane is listed (the picker's full card list). + (day + ? eatDay(s.scheduledDepartureDate) === day + : s.scheduledDepartureDate.getTime() > Date.now() - 3_600_000) && + (!booking.originYardId || s.originStationId === booking.originYardId) && + (!booking.destinationYardId || + s.destinationStationId === booking.destinationYardId), + ) + .sort( + (a, b) => + a.scheduledDepartureDate!.getTime() - + b.scheduledDepartureDate!.getTime(), + ); + const options = await this.buildTrainOptions(booking, candidates); + const now = Date.now(); + return options.map((o) => ({ + ...o, + isOpen: + o.isOpen && + (o.bookingClosesAt == null || o.bookingClosesAt.getTime() > now), + })); + } + + /** Resolve form-entered cargo onto an (unpersisted) booking probe. */ + private async withCargoOverrides( + booking: Booking, + overrides?: TrainOptionCargoOverrides, + ): Promise { const sizeFts = (overrides?.containerSizes ?? []) .map((s) => parseInt(s, 10)) .filter((n) => Number.isFinite(n) && n > 0); @@ -1092,25 +1174,14 @@ export class BookingBatchService implements OnModuleInit { if (overrides?.wagons && overrides.wagons > 0) { booking = { ...booking, wagonsRequired: overrides.wagons } as Booking; } - const corridor = await this.trainSchedulesRepository.findAll({ - where: [ - { status: TrainScheduleStatusEnum.Draft }, - { status: TrainScheduleStatusEnum.Scheduled }, - ], - }); - const candidates = corridor - .filter( - (s) => - s.scheduledDepartureDate != null && - eatDay(s.scheduledDepartureDate) === day && - s.direction === 'EXPORT', - ) - .sort( - (a, b) => - a.scheduledDepartureDate!.getTime() - - b.scheduledDepartureDate!.getTime(), - ); + return booking; + } + /** One availability card per candidate schedule — the export picker's math. */ + private async buildTrainOptions( + booking: Booking, + candidates: TrainSchedule[], + ): Promise { const wagonDims = await this.loadWagonDims(); const allowed = this.allowedDimsWithTypes(booking, wagonDims); const neededWagons = this.wagonsFor(booking, wagonDims); @@ -1150,13 +1221,21 @@ export class BookingBatchService implements OnModuleInit { const ledger = new WagonStockLedger( stock.remainingByTypeId, Math.max(1, budget.stops.length - 1), + stock.byYardId, + budget.stops, ); + // On a multi-yard consist the pool that matters is the one standing at + // the booking's own boarding yard — a type carried only in Mojo must not + // be advertised to a customer boarding at Dire. + const carriedAtBoardYard = (wagonTypeId: string): number => { + const boardYardId = stock.byYardId ? budget.stops[leg.fromEdge] : null; + if (boardYardId) return stock.byYardId?.get(boardYardId)?.get(wagonTypeId) ?? 0; + return stock.remainingByTypeId.get(wagonTypeId) ?? 0; + }; const byWagonType = allowed .filter( ({ wagonTypeId }) => - stock.mode !== 'TRAIN' || - !wagonTypeId || - (stock.remainingByTypeId.get(wagonTypeId) ?? 0) > 0, + stock.mode !== 'TRAIN' || !wagonTypeId || carriedAtBoardYard(wagonTypeId) > 0, ) .map(({ wagonTypeId, dims }) => { const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined; @@ -1219,8 +1298,9 @@ export class BookingBatchService implements OnModuleInit { ): Promise<{ freeWagons: number; need: number; trainsForDay: boolean }> { const corridor = await this.trainSchedulesRepository.findAll({ where: [ - { status: TrainScheduleStatusEnum.Draft }, - { status: TrainScheduleStatusEnum.Scheduled }, + // Dedicated shipping-line trains are never customer-booking targets. + { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() }, + { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() }, ], }); const candidates = corridor.filter( @@ -2365,11 +2445,14 @@ export class BookingBatchService implements OnModuleInit { originStationId: originYardId, destinationStationId: destinationYardId, status: TrainScheduleStatusEnum.Draft, + // Dedicated shipping-line trains never join the customer day pool. + shippingLineCompanyId: IsNull(), }, { originStationId: originYardId, destinationStationId: destinationYardId, status: TrainScheduleStatusEnum.Scheduled, + shippingLineCompanyId: IsNull(), }, ], }); @@ -3100,8 +3183,9 @@ export class BookingBatchService implements OnModuleInit { if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); const schedules = await this.trainSchedulesRepository.findAll({ where: [ - { status: TrainScheduleStatusEnum.Draft }, - { status: TrainScheduleStatusEnum.Scheduled }, + // Dedicated shipping-line trains are never customer-booking targets. + { status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() }, + { status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() }, ], }); const today = eatDay(new Date()); @@ -3176,6 +3260,99 @@ export class BookingBatchService implements OnModuleInit { } } + /** + * Auto-allocate an accepted SHIPPING-LINE booking onto its company's + * dedicated train for the booking's lane and shipment day. + * + * Runs at operation-accept: shipping lines pay later on the credit ledger, + * so there is no pay window between accept and wagon placement — the + * booking boards its train immediately. Customer bookings never come here; + * they keep the batch pool → reserve → pay → allocate pipeline. + * + * Wagon shortage parks the booking WAITING_FOR_WAGON on the schedule + * (without the PAID stamps the customer hold writes — nothing was paid). + * No dedicated train on the day is not an error: the booking simply stays + * in the ordinary day pool for the batch engine. + */ + async allocateShippingLineAccepted(bookingId: string): Promise { + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: bookingId }, + relations: { bookingContainers: { containerType: true }, cargoType: true }, + }); + if (!booking?.shippingLineCompanyId || !booking.scheduledDate) return; + if (isRoadService(booking.serviceType)) return; + + const day = eatDay(booking.scheduledDate); + const dedicated = await this.dataSource.getRepository(TrainSchedule).find({ + where: [ + { + shippingLineCompanyId: booking.shippingLineCompanyId, + originStationId: booking.originYardId, + destinationStationId: booking.destinationYardId, + status: TrainScheduleStatusEnum.Draft, + }, + { + shippingLineCompanyId: booking.shippingLineCompanyId, + originStationId: booking.originYardId, + destinationStationId: booking.destinationYardId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }); + const target = dedicated.find( + (s) => + s.scheduledDepartureDate && eatDay(s.scheduledDepartureDate) === day, + ); + if (!target) { + this.logger.log( + `[BATCH] shipping-line booking ${booking.reference} has no dedicated ` + + `train on ${day} — left in the day pool for the batch engine`, + ); + return; + } + + // Point the booking at its train BEFORE the shortage probe — the probe + // reads the link to size the need against that schedule's wagons. + await this.dataSource.getRepository(Booking).update(booking.id, { + trainScheduleId: target.id, + } as never); + booking.trainScheduleId = target.id; + + // One dedicated train carries ONE booking: the accept claims the train by + // closing its booking window on the spot. Both gates a later booking + // passes — the day picker (isStillOpen on windowClosesAt) and the + // completion's dedicated-day check — read these fields, so a second + // booking can never pick this train. + await this.dataSource.getRepository(TrainSchedule).update(target.id, { + bookingWindowStatus: "CLOSED", + windowClosesAt: new Date(), + } as never); + this.notifyBoardChanged(target.id, "shipping_line_train_claimed"); + + const shortage = + await this.trainSchedulingService.previewPaidBookingWagonShortage( + target.id, + booking.id, + ); + if (shortage) { + // Parked for staff to attach wagons — WITHOUT the customer hold's PAID + // stamps: a shipping line has paid nothing, its debt sits on the ledger. + await this.dataSource.getRepository(Booking).update(booking.id, { + schedulingStatus: "WAITING_FOR_WAGON", + } as never); + this.logger.warn( + `Shipping-line booking ${booking.reference} WAITING FOR WAGON on its ` + + `dedicated train ${target.reference ?? target.id}: needs ` + + `${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` + + `${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}).`, + ); + this.notifyBoardChanged(target.id, "booking_waiting_wagon"); + return; + } + + await this.allocate(target.id, booking, "shipping_line"); + } + /** * One reminder per hold, shortly before its pay deadline (the window tick * calls this every pass; `payment_reminder_sent_at` dedups). Skips paid @@ -3535,7 +3712,7 @@ export class BookingBatchService implements OnModuleInit { private async allocate( scheduleId: string, booking: Booking, - reason: "paid" | "gov", + reason: "paid" | "gov" | "shipping_line", ): Promise { // Stamp the computed wagon need on the link. Several callers pass a booking // loaded without cargo relations (ensurePaidBookingAllocated), and a NULL @@ -3680,23 +3857,24 @@ export class BookingBatchService implements OnModuleInit { // (provider query errored / payment still in flight) means we could not // confirm "not paid" — never expire on unknown; the next settle tick // asks again. - if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) { - const reconcile = await this.billing.reconcilePayable(booking.id); - if (reconcile.paid) { - this.logger.log( - `[BATCH] expire skipped for ${booking.reference} — gateway ` + - `reconcile found a settled payment; payment.succeeded will allocate it`, - ); - return; - } - if (reconcile.unverifiable) { - this.logger.warn( - `[BATCH] expire deferred for ${booking.reference} — settlement ` + - `unverifiable at the gateway; retrying next settle tick`, - ); - return; - } - } + // TODO: CBE has no reconcile endpoint yet — re-enable once available. + // if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) { + // const reconcile = await this.billing.reconcilePayable(booking.id); + // if (reconcile.paid) { + // this.logger.log( + // `[BATCH] expire skipped for ${booking.reference} — gateway ` + + // `reconcile found a settled payment; payment.succeeded will allocate it`, + // ); + // return; + // } + // if (reconcile.unverifiable) { + // this.logger.warn( + // `[BATCH] expire deferred for ${booking.reference} — settlement ` + + // `unverifiable at the gateway; retrying next settle tick`, + // ); + // return; + // } + // } } const freedScheduleId = booking.trainScheduleId; await this.bookingsRepository.update(booking.id, { @@ -4613,6 +4791,8 @@ export class BookingBatchService implements OnModuleInit { return new WagonStockLedger( stock.remainingByTypeId, Math.max(1, budget.stops.length - 1), + stock.byYardId, + budget.stops, ); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts index 0a8cc99fe..ea936465e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts @@ -11,6 +11,8 @@ describe('BookingJourneyService.autoPlaceOnFreedWagons', () => { {} as never, // yardFacilities {} as never, // facilityHandling { emit: jest.fn() } as never, // events + {} as never, // notifications + {} as never, // inbox ); const schedule = { id: 'sched-1', trainSetId: 'ts-1' }; 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 bf6ab5069..56b28fb7f 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 @@ -24,7 +24,10 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin import { Wagon } from '../wagons/entities/wagon.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; -import { assertExportReceivedWithGrn } from '../../common/export-received-gate'; +import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { notifyCarriageAcceptanceReady } from '../notifications/notify-company.util'; /** * Per-booking journey along a train's corridor — for EVERY trade direction. @@ -52,6 +55,8 @@ export class BookingJourneyService { private readonly yardFacilities: YardFacilitiesService, private readonly facilityHandling: FacilityHandlingService, private readonly events: EventEmitter2, + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} @@ -76,6 +81,21 @@ export class BookingJourneyService { // Export cargo must be in the warehouse with a GRN before it can be loaded, // however it arrived and whatever it is allocated to. await assertExportReceivedWithGrn(this.dataSource, booking); + // Direct truck-to-train cargo never sees the warehouse, so loading IS its + // handover moment — the carriage acceptance sheet must go out to the + // customer right here, not on a receive event that will never fire. + if ( + booking.tradeDirection === 'EXPORT' && + booking.exportHandoverMode === DIRECT_TO_TRAIN + ) { + await notifyCarriageAcceptanceReady( + this.dataSource, + this.notifications, + this.inbox, + booking.id, + this.logger, + ); + } const now = new Date(); await this.dataSource.transaction(async (manager) => { 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 9468387c5..1c2d99401 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 @@ -12,6 +12,7 @@ import { Booking } from '../bookings/entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util'; +import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { BATCH_TIMEZONE } from './booking-batch.constants'; @@ -66,10 +67,16 @@ export class BookingNotifierService { ): Promise { this.logger.log(`${logLabel} — ${this.ref(b)}`); // One resolver for both channels — the company row's own email column is - // only set for a Fayda-verified owner (see companyNotifyEmailExpr). - const { phone, email } = b.companyId - ? await resolveCompanyNotifyContact(this.dataSource, b.companyId) - : { phone: null, email: null }; + // only set for a Fayda-verified owner (see companyNotifyEmailExpr). A + // shipping-line booking has no company; its contact is the line's row. + const { phone, email } = b.shippingLineCompanyId + ? await resolveShippingLineNotifyTarget( + this.dataSource, + b.shippingLineCompanyId, + ) + : b.companyId + ? await resolveCompanyNotifyContact(this.dataSource, b.companyId) + : { phone: null, email: null }; if (phone) { try { @@ -90,13 +97,42 @@ export class BookingNotifierService { } } - /** Persist + push an in-app item to all portal users of the booking's company. */ + /** + * Persist + push an in-app item to the booking's portal owner: every portal + * user of the company, or — for a shipping-line booking — the line's own + * account, deep-linked into the shipping-line app (/shipping-line/*). + */ private inApp( b: Booking, title: string, body: string, overrides: Partial = {}, ): void { + if (b.shippingLineCompanyId) { + void (async () => { + const { userId } = await resolveShippingLineNotifyTarget( + this.dataSource, + b.shippingLineCompanyId!, + ); + if (!userId) return; + void this.inbox.notify({ + recipients: { userIds: [userId] }, + audience: NotificationAudience.PORTAL, + type: NotificationType.SCHEDULE_UPDATE, + title, + body, + data: { bookingId: b.id, reference: b.reference }, + ...overrides, + // After the spread: the bell must land the line on ITS booking page. + link: `/shipping-line/bookings/${b.id}`, + }); + })().catch((err) => + this.logger.warn( + `shipping-line inApp failed for ${this.ref(b)}: ${(err as Error).message}`, + ), + ); + return; + } if (!b.companyId) return; // government/unlinked bookings have no portal users void this.inbox.notify({ recipients: { companyId: b.companyId }, @@ -209,11 +245,19 @@ export class BookingNotifierService { }); } - secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void { + secured( + b: Booking, + reason: 'paid' | 'gov' | 'shipping_line', + scheduleId?: string | null, + ): void { void (async () => { const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId); const msg = `Booking ${b.reference ?? b.id} allocated on ${label}${ - reason === 'gov' ? ' (government)' : '' + reason === 'gov' + ? ' (government)' + : reason === 'shipping_line' + ? ' (shipping line)' + : '' }.`; void this.notifyContact(b, msg, 'ALLOCATED'); this.inApp(b, 'Wagon allocated', msg); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index c87d118c8..54056404a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -5,7 +5,7 @@ import { UserTradeAccessService } from "../../user-trade-access/user-trade-acces import { resolveAuthUserId } from "../../../common/resolve-auth-user-id"; import { - Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res, + Body, Controller, Delete, Get, Param, ParseIntPipe, ParseUUIDPipe, Patch, Post, Query, Res, } from "@nestjs/common"; import { CurrentUser } from "@edr/api-common"; import { @@ -15,6 +15,7 @@ import { PortalCustomer, TrainSchedulingCancel, TrainSchedulingCreate, + TrainSchedulingEditTrainNumber, TrainSchedulingReschedule, TrainSchedulingRulesManage, TrainSchedulingUpdate, @@ -36,7 +37,11 @@ import { UpdateImportLoadingStatusDto } from "../dto/update-import-loading-statu import { PreviewBulkTrainScheduleDto } from "../dto/preview-bulk-train-schedule.dto"; import { PreviewContainerTrainScheduleDto } from "../dto/preview-container-train-schedule.dto"; import { PreviewTrainScheduleDto } from "../dto/preview-train-schedule.dto"; -import { RecordCheckpointDto } from "../dto/record-checkpoint.dto"; +import { + DispatchScheduleDto, + RecordCheckpointDto, + UpdateCheckpointDto, +} from "../dto/record-checkpoint.dto"; import { ImportDjiboutiActionDto, UploadImportDjiboutiDocumentDto, @@ -52,6 +57,8 @@ import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-q import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto"; import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.dto"; import { UpdateScheduleDateDto } from "../dto/update-schedule-date.dto"; +import { MergeScheduleTrainDto } from "../dto/merge-schedule-train.dto"; +import { UpdateScheduleTrainNumberDto } from "../dto/update-schedule-train-number.dto"; import { MaintenanceRescheduleDto } from "../dto/maintenance-reschedule.dto"; import { TrainSchedulingService } from "../services/train-scheduling.service"; import { BookingBatchService } from "../booking-batch.service"; @@ -513,9 +520,14 @@ export class TrainSchedulingController { @Post("schedules/:id/dispatch") @BookingStaff(FREIGHT_PERMS.trainScheduling.dispatch) - @ApiOperation({ summary: "Dispatch a scheduled train" }) - dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) { - return this.trainSchedulingService.dispatchSchedule(id); + @ApiOperation({ + summary: "Dispatch a scheduled train (optional actual departure time, past allowed)", + }) + dispatchSchedule( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: DispatchScheduleDto, + ) { + return this.trainSchedulingService.dispatchSchedule(id, dto); } @Get("intercity/bookings") @@ -795,6 +807,47 @@ export class TrainSchedulingController { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Patch("schedules/:id/train-number") + @TrainSchedulingEditTrainNumber() + @ApiOperation({ + summary: + "Edit a departure's train number and voyage number — allowed only until the train is dispatched", + }) + async updateScheduleTrainNumber( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateScheduleTrainNumberDto, + ) { + await this.trainSchedulingService.updateScheduleTrainNumber(id, dto); + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + + @Get("schedules/:id/merge-preview/:targetTrainId") + @TrainSchedulingUpdate() + @ApiOperation({ + summary: + "What merging a train into this schedule would do — affected schedules, wagon totals and any blocking reasons. Read-only.", + }) + async previewScheduleMerge( + @Param("id", ParseUUIDPipe) id: string, + @Param("targetTrainId", ParseUUIDPipe) targetTrainId: string, + ) { + return this.trainSchedulingService.previewMerge(id, targetTrainId); + } + + @Post("schedules/:id/merge") + @TrainSchedulingUpdate() + @ApiOperation({ + summary: + "Merge another train into this schedule: its wagons join this consist, a same-day schedule on it is absorbed, and the emptied train is deactivated", + }) + async mergeScheduleTrain( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: MergeScheduleTrainDto, + ) { + await this.trainSchedulingService.mergeScheduleTrain(id, dto); + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + @Post("schedules/:id/maintenance") @TrainSchedulingReschedule() @ApiOperation({ @@ -912,6 +965,20 @@ export class TrainSchedulingController { return this.trainSchedulingService.recordCheckpoint(id, dto); } + @Patch("schedules/:id/checkpoints/:sequenceNo") + @TrainSchedulingUpdate() + @ApiOperation({ + summary: + "Edit a logged leg's time/note (no side effects; allowed while dispatched or after arrival)", + }) + updateCheckpoint( + @Param("id", ParseUUIDPipe) id: string, + @Param("sequenceNo", ParseIntPipe) sequenceNo: number, + @Body() dto: UpdateCheckpointDto, + ) { + return this.trainSchedulingService.updateCheckpoint(id, sequenceNo, dto); + } + @Post("schedules/:id/arrive") @TrainSchedulingUpdate() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index e8716eb99..18904c032 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -172,6 +172,17 @@ export class CreateContainerTrainScheduleDto { @IsBoolean() reverseWagonOrder?: boolean; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Dedicate this departure to one shipping line. The schedule is then hidden ' + + 'from every customer-facing read (windows, day pools, home cards) and shown ' + + 'only to that shipping line in its portal. Omit for a normal customer train.', + }) + @IsOptional() + @IsUUID() + shippingLineCompanyId?: string; + @ApiPropertyOptional({ type: CreateScheduleWindowRuleDto, description: diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/merge-schedule-train.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/merge-schedule-train.dto.ts new file mode 100644 index 000000000..8f4c09d43 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/merge-schedule-train.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; + +/** + * Merge another train into this schedule's train. The schedule always survives: + * its train set is repointed at `targetTrainId`, that train's wagons join this + * consist, and the source train is left empty and deactivated. + */ +export class MergeScheduleTrainDto { + @ApiProperty({ + description: "The train being merged IN. This schedule's train absorbs it.", + }) + @IsUUID() + targetTrainId!: string; + + @ApiPropertyOptional({ + description: 'Why the trains were merged — kept on the audit trail.', + }) + @IsOptional() + @IsString() + @MaxLength(500) + reason?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts index da7ebc0e7..1495185f0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts @@ -10,8 +10,6 @@ import { Min, } from 'class-validator'; -import { IsNotBackdated } from '../../../common/validators/is-not-backdated.validator'; - export class RecordCheckpointDto { @ApiProperty({ description: 'Station position along the route (0 = origin).' }) @IsInt() @@ -24,17 +22,17 @@ export class RecordCheckpointDto { kind?: TrainCheckpointKind; /** - * A checkpoint records where the train is as staff observe it, and the final - * one arrives the schedule — so a backdated value rewrites the journey after - * the fact. Only "now" is accepted; omit the field and the service stamps it. + * When the train was actually at the station — staff often log after the + * fact, so a past value is allowed. The service rejects the future and any + * value out of order with the neighbouring legs. */ @ApiProperty({ required: false, - description: 'ISO timestamp; defaults to now. Cannot be earlier than now.', + description: + 'ISO timestamp; defaults to now. Past allowed, future rejected, must be in corridor order.', }) @IsOptional() @IsISO8601() - @IsNotBackdated() occurredAt?: string; @ApiProperty({ required: false }) @@ -43,3 +41,30 @@ export class RecordCheckpointDto { @MaxLength(500) note?: string; } + +/** Edit an already-logged leg's time/note — no side effects (no unload, no arrival). */ +export class UpdateCheckpointDto { + @ApiProperty({ + required: false, + description: 'ISO timestamp. Past allowed, future rejected, must be in corridor order.', + }) + @IsOptional() + @IsISO8601() + occurredAt?: string; + + @ApiProperty({ required: false, nullable: true }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string | null; +} + +export class DispatchScheduleDto { + @ApiProperty({ + required: false, + description: 'Actual departure time; defaults to now. Past allowed, future rejected.', + }) + @IsOptional() + @IsISO8601() + actualDepartureAt?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-train-number.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-train-number.dto.ts new file mode 100644 index 000000000..5756732c4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-train-number.dto.ts @@ -0,0 +1,40 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +/** + * Edit a departure's operational run identifiers. Both fields are optional so + * either can be corrected alone; the service rejects a body carrying neither, + * so an empty request cannot write an audit row for a no-op. + * + * Sending an empty string clears the field; omitting it leaves it unchanged. + */ +export class UpdateScheduleTrainNumberDto { + @ApiPropertyOptional({ + example: '9201', + description: "Run number for this departure. Empty string clears it.", + maxLength: 20, + }) + @IsOptional() + @IsString() + @MaxLength(20) + trainNumber?: string; + + @ApiPropertyOptional({ + example: 'V-2026-014', + description: 'Voyage (sailing) number for this departure. Empty string clears it.', + maxLength: 20, + }) + @IsOptional() + @IsString() + @MaxLength(20) + voyageNumber?: string; + + @ApiPropertyOptional({ + description: 'Why the numbers changed — kept on the audit trail.', + maxLength: 500, + }) + @IsOptional() + @IsString() + @MaxLength(500) + reason?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index 94882833b..50debc6c0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -57,9 +57,10 @@ export class TrainSchedulingGlobalRules extends BaseEntity { /** * Local (Africa/Addis_Ababa) hour the booking desk shuts each day. A not-yet-full * train whose next cycle would reopen at/after this hour pauses until the next - * morning's windowOpenHour. Set equal to windowOpenHour for a 24-hour desk. + * morning's windowOpenHour. Set equal to windowOpenHour for a 24-hour desk + * (the default). */ - @Column({ name: 'window_close_hour', type: 'int', default: 17 }) + @Column({ name: 'window_close_hour', type: 'int', default: 8 }) windowCloseHour!: number; // Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/schedule-merge.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/schedule-merge.spec.ts new file mode 100644 index 000000000..c3acfbecb --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/schedule-merge.spec.ts @@ -0,0 +1,399 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; + +import { TrainSchedulingService } from './services/train-scheduling.service'; + +/** + * Merging one train into a schedule. The schedule ALWAYS survives: its train + * set is repointed at the target train, that train's wagons join the consist, + * a same-day schedule on the target is absorbed (bookings move here, it is + * soft-deleted), and the emptied source train is deactivated. + * + * Driven against stub repositories — every rule under test is service logic. + */ +describe('TrainSchedulingService — train merge', () => { + const DAY = '2026-08-12T00:00:00.000Z'; + const OTHER_DAY = '2026-08-14T00:00:00.000Z'; + + /** Rows each repository returns, keyed by entity. */ + type Fixture = { + schedule: Record | null; + train?: Record | null; + trainSets?: Record[]; + schedules?: Record[]; + wagons?: Record[]; + wagonTypes?: Record[]; + scheduleBookings?: Record[]; + allocations?: Record[]; + milestones?: Record[]; + setWagons?: Record[]; + }; + + const makeService = (fx: Fixture) => { + const updates: Array<{ entity: string; args: unknown[] }> = []; + const softDeletes: string[] = []; + + const repoFor = (entity: unknown) => { + const name = (entity as { name?: string })?.name ?? String(entity); + const rows = (): Record[] => { + switch (name) { + case 'Train': + return fx.train ? [fx.train] : []; + case 'TrainSet': + return fx.trainSets ?? []; + case 'TrainSchedule': + return fx.schedules ?? []; + case 'Wagon': + return fx.wagons ?? []; + case 'WagonType': + return fx.wagonTypes ?? []; + case 'TrainScheduleBooking': + return fx.scheduleBookings ?? []; + case 'WagonBookingAllocation': + return fx.allocations ?? []; + case 'RouteMilestone': + return fx.milestones ?? []; + case 'TrainSetWagon': + return fx.setWagons ?? []; + default: + return []; + } + }; + return { + find: jest.fn().mockImplementation(async () => rows()), + findOne: jest.fn().mockImplementation(async () => rows()[0] ?? null), + update: jest.fn().mockImplementation(async (...args: unknown[]) => { + updates.push({ entity: name, args }); + }), + softDelete: jest.fn().mockImplementation(async (id: string) => { + softDeletes.push(id); + }), + }; + }; + + const dataSource = { + getRepository: jest.fn().mockImplementation(repoFor), + transaction: jest + .fn() + .mockImplementation(async (cb: (m: unknown) => Promise) => + cb({ getRepository: repoFor }), + ), + }; + + const service = Object.create( + TrainSchedulingService.prototype, + ) as TrainSchedulingService; + Object.assign(service, { + dataSource, + trainSchedulesRepository: { + findByIdWithFullGraph: jest.fn().mockResolvedValue(fx.schedule), + findById: jest.fn().mockResolvedValue(fx.schedule), + }, + logger: { log: jest.fn(), warn: jest.fn() }, + }); + return { service, updates, softDeletes }; + }; + + /** A draft schedule on T1 with 10 wagons and no locomotive caps. */ + const baseSchedule = (over: Record = {}) => ({ + id: 'S1', + reference: 'S-2026-00001', + status: 'DRAFT', + scheduledDepartureDate: DAY, + routeId: null, + maxWagons: 0, + trainSetId: 'TS1', + trainSet: { + id: 'TS1', + trainId: 'T1', + wagons: Array.from({ length: 10 }, (_, i) => ({ + id: `sw-${i}`, + sequenceNo: i + 1, + lengthMeters: 14, + wagonType: { tareWeightTons: 22.4 }, + })), + }, + ...over, + }); + + const targetWagons = (n: number) => + Array.from({ length: n }, (_, i) => ({ + id: `w-${i}`, + wagonNumber: `200${i}`, + wagonTypeId: 'wt-1', + trainId: 'T2', + })); + + describe('guards', () => { + it('refuses to merge into a dispatched schedule', async () => { + const { service } = makeService({ + schedule: baseSchedule({ status: 'DISPATCHED' }), + }); + + await expect( + service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('refuses to merge a train into itself', async () => { + const { service } = makeService({ schedule: baseSchedule() }); + + await expect( + service.mergeScheduleTrain('S1', { targetTrainId: 'T1' }), + ).rejects.toThrow(/already this schedule's train/i); + }); + + it('404s on an unknown schedule', async () => { + const { service } = makeService({ schedule: null }); + + await expect( + service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('blocks when the target train has no wagons to give', async () => { + const { service } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + wagons: [], + }); + + await expect( + service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }), + ).rejects.toThrow(/no wagons to merge/i); + }); + }); + + describe('preview', () => { + it('reports the merged wagon total and the emptied source train', async () => { + const { service } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2', trainNumber: '8002' }, + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + }); + + const preview = await service.previewMerge('S1', 'T2'); + + expect(preview.canMerge).toBe(true); + expect(preview.wagons).toEqual({ current: 10, incoming: 40, merged: 50 }); + expect(preview.sourceTrainWillDeactivate).toBe(true); + expect(preview.absorbedSchedule).toBeNull(); + }); + + it('names the same-day schedule whose bookings move here', async () => { + const { service } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + trainSets: [{ id: 'TS2', trainId: 'T2' }], + schedules: [ + { + id: 'S2', + reference: 'S-2026-00002', + status: 'SCHEDULED', + scheduledDepartureDate: DAY, + trainSetId: 'TS2', + }, + ], + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + scheduleBookings: [ + { id: 'sb-1', bookingId: 'bk-1', trainScheduleId: 'S2' }, + { id: 'sb-2', bookingId: 'bk-2', trainScheduleId: 'S2' }, + ], + }); + + const preview = await service.previewMerge('S1', 'T2'); + + expect(preview.absorbedSchedule).toMatchObject({ + id: 'S2', + reference: 'S-2026-00002', + bookingsMoving: 2, + }); + }); + + it('lists an other-day schedule as wagons-only, never absorbed', async () => { + const { service } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + trainSets: [{ id: 'TS2', trainId: 'T2' }], + schedules: [ + { + id: 'S3', + reference: 'S-2026-00003', + status: 'DRAFT', + scheduledDepartureDate: OTHER_DAY, + trainSetId: 'TS2', + }, + ], + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + }); + + const preview = await service.previewMerge('S1', 'T2'); + + expect(preview.absorbedSchedule).toBeNull(); + expect(preview.affectedSchedules).toHaveLength(1); + expect(preview.affectedSchedules[0]).toMatchObject({ id: 'S3' }); + }); + + it('leaves a dispatched schedule on the target untouched', async () => { + const { service } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + trainSets: [{ id: 'TS2', trainId: 'T2' }], + schedules: [ + { + id: 'S4', + status: 'DISPATCHED', + scheduledDepartureDate: DAY, + trainSetId: 'TS2', + }, + ], + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + }); + + const preview = await service.previewMerge('S1', 'T2'); + + // Same day, but dispatched — its cargo stays put. + expect(preview.absorbedSchedule).toBeNull(); + expect(preview.affectedSchedules).toHaveLength(0); + expect(preview.untouchedSchedules).toHaveLength(1); + }); + }); + + describe('commit', () => { + it('repoints the set, moves the wagons and deactivates the source train', async () => { + const { service, updates } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + }); + + await service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }); + + const setRepoint = updates.find( + (u) => u.entity === 'TrainSet' && u.args[0] === 'TS1', + ); + expect(setRepoint?.args[1]).toMatchObject({ trainId: 'T2' }); + + const wagonMove = updates.find((u) => u.entity === 'Wagon'); + expect(wagonMove?.args[1]).toMatchObject({ trainId: 'T2' }); + + const trainPark = updates.find( + (u) => u.entity === 'Train' && u.args[0] === 'T1', + ); + expect(trainPark?.args[1]).toMatchObject({ status: 'DEACTIVATED' }); + }); + + it('moves the absorbed schedule\'s bookings here and soft-deletes it', async () => { + const { service, updates, softDeletes } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + trainSets: [{ id: 'TS2', trainId: 'T2' }], + schedules: [ + { + id: 'S2', + reference: 'S-2026-00002', + status: 'SCHEDULED', + scheduledDepartureDate: DAY, + trainSetId: 'TS2', + }, + ], + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + scheduleBookings: [ + { id: 'sb-1', bookingId: 'bk-1', trainScheduleId: 'S2' }, + ], + }); + + await service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }); + + const bookingMove = updates.find( + (u) => u.entity === 'TrainScheduleBooking', + ); + expect(bookingMove?.args[0]).toMatchObject({ trainScheduleId: 'S2' }); + expect(bookingMove?.args[1]).toMatchObject({ trainScheduleId: 'S1' }); + + // Soft-deleted, not cancelled — the bookings still exist and still depart. + expect(softDeletes).toEqual(['S2']); + }); + + it('appends merged wagons after the existing consist', async () => { + const { service, updates } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + wagons: targetWagons(2), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + setWagons: [ + { id: 'in-0', trainSetId: 'TS2', physicalWagonId: 'w-0' }, + { id: 'in-1', trainSetId: 'TS2', physicalWagonId: 'w-1' }, + ], + }); + + await service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }); + + // 10 existing wagons occupy 1..10, so the merged pair lands at 11 and 12 + // — staff reorder them in the train builder afterwards. + const seqs = updates + .filter((u) => u.entity === 'TrainSetWagon') + .map((u) => (u.args[1] as { sequenceNo: number }).sequenceNo); + expect(seqs).toEqual([11, 12]); + }); + }); + + describe('capacity', () => { + it('blocks a merge that overruns the locomotive length cap', async () => { + const { service } = makeService({ + schedule: baseSchedule({ + trainSet: { + id: 'TS1', + trainId: 'T1', + // A short loco: 100m of train, already 10 × 14m = 140m used. + locomotive: { + maxPullWeightTons: 5000, + maxTrainLengthMeters: 100, + }, + wagons: Array.from({ length: 10 }, (_, i) => ({ + id: `sw-${i}`, + sequenceNo: i + 1, + lengthMeters: 14, + wagonType: { tareWeightTons: 22.4 }, + })), + }, + }), + train: { id: 'T2', code: 'TR-2' }, + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + }); + + await expect( + service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }), + ).rejects.toThrow(/exceeds max train length/i); + }); + + it('blocks a merge that overruns the pull-weight cap', async () => { + const { service } = makeService({ + schedule: baseSchedule({ + trainSet: { + id: 'TS1', + trainId: 'T1', + locomotive: { + maxPullWeightTons: 300, + maxTrainLengthMeters: 10000, + }, + wagons: [], + }, + }), + train: { id: 'T2', code: 'TR-2' }, + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + }); + + await expect( + service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }), + ).rejects.toThrow(/exceeds max pull weight/i); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/schedule-train-number.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/schedule-train-number.spec.ts new file mode 100644 index 000000000..d7dae50f4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/schedule-train-number.spec.ts @@ -0,0 +1,114 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; + +import { TrainSchedulingService } from './services/train-scheduling.service'; +import type { UpdateScheduleTrainNumberDto } from './dto/update-schedule-train-number.dto'; + +/** + * Guards around renumbering a departure. Exercised against a stub repository — + * the rules (dispatch lock, empty-body rejection, clear-vs-leave semantics) are + * pure service logic and need no database. + */ +describe('TrainSchedulingService.updateScheduleTrainNumber', () => { + const makeService = (schedule: Record | null) => { + const update = jest.fn().mockResolvedValue(undefined); + const findById = jest.fn().mockResolvedValue(schedule); + const service = Object.create( + TrainSchedulingService.prototype, + ) as TrainSchedulingService; + Object.assign(service, { + trainSchedulesRepository: { findById, update }, + logger: { log: jest.fn(), warn: jest.fn() }, + }); + return { service, update, findById }; + }; + + const call = (service: TrainSchedulingService, dto: UpdateScheduleTrainNumberDto) => + service.updateScheduleTrainNumber('sched-1', dto); + + it('updates both numbers on a SCHEDULED train', async () => { + const { service, update } = makeService({ + id: 'sched-1', + status: 'SCHEDULED', + trainNumber: '9101', + voyageNumber: null, + }); + + await call(service, { trainNumber: '9201', voyageNumber: 'V-2026-014' }); + + expect(update).toHaveBeenCalledWith('sched-1', { + trainNumber: '9201', + voyageNumber: 'V-2026-014', + }); + }); + + it('refuses to renumber a dispatched train', async () => { + // The numbers are already printed on paperwork that left with the train. + const { service, update } = makeService({ + id: 'sched-1', + status: 'DISPATCHED', + trainNumber: '9101', + }); + + await expect(call(service, { trainNumber: '9201' })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(update).not.toHaveBeenCalled(); + }); + + it.each(['ARRIVED', 'CANCELLED', 'COMPLETED'])( + 'refuses to renumber a %s schedule', + async (status) => { + const { service, update } = makeService({ id: 'sched-1', status }); + + await expect(call(service, { trainNumber: '9201' })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(update).not.toHaveBeenCalled(); + }, + ); + + it('rejects a body carrying neither number before touching the schedule', async () => { + const { service, update, findById } = makeService({ + id: 'sched-1', + status: 'DRAFT', + }); + + await expect(call(service, {})).rejects.toBeInstanceOf(BadRequestException); + expect(findById).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + it('leaves an omitted field untouched rather than clearing it', async () => { + const { service, update } = makeService({ + id: 'sched-1', + status: 'DRAFT', + trainNumber: '9101', + voyageNumber: 'V-1', + }); + + await call(service, { trainNumber: '9201' }); + + expect(update).toHaveBeenCalledWith('sched-1', { trainNumber: '9201' }); + expect(update.mock.calls[0][1]).not.toHaveProperty('voyageNumber'); + }); + + it('clears a field when an empty string is sent', async () => { + const { service, update } = makeService({ + id: 'sched-1', + status: 'DRAFT', + voyageNumber: 'V-1', + }); + + await call(service, { voyageNumber: ' ' }); + + expect(update).toHaveBeenCalledWith('sched-1', { voyageNumber: null }); + }); + + it('404s on an unknown schedule', async () => { + const { service } = makeService(null); + + await expect(call(service, { trainNumber: '9201' })).rejects.toBeInstanceOf( + NotFoundException, + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index 0a7384cfa..15cf4f592 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -94,6 +94,7 @@ describe('TrainSchedulingService', () => { let wagonBookingAllocationsRepository: Record; let wagonAllocationContainerItemsRepository: Record; let wagonAllocationBulkLoadsRepository: Record; + let trainCheckpointEventsRepository: Record; beforeEach(() => { // findGroupSiblings runs a query builder off dataSource.manager; default it @@ -127,6 +128,7 @@ describe('TrainSchedulingService', () => { findByIdWithFullGraph: jest.fn(), findAll: jest.fn(), updateStatus: jest.fn(), + update: jest.fn(), maxReferenceSequence: jest.fn().mockResolvedValue(0), }; trainScheduleBookingsRepository = { @@ -150,7 +152,7 @@ describe('TrainSchedulingService', () => { findAll: jest.fn().mockResolvedValue([]), }; - const trainCheckpointEventsRepository = { + trainCheckpointEventsRepository = { findBySchedule: jest.fn().mockResolvedValue([]), findAll: jest.fn().mockResolvedValue([]), create: jest.fn(), @@ -181,6 +183,7 @@ describe('TrainSchedulingService', () => { autoArriveAtFinalYard: jest.fn().mockResolvedValue([]), } as never, // bookingJourneyService { dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier + { getLogoImageUrl: jest.fn().mockResolvedValue(null) } as never, // logoSettings ); const defaultFleetWagons = [ @@ -1131,6 +1134,36 @@ describe('TrainSchedulingService', () => { expect(html).toContain('2 (1 empty)'); }); + it('lists loaded empty containers by number and states they are empty', () => { + const schedule = { + id: 'schedule-1', + trainNumber: '8301', + direction: 'EXPORT', + trainSet: { + wagons: [makeWagon(1, 'W-001', []), makeWagon(2, 'W-002', []), makeWagon(3, 'W-003', [])], + }, + scheduleBookings: [], + }; + + const html = (service as never as { + buildExportLoadListHtml: (s: unknown, o?: unknown) => string; + }).buildExportLoadListHtml(schedule, { + emptyContainers: [ + { containerNumber: 'CMU9876543', containerSize: '40', wagonSequenceNo: 1 }, + { containerNumber: 'TEMU1112223', containerSize: '20', wagonSequenceNo: 2 }, + { containerNumber: 'TEMU4445556', containerSize: '20', wagonSequenceNo: 2 }, + ], + }); + + expect(html).toContain('CMU9876543'); + expect(html).toContain('TEMU1112223, TEMU4445556'); + expect(html.match(/EMPTY CONTAINER/g)).toHaveLength(2); + // Wagon 3 carries nothing at all, so it keeps the bare-wagon wording. + expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1); + expect(html).toContain('3 (1 empty)'); + expect(html).toContain('Empty containers3'); + }); + it('renders wagons in consist order regardless of the order the relation returns', () => { const schedule = { id: 'schedule-1', @@ -1607,6 +1640,61 @@ describe('TrainSchedulingService', () => { }); }); + describe('updateCheckpoint — leg time correction', () => { + const t = (h: number) => new Date(Date.UTC(2026, 0, 1, h)); + const schedule = { + id: 'sch-track', + status: 'ARRIVED', + routeId: null, + originStationId: 'y0', + destinationStationId: 'y1', + actualDepartureAt: t(8), + }; + const events = () => [ + { id: 'e0', yardId: 'y0', sequenceNo: 0, kind: 'DEPARTED', occurredAt: t(8) }, + { id: 'e1', yardId: 'y1', sequenceNo: 1, kind: 'ARRIVED', occurredAt: t(12) }, + ]; + + beforeEach(() => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule); + trainCheckpointEventsRepository.findBySchedule.mockImplementation(async () => events()); + }); + + it('rejects a leg time earlier than the previous leg', async () => { + await expect( + service.updateCheckpoint('sch-track', 1, { occurredAt: t(7).toISOString() }), + ).rejects.toThrow(/cannot be earlier than/); + expect(trainCheckpointEventsRepository.update).not.toHaveBeenCalled(); + }); + + it('rejects a leg time later than the next leg', async () => { + await expect( + service.updateCheckpoint('sch-track', 0, { occurredAt: t(13).toISOString() }), + ).rejects.toThrow(/cannot be later than/); + }); + + it('rejects a future time', async () => { + const future = new Date(Date.now() + 3_600_000).toISOString(); + await expect( + service.updateCheckpoint('sch-track', 1, { occurredAt: future }), + ).rejects.toThrow(/future/); + }); + + it('accepts an in-order past time and re-stamps arrival for the final leg', async () => { + await service.updateCheckpoint('sch-track', 1, { + occurredAt: t(11).toISOString(), + note: 'late log', + }); + expect(trainCheckpointEventsRepository.update).toHaveBeenCalledWith('e1', { + occurredAt: t(11), + note: 'late log', + }); + expect(trainSchedulesRepository.update).toHaveBeenCalledWith('sch-track', { + actualArrivalAt: t(11), + }); + }); + }); + describe('effectiveWagonsRequired', () => { const effective = (booking: unknown): number => (service as never as { effectiveWagonsRequired(b: unknown): number }) @@ -1646,4 +1734,118 @@ describe('TrainSchedulingService', () => { ).toBe(5); }); }); + + describe('maintenanceReschedule — window reopens when it had already finished', () => { + const { TrainSchedule } = jest.requireActual( + '../../train-schedules/entities/train-schedule.entity', + ); + + const doneExportSchedule = (extra: Record = {}) => ({ + id: 'sch-done', + status: 'SCHEDULED', + direction: 'EXPORT', + windowPhase: 'DONE', + bookingWindowStatus: 'CLOSED', + scheduledDepartureDate: new Date('2027-06-20T05:00:00.000Z'), + scheduledArrivalDate: null, + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + scheduleBookings: [], + // Frozen rule snapshot: desk 8–17 EAT, 24h lead, close 120min before departure. + ruleWindowOpenHour: 8, + ruleWindowCloseHour: 17, + ruleExportBookingLeadHours: 24, + ruleExportCloseOffsetMinutes: 120, + ...extra, + }); + + let scheduleUpdate: jest.Mock; + + beforeEach(() => { + scheduleUpdate = jest.fn().mockResolvedValue({ affected: 1 }); + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === TrainSchedulingGlobalRules) { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity === TrainSchedule) { + return { update: scheduleUpdate, find: jest.fn().mockResolvedValue([]) }; + } + return { + find: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(null), + update: jest.fn(), + }; + }); + trainSchedulesRepository.findById.mockResolvedValue(null); + }); + + it('reopens a DONE export window against the new departure', async () => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue( + doneExportSchedule(), + ); + + // New departure 12:00 EAT → window opens 24h earlier (12:00 EAT, inside + // the desk) and closes at departure − 120min = 10:00 EAT. + await service.maintenanceReschedule('sch-done', { + newDepartureDate: '2027-06-20T09:00:00.000Z', + } as never); + + expect(scheduleUpdate).toHaveBeenCalledWith( + 'sch-done', + expect.objectContaining({ + scheduledDepartureDate: new Date('2027-06-20T09:00:00.000Z'), + windowPhase: 'PRE_WINDOW', + bookingWindowStatus: 'CLOSED', + windowOpensAt: new Date('2027-06-19T09:00:00.000Z'), + windowClosesAt: new Date('2027-06-20T07:00:00.000Z'), + docReviewCompletedAt: null, + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + }), + ); + }); + + it('keeps a FULL train closed — nothing left to sell', async () => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue( + doneExportSchedule({ bookingWindowStatus: 'FULL' }), + ); + + await service.maintenanceReschedule('sch-done', { + newDepartureDate: '2027-06-20T09:00:00.000Z', + } as never); + + const written = scheduleUpdate.mock.calls[0][1]; + expect(written.scheduledDepartureDate).toEqual( + new Date('2027-06-20T09:00:00.000Z'), + ); + expect(written.windowPhase).toBeUndefined(); + expect(written.windowOpensAt).toBeUndefined(); + }); + + it('moves an OPEN export close to the new departure but keeps the open', async () => { + const opensAt = new Date('2027-06-19T03:00:00.000Z'); + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue( + doneExportSchedule({ + windowPhase: 'OPEN', + bookingWindowStatus: 'OPEN', + windowOpensAt: opensAt, + windowClosesAt: new Date('2027-06-20T03:00:00.000Z'), + }), + ); + + // Departure pushed 3 days later → close = new departure − 120min; the + // open customers already booked against stays untouched. + await service.maintenanceReschedule('sch-done', { + newDepartureDate: '2027-06-23T05:00:00.000Z', + } as never); + + const written = scheduleUpdate.mock.calls[0][1]; + expect(written.scheduledDepartureDate).toEqual( + new Date('2027-06-23T05:00:00.000Z'), + ); + expect(written.windowClosesAt).toEqual(new Date('2027-06-23T03:00:00.000Z')); + expect(written.windowOpensAt).toBeUndefined(); + expect(written.windowPhase).toBeUndefined(); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 657703ada..9a20c0486 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -47,6 +47,7 @@ import { Container } from '../../container-management/entities/container.entity' import { Locomotive } from '../../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../../locomotives/locomotives.repository'; import { formatRouteLabel, Route } from '../../routes/entities/route.entity'; +import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; import { WagonMovement } from '../../wagons/entities/wagon-movement.entity'; import { Train } from '../../trains/entities/train.entity'; import { TrainSetLocomotive } from '../../train-sets/entities/train-set-locomotive.entity'; @@ -90,12 +91,15 @@ import { ImportDjiboutiOperation, type ImportDjiboutiDocumentType, } from '../entities/import-djibouti-operation.entity'; +import { EmptyContainerReturn } from '../../import-operations/entities/empty-container-return.entity'; import { ImportDjiboutiActionDto, UploadImportDjiboutiDocumentDto, } from '../dto/import-djibouti-operation.dto'; import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto'; import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto'; +import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto'; +import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto'; import { MaintenanceRescheduleDto } from '../dto/maintenance-reschedule.dto'; import { type BookingWindowConfig } from '../booking-window.config'; import { BookingWindowGateway } from '../booking-window.gateway'; @@ -127,19 +131,23 @@ import { perEdgeConsistUsage, validateContainerPlacements, validateMixedTrainLimitsPerEdge, + MAX_TEU_SLOTS_PER_WAGON, type ContainerPlacementInput, type WagonPlanSlot, } from '../utils/wagon-plan.util'; import { CorridorBudget } from '../corridor-capacity.util'; import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util'; +import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util'; import { pickLowestFreeNumber, pickTrainNumberPool } from '../train-number.util'; import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, bulkTonsPerWagon, + consistViolations, deriveTrainCapacityFromLocomotive, combinedLocomotiveLimits, + trainHardCaps, trainSetLocomotiveLimits, wagonTypeDimensionsFromEntity, LocomotiveLimits, @@ -156,6 +164,8 @@ import { } from '../booking-batch.constants'; import { orderConsistWagons } from '../consist-order.util'; import { + bookingCloseCutoff, + clampCloseToOfficeHours, computeExportWindowTimes, computeImportWindowTimes, earliestSchedulableDeparture, @@ -166,11 +176,17 @@ import { import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity'; import { BookingJourneyService } from '../booking-journey.service'; import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository'; -import { RecordCheckpointDto } from '../dto/record-checkpoint.dto'; +import { + DispatchScheduleDto, + RecordCheckpointDto, + UpdateCheckpointDto, +} from '../dto/record-checkpoint.dto'; import { RouteMilestone } from '../../routes/entities/route-milestone.entity'; import { deriveTradeDirection } from '../../../common/derive-trade-direction.util'; import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service'; import { WarehouseReleaseDocumentService } from '../../warehouses/warehouse-release-document.service'; +import { LogoSettingsService } from '../../logo-settings/logo-settings.service'; +import { logoImageCss, logoMarkup } from '../../billing/documents/logo-markup.util'; import { autoFillPlacements, findMissingContainerNumberIssues, @@ -325,6 +341,8 @@ const DEFAULT_TRAIN_LIMITS: Required = { interface BookingWindowRow { schedule_id: string; reference: string | null; + /** Operational run number (e.g. 8001 import / 8002 export), typed by staff. */ + train_number: string | null; contract_id: string | null; contract_kind: string | null; direction: string | null; @@ -368,6 +386,7 @@ export class TrainSchedulingService { private readonly bookingWindowGateway: BookingWindowGateway, private readonly bookingJourneyService: BookingJourneyService, private readonly bookingNotifier: BookingNotifierService, + private readonly logoSettings: LogoSettingsService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, private readonly configService?: ConfigService, // forwardRef: BookingBatchService injects this service back; @Optional so @@ -456,10 +475,14 @@ export class TrainSchedulingService { * used for lifecycle changes outside the window tick (create, cancel, * finalize, restamp). A push failure must never break the mutation. */ - private async emitWindowState(scheduleId: string): Promise { + async emitWindowState(scheduleId: string): Promise { try { const fresh = await this.trainSchedulesRepository.findById(scheduleId); - if (fresh) this.bookingWindowGateway.emitPhase(fresh); + // Dedicated shipping-line departures are never announced to the portal — + // the broadcast reaches every customer client. + if (fresh && !fresh.shippingLineCompanyId) { + this.bookingWindowGateway.emitPhase(fresh); + } } catch (err) { this.logger.warn( `Booking-window push failed for ${scheduleId}: ${(err as Error).message}`, @@ -500,7 +523,11 @@ export class TrainSchedulingService { // and a newborn anchoring to it would inherit that dead window verbatim. .andWhere('s.status != :cancelledStatus', { cancelledStatus: TrainScheduleStatusEnum.Cancelled, - }); + }) + // A dedicated shipping-line departure is never a sibling either: it runs + // no window cycle, so it must neither anchor a customer group nor be + // dragged through one's open/doc-review/payment instants. + .andWhere('s.shippingLineCompanyId IS NULL'); if (excludeScheduleId) { qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId }); } @@ -928,6 +955,68 @@ export class TrainSchedulingService { return fresh ?? schedule; } + /** + * Correct a departure's operational run identifiers — the train number and + * voyage number yards and customs quote. + * + * Editable only until the train leaves: once DISPATCHED (or beyond) the + * numbers are printed on paperwork and quoted downstream, so a late edit would + * desync records that already left with the train. The audit row is written by + * the global AuditInterceptor from the registered route. + */ + async updateScheduleTrainNumber( + id: string, + dto: UpdateScheduleTrainNumberDto, + ): Promise { + if (dto.trainNumber === undefined && dto.voyageNumber === undefined) { + throw new BadRequestException( + 'Provide a train number or a voyage number to update.', + ); + } + + const schedule = await this.trainSchedulesRepository.findById(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + + // Only a train that has not left can be renumbered. CANCELLED is excluded + // too — renumbering a dead schedule has no meaning. + const editable: string[] = [ + TrainScheduleStatusEnum.Draft, + TrainScheduleStatusEnum.Scheduled, + ]; + if (!editable.includes(schedule.status)) { + throw new BadRequestException( + `Cannot change the train or voyage number of a ${schedule.status} schedule — ` + + 'the numbers are fixed once the train is dispatched.', + ); + } + + // An empty string clears the field; an omitted field is left untouched. + const patch: Partial = {}; + if (dto.trainNumber !== undefined) { + patch.trainNumber = dto.trainNumber.trim() || null; + } + if (dto.voyageNumber !== undefined) { + patch.voyageNumber = dto.voyageNumber.trim() || null; + } + + await this.trainSchedulesRepository.update(id, patch); + this.logger.log( + `Schedule ${schedule.reference ?? id} renumbered` + + (patch.trainNumber !== undefined + ? ` — train ${schedule.trainNumber ?? '—'} → ${patch.trainNumber ?? '—'}` + : '') + + (patch.voyageNumber !== undefined + ? ` — voyage ${schedule.voyageNumber ?? '—'} → ${patch.voyageNumber ?? '—'}` + : '') + + (dto.reason?.trim() ? ` (${dto.reason.trim()})` : ''), + ); + + const fresh = await this.trainSchedulesRepository.findById(id); + return fresh ?? schedule; + } + /** * Reschedule ONE train's departure date (staff action on the ops board). Only * allowed while the booking window has not opened yet — an OPEN/past schedule @@ -1071,6 +1160,124 @@ export class TrainSchedulingService { return fresh ?? schedule; } + /** + * Booking-window fields that must follow a train's departure moving to + * `departure` (any window phase). Shared by every reschedule path so the + * "closes in" countdown always tracks the real departure. + * + * PRE_WINDOW: the stamped open/close were derived from the old departure + * and the window hasn't opened yet, so re-derive them from the schedule's + * own rule snapshot against the new date (joining the target day's route + * group timeline when one exists, exactly like updateScheduleDate). + * + * DONE: the window already finished (e.g. the close offset hit and then the + * train was moved to a later departure). The window must follow the new + * departure, so it REOPENS: re-derive open/close the same way, reset the + * phase to PRE_WINDOW and clamp a past open into the present so the tick + * opens it immediately. A FULL train stays closed — there is nothing left + * to sell — and so does one whose re-derived window would already be over. + * + * OPEN: customers are already booking against the open they were shown, so + * the open stays put — but the close was capped at the OLD departure's + * cutoff, so it must follow the new one (import: open + duration under + * office hours, capped at the cutoff; export: the cutoff itself). Moving + * the train later extends the "closes in" countdown, moving it earlier + * shortens it (a close now in the past is picked up by the next tick). + * + * DOC_REVIEW/PAYMENT keep their running timeline. + */ + async windowFieldsForNewDeparture( + schedule: TrainSchedule, + departure: Date, + ): Promise< + Partial< + Pick< + TrainSchedule, + | 'windowOpensAt' + | 'windowClosesAt' + | 'windowPhase' + | 'bookingWindowStatus' + | 'docReviewCompletedAt' + | 'docReviewEndsAt' + | 'paymentPhaseEndsAt' + > + > + > { + const reopenFromDone = + schedule.windowPhase === 'DONE' && schedule.bookingWindowStatus !== 'FULL'; + const shiftOpenClose = + schedule.windowPhase === 'OPEN' && schedule.windowOpensAt != null; + const windowFields = + shiftOpenClose + ? await (async () => { + const merged = effectiveWindowConfig( + schedule, + await this.getWindowConfig(), + ); + const opensAt = schedule.windowOpensAt!; + const cutoff = bookingCloseCutoff(departure, schedule.direction, merged); + let closesAt = cutoff; + if (schedule.direction !== 'EXPORT') { + closesAt = clampCloseToOfficeHours( + opensAt, + new Date(opensAt.getTime() + merged.windowDurationHours * 3_600_000), + merged, + ); + if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff; + } + return { windowClosesAt: closesAt }; + })() + : schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone + ? await (async () => { + const merged = effectiveWindowConfig( + schedule, + await this.getWindowConfig(), + ); + const times = + schedule.direction === 'EXPORT' + ? computeExportWindowTimes(departure, merged) + : computeImportWindowTimes(departure, merged, new Date()); + const anchor = + schedule.direction === 'EXPORT' + ? null + : await this.findGroupWindowAnchor( + this.dataSource.manager, + schedule.originStationId, + schedule.destinationStationId, + departure, + ); + if (anchor) { + // groupWindowFieldsFrom copies the anchor's live phase and + // deadlines, so a DONE train joining a live group re-enters the + // group's cycle directly — no extra reset needed. + return this.groupWindowFieldsFrom(anchor, departure); + } + if (!reopenFromDone) { + return { + windowOpensAt: times.windowOpensAt, + windowClosesAt: times.windowClosesAt, + }; + } + const now = new Date(); + const windowOpensAt = + times.windowOpensAt < now ? now : times.windowOpensAt; + if (times.windowClosesAt.getTime() <= windowOpensAt.getTime()) { + return {}; // no window fits before the new departure — stay closed + } + return { + windowOpensAt, + windowClosesAt: times.windowClosesAt, + windowPhase: 'PRE_WINDOW', + bookingWindowStatus: 'CLOSED', + docReviewCompletedAt: null, + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + }; + })() + : {}; + return windowFields; + } + /** * Maintenance reschedule: the admin moves a train (with everything aboard) to * a new departure. Unlike {@link updateScheduleDate} this runs at ANY window @@ -1081,9 +1288,10 @@ export class TrainSchedulingService { * aboard/targeted booking's scheduledDate (the day-pool queries key on it, * so a booking left on the old day would fall out of its own train's pool). * - STAYS: train set, wagon assignments, schedule↔booking links, route, - * maxWagons, and the window RULE snapshot. Stamped window times are only - * re-derived for PRE_WINDOW schedules (their window hasn't run yet); a - * schedule mid- or post-window keeps its timeline untouched. + * maxWagons, and the window RULE snapshot. Stamped window times are + * re-derived for PRE_WINDOW schedules (their window hasn't run yet); an + * OPEN schedule keeps its open but its close follows the new departure; + * DOC_REVIEW/PAYMENT keep their timeline untouched. * * Customers of every moved booking are notified (maintenanceMoved). */ @@ -1115,39 +1323,7 @@ export class TrainSchedulingService { ? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs) : undefined; - // PRE_WINDOW only: the stamped open/close were derived from the old - // departure and the window hasn't opened yet, so re-derive them from the - // schedule's own rule snapshot against the new date (joining the target - // day's route group timeline when one exists, exactly like - // updateScheduleDate). Mid/post-window schedules keep their timeline. - const windowFields = - schedule.windowPhase === 'PRE_WINDOW' - ? await (async () => { - const merged = effectiveWindowConfig( - schedule, - await this.getWindowConfig(), - ); - const times = - schedule.direction === 'EXPORT' - ? computeExportWindowTimes(departure, merged) - : computeImportWindowTimes(departure, merged, new Date()); - const anchor = - schedule.direction === 'EXPORT' - ? null - : await this.findGroupWindowAnchor( - this.dataSource.manager, - schedule.originStationId, - schedule.destinationStationId, - departure, - ); - return anchor - ? this.groupWindowFieldsFrom(anchor, departure) - : { - windowOpensAt: times.windowOpensAt, - windowClosesAt: times.windowClosesAt, - }; - })() - : {}; + const windowFields = await this.windowFieldsForNewDeparture(schedule, departure); await this.dataSource.getRepository(TrainSchedule).update(id, { scheduledDepartureDate: departure, @@ -1192,7 +1368,7 @@ export class TrainSchedulingService { * in the future) using the CURRENT global-rules config. Schedules already OPEN or * past their window are left untouched — customers may have booked against the * times they were shown, so those stay frozen. Returns the count re-stamped. - */ + */ async restampPendingWindows(): Promise { const cfg = await this.getWindowConfig(); const now = new Date(); @@ -1357,6 +1533,24 @@ export class TrainSchedulingService { const scheduleWarnings: string[] = []; + // Dedicating the departure to a shipping line: the id comes from the + // request, so verify it is a real, active line before stamping it. + if (dto.shippingLineCompanyId) { + const line = await this.dataSource + .getRepository(ShippingLineCompany) + .findOne({ where: { id: dto.shippingLineCompanyId } }); + if (!line) { + throw new NotFoundException( + `Shipping line ${dto.shippingLineCompanyId} not found`, + ); + } + if (line.status !== 'active') { + throw new BadRequestException( + `Shipping line ${line.name} is suspended — it cannot be assigned a train`, + ); + } + } + // The pulling set comes either from a built train (Train Builder) or from // hand-picked locomotive ids (legacy path). A built train also links the // schedule's train set back to it (`train_sets.train_id`) so its lifecycle @@ -1395,6 +1589,7 @@ export class TrainSchedulingService { `Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`, ); } + await this.assertRouteCoversWagonYards(builtTrain, route); const conflict = await this.findTrainRouteDayConflict( builtTrain.id, route.id, @@ -1489,8 +1684,11 @@ export class TrainSchedulingService { // doc-review/payment phase — so there is no cross-expiry to fix, and two // export trains departing the same day at different times must keep their // own departure-anchored windows. + // Dedicated shipping-line departures never group either: they run no + // window cycle at all, so sharing a customer group's timeline (or + // anchoring one) would drag them into phases they must not have. const groupAnchor = - direction === 'EXPORT' + direction === 'EXPORT' || dto.shippingLineCompanyId ? null : await this.findGroupWindowAnchor( manager, @@ -1555,45 +1753,71 @@ export class TrainSchedulingService { // only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an // already-open schedule keeps this snapshot, and the batch board draws its // windows from it rather than the live config. - const ruleSnapshot = windowRuleSnapshot(windowCfg); - const computedTimes = - direction === 'EXPORT' - ? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) } - : { - // IMPORT and DOMESTIC share the import booking-day window cycle. - ...ruleSnapshot, - ...computeImportWindowTimes(departure, windowCfg, new Date()), - }; - // Inside-lead departure (e.g. a huge configured lead): the raw open lands - // in the past — clamp it to `now` so the window tick opens it immediately. - if (computedTimes.windowOpensAt.getTime() < Date.now()) { - computedTimes.windowOpensAt = new Date(); + let windowFields: Partial; + if (dto.shippingLineCompanyId) { + // Dedicated shipping-line departure: NO window cycle at all. The line + // books whenever it wants from creation until the close offset before + // departure. windowPhase stays NULL, so the window engine, restamp and + // the customer window lists all skip this schedule; the close-offset + // gate is enforced by the shipping-line completion path, which reads + // windowClosesAt stamped here. + const offsetMinutes = windowCfg.importCloseOffsetMinutes ?? 0; + const closesAt = new Date(departure.getTime() - offsetMinutes * 60_000); + if (closesAt.getTime() <= Date.now()) { + throw new BadRequestException( + 'With the booking-close offset applied, this departure would already be ' + + 'closed for shipping-line booking — pick a later departure.', + ); + } + windowFields = { + bookingWindowStatus: 'OPEN', + windowPhase: null, + windowOpensAt: new Date(), + windowClosesAt: closesAt, + ruleImportCloseOffsetMinutes: offsetMinutes || null, + windowRuleCustom: dto.windowRule != null, + }; + } else { + const ruleSnapshot = windowRuleSnapshot(windowCfg); + const computedTimes = + direction === 'EXPORT' + ? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) } + : { + // IMPORT and DOMESTIC share the import booking-day window cycle. + ...ruleSnapshot, + ...computeImportWindowTimes(departure, windowCfg, new Date()), + }; + // Inside-lead departure (e.g. a huge configured lead): the raw open lands + // in the past — clamp it to `now` so the window tick opens it immediately. + if (computedTimes.windowOpensAt.getTime() < Date.now()) { + computedTimes.windowOpensAt = new Date(); + } + if ( + computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime() + ) { + throw new BadRequestException( + 'These booking-window settings leave no window before departure — with the ' + + 'desk hours and close offset applied, the window would only open once the ' + + 'train has left.', + ); + } + windowFields = { + bookingWindowStatus: 'CLOSED', + windowPhase: 'PRE_WINDOW', + ...(groupAnchor + ? this.groupWindowFieldsFrom(groupAnchor, departure) + : computedTimes), + // `windowRuleSnapshot` never stamps the pay window (NULL = follow the + // live global value for the direction), so an explicit staff override is + // persisted here — the same field the post-creation override writes. + ...(dto.windowRule?.paymentWindowMinutes !== undefined + ? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes } + : {}), + // Hand-configured windows opt OUT of the global re-stamp, or the next + // global-rules edit would overwrite exactly what staff chose here. + windowRuleCustom: dto.windowRule != null, + }; } - if ( - computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime() - ) { - throw new BadRequestException( - 'These booking-window settings leave no window before departure — with the ' + - 'desk hours and close offset applied, the window would only open once the ' + - 'train has left.', - ); - } - const windowFields = { - bookingWindowStatus: 'CLOSED', - windowPhase: 'PRE_WINDOW', - ...(groupAnchor - ? this.groupWindowFieldsFrom(groupAnchor, departure) - : computedTimes), - // `windowRuleSnapshot` never stamps the pay window (NULL = follow the - // live global value for the direction), so an explicit staff override is - // persisted here — the same field the post-creation override writes. - ...(dto.windowRule?.paymentWindowMinutes !== undefined - ? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes } - : {}), - // Hand-configured windows opt OUT of the global re-stamp, or the next - // global-rules edit would overwrite exactly what staff chose here. - windowRuleCustom: dto.windowRule != null, - }; // A built train's own consist is the schedule's capacity: full when all // its wagons are allocated. Trains built without wagons yet fall back to // the configured limit. @@ -1621,6 +1845,7 @@ export class TrainSchedulingService { trainNumber: pairTrainNumber ?? undefined, maxWagons, reverseWagonOrder: dto.reverseWagonOrder ?? false, + shippingLineCompanyId: dto.shippingLineCompanyId ?? null, ...windowFields, }), ); @@ -1998,7 +2223,15 @@ export class TrainSchedulingService { return { ...detail, warnings, deferredBookings }; } - async unassignBooking(scheduleId: string, bookingId: string, userId?: string) { + async unassignBooking( + scheduleId: string, + bookingId: string, + userId?: string, + opts: { + /** false = system detach (e.g. booking cancelled) — no "removed from train, rebook" notice. */ + notifyCustomer?: boolean; + } = {}, + ) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); @@ -2125,7 +2358,9 @@ export class TrainSchedulingService { const removedBooking = await this.dataSource .getRepository(Booking) .findOne({ where: { id: bookingId }, relations: { company: true } }); - if (removedBooking) this.bookingNotifier.removedFromTrain(removedBooking); + if (removedBooking && opts.notifyCustomer !== false) { + this.bookingNotifier.removedFromTrain(removedBooking); + } this.logger.log( `Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`, ); @@ -2473,7 +2708,7 @@ export class TrainSchedulingService { return this.getTrainScheduleById(scheduleId); } - async dispatchSchedule(scheduleId: string) { + async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); @@ -2481,6 +2716,9 @@ export class TrainSchedulingService { if (schedule.status !== TrainScheduleStatusEnum.Scheduled) { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } + // Staff may record the departure after the fact — past is fine, future is not. + const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date(); + this.assertNotFuture(now, 'Departure time'); await this.assertImportDjiboutiMayDepart(schedule); // Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon) // never blocks departure — the dispatch confirm dialog warns and staff decide. @@ -2505,7 +2743,6 @@ export class TrainSchedulingService { } } - const now = new Date(); await this.dataSource.transaction(async (manager) => { const trainNumber = await this.assignTrainNumber(manager, schedule); if (setLocomotiveIds.length) { @@ -2830,6 +3067,10 @@ export class TrainSchedulingService { // dispatch pre-check keeps reporting these bookings as unloaded). const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId); if (wagonAssignedIds.size) { + // Re-check the 1×40ft/2×20ft-per-wagon packing rule right before loading + // is confirmed — allocation time already enforces it, but a wagon swap or + // an edited allocation since then could have broken it unnoticed. + await this.assertWagonContainerCapacity(scheduleId); // Export cargo must be received at the warehouse with a GRN before it can // be confirmed loaded — an allocation is not proof the goods are in hand. if (this.isExportSchedule(schedule)) { @@ -2897,6 +3138,9 @@ export class TrainSchedulingService { .map((wagon) => ({ sequenceNo: wagon.sequenceNo, wagonNumber: wagon.physicalWagon?.wagonNumber ?? null, + wagonType: wagon.wagonType?.code ?? wagon.wagonType?.name ?? null, + tareWeightTons: wagon.wagonType?.tareWeightTons ?? null, + equatedLengthM: wagon.wagonType?.equatedLengthM ?? null, allocations: (wagon.allocations ?? []).map((allocation) => ({ bookingId: allocation.bookingId, bookingReference: allocation.booking?.reference ?? null, @@ -2917,7 +3161,7 @@ export class TrainSchedulingService { const loadList = await this.generateImportLoadList(scheduleId, { performedBy: 'DOCUMENT_GENERATION', }); - const html = this.buildImportLoadListHtml(loadList); + const html = this.buildImportLoadListHtml(loadList, await this.logoSettings.getLogoImageUrl()); // Styled table-aware fallback (marshalling grid) when Chromium is unavailable — // NOT the release-order fallback (would mislabel this as a gate-clearance order). const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Import marshalling / load list'); @@ -2937,7 +3181,10 @@ export class TrainSchedulingService { throw new BadRequestException('Export marshalling document applies only to EXPORT schedules'); } - const html = this.buildExportLoadListHtml(schedule); + const html = this.buildExportLoadListHtml(schedule, { + emptyContainers: await this.loadedEmptyContainers(scheduleId), + logoImageUrl: await this.logoSettings.getLogoImageUrl(), + }); // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Export marshalling / load list'); const reference = schedule.trainNumber ?? schedule.id; @@ -3009,6 +3256,8 @@ export class TrainSchedulingService { positionLabel, wagons, unassignedBookings, + emptyContainers: await this.loadedEmptyContainers(scheduleId), + logoImageUrl: await this.logoSettings.getLogoImageUrl(), }); // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list'); @@ -3045,6 +3294,18 @@ export class TrainSchedulingService { return null; } + /** + * Empty containers riding this departure back to Djibouti. They carry no + * booking and no wagon allocation, so the marshalling document would show + * their wagons as bare — staff checking the paper against the train would + * find boxes that the list denies are there. + */ + private loadedEmptyContainers(scheduleId: string): Promise { + return this.dataSource + .getRepository(EmptyContainerReturn) + .find({ where: { trainScheduleId: scheduleId } }); + } + private buildExportLoadListHtml( schedule: TrainSchedule, opts?: { @@ -3052,6 +3313,8 @@ export class TrainSchedulingService { positionLabel?: string; wagons?: TrainSetWagon[]; unassignedBookings?: Booking[]; + emptyContainers?: EmptyContainerReturn[]; + logoImageUrl?: string | null; }, ): string { const esc = (value: unknown) => @@ -3069,6 +3332,16 @@ export class TrainSchedulingService { const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort( (a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0), ); + // Empties sit on wagons that carry no booking allocation, keyed by the wagon + // slot recorded when they were loaded. + const emptiesByWagon = new Map(); + for (const empty of opts?.emptyContainers ?? []) { + if (empty.wagonSequenceNo == null) continue; + emptiesByWagon.set(empty.wagonSequenceNo, [ + ...(emptiesByWagon.get(empty.wagonSequenceNo) ?? []), + empty, + ]); + } const rows = wagons .flatMap((wagon) => { // Wagon identity is the same on every row the wagon produces, loaded or not. @@ -3083,6 +3356,21 @@ export class TrainSchedulingService { // check this document against the physical train — a wagon with no row // reads as a wagon that is not there, and the count stops matching. if (allocations.length === 0) { + const empties = emptiesByWagon.get(Number(wagon.sequenceNo)) ?? []; + // Empty boxes returning to Djibouti: numbers listed like any other + // container, state spelled out so nobody reads them as laden. + if (empties.length) { + return [ + ` + ${wagonCells} + EMPTY CONTAINER + - + ${esc(empties.map((empty) => empty.containerNumber).filter(Boolean).join(', '))} + - + - + `, + ]; + } return [ ` ${wagonCells} @@ -3133,14 +3421,19 @@ export class TrainSchedulingService { }) .join('') : ''; - const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length; + const emptyWagons = wagons.filter( + (wagon) => + (wagon.allocations ?? []).length === 0 && + !emptiesByWagon.get(Number(wagon.sequenceNo))?.length, + ).length; const totalWeight = wagons.reduce( (sum, wagon) => sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), 0, ); - // Container count summary (40ft, 20ft) + // Container count summary (40ft, 20ft) — empties returning to Djibouti are + // physically on the train, so they count, and are called out on their own tile. let count40ft = 0, count20ft = 0; wagons.forEach((wagon) => { (wagon.allocations ?? []).forEach((allocation) => { @@ -3151,6 +3444,11 @@ export class TrainSchedulingService { }); }); }); + const emptyContainers = [...emptiesByWagon.values()].flat(); + for (const empty of emptyContainers) { + if (empty.containerSize?.includes('20')) count20ft++; + else count40ft++; + } return ` @@ -3170,6 +3468,7 @@ export class TrainSchedulingService { .tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; } .tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; } .tile strong { font-size: 11px; } + ${logoImageCss()} table { width: 100%; border-collapse: collapse; } th { background: #f8fafc; color: #475569; text-align: left; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } @@ -3184,6 +3483,7 @@ export class TrainSchedulingService {
+ ${logoMarkup(opts?.logoImageUrl)}
Ethio-Djibouti Railway S.C.

${esc(opts?.title ?? 'Export Marshalling Document / Load List')}

@@ -3204,6 +3504,7 @@ export class TrainSchedulingService {
Containers 40ft${esc(count40ft)}
Containers 20ft${esc(count20ft)}
Total containers${esc(count40ft + count20ft)}
+ ${emptyContainers.length ? `
Empty containers${esc(emptyContainers.length)}
` : ''}
Prepared person${esc(schedule.preparedByUserId)}
Check person${esc(schedule.checkedByUserId)}
Wagons${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
@@ -3262,6 +3563,12 @@ export class TrainSchedulingService { * Every export booking being confirmed loaded must already be received at the * warehouse with a GRN. An allocation puts a booking on a wagon on paper; this * is the check that the cargo is physically in the yard before we call it loaded. + * + * Direct truck-to-train (exportHandoverMode = DIRECT_TO_TRAIN) is excluded — + * that cargo is manually loaded from the customer's truck straight onto the + * wagon, never sees the warehouse, and is never GRN'd. Its custody is attested + * by the carriage acceptance sheet instead (same carve-out as the shared + * assertExportReceivedWithGrn gate — see common/export-received-gate.ts). */ private async assertExportBookingsReceived(bookingIds: string[]): Promise { if (!bookingIds.length) return; @@ -3270,6 +3577,7 @@ export class TrainSchedulingService { FROM freight.bookings b WHERE b.id = ANY($1) AND b.deleted_at IS NULL + AND b.export_handover_mode IS DISTINCT FROM 'DIRECT_TO_TRAIN' AND NOT EXISTS ( SELECT 1 FROM freight.warehouse_inventory inv WHERE inv.booking_id = b.id @@ -3290,7 +3598,76 @@ export class TrainSchedulingService { } } - private buildImportLoadListHtml(loadList: Awaited>): string { + /** + * Re-check the 1×40ft / 2×20ft-per-wagon packing rule at loading confirmation + * time. `validateContainerPlacements` already enforces this the moment a + * booking is allocated to a wagon, but nothing re-checks it afterwards — a + * wagon swap, an edited allocation, or a container item added out-of-band + * between allocation and loading could still leave a wagon over its 2-TEU + * capacity undetected until the train is already loaded. This closes that gap + * by summing the TEU actually persisted per wagon (40ft = 2 TEU, 20ft = 1 TEU, + * same size resolution as the marshalling document) right before loading is + * confirmed. + */ + private async assertWagonContainerCapacity(scheduleId: string): Promise { + const rows: Array<{ sequenceNo: number; wagonNumber: string | null; teuUsed: string }> = + await this.dataSource.query( + `SELECT tsw.sequence_no AS "sequenceNo", + pw.wagon_number AS "wagonNumber", + SUM( + CASE COALESCE(ct.size_ft, bct.size_ft) + WHEN 40 THEN 2 + WHEN 20 THEN 1 + ELSE + CASE + WHEN bc.container_size ILIKE '%40%' THEN 2 + WHEN bc.container_size ILIKE '%20%' THEN 1 + ELSE 0 + END + END + ) AS "teuUsed" + FROM freight.train_set_wagons tsw + JOIN freight.wagon_booking_allocations wba + ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL + JOIN freight.wagon_allocation_container_items wci + ON wci.wagon_booking_allocation_id = wba.id AND wci.deleted_at IS NULL + LEFT JOIN freight.container_types ct ON ct.id = wci.container_type_id + LEFT JOIN freight.booking_container bc ON bc.id = wci.booking_container_id + LEFT JOIN freight.container_types bct ON bct.id = bc.container_type_id + LEFT JOIN freight.wagons pw ON pw.id = tsw.physical_wagon_id + WHERE tsw.train_set_id = ( + SELECT train_set_id FROM freight.train_schedules WHERE id = $1 + ) + AND tsw.deleted_at IS NULL + GROUP BY tsw.id, tsw.sequence_no, pw.wagon_number + HAVING SUM( + CASE COALESCE(ct.size_ft, bct.size_ft) + WHEN 40 THEN 2 + WHEN 20 THEN 1 + ELSE + CASE + WHEN bc.container_size ILIKE '%40%' THEN 2 + WHEN bc.container_size ILIKE '%20%' THEN 1 + ELSE 0 + END + END + ) > $2`, + [scheduleId, MAX_TEU_SLOTS_PER_WAGON], + ); + if (rows.length) { + const labels = rows + .map((r) => `wagon #${r.sequenceNo}${r.wagonNumber ? ` (${r.wagonNumber})` : ''}`) + .join(', '); + throw new BadRequestException( + `These wagons exceed capacity (max 1×40ft or 2×20ft per wagon) — fix the container placement before confirming loading: ${labels}.`, + ); + } + } + + private buildImportLoadListHtml( + loadList: Awaited>, + logoImageUrl?: string | null, + ): string { const esc = (value: unknown) => String(value ?? '-') .replace(/&/g, '&') @@ -3323,26 +3700,37 @@ export class TrainSchedulingService { const allocationRows = loadList.wagons .flatMap((wagon) => { const wagonCells = `${esc(wagon.sequenceNo)} - ${esc(wagon.wagonNumber)}`; + ${esc(wagon.wagonNumber)} + ${esc(wagon.wagonType)} + ${wagon.tareWeightTons == null ? '-' : esc(Number(wagon.tareWeightTons).toFixed(2))} + ${wagon.equatedLengthM == null ? '-' : esc(Number(wagon.equatedLengthM).toFixed(3))} + ${esc(loadList.origin)} + ${esc(loadList.destination)}`; // An empty wagon still runs in the consist, so it still gets a line — see // buildExportLoadListHtml. if (wagon.allocations.length === 0) { return [ ` ${wagonCells} - EMPTY — no cargo allocated + EMPTY — no cargo allocated `, ]; } return wagon.allocations.map( (allocation) => { const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-'; + const sealNumbers = (allocation.containerItems ?? []) + .map((item) => item.sealNumber) + .filter(Boolean) + .join(', '); return ` ${wagonCells} ${esc(allocation.bookingReference ?? allocation.bookingId)} ${esc(companyName)} ${esc(allocation.loadType)} ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} + ${esc(sealNumbers || '-')} + ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))} `; }, @@ -3370,6 +3758,7 @@ export class TrainSchedulingService { .tile { border: 1px solid #cbd5e1; padding: 10px; min-height: 58px; } .tile span { display: block; color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; } .tile strong { font-size: 13px; } + ${logoImageCss()} .status { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-top: 14px; } .step { border: 1px solid #cbd5e1; padding: 8px; font-size: 10px; text-align: center; min-height: 48px; } .done { background: #ecfdf5; border-color: #22c55e; color: #14532d; font-weight: 700; } @@ -3391,6 +3780,7 @@ export class TrainSchedulingService {
+ ${logoMarkup(logoImageUrl)}
Ethio-Djibouti Railway S.C.

Import Load List /
Marshalling Document

Djibouti-side gatepass, loading, and departure manifest
@@ -3431,15 +3821,22 @@ export class TrainSchedulingService { Seq Wagon + Wagon Type + Tare + Equated + Departure Station + Arrival Station Booking Company Load Container numbers + Seal No + Note Weight T - ${allocationRows || 'No wagons on this train set.'} + ${allocationRows || 'No wagons on this train set.'} @@ -3828,6 +4225,7 @@ export class TrainSchedulingService { ? TrainCheckpointKind.Arrived : TrainCheckpointKind.Passed); const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date(); + await this.assertCheckpointTime(schedule, stations, dto.sequenceNo, occurredAt); // Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates. const [existing] = await this.trainCheckpointEventsRepository.findAll({ @@ -3851,8 +4249,14 @@ export class TrainSchedulingService { }); } + // The origin DEPARTED checkpoint IS the departure — keep the schedule's + // headline timestamp on the same clock the operator just entered. + if (dto.sequenceNo === 0) { + await this.trainSchedulesRepository.update(scheduleId, { actualDepartureAt: occurredAt }); + } + if (dto.sequenceNo === finalSeq) { - await this.arriveSchedule(scheduleId); + await this.arriveSchedule(scheduleId, occurredAt); } 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 @@ -3871,12 +4275,26 @@ export class TrainSchedulingService { .getRepository(Locomotive) .update({ id: In(locoIds) }, { currentYardId: station.yardId }); } + // Only wagons the train has actually COLLECTED move with it. On a + // consist spread across yards (20 in Dire, 33 in Mojo), reaching Mojo + // moves the Dire wagons — the ones already aboard — and picks up the + // Mojo ones standing here. Wagons waiting at yards further down the + // line stay where they are until the train physically gets to them. + const passedYardIds = stations + .filter((s) => s.sequenceNo <= dto.sequenceNo) + .map((s) => s.yardId); await manager .getRepository(Wagon) - .update( - { currentTrainScheduleId: scheduleId }, - { currentYardId: station.yardId }, - ); + .createQueryBuilder() + .update(Wagon) + .set({ currentYardId: station.yardId }) + .where('current_train_schedule_id = :scheduleId', { scheduleId }) + // A yard-less wagon has no "waiting further down the line" position + // to protect, so it rides along as it always did. + .andWhere('(current_yard_id IS NULL OR current_yard_id IN (:...passedYardIds))', { + passedYardIds, + }) + .execute(); if (schedule.trainSet?.trainId) { await manager .getRepository(Train) @@ -3888,11 +4306,133 @@ export class TrainSchedulingService { return this.getScheduleCheckpoints(scheduleId); } + /** + * Correct an already-logged leg's time/note. Pure edit: no auto-unload, no + * position fix, no arrival — those already happened when the leg was logged. + * Allowed on DISPATCHED and ARRIVED trains (a journey is corrected after the + * fact as often as during it). The origin/final legs also re-stamp the + * schedule's departure/arrival so the headline figures follow the edit. + */ + async updateCheckpoint(scheduleId: string, sequenceNo: number, dto: UpdateCheckpointDto) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if ( + schedule.status !== TrainScheduleStatusEnum.Dispatched && + schedule.status !== TrainScheduleStatusEnum.Arrived + ) { + throw new BadRequestException('Only DISPATCHED or ARRIVED trains have checkpoints to edit'); + } + const stations = await this.buildScheduleStations(schedule); + const station = stations.find((s) => s.sequenceNo === sequenceNo); + if (!station) { + throw new BadRequestException(`Station ${sequenceNo} is not on this route`); + } + // Match by yard, like getScheduleCheckpoints — legacy rows may carry an + // older station numbering. + const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId); + const existing = + events.find((e) => e.yardId === station.yardId) ?? + events.find((e) => e.sequenceNo === sequenceNo); + if (!existing) { + throw new BadRequestException(`Station ${station.label} has not been logged yet`); + } + + const patch: Partial = {}; + if (dto.occurredAt) { + const occurredAt = new Date(dto.occurredAt); + await this.assertCheckpointTime(schedule, stations, sequenceNo, occurredAt, existing.id); + patch.occurredAt = occurredAt; + } + if (dto.note !== undefined) patch.note = dto.note; + if (Object.keys(patch).length) { + await this.trainCheckpointEventsRepository.update(existing.id, patch); + } + + if (patch.occurredAt) { + const finalSeq = stations[stations.length - 1].sequenceNo; + if (sequenceNo === 0) { + await this.trainSchedulesRepository.update(scheduleId, { + actualDepartureAt: patch.occurredAt, + }); + } else if (sequenceNo === finalSeq && schedule.status === TrainScheduleStatusEnum.Arrived) { + await this.trainSchedulesRepository.update(scheduleId, { + actualArrivalAt: patch.occurredAt, + }); + } + } + + return this.getScheduleCheckpoints(scheduleId); + } + + private assertNotFuture(at: Date, what: string) { + if (Number.isNaN(at.getTime())) { + throw new BadRequestException(`${what} is not a valid date`); + } + // Small skew allowance so an honest "now" from a client clock passes. + if (at.getTime() > Date.now() + 60_000) { + throw new BadRequestException(`${what} cannot be in the future`); + } + } + + /** + * A leg's time must not be in the future and must sit in corridor order: + * no earlier than every logged leg before it (and the dispatch time, for + * legs after the origin), no later than every logged leg after it. + * `ignoreEventId` excludes the row being edited from its own bounds. + */ + private async assertCheckpointTime( + schedule: TrainSchedule, + stations: { sequenceNo: number; yardId: string; label: string }[], + sequenceNo: number, + occurredAt: Date, + ignoreEventId?: string, + ) { + this.assertNotFuture(occurredAt, 'Checkpoint time'); + + const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo])); + const labelBySeq = new Map(stations.map((s) => [s.sequenceNo, s.label])); + const events = (await this.trainCheckpointEventsRepository.findBySchedule(schedule.id)).filter( + (e) => e.id !== ignoreEventId, + ); + const seqOf = (e: TrainCheckpointEvent) => seqByYard.get(e.yardId) ?? e.sequenceNo; + const fmt = (d: Date) => d.toISOString().replace('T', ' ').slice(0, 16) + ' UTC'; + + let floor: { at: Date; label: string } | null = null; + let ceil: { at: Date; label: string } | null = null; + for (const e of events) { + const s = seqOf(e); + if (s < sequenceNo && (!floor || e.occurredAt > floor.at)) { + floor = { at: e.occurredAt, label: labelBySeq.get(s) ?? `station ${s}` }; + } + if (s > sequenceNo && (!ceil || e.occurredAt < ceil.at)) { + ceil = { at: e.occurredAt, label: labelBySeq.get(s) ?? `station ${s}` }; + } + } + // The origin leg rewrites the departure itself; every later leg must + // follow it. + if (sequenceNo > 0 && schedule.actualDepartureAt && (!floor || schedule.actualDepartureAt > floor.at)) { + floor = { at: schedule.actualDepartureAt, label: 'departure' }; + } + + if (floor && occurredAt < floor.at) { + throw new BadRequestException( + `Checkpoint time cannot be earlier than ${floor.label} (${fmt(floor.at)})`, + ); + } + if (ceil && occurredAt > ceil.at) { + throw new BadRequestException( + `Checkpoint time cannot be later than ${ceil.label} (${fmt(ceil.at)})`, + ); + } + } + /** * Mark a dispatched train arrived: close out the schedule, move the locomotive * and wagons to the destination yard, and free the assets for re-use. */ - async arriveSchedule(scheduleId: string) { + async arriveSchedule(scheduleId: string, arrivedAt?: Date) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); @@ -3901,7 +4441,9 @@ export class TrainSchedulingService { throw new BadRequestException('Only DISPATCHED trains can arrive'); } - const now = new Date(); + // The arrival clock: the operator's entered time when arriving via the final + // checkpoint (already order/future-checked there), else now. + const now = arrivedAt ?? new Date(); await this.dataSource.transaction(async (manager) => { await this.trainSchedulesRepository.updateStatus( @@ -4072,13 +4614,22 @@ export class TrainSchedulingService { const [schedules, total] = await this.trainSchedulesRepository.findAndCount({ where, relations: { - trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true }, + trainSet: { + locomotive: true, + locomotives: { locomotive: true }, + train: true, + // Slot allocations back the list's "used wagons" figure — without + // them the row can only report the coupled consist size, which is + // what made the list disagree with the detail page's wagon plan. + wagons: { allocations: 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 }, + shippingLineCompany: true, }, order: { [sortBy]: sortOrder } as never, skip, @@ -4253,7 +4804,10 @@ export class TrainSchedulingService { (b) => !(targetScheduleId && b.trainScheduleId === targetScheduleId) && !SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') && - !b.isGovernment, + !b.isGovernment && + // Shipping-line bookings pay later on the credit ledger — never PAID + // up front, schedulable from accept (FULLY_EXECUTED) like government. + !b.shippingLineCompanyId, ); if (invalidStatus.length) { const statuses = [...new Set(invalidStatus.map((b) => b.status))]; @@ -4747,12 +5301,26 @@ export class TrainSchedulingService { const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code])); const counts = new Map(); + // A built consist spread across several yards can only offer, at each yard, + // the wagons standing there. A single-yard consist keeps the original + // behaviour: the whole train counts wherever it currently sits. + const consistYards = builtTrainId + ? new Set( + wagons + .filter((w) => w.trainId === builtTrainId && w.currentYardId) + .map((w) => w.currentYardId as string), + ) + : new Set(); + const consistIsSplit = consistYards.size > 1; + for (const wagon of wagons) { // Train-bound schedule: the built train's own consist IS the fleet — only - // its wagons count (wherever they currently sit; they travel with the - // train), and loose yard wagons never do. + // its wagons count, and loose yard wagons never do. A single-yard consist + // counts wherever it sits (it travels with the train); a split consist is + // counted at the yard each wagon actually stands in. if (builtTrainId) { if (wagon.trainId !== builtTrainId) continue; + if (consistIsSplit && wagon.currentYardId !== originYardId) continue; } else { // Schedule-scoped availability: pins held by OTHER schedules never // consume a wagon here — the same physical wagon may serve the July 17 @@ -5112,12 +5680,23 @@ export class TrainSchedulingService { // consist views draw the schedule exactly like the train builder; a schedule // created with reverseWagonOrder pins back-to-front (physically-last wagon // takes slot #1). Unsequenced wagons sort after every sequenced one. + const consistYards = new Set( + wagons + .filter((w) => w.trainId === builtTrainId && w.currentYardId) + .map((w) => w.currentYardId as string), + ); + // Split consist: a slot boarding at a given yard must take a wagon that + // physically stands there — the train cannot load a Mojo wagon at Dire. + // A single-yard consist ignores this (the whole train is at one place). + const requiredYardId = + consistYards.size > 1 ? (slot.boardYardId ?? originYardId) : null; const candidates = wagons .filter( (w) => w.trainId === builtTrainId && w.wagonTypeId === slot.wagonTypeId && - spanFree(w.id), + spanFree(w.id) && + (!requiredYardId || w.currentYardId === requiredYardId), ) .sort((a, b) => { if (a.sequenceNumber == null || b.sequenceNumber == null) { @@ -5286,14 +5865,28 @@ export class TrainSchedulingService { }); const remainingByTypeId = new Map(); const codesByTypeId = new Map(); + const byYardId = new Map>(); for (const wagon of wagons) { remainingByTypeId.set( wagon.wagonTypeId, (remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1, ); if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code); + if (wagon.currentYardId) { + const perType = byYardId.get(wagon.currentYardId) ?? new Map(); + perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1); + byYardId.set(wagon.currentYardId, perType); + } } - return { mode: 'TRAIN', remainingByTypeId, codesByTypeId }; + // Single-yard consist (the overwhelming majority): the whole train is + // offered at every boarding yard exactly as before — the per-yard split is + // only meaningful once the consist is genuinely spread across yards. + return { + mode: 'TRAIN', + remainingByTypeId, + codesByTypeId, + ...(byYardId.size > 1 ? { byYardId } : {}), + }; } /** @@ -5621,6 +6214,45 @@ export class TrainSchedulingService { return saved; } + /** + * A built train's wagons may stand in several yards. The route must pass + * through every one of them as origin or an intermediate stop — never only + * as the final destination (the train has to pick the wagons up en route). + */ + private async assertRouteCoversWagonYards(train: Train, route: Route) { + const wagons = await this.dataSource.getRepository(Wagon).find({ + where: { trainId: train.id }, + select: { id: true, currentYardId: true }, + }); + const wagonYards = [...new Set(wagons.map((w) => w.currentYardId).filter((y): y is string => !!y))]; + if (!wagonYards.length) return; + + const milestones = await this.dataSource + .getRepository(RouteMilestone) + .find({ where: { routeId: route.id }, order: { sequenceNo: 'ASC' } }); + const stops = milestones.length >= 2 + ? milestones.map((m) => m.yardId) + : [route.originYardId, route.destinationYardId]; + // Every stop except the last one is a pickup point. + const pickupYards = new Set(stops.slice(0, -1)); + + const uncovered = wagonYards.filter((y) => !pickupYards.has(y)); + if (!uncovered.length) return; + + const labels = await this.yardLabelMap(uncovered); + const destination = stops[stops.length - 1]; + const detail = uncovered + .map((y) => + y === destination + ? `${labels.get(y) ?? y} (only as the destination)` + : `${labels.get(y) ?? y} (not on route)`, + ) + .join(', '); + throw new BadRequestException( + `Route ${formatRouteLabel(route)} does not pass through every yard where train ${train.code}'s wagons stand: ${detail}`, + ); + } + private async getSchedulableRoute(routeId: string) { const route = await this.dataSource.getRepository(Route).findOne({ where: { id: routeId }, @@ -5752,17 +6384,32 @@ export class TrainSchedulingService { } private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) { + // Wagon figures must match the detail page's wagon plan (WagonPlanGrid) — + // see computeScheduleWagonUsage for why the stored counter cannot be used. + const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } = + computeScheduleWagonUsage({ + wagonSlots: schedule.trainSet?.wagons, + storedWagonCount: schedule.trainSet?.wagonCount, + scheduleBookings: schedule.scheduleBookings, + maxWagons: schedule.maxWagons, + }); + return { id: schedule.id, reference: schedule.reference ?? null, createdAt: schedule.createdAt ?? null, scheduleDate: schedule.scheduledDepartureDate, trainNumber: schedule.trainNumber ?? null, + voyageNumber: schedule.voyageNumber ?? null, direction: schedule.direction ?? null, routeName: schedule.route ? formatRouteLabel(schedule.route) : null, origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, + // Dedicated shipping-line departure (hidden from customers) — the list + // highlights these rows so staff can tell them apart at a glance. + shippingLineCompanyId: schedule.shippingLineCompanyId ?? null, + shippingLineCompanyName: schedule.shippingLineCompany?.name ?? null, // Built train (Train Builder) behind this departure, when scheduled by train. train: schedule.trainSet?.train ? { @@ -5786,6 +6433,14 @@ export class TrainSchedulingService { currentYardId: loco.currentYardId ?? null, })), wagonCount: schedule.trainSet?.wagonCount ?? 0, + /** Coupled slots carrying a booking allocation — matches the wagon plan. */ + wagonsUsed, + /** Coupled consist size; the denominator of "used". */ + wagonsTotal, + /** Claimed by bookings (incl. unpaid) — not bookable. */ + wagonsReserved, + /** Consist minus what bookings have claimed; what is still bookable. */ + wagonsRemaining, totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)), totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)), bookingsCount: schedule.scheduleBookings?.length ?? 0, @@ -6716,6 +7371,7 @@ export class TrainSchedulingService { `SELECT DISTINCT ON (ts.id) ts.id AS schedule_id, ts.reference AS reference, + ts.train_number, cr.contract_id AS contract_id, c.contract_kind AS contract_kind, ts.direction, @@ -6746,6 +7402,7 @@ export class TrainSchedulingService { LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id WHERE ts.deleted_at IS NULL AND ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.shipping_line_company_id IS NULL AND ts.window_phase IS NOT NULL AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') AND ts.scheduled_departure_date >= now() @@ -6772,6 +7429,7 @@ export class TrainSchedulingService { const rows: Array = await this.dataSource.query( `SELECT DISTINCT ts.id AS schedule_id, ts.reference AS reference, + ts.train_number, cr.contract_id AS contract_id, c.contract_kind AS contract_kind, ts.direction, @@ -6801,6 +7459,7 @@ export class TrainSchedulingService { LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id WHERE ts.deleted_at IS NULL AND ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.shipping_line_company_id IS NULL AND ts.window_phase IS NOT NULL AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') AND ts.scheduled_departure_date >= now() @@ -6817,9 +7476,7 @@ export class TrainSchedulingService { */ async listAllBookingWindows() { const rows: Array< - Omit & { - train_number: string | null; - } + Omit > = await this.dataSource.query( `SELECT ts.id AS schedule_id, ts.reference AS reference, @@ -6849,14 +7506,13 @@ export class TrainSchedulingService { AND ts.scheduled_departure_date >= now() ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, ); - return rows.map((r) => ({ - ...this.mapBookingWindowRow({ + return rows.map((r) => + this.mapBookingWindowRow({ ...r, contract_id: null, contract_kind: null, }), - trainNumber: r.train_number, - })); + ); } private mapBookingWindowRow(r: BookingWindowRow) { @@ -6874,6 +7530,7 @@ export class TrainSchedulingService { return { scheduleId: r.schedule_id, reference: r.reference ?? null, + trainNumber: r.train_number ?? null, contractId: r.contract_id, contractKind: r.contract_kind, direction: r.direction, @@ -6912,6 +7569,8 @@ export class TrainSchedulingService { const schedules = await this.trainSchedulesRepository.findAll({ where: { bookingWindowStatus: 'OPEN', + // Dedicated shipping-line trains never surface to customer booking. + shippingLineCompanyId: IsNull(), }, relations: { trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true }, @@ -7703,6 +8362,7 @@ export class TrainSchedulingService { status: schedule.status, freightType: this.resolveScheduleFreightType(schedule), trainNumber: schedule.trainNumber ?? null, + voyageNumber: schedule.voyageNumber ?? null, maxWagons: schedule.maxWagons ?? null, direction: schedule.direction ?? null, reverseWagonOrder: schedule.reverseWagonOrder ?? false, @@ -8345,7 +9005,12 @@ export class TrainSchedulingService { .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); const eligible = linkedBookings.filter( - (b) => SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') || b.isGovernment, + (b) => + SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') || + b.isGovernment || + // Shipping-line bookings board without paying up front — their charge + // sits on the credit ledger, so accept (FULLY_EXECUTED) is boardable. + Boolean(b.shippingLineCompanyId), ); if (!eligible.length) return empty; @@ -8635,40 +9300,101 @@ export class TrainSchedulingService { } const allocRepo = this.dataSource.getRepository(WagonBookingAllocation); + // Cargo type → allowed wagon types rides along: for bulk, the commodity's + // own wagon-type list (the planner's rule) decides, not only the wagon + // type's generic supportedLoadTypes. const loadAllocations = (trainSetWagonId: string) => - allocRepo.find({ where: { trainSetWagonId } }); + allocRepo.find({ + where: { trainSetWagonId }, + relations: { booking: { cargoType: { wagonTypes: true } } }, + }); const sourceAllocs = await loadAllocations(source.id); if (!sourceAllocs.length) { throw new BadRequestException('Source wagon has no load to move'); } - // Target: a slot of this train set, or an empty consist-only wagon of the - // built train (physical wagon with no slot row yet). + // Leg spans: a physical wagon carries one slot PER LEG (cross-leg sharing — + // Gelan→Adama and Adama→Doraleh loads ride the same wagon in two slots), so + // "the slot on that wagon" only means the one whose leg overlaps the moving + // load's leg. Null board/alight = the schedule's own endpoints. + const stops = await this.stopYardsForSchedule(schedule); + const spanOf = (slot: { + boardYardId?: string | null; + alightYardId?: string | null; + }): [number, number] => { + const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0; + const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : stops.length - 1; + return [from < 0 ? 0 : from, to < 0 ? Math.max(1, stops.length - 1) : to]; + }; + const overlaps = (a: [number, number], b: [number, number]) => a[0] < b[1] && b[0] < a[1]; + const sourceSpan = spanOf(source); + + // Target: a slot of this train set, or a physical wagon of this train — + // coupled-but-empty consist wagon (built train), or a wagon already pinned + // by another slot of this set (then: the overlapping-leg slot, or a fresh + // slot for a free leg). const slotById = slots.find((w) => w.id === dto.targetWagonId) ?? null; - const wagonForTarget = slotById - ? null - : schedule.trainSet?.trainId - ? await this.dataSource.getRepository(Wagon).findOne({ - where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId }, - relations: { wagonType: true }, - }) - : null; + let wagonForTarget: Wagon | null = null; + if (!slotById) { + const wagon = await this.dataSource.getRepository(Wagon).findOne({ + where: { id: dto.targetWagonId }, + relations: { wagonType: true }, + }); + const onThisTrain = + !!wagon && + ((!!schedule.trainSet?.trainId && wagon.trainId === schedule.trainSet.trainId) || + slots.some((w) => w.physicalWagonId === wagon.id)); + wagonForTarget = onThisTrain ? wagon : null; + } if (!slotById && !wagonForTarget) { throw new NotFoundException('Target wagon is not part of this schedule'); } - // A physical wagon holds at most one slot. When the caller addressed the - // wagon directly but a slot is already pinned to it, move into that slot - // rather than minting a second one on the same wagon. const targetSlot = slotById ?? (wagonForTarget - ? (slots.find((w) => w.physicalWagonId === wagonForTarget.id) ?? null) + ? (slots.find( + (w) => + w.physicalWagonId === wagonForTarget.id && overlaps(spanOf(w), sourceSpan), + ) ?? null) : null); const consistWagon = targetSlot ? null : wagonForTarget; + + // Leg clash guard: after the move, no two slots on one physical wagon may + // ride the same edge. Source load → target wagon; on a swap, target load → + // source wagon. + const targetPhysicalId = targetSlot?.physicalWagonId ?? consistWagon?.id ?? null; + const clashOn = ( + physicalWagonId: string | null, + excludeSlotId: string | null, + span: [number, number], + ) => + !!physicalWagonId && + slots.some( + (w) => + w.physicalWagonId === physicalWagonId && + w.id !== excludeSlotId && + w.id !== source.id && + (w.allocations?.length ?? 0) > 0 && + overlaps(spanOf(w), span), + ); + if (clashOn(targetPhysicalId, targetSlot?.id ?? null, sourceSpan)) { + throw new BadRequestException( + 'That wagon already carries another load on the same leg — pick a wagon free on that leg.', + ); + } const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : []; if (targetSlot && targetSlot.id === source.id) { return this.getTrainScheduleById(scheduleId); } + if ( + targetSlot && + targetAllocs.length && + clashOn(source.physicalWagonId ?? null, source.id, spanOf(targetSlot)) + ) { + throw new BadRequestException( + 'Swap refused: the source wagon already carries another load on the incoming load’s leg.', + ); + } const loadTypesOf = (allocs: WagonBookingAllocation[]) => [ ...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())), @@ -8681,10 +9407,25 @@ export class TrainSchedulingService { slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`; const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) => slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon'); + // Bulk is allowed on a wagon type when every bulk load's cargo type lists + // it (cargo-type ↔ wagon-type config, same rule the wagon planner uses). + const bulkCargoAllows = (allocs: WagonBookingAllocation[], wagonTypeId?: string) => { + const bulk = allocs.filter((a) => (a.loadType ?? 'CONTAINER').toUpperCase() === 'BULK'); + return ( + !!wagonTypeId && + bulk.length > 0 && + bulk.every((a) => + (a.booking?.cargoType?.wagonTypes ?? []).some((wt) => wt.id === wagonTypeId), + ) + ); + }; const checkReceives = ( allocs: WagonBookingAllocation[], label: string, - wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined, + wagonType: + | { id?: string; code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } + | null + | undefined, capacityTons: number, ) => { const incoming = loadTypesOf(allocs); @@ -8695,6 +9436,7 @@ export class TrainSchedulingService { const ok = supported.includes(loadType) || (loadType === 'CONTAINER' && wagonType.supportsContainer) || + (loadType === 'BULK' && bulkCargoAllows(allocs, wagonType.id)) || supported.length === 0; if (!ok) { throw new BadRequestException( @@ -9122,4 +9864,440 @@ export class TrainSchedulingService { }); return new Set(allocations.map((a) => a.bookingId)); } + + // ── Train merge ──────────────────────────────────────────────────────────── + // Combine two trains into one departure. The schedule the action is taken + // from ALWAYS survives: its train set is repointed at the target train, the + // target's wagons join this consist, and the source train is emptied and + // deactivated. When the target also runs a schedule on the SAME DAY, that + // schedule's bookings move here and it is soft-deleted; the target's + // other-day schedules contribute wagons only. + + /** Statuses whose schedules may take part in a merge. */ + private static readonly MERGEABLE_STATUSES: string[] = [ + TrainScheduleStatusEnum.Draft, + TrainScheduleStatusEnum.Scheduled, + ]; + + /** + * Everything a merge needs to decide, gathered once. Both `previewMerge` and + * `mergeScheduleTrain` run this so the modal shows exactly what will happen + * and the commit cannot diverge from it. + */ + private async planMerge(scheduleId: string, targetTrainId: string) { + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!TrainSchedulingService.MERGEABLE_STATUSES.includes(schedule.status)) { + throw new BadRequestException( + `Cannot merge into a ${schedule.status} schedule — only draft or scheduled departures can be merged.`, + ); + } + + const sourceTrainId = schedule.trainSet?.trainId ?? null; + if (sourceTrainId && sourceTrainId === targetTrainId) { + throw new BadRequestException( + 'That is already this schedule\'s train — pick a different one to merge in.', + ); + } + + const targetTrain = await this.dataSource + .getRepository(Train) + .findOne({ where: { id: targetTrainId } }); + if (!targetTrain) { + throw new NotFoundException(`Train ${targetTrainId} not found`); + } + + // Every schedule the target train is committed to, via its train sets. + // Locomotives come along: the merged train is pulled by the union of this + // schedule's locos and the target train's, so capacity checks need both. + const targetSets = await this.dataSource + .getRepository(TrainSet) + .find({ + where: { trainId: targetTrainId }, + relations: { locomotives: { locomotive: true }, locomotive: true }, + }); + const targetSetIds = targetSets.map((s) => s.id); + const targetSchedules = targetSetIds.length + ? await this.dataSource.getRepository(TrainSchedule).find({ + where: { trainSetId: In(targetSetIds) }, + }) + : []; + + // The same-day schedule is the one whose bookings move here. Only a + // draft/scheduled one qualifies — a dispatched departure keeps its cargo. + const sameDay = (a: Date | string, b: Date | string) => + new Date(a).toISOString().slice(0, 10) === + new Date(b).toISOString().slice(0, 10); + + const absorbed = + targetSchedules.find( + (s) => + s.id !== schedule.id && + sameDay(s.scheduledDepartureDate, schedule.scheduledDepartureDate) && + TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status), + ) ?? null; + + // Wagons ride with the train, so every OTHER draft/scheduled schedule on it + // is affected too — it gains the merged consist but never the bookings. + const affectedOthers = targetSchedules.filter( + (s) => + s.id !== schedule.id && + s.id !== absorbed?.id && + TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status), + ); + const untouched = targetSchedules.filter( + (s) => + s.id !== schedule.id && + s.id !== absorbed?.id && + !TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status), + ); + + // The wagons joining this consist: whatever physically sits on the target + // train today. + const incomingWagons = await this.dataSource + .getRepository(Wagon) + .find({ where: { trainId: targetTrainId }, order: { wagonNumber: 'ASC' } }); + + // EVERY wagon physically on the source train moves with the merge — not + // just the ones coupled into this schedule's set. A wagon left behind + // would strand on the deactivated train. "Loose" = on the train but not + // backing a set slot; it joins the counts and the capacity math. + const sourceWagons = sourceTrainId + ? await this.dataSource + .getRepository(Wagon) + .find({ where: { trainId: sourceTrainId }, order: { wagonNumber: 'ASC' } }) + : []; + const coupledPhysicalIds = new Set( + (schedule.trainSet?.wagons ?? []) + .map((w) => w.physicalWagonId) + .filter(Boolean), + ); + const looseSourceWagons = sourceWagons.filter( + (w) => !coupledPhysicalIds.has(w.id), + ); + + const movingBookings = absorbed + ? await this.dataSource.getRepository(TrainScheduleBooking).find({ + where: { trainScheduleId: absorbed.id }, + relations: { booking: true }, + }) + : []; + + return { + schedule, + sourceTrainId, + targetTrain, + targetSets, + absorbed, + affectedOthers, + untouched, + incomingWagons, + looseSourceWagons, + movingBookings, + }; + } + + /** + * Blocking checks, run against the plan. Returns human-readable reasons; an + * empty array means the merge may proceed. Kept separate from `planMerge` so + * the preview can SHOW the reasons rather than throwing on them. + */ + private async mergeBlockers( + plan: Awaited>, + ): Promise { + const blockers: string[] = []; + const { schedule, incomingWagons, movingBookings, absorbed } = plan; + + if (incomingWagons.length === 0) { + blockers.push( + `${plan.targetTrain.code} has no wagons to merge — nothing would move.`, + ); + } + + // ── Capacity: the merged consist must fit the merged train's locomotives ─ + // Existing side = coupled set slots PLUS loose wagons riding the source + // train without a slot — they all move, so they all count. + const existingSlots = (schedule.trainSet?.wagons ?? []).map((w) => ({ + lengthMeters: Number(w.lengthMeters) || 0, + tareWeightTons: Number(w.wagonType?.tareWeightTons) || 0, + cargoTons: 0, + })); + const wagonTypeIds = [ + ...new Set( + [...incomingWagons, ...plan.looseSourceWagons] + .map((w) => w.wagonTypeId) + .filter(Boolean), + ), + ]; + const wagonTypes = wagonTypeIds.length + ? await this.dataSource + .getRepository(WagonType) + .find({ where: { id: In(wagonTypeIds) } }) + : []; + const typeById = new Map(wagonTypes.map((t) => [t.id, t])); + const slotFromWagon = (w: Wagon) => { + const t = typeById.get(w.wagonTypeId); + return { + lengthMeters: Number(t?.lengthMeters) || 0, + tareWeightTons: Number(t?.tareWeightTons) || 0, + cargoTons: 0, + }; + }; + const incomingSlots = incomingWagons.map(slotFromWagon); + const looseSlots = plan.looseSourceWagons.map(slotFromWagon); + + // The merged train is pulled by the union of this schedule's locomotives + // and whatever already pulls the target train (its sets keep their locos). + // Pull weight adds up across the pool; length stays the tightest cap. + const locoPool = [ + ...this.locomotivesOfTrainSet(schedule.trainSet), + ...plan.targetSets.flatMap((set) => this.locomotivesOfTrainSet(set)), + ]; + const limits = combinedLocomotiveLimits([ + ...new Map(locoPool.map((l) => [l.id, l])).values(), + ]); + if (limits) { + const rules = await this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .find({ take: 1 }); + const caps = trainHardCaps(limits, { + maxTrainWeightTons: rules[0]?.maxTrainWeightTons ?? undefined, + maxTrainLengthMeters: rules[0]?.maxTrainLengthMeters ?? undefined, + }); + const merged = [...existingSlots, ...looseSlots, ...incomingSlots]; + // Merge is a physical consist move, so only the physical axes gate it: + // can this schedule's locomotives pull the merged weight and length. + // `schedule.maxWagons` is the booking-window planning ceiling — using it + // as a slot cap here blocked every merge into a bigger train (e.g. a + // 3-wagon plan absorbing a 47-wagon train). The commit raises the + // ceiling to the merged size instead. + const violations = consistViolations(merged, { + maxWeightTons: caps.maxWeightTons, + maxLengthMeters: caps.maxLengthMeters, + maxWagonSlots: merged.length, + }); + blockers.push(...violations); + } + + // ── Legs: an absorbed booking must be servable by THIS schedule's route ── + if (absorbed && movingBookings.length) { + const routeYardIds = await this.routeYardSequence(schedule.routeId ?? null); + if (routeYardIds.length) { + const position = new Map(routeYardIds.map((id, i) => [id, i])); + const slotIds = movingBookings.map((mb) => mb.bookingId); + const allocations = slotIds.length + ? await this.dataSource.getRepository(WagonBookingAllocation).find({ + where: { bookingId: In(slotIds) }, + relations: { trainSetWagon: true }, + }) + : []; + const offRoute = new Set(); + for (const alloc of allocations) { + const board = alloc.trainSetWagon?.boardYardId ?? null; + const alight = alloc.trainSetWagon?.alightYardId ?? null; + // Null on both = rides the whole route; always compatible. + if (!board && !alight) continue; + const from = board ? position.get(board) : 0; + const to = alight ? position.get(alight) : routeYardIds.length - 1; + if (from === undefined || to === undefined || from >= to) { + offRoute.add(alloc.bookingId); + } + } + if (offRoute.size) { + blockers.push( + `${offRoute.size} booking(s) on ${absorbed.reference ?? 'the merged schedule'} ` + + 'travel legs this schedule\'s route does not serve in the same order.', + ); + } + } + } + + return blockers; + } + + /** Ordered yard ids along a route, origin first. Empty when unknown. */ + private async routeYardSequence(routeId: string | null): Promise { + if (!routeId) return []; + const milestones = await this.dataSource + .getRepository(RouteMilestone) + .find({ where: { routeId }, order: { sequenceNo: 'ASC' } }); + return milestones + .map((m) => m.yardId) + .filter((id): id is string => Boolean(id)); + } + + /** + * What a merge WOULD do, without doing it. Drives the confirmation modal: + * which schedules gain wagons, which one is absorbed, and why it is blocked. + */ + async previewMerge(scheduleId: string, targetTrainId: string) { + const plan = await this.planMerge(scheduleId, targetTrainId); + const blockers = await this.mergeBlockers(plan); + + // Coupled slots plus loose wagons on the source train — everything moves. + const existingCount = + (plan.schedule.trainSet?.wagons?.length ?? 0) + + plan.looseSourceWagons.length; + return { + canMerge: blockers.length === 0, + blockers, + targetTrain: { + id: plan.targetTrain.id, + code: plan.targetTrain.code, + trainNumber: plan.targetTrain.trainNumber ?? null, + }, + wagons: { + current: existingCount, + incoming: plan.incomingWagons.length, + merged: existingCount + plan.incomingWagons.length, + }, + /** The same-day schedule whose bookings move here and is then removed. */ + absorbedSchedule: plan.absorbed + ? { + id: plan.absorbed.id, + reference: plan.absorbed.reference ?? null, + scheduledDepartureDate: plan.absorbed.scheduledDepartureDate, + status: plan.absorbed.status, + bookingsMoving: plan.movingBookings.length, + } + : null, + /** Other draft/scheduled schedules on the target — wagons only. */ + affectedSchedules: plan.affectedOthers.map((s) => ({ + id: s.id, + reference: s.reference ?? null, + scheduledDepartureDate: s.scheduledDepartureDate, + status: s.status, + })), + /** On the target train but left alone (dispatched, cancelled, …). */ + untouchedSchedules: plan.untouched.map((s) => ({ + id: s.id, + reference: s.reference ?? null, + scheduledDepartureDate: s.scheduledDepartureDate, + status: s.status, + })), + sourceTrainWillDeactivate: Boolean(plan.sourceTrainId), + }; + } + + /** + * Execute the merge. One transaction: repoint the train set, move the wagons + * (appended last so the builder can reorder them later), carry the absorbed + * schedule's bookings across, soft-delete that schedule, and deactivate the + * emptied source train. + */ + async mergeScheduleTrain( + scheduleId: string, + dto: MergeScheduleTrainDto, + ): Promise { + const plan = await this.planMerge(scheduleId, dto.targetTrainId); + const blockers = await this.mergeBlockers(plan); + if (blockers.length) { + throw new BadRequestException(blockers.join(' ')); + } + + const { + schedule, + sourceTrainId, + targetTrain, + absorbed, + incomingWagons, + movingBookings, + } = plan; + const trainSetId = schedule.trainSetId; + + await this.dataSource.transaction(async (manager) => { + // 1. This schedule's set now runs on the target train. + await manager.getRepository(TrainSet).update(trainSetId, { + trainId: targetTrain.id, + }); + + // 2. The physical wagons follow the train — the target's stay put, and + // EVERY wagon on the source train (coupled or loose) moves across so + // nothing strands on the deactivated train. + if (incomingWagons.length) { + await manager.getRepository(Wagon).update( + { id: In(incomingWagons.map((w) => w.id)) }, + { trainId: targetTrain.id }, + ); + } + if (sourceTrainId) { + await manager + .getRepository(Wagon) + .update({ trainId: sourceTrainId }, { trainId: targetTrain.id }); + } + + // 3. Carry the target's train-set wagon rows into THIS consist, appended + // after the existing wagons. Sequence is provisional — staff reorder + // in the train builder afterwards. + const existing = schedule.trainSet?.wagons ?? []; + let nextSequence = + existing.reduce((max, w) => Math.max(max, w.sequenceNo ?? 0), 0) + 1; + const incomingSetWagons = await manager.getRepository(TrainSetWagon).find({ + where: { physicalWagonId: In(incomingWagons.map((w) => w.id)) }, + }); + for (const row of incomingSetWagons) { + if (row.trainSetId === trainSetId) continue; + await manager.getRepository(TrainSetWagon).update(row.id, { + trainSetId, + sequenceNo: nextSequence, + }); + nextSequence += 1; + } + + // 4. The absorbed schedule's bookings move here. `bookingId` is uniquely + // indexed, so these rows are UPDATED across rather than re-inserted. + if (absorbed && movingBookings.length) { + await manager + .getRepository(TrainScheduleBooking) + .update( + { trainScheduleId: absorbed.id }, + { trainScheduleId: schedule.id }, + ); + } + + // 5. The absorbed schedule is soft-deleted — its bookings still exist and + // still depart that day, so nobody is notified and nothing is lost. + if (absorbed) { + await manager.getRepository(TrainSchedule).softDelete(absorbed.id); + } + + // 6. The source train is now empty; park it. + if (sourceTrainId) { + await manager.getRepository(Train).update(sourceTrainId, { + status: Freight.TrainStatus.Deactivated, + }); + } + + // 7. Keep the set's cached totals honest. + const mergedCount = + (schedule.trainSet?.wagons?.length ?? 0) + incomingSetWagons.length; + await manager + .getRepository(TrainSet) + .update(trainSetId, { wagonCount: mergedCount }); + + // 8. Booking capacity follows the consist: raise (never lower) the + // planning ceiling so the merged wagons are actually sellable. + if (mergedCount > (schedule.maxWagons ?? 0)) { + await manager + .getRepository(TrainSchedule) + .update(schedule.id, { maxWagons: mergedCount }); + } + }); + + this.logger.log( + `Schedule ${schedule.reference ?? scheduleId} merged with train ${targetTrain.code}` + + ` — ${incomingWagons.length} wagon(s) moved` + + (absorbed + ? `, absorbed ${absorbed.reference ?? absorbed.id} (${movingBookings.length} booking(s))` + : '') + + (sourceTrainId ? ', source train deactivated' : '') + + (dto.reason?.trim() ? ` (${dto.reason.trim()})` : ''), + ); + + const fresh = await this.trainSchedulesRepository.findById(scheduleId); + return fresh ?? schedule; + } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.spec.ts new file mode 100644 index 000000000..6507b8a8c --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.spec.ts @@ -0,0 +1,113 @@ +import { computeScheduleWagonUsage } from './schedule-wagon-usage.util'; + +/** A coupled slot; `allocated` = a booking actually sits on it. */ +const slot = (allocated = false) => ({ allocations: allocated ? [{}] : [] }); +const booking = (wagonsRequired: number | null) => ({ booking: { wagonsRequired } }); + +describe('computeScheduleWagonUsage', () => { + it('reports allocated slots as used, not the coupled consist size', () => { + // The reported bug: a 37-wagon consist carrying 3 allocated bookings read + // "37 wgn used" in the list while the detail page read "3 in use". + const slots = [...Array(34).fill(slot(false)), ...Array(3).fill(slot(true))]; + + const usage = computeScheduleWagonUsage({ + wagonSlots: slots, + storedWagonCount: 37, + scheduleBookings: [], + }); + + expect(usage.wagonsUsed).toBe(3); + expect(usage.wagonsTotal).toBe(37); + }); + + it('counts a built train with no bookings as 0 used', () => { + const usage = computeScheduleWagonUsage({ + wagonSlots: Array(40).fill(slot(false)), + storedWagonCount: 40, + scheduleBookings: [], + }); + + expect(usage.wagonsUsed).toBe(0); + expect(usage.wagonsRemaining).toBe(40); + }); + + it('treats wagons of an unpaid booking as reserved, so they are not bookable', () => { + // Booking claims 5 wagons but has no wagon plan yet: 0 used, still only 5 + // bookable on a 10-wagon train — the reservation is not free space. + const usage = computeScheduleWagonUsage({ + wagonSlots: Array(10).fill(slot(false)), + storedWagonCount: 10, + scheduleBookings: [booking(5)], + }); + + expect(usage.wagonsUsed).toBe(0); + expect(usage.wagonsReserved).toBe(5); + expect(usage.wagonsRemaining).toBe(5); + }); + + it('does not double-count a booking that is both reserved and allocated', () => { + // 3 allocated slots for a booking that reserved 3 wagons: 7 remain, not 4. + const usage = computeScheduleWagonUsage({ + wagonSlots: [...Array(7).fill(slot(false)), ...Array(3).fill(slot(true))], + storedWagonCount: 10, + scheduleBookings: [booking(3)], + }); + + expect(usage.wagonsUsed).toBe(3); + expect(usage.wagonsReserved).toBe(3); + expect(usage.wagonsRemaining).toBe(7); + }); + + it('never reports negative remaining when claims exceed the consist', () => { + const usage = computeScheduleWagonUsage({ + wagonSlots: Array(2).fill(slot(false)), + storedWagonCount: 2, + scheduleBookings: [booking(5)], + }); + + expect(usage.wagonsRemaining).toBe(0); + }); + + it('sells against the planned ceiling, not the partially coupled consist', () => { + // S-2026-00003: planned 3 wagons, 2 coupled + allocated for a paid booking + // that reserved 2 — the list read "2/2 used, 0 bookable" while the detail + // page and the booking gate (remainingWagonsForLeg vs maxWagons) both said + // 1 wagon was still free. Wagons couple on demand; the ceiling is capacity. + const usage = computeScheduleWagonUsage({ + wagonSlots: Array(2).fill(slot(true)), + storedWagonCount: 2, + scheduleBookings: [booking(2)], + maxWagons: 3, + }); + + expect(usage.wagonsUsed).toBe(2); + expect(usage.wagonsTotal).toBe(3); + expect(usage.wagonsRemaining).toBe(1); + }); + + it('falls back to the stored counter when slot rows were not loaded', () => { + const usage = computeScheduleWagonUsage({ + wagonSlots: [], + storedWagonCount: 12, + scheduleBookings: [], + }); + + expect(usage.wagonsTotal).toBe(12); + expect(usage.wagonsUsed).toBe(0); + }); + + it('tolerates missing relations and null wagonsRequired', () => { + const usage = computeScheduleWagonUsage({ + wagonSlots: null, + storedWagonCount: null, + scheduleBookings: [booking(null)], + }); + + expect(usage).toEqual({ + wagonsUsed: 0, + wagonsTotal: 0, + wagonsReserved: 0, + wagonsRemaining: 0, + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.ts new file mode 100644 index 000000000..899e4c066 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.ts @@ -0,0 +1,67 @@ +/** + * Wagon figures for a train-schedule list row. + * + * The list used to report `trainSet.wagonCount` — the COUPLED CONSIST SIZE — + * under the label "wgn used", so a 37-wagon train carrying 3 allocated bookings + * read "37 wgn used" in the list while its detail page (WagonPlanGrid) read + * "37 wagons · 3 in use". These helpers make the list agree with the detail + * page, which is the figure staff trust. + */ + +/** The shape this math needs — a slot counts as used when it has allocations. */ +export interface WagonSlotLike { + allocations?: unknown[] | null; +} + +export interface ScheduleBookingLike { + booking?: { wagonsRequired?: number | null } | null; +} + +export interface ScheduleWagonUsage { + /** Coupled slots carrying at least one booking allocation. */ + wagonsUsed: number; + /** Schedule capacity — the larger of coupled consist and planned ceiling. */ + wagonsTotal: number; + /** Wagons claimed by bookings, including bookings that have not paid. */ + wagonsReserved: number; + /** Capacity minus what bookings have claimed — what is still bookable. */ + wagonsRemaining: number; +} + +export function computeScheduleWagonUsage(input: { + wagonSlots?: WagonSlotLike[] | null; + /** Stored counter; used only when the slot rows were not loaded. */ + storedWagonCount?: number | null; + scheduleBookings?: ScheduleBookingLike[] | null; + /** Planned wagon ceiling (`maxWagons`) — what booking capacity is sold against. */ + maxWagons?: number | null; +}): ScheduleWagonUsage { + const slots = input.wagonSlots ?? []; + + // Same predicate as the detail page's WagonPlanGrid: a slot is in use only + // when a booking is actually allocated onto it. + const wagonsUsed = slots.filter((slot) => (slot.allocations?.length ?? 0) > 0).length; + + // Prefer live slot rows; the stored counter drifts when a consist is edited + // without a recompute, which is why the list and detail disagreed on totals. + const coupled = slots.length || (input.storedWagonCount ?? 0); + + // Wagons are coupled on demand as bookings are allocated, so a partially + // built consist does not cap what is bookable — the planned ceiling does + // (remainingWagonsForLeg sells against maxWagons). Without this, a schedule + // planned for 3 wagons with 2 coupled+allocated read "2/2 used, 0 bookable" + // while its detail page and the booking gate both said 1 wagon was free. + const wagonsTotal = Math.max(coupled, input.maxWagons ?? 0); + + // An unpaid booking still holds its wagons, so reserved space is NOT bookable. + const wagonsReserved = (input.scheduleBookings ?? []).reduce( + (sum, link) => sum + (link.booking?.wagonsRequired ?? 0), + 0, + ); + + // Reserved subsumes allocated — an allocated booking still counts its wagons — + // so remaining subtracts whichever claim is larger, never both. + const wagonsRemaining = Math.max(0, wagonsTotal - Math.max(wagonsUsed, wagonsReserved)); + + return { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining }; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index f7db22506..29a67da45 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -43,6 +43,16 @@ export type WagonStock = { remainingByTypeId: Map; /** Wagon-type code per id, for human-readable shortfall messages. */ codesByTypeId: Map; + /** + * Multi-yard consist only: yardId → (wagonTypeId → count) for the wagons + * standing at that yard. A train whose wagons are split across yards can + * only offer, at each boarding yard, the wagons physically standing there — + * a wagon waiting in Mojo is not bookable from Dire, and one picked up at + * Dire is not re-offered at Mojo. Absent (undefined) when every wagon sits + * in one yard, which keeps single-yard trains on the original whole-train + * math. + */ + byYardId?: Map>; }; export type FlexPlanResult = { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts index 47823cddd..5815ce435 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts @@ -68,3 +68,94 @@ describe('WagonStockLedger', () => { expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 3 })).toBe(10); }); }); + +describe('WagonStockLedger — multi-yard consist', () => { + // The reported case: a built train of 53 wagons, 20 standing in Dire and 33 + // in Mojo. Each yard may only sell the wagons physically standing there. + const DIRE = 'yard-dire'; + const MOJO = 'yard-mojo'; + const ADDIS = 'yard-addis'; + const STOPS = [DIRE, MOJO, ADDIS]; + const EDGES = STOPS.length - 1; + const splitStock = () => + new Map([ + [DIRE, new Map([['nw5', 20]])], + [MOJO, new Map([['nw5', 33]])], + ]); + // Legs along Dire → Mojo → Addis. + const DIRE_TO_ADDIS = { fromEdge: 0, toEdge: 2 }; + const MOJO_TO_ADDIS = { fromEdge: 1, toEdge: 2 }; + + const splitLedger = () => + new WagonStockLedger(new Map([['nw5', 53]]), EDGES, splitStock(), STOPS); + + it('offers each yard only the wagons standing there', () => { + const ledger = splitLedger(); + expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(20); + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33); + }); + + it('keeps the yards independent — Dire bookings never eat Mojo stock', () => { + const ledger = splitLedger(); + // A Dire booking rides the whole corridor, occupying the Mojo→Addis edge… + expect(ledger.consume(['nw5'], 20, DIRE_TO_ADDIS)).toBe(20); + expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(0); + // …but those are Dire's steel, so Mojo still has its own 33 to sell. + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33); + expect(ledger.consume(['nw5'], 33, MOJO_TO_ADDIS)).toBe(33); + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(0); + }); + + it('never lends a free Dire wagon to a Mojo customer', () => { + const ledger = splitLedger(); + // Only 5 of Dire's 20 sell; the other 15 ride past Mojo empty. + expect(ledger.consume(['nw5'], 5, DIRE_TO_ADDIS)).toBe(5); + // Mojo is still capped at its own 33 — the 15 empty Dire wagons are not + // offered here, exactly as the operator requires. + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33); + expect(ledger.consume(['nw5'], 40, MOJO_TO_ADDIS)).toBe(33); + }); + + it('offers nothing at the destination — there is nothing to pick up there', () => { + const ledger = splitLedger(); + // A leg boarding at the last stop has no pool of its own. + expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 2 })).toBe(0); + }); + + it('second example: Addis → Dire → Indode → Mojo → Djibouti', () => { + const [ADD, DIRE_2, INDODE, MOJO_2, DJIBOUTI] = [ + 'yard-add', + 'yard-dire', + 'yard-indode', + 'yard-mojo', + 'yard-djibouti', + ]; + const stops = [ADD, DIRE_2, INDODE, MOJO_2, DJIBOUTI]; + const ledger = new WagonStockLedger( + new Map([['nw5', 53]]), + stops.length - 1, + new Map([ + [DIRE_2, new Map([['nw5', 20]])], + [MOJO_2, new Map([['nw5', 33]])], + ]), + stops, + ); + const to = (fromEdge: number) => ({ fromEdge, toEdge: stops.length - 1 }); + // Addis: the train starts empty — nothing to sell. + expect(ledger.availableFor(['nw5'], to(0))).toBe(0); + // Dire: the 20 wagons waiting there. + expect(ledger.availableFor(['nw5'], to(1))).toBe(20); + // Indode: the same 20 wagons, which have moved with the train. + expect(ledger.availableFor(['nw5'], to(2))).toBe(0); + // Mojo: its own 33 only. + expect(ledger.availableFor(['nw5'], to(3))).toBe(33); + }); + + it('single-yard consist keeps the original whole-train behaviour', () => { + // No byYardId (the train is not split) — every leg sees the whole train, + // exactly as before this feature. + const ledger = new WagonStockLedger(new Map([['nw5', 53]]), EDGES); + expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(53); + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(53); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts index 0e4f6949d..bf5b935ad 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts @@ -20,17 +20,53 @@ import type { CorridorLeg } from './corridor-capacity.util'; * Gelan→Adama never competes for stock with an export on Adama→Doraleh. */ export class WagonStockLedger { + /** + * Usage rows keyed by pool. A single-yard train has one pool (''), so this is + * exactly the original per-type accounting. A multi-yard consist keys by + * boarding yard as well, because the Dire wagons and the Mojo wagons are + * disjoint sets of steel: 5 Dire wagons riding the whole corridor occupy the + * Mojo→Addis edge, but they must not shrink what Mojo itself can offer. + */ private readonly usedPerEdge = new Map(); constructor( private readonly remainingByTypeId: Map, private readonly edgeCount: number, + /** + * Multi-yard consist only (see {@link WagonStock.byYardId}): the wagons + * standing at each yard. When present, a leg is served ONLY by the wagons + * standing at the yard it boards from — a Dire→Addis booking on a train + * whose wagons sit 20 in Dire and 33 in Mojo sees 20, and a Mojo→Addis + * booking sees 33, never the Dire wagons that ride past empty. + */ + private readonly byYardId?: Map>, + /** Ordered corridor stops, parallel to the edges — maps an edge to its yard. */ + private readonly stops: readonly string[] = [], ) {} + /** The yard a leg boards from, or '' when the train is not split across yards. */ + private poolYardOf(leg: CorridorLeg): string { + if (!this.byYardId) return ''; + return this.stops[leg.fromEdge] ?? ''; + } + + /** Usage-row key: one row per (pool, wagon type). */ + private rowKey(wagonTypeId: string, leg: CorridorLeg): string { + const pool = this.poolYardOf(leg); + return pool ? `${pool}\u0000${wagonTypeId}` : wagonTypeId; + } + + /** Wagons of one type offered at the yard a leg boards from. */ + private totalForType(wagonTypeId: string, leg: CorridorLeg): number { + const pool = this.poolYardOf(leg); + if (!pool) return this.remainingByTypeId.get(wagonTypeId) ?? 0; + return this.byYardId?.get(pool)?.get(wagonTypeId) ?? 0; + } + /** Free wagons of ONE type on a leg: total minus its busiest edge within that leg. */ private availableForType(wagonTypeId: string, leg: CorridorLeg): number { - const total = this.remainingByTypeId.get(wagonTypeId) ?? 0; - const row = this.usedPerEdge.get(wagonTypeId); + const total = this.totalForType(wagonTypeId, leg); + const row = this.usedPerEdge.get(this.rowKey(wagonTypeId, leg)); if (!row) return total; let busiest = 0; for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) { @@ -69,10 +105,11 @@ export class WagonStockLedger { if (!deepest) break; const take = Math.min(outstanding, deepest.free); - let row = this.usedPerEdge.get(deepest.id); + const key = this.rowKey(deepest.id, leg); + let row = this.usedPerEdge.get(key); if (!row) { row = new Array(this.edgeCount).fill(0); - this.usedPerEdge.set(deepest.id, row); + this.usedPerEdge.set(key, row); } for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) { row[edge] = (row[edge] ?? 0) + take; diff --git a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts index 0c9415e42..08a424cdc 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts @@ -44,16 +44,16 @@ export class BuildTrainDto { @ApiPropertyOptional({ type: [String], format: 'uuid', - description: 'Wagons to attach at build time, in consist order (must sit in the same yard)', + description: 'Wagons to attach at build time, in consist order (any yard)', }) @IsOptional() @IsArray() @IsUUID('all', { each: true }) wagonIds?: string[]; - @ApiProperty({ maxLength: 100, description: 'Vogue number' }) + @ApiProperty({ maxLength: 100, description: 'Voyage number' }) @IsString() - @IsNotEmpty({ message: 'Vogue number is required' }) + @IsNotEmpty({ message: 'Voyage number is required' }) @MaxLength(100) trainName!: string; diff --git a/apps/edr-freight-api/src/modules/trains/dto/send-wagon-to-maintenance.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/send-wagon-to-maintenance.dto.ts new file mode 100644 index 000000000..269c4f9b4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/send-wagon-to-maintenance.dto.ts @@ -0,0 +1,15 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class SendWagonToMaintenanceDto { + @ApiPropertyOptional({ + description: + "Why the wagon is going to maintenance. Stored on the wagon's status-history " + + 'log alongside the train it was detached from, matching the fleet desk flow.', + maxLength: 500, + }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index 834663170..277c3be38 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -23,6 +23,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; +import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto'; import { UpdateTrainDetailsDto } from './dto/update-train-details.dto'; import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; import { UpdateTrainYardDto } from './dto/update-train-yard.dto'; @@ -39,6 +40,11 @@ import { TrainBuilderService } from './train-builder.service'; FREIGHT_PERMS.trains.update, FREIGHT_PERMS.trains.assignWagons, FREIGHT_PERMS.trains.delete, + FREIGHT_PERMS.trains.changeLocomotives, + FREIGHT_PERMS.trains.changeYard, + FREIGHT_PERMS.trains.changeWagonYard, + FREIGHT_PERMS.trains.toggleActive, + FREIGHT_PERMS.trains.disband, ]) export class TrainBuilderController { constructor(private readonly trainBuilderService: TrainBuilderService) {} @@ -72,7 +78,7 @@ export class TrainBuilderController { } @Put(':id/locomotives') - @FleetManage(FREIGHT_PERMS.trains.update) + @FleetManage(FREIGHT_PERMS.trains.changeLocomotives) @ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' }) setLocomotives( @Param('id', ParseUUIDPipe) id: string, @@ -94,7 +100,7 @@ export class TrainBuilderController { } @Patch(':id/yard') - @FleetManage(FREIGHT_PERMS.trains.update) + @FleetManage(FREIGHT_PERMS.trains.changeYard) @ApiOperation({ summary: 'Relocate the train — its locomotives and wagons move to the new yard with it', }) @@ -102,6 +108,26 @@ export class TrainBuilderController { return this.trainBuilderService.setYard(id, dto.currentYardId); } + @Patch(':id/wagons/:wagonId/yard') + @FleetManage(FREIGHT_PERMS.trains.changeWagonYard) + @ApiOperation({ + summary: + 'Move one coupled wagon to another yard — refused while any live schedule has the wagon allocated', + }) + setWagonYard( + @Param('id', ParseUUIDPipe) id: string, + @Param('wagonId', ParseUUIDPipe) wagonId: string, + @Body() dto: UpdateTrainYardDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainBuilderService.setWagonYard( + id, + wagonId, + dto.currentYardId, + resolveAuthUserId(user), + ); + } + @Post(':id/wagons') @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" }) @@ -131,8 +157,14 @@ export class TrainBuilderController { @Param('id', ParseUUIDPipe) id: string, @Param('wagonId', ParseUUIDPipe) wagonId: string, @CurrentUser() user: AuthUserPayload, + @Body() dto?: SendWagonToMaintenanceDto, ) { - return this.trainBuilderService.sendWagonToMaintenance(id, wagonId, resolveAuthUserId(user)); + return this.trainBuilderService.sendWagonToMaintenance( + id, + wagonId, + resolveAuthUserId(user), + dto?.note, + ); } @Post(':id/reorder-wagons') @@ -143,7 +175,7 @@ export class TrainBuilderController { } @Post(':id/deactivate') - @FleetManage(FREIGHT_PERMS.trains.update) + @FleetManage(FREIGHT_PERMS.trains.toggleActive) @ApiOperation({ summary: 'Deactivate the train (park it) — only allowed with no active schedule', }) @@ -152,14 +184,14 @@ export class TrainBuilderController { } @Post(':id/activate') - @FleetManage(FREIGHT_PERMS.trains.update) + @FleetManage(FREIGHT_PERMS.trains.toggleActive) @ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' }) activate(@Param('id', ParseUUIDPipe) id: string) { return this.trainBuilderService.activate(id); } @Delete(':id') - @FleetManage(FREIGHT_PERMS.trains.delete) + @FleetManage(FREIGHT_PERMS.trains.disband) @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' }) disband(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.maintenance.spec.ts b/apps/edr-freight-api/src/modules/trains/train-builder.maintenance.spec.ts new file mode 100644 index 000000000..a6157db7c --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/train-builder.maintenance.spec.ts @@ -0,0 +1,83 @@ +import { buildMaintenanceNotes, formatTrainRunLabel } from './train-builder.service'; + +describe('formatTrainRunLabel', () => { + it('names the train by its export and import run numbers', () => { + // What staff actually recognise — NOT the internal code (TRN-LEDGER-PW2). + expect( + formatTrainRunLabel({ + exportTrainNumber: '9201', + importTrainNumber: '9202', + trainNumber: 'TRN-7', + code: 'TRN-LEDGER-PW2', + }), + ).toBe('export 9201 / import 9202'); + }); + + it('shows only the run number that is set', () => { + expect( + formatTrainRunLabel({ exportTrainNumber: '9201', code: 'TRN-LEDGER-PW2' }), + ).toBe('export 9201'); + expect( + formatTrainRunLabel({ importTrainNumber: '9202', code: 'TRN-LEDGER-PW2' }), + ).toBe('import 9202'); + }); + + it('falls back to the train number, then the code, when no run is set', () => { + expect(formatTrainRunLabel({ trainNumber: 'TRN-7', code: 'TRN-LEDGER-PW2' })).toBe( + 'TRN-7', + ); + expect(formatTrainRunLabel({ code: 'TRN-LEDGER-PW2' })).toBe('TRN-LEDGER-PW2'); + }); + + it('ignores blank run numbers rather than printing empty labels', () => { + expect( + formatTrainRunLabel({ exportTrainNumber: ' ', importTrainNumber: null, code: 'C-1' }), + ).toBe('C-1'); + }); + + it('never returns an empty label', () => { + expect(formatTrainRunLabel({})).toBe('unknown'); + }); +}); + +describe('buildMaintenanceNotes', () => { + it('records the operator reason together with the train it came off', () => { + const notes = buildMaintenanceNotes('export 9201 / import 9202', 'Brake shoe worn through'); + + expect(notes.statusLogNote).toBe( + 'Brake shoe worn through (detached from train export 9201 / import 9202)', + ); + expect(notes.movementNote).toBe( + 'Sent to maintenance from train export 9201 / import 9202: Brake shoe worn through', + ); + }); + + it('still records the train number when no reason is given', () => { + // The reason is optional, but which train a wagon left is never optional — + // the history has to answer that on its own. + const notes = buildMaintenanceNotes('export 9201 / import 9202'); + + expect(notes.statusLogNote).toBe('Detached from train export 9201 / import 9202'); + expect(notes.movementNote).toBe('Sent to maintenance from train export 9201 / import 9202'); + }); + + it('treats a whitespace-only reason as no reason', () => { + const notes = buildMaintenanceNotes('export 9201 / import 9202', ' '); + + expect(notes.statusLogNote).toBe('Detached from train export 9201 / import 9202'); + expect(notes.movementNote).toBe('Sent to maintenance from train export 9201 / import 9202'); + }); + + it('trims padding around a real reason', () => { + const notes = buildMaintenanceNotes('export 9201 / import 9202', ' Coupler damage '); + + expect(notes.statusLogNote).toBe('Coupler damage (detached from train export 9201 / import 9202)'); + expect(notes.movementNote).toBe('Sent to maintenance from train export 9201 / import 9202: Coupler damage'); + }); + + it('handles a null reason from an older client', () => { + expect(buildMaintenanceNotes('export 9201 / import 9202', null).statusLogNote).toBe( + 'Detached from train export 9201 / import 9202', + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index fbb5301fb..827596bb7 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -19,6 +19,7 @@ import { TrainSet } from '../train-sets/entities/train-set.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; +import { WagonStatusLog } from '../wagons/entities/wagon-status-log.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; @@ -282,6 +283,10 @@ export class TrainBuilderService { wagonNumber: wagon.wagonNumber, sequenceNumber: wagon.sequenceNumber, status: wagon.status, + currentYardId: wagon.currentYardId ?? null, + currentYard: wagon.currentYard + ? { id: wagon.currentYard.id, code: wagon.currentYard.code, label: wagon.currentYard.label } + : null, wagonType: wagon.wagonType ? { id: wagon.wagonType.id, @@ -325,6 +330,27 @@ export class TrainBuilderService { : null, locomotives, wagons, + // Where the consist physically stands. A train built from several yards + // only picks a yard's wagons up when it reaches that yard, and a customer + // boarding there can only book the wagons standing there — the schedule + // route must therefore cover every one of these yards before its + // destination. + wagonYards: [ + ...wagons + .reduce((acc, wagon) => { + const id = wagon.currentYardId ?? 'UNASSIGNED'; + const entry = acc.get(id) ?? { + yardId: wagon.currentYardId ?? null, + code: wagon.currentYard?.code ?? null, + label: wagon.currentYard?.label ?? null, + wagonCount: 0, + }; + entry.wagonCount += 1; + acc.set(id, entry); + return acc; + }, new Map()) + .values(), + ].sort((a, b) => b.wagonCount - a.wagonCount), totals: { wagonCount: wagons.length, totalTareTons, @@ -427,10 +453,13 @@ export class TrainBuilderService { } /** - * Relocate the train to another yard. The consist moves as one unit: every - * coupled locomotive and wagon follows to the new yard (so their current - * yards always match the train's), and each wagon gets a movement-ledger row. - * Blocked while the train is out on a dispatched run. + * Relocate the train to another yard. The locomotives always follow. Of the + * wagons, only those standing WITH the train move: on a consist spread + * across yards (20 in Dire, 33 waiting in Mojo), moving the train Dire→Mojo + * relocates the 20 it is actually pulling and leaves the Mojo wagons where + * they stand — the train collects those by arriving, not by this call. + * Each moved wagon gets a movement-ledger row. Blocked while the train is + * out on a dispatched run. */ async setYard(id: string, currentYardId: string) { await this.dataSource.transaction(async (manager) => { @@ -438,6 +467,7 @@ export class TrainBuilderService { if (train.currentYardId === currentYardId) return; const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } }); if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`); + const previousYardId = train.currentYardId ?? null; await manager.getRepository(Train).update(train.id, { currentYardId: yard.id }); @@ -453,7 +483,17 @@ export class TrainBuilderService { ); } - const wagons = await manager.getRepository(Wagon).find({ where: { trainId: train.id } }); + const allWagons = await manager + .getRepository(Wagon) + .find({ where: { trainId: train.id } }); + // Wagons travelling with the train = those at the yard it is leaving. + // A yard-less wagon has no standing position of its own, so it follows. + const wagons = allWagons.filter( + (wagon) => + wagon.currentYardId == null || + previousYardId == null || + wagon.currentYardId === previousYardId, + ); const now = new Date(); for (const wagon of wagons) { if (wagon.currentYardId === yard.id) continue; @@ -474,7 +514,43 @@ export class TrainBuilderService { return this.getComposition(id); } - /** Append AVAILABLE wagons from the train's own yard to the consist. */ + /** + * Move ONE coupled wagon to another yard (the train and the rest of the + * consist stay put). Refused while any live (DRAFT/SCHEDULED/DISPATCHED) + * schedule has the wagon allocated to a slot — its standing yard is part of + * that schedule's route validation. Ledger row mirrors `setYard`. + */ + async setWagonYard(id: string, wagonId: string, currentYardId: string, userId?: string | null) { + await this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); + if (!wagon || wagon.trainId !== train.id) { + throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`); + } + if (wagon.currentYardId === currentYardId) return; + const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } }); + if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`); + if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} is allocated to a scheduled or dispatched run; its yard cannot be changed`, + ); + } + await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id }); + await manager.getRepository(WagonMovement).save( + manager.getRepository(WagonMovement).create({ + wagonId: wagon.id, + fromYardId: wagon.currentYardId ?? null, + toYardId: yard.id, + kind: WagonMovementKind.Manual, + movedByUserId: userId ?? null, + occurredAt: new Date(), + }), + ); + }); + return this.getComposition(id); + } + + /** Append AVAILABLE, unassigned wagons (any yard) to the consist. */ async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) { await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); @@ -501,15 +577,13 @@ export class TrainBuilderService { if (!wagon || wagon.trainId !== train.id) { throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); } - if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) { - throw new ConflictException( - `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, - ); - } + await this.assertDetachableAndReleaseStaleSlots(manager, wagon); await manager.getRepository(Wagon).update(wagon.id, { trainId: null, sequenceNumber: null, status: WagonStatus.Available, + importTrainNumber: null, + exportTrainNumber: null, }); await this.resequenceWagons(manager, train.id); await this.syncLiveScheduleAfterConsistChange( @@ -528,23 +602,44 @@ export class TrainBuilderService { * moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until * it clears maintenance. The freed sequence gap is closed. */ - async sendWagonToMaintenance(id: string, wagonId: string, userId?: string | null) { + async sendWagonToMaintenance( + id: string, + wagonId: string, + userId?: string | null, + note?: string | null, + ) { await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); if (!wagon || wagon.trainId !== train.id) { throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); } - if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) { - throw new ConflictException( - `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, - ); - } + await this.assertDetachableAndReleaseStaleSlots(manager, wagon); + const previousStatus = wagon.status; + const notes = buildMaintenanceNotes(formatTrainRunLabel(train), note); await manager.getRepository(Wagon).update(wagon.id, { trainId: null, sequenceNumber: null, status: WagonStatus.Maintenance, + importTrainNumber: null, + exportTrainNumber: null, }); + + // Status-history row, same as the fleet desk's "Send to maintenance" — + // without it a maintenance detach made here is invisible in the wagon's + // status history. The train number is folded into the note so the history + // answers "which train did it come off, and why" in one line. + if (previousStatus !== WagonStatus.Maintenance) { + await manager.getRepository(WagonStatusLog).save( + manager.getRepository(WagonStatusLog).create({ + wagonId: wagon.id, + fromStatus: previousStatus, + toStatus: WagonStatus.Maintenance, + changedByUserId: userId ?? null, + note: notes.statusLogNote, + }), + ); + } // Audit row: which train it came off and when. The wagon does not change // yard here, so from/to are the same — the ledger is the wagon's history // surface, and a maintenance detach has to be in it. @@ -556,7 +651,7 @@ export class TrainBuilderService { fromYardId: yardId, toYardId: yardId, kind: WagonMovementKind.Maintenance, - note: `Sent to maintenance from train ${train.trainNumber ?? train.code}`, + note: notes.movementNote, occurredAt: new Date(), }), ); @@ -602,6 +697,57 @@ export class TrainBuilderService { return rows.length > 0; } + /** + * Detach guard for removeWagon / sendWagonToMaintenance. A wagon is truly + * pinned only while a live schedule still NEEDS it: a slot carrying booking + * allocations, or any slot on a DISPATCHED run. An empty (allocation-free) + * slot on a DRAFT/SCHEDULED schedule is a stale reservation — its load was + * moved to another wagon (moveWagonLoad keeps the emptied slot) or its + * booking left through a path that didn't clean up — and used to pin the + * wagon forever. Release those slots here instead of blocking, with the + * same recount removeTrainSetWagonSlot does (wagonCount / totalLengthMeters + * feed the schedule capacity math). + */ + private async assertDetachableAndReleaseStaleSlots( + manager: EntityManager, + wagon: Wagon, + ): Promise { + const rows: { id: string; train_set_id: string; status: string; allocs: string }[] = + await manager.query( + `SELECT tsw.id, tsw.train_set_id, ts.status, + (SELECT count(*) + FROM freight.wagon_booking_allocations a + WHERE a.train_set_wagon_id = tsw.id + AND a.deleted_at IS NULL) AS allocs + FROM freight.train_set_wagons tsw + JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id + WHERE tsw.physical_wagon_id = $1 + AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') + AND ts.deleted_at IS NULL + AND tsw.deleted_at IS NULL`, + [wagon.id], + ); + if (!rows.length) return; + if (rows.some((r) => Number(r.allocs) > 0 || r.status === 'DISPATCHED')) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, + ); + } + await manager.getRepository(TrainSetWagon).delete(rows.map((r) => r.id)); + for (const trainSetId of [...new Set(rows.map((r) => r.train_set_id))]) { + const remaining = await manager.getRepository(TrainSetWagon).find({ + where: { trainSetId }, + select: { id: true, lengthMeters: true }, + }); + await manager.getRepository(TrainSet).update(trainSetId, { + wagonCount: remaining.length, + totalLengthMeters: round( + remaining.reduce((sum, w) => sum + (Number(w.lengthMeters) || 0), 0), + ), + }); + } + } + /** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */ async reorderWagons(id: string, dto: ReorderTrainWagonsDto) { await this.dataSource.transaction(async (manager) => { @@ -761,7 +907,13 @@ export class TrainBuilderService { .getRepository(Wagon) .update( { trainId: train.id }, - { trainId: null, sequenceNumber: null, status: WagonStatus.Available }, + { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Available, + importTrainNumber: null, + exportTrainNumber: null, + }, ); await manager.getRepository(TrainLocomotive).delete({ trainId: train.id }); await manager.getRepository(Train).remove(train); @@ -1003,11 +1155,8 @@ export class TrainBuilderService { if (wagon.status !== WagonStatus.Available) { throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`); } - if (wagon.currentYardId !== train.currentYardId) { - throw new BadRequestException( - `Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`, - ); - } + // Wagons may sit in any yard — the schedule's route must pass through + // every wagon yard before its destination (checked at scheduling time). toAttach.push(wagon); } if (!toAttach.length) return []; @@ -1021,6 +1170,10 @@ export class TrainBuilderService { trainId: train.id, sequenceNumber: sequence, status: WagonStatus.Assigned, + // Wagon inherits the train's run numbers on coupling — no per-wagon + // number entry, they ride whatever numbers the train was built with. + importTrainNumber: train.importTrainNumber, + exportTrainNumber: train.exportTrainNumber, }); } return toAttach; @@ -1097,6 +1250,48 @@ export class TrainBuilderService { * pinned to a reordered wagon adopt the wagon's new position; unpinned slots * trail behind in their previous relative order. */ +/** + * How a train is named in a wagon's history. Staff identify a train by its + * OPERATIONAL run numbers — the fixed export (odd) and import (even) numbers + * typed at build time — not by its internal code (`TRN-LEDGER-PW2`), which is a + * ledger key and means nothing on the ground. Both runs are shown when set, + * since one built train carries the pair. Falls back to the train number, then + * the code, only when no run number exists. + */ +export function formatTrainRunLabel(train: { + exportTrainNumber?: string | null; + importTrainNumber?: string | null; + trainNumber?: string | null; + code?: string | null; +}): string { + const exportNo = train.exportTrainNumber?.trim(); + const importNo = train.importTrainNumber?.trim(); + const runs = [ + exportNo ? `export ${exportNo}` : null, + importNo ? `import ${importNo}` : null, + ].filter(Boolean); + if (runs.length) return runs.join(' / '); + return train.trainNumber?.trim() || train.code?.trim() || 'unknown'; +} + +/** + * Notes for a maintenance detach. The train's run numbers are always recorded — + * staff need to know which consist a wagon came off — and the operator's reason + * is folded in when given, so the wagon's status history answers "which train, + * and why" in one line (matching the fleet desk's Send-to-maintenance note). + */ +export function buildMaintenanceNotes(trainLabel: string, note?: string | null) { + const reason = note?.trim(); + return { + statusLogNote: reason + ? `${reason} (detached from train ${trainLabel})` + : `Detached from train ${trainLabel}`, + movementNote: reason + ? `Sent to maintenance from train ${trainLabel}: ${reason}` + : `Sent to maintenance from train ${trainLabel}`, + }; +} + export function orderSlotsByWagonSequence< T extends Pick, >(slots: T[], newSeq: Map): T[] { diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts index 1080480fa..c5af8cee2 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.repository.ts @@ -21,52 +21,6 @@ export class VehiclesRepository extends BaseRepository { return this.repository.findOne({ where: { id } }); } - async findAllWithFilters(query: { - page?: number; - pageSize?: number; - search?: string; - status?: string; - sortBy?: string; - sortOrder?: 'ASC' | 'DESC'; - }) { - const page = query.page || 1; - const pageSize = query.pageSize || 10; - const skip = (page - 1) * pageSize; - - let queryBuilder = this.repository.createQueryBuilder('vehicle'); - - if (query.search) { - queryBuilder = queryBuilder.where( - '(vehicle.plateNumber ILIKE :search OR vehicle.manufacturer ILIKE :search OR vehicle.model ILIKE :search)', - { search: `%${query.search}%` }, - ); - } - - if (query.status) { - queryBuilder = queryBuilder.andWhere('vehicle.status = :status', { - status: query.status, - }); - } - - const sortBy = query.sortBy || 'createdAt'; - const sortOrder = query.sortOrder || 'DESC'; - - queryBuilder = queryBuilder - .orderBy(`vehicle.${sortBy}`, sortOrder) - .skip(skip) - .take(pageSize); - - const [data, total] = await queryBuilder.getManyAndCount(); - - return { - data, - total, - page, - pageSize, - totalPages: Math.ceil(total / pageSize), - }; - } - async createVehicle(vehicleData: Partial): Promise { const vehicle = this.repository.create(vehicleData); return this.repository.save(vehicle); diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts index af627f25a..298cae11e 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts @@ -28,6 +28,11 @@ import { NormalizedFaydaUserInfo, VerifaydaPurpose, } from './verifayda.types'; +import { randomUUID } from 'node:crypto'; +import { isBypassEnv } from '../../common/dev-bypass.util'; + +/** Sentinel `code` that skips the real eSignet exchange in dev/staging. */ +export const DEV_BYPASS_FAYDA_CODE = 'DEV_BYPASS'; export interface StartVerificationInput { purpose: VerifaydaPurpose; @@ -143,6 +148,26 @@ export class VerifaydaService { async completeVerification( query: VerifaydaCallbackDto, ): Promise { + // Dev/staging only: caller sends the sentinel code instead of a real + // eSignet redirect — skip the token exchange/session entirely and hand + // back a canned VERIFY result. `sub` is unique per call so binding both + // owner and PoA in the same bypass session doesn't collide. + if (isBypassEnv() && query.code === DEV_BYPASS_FAYDA_CODE) { + this.logger.warn('Fayda verification BYPASSED (dev/staging)'); + return { + purpose: 'VERIFY', + verified: true, + sub: `dev-bypass-${randomUUID()}`, + fullName: 'Dev Bypass User', + email: 'dev-bypass@example.com', + phoneNumber: '+251900000000', + birthdate: '1990-01-01', + gender: 'M', + address: 'Dev Bypass Address', + userDataSaved: false, + }; + } + if (query.error) { this.logger.warn(`Fayda callback returned error: ${query.error}`); if (query.state) { diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts index e747b69f2..615ef0701 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts @@ -1,5 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { + ArrayMaxSize, + IsArray, IsInt, IsNotEmpty, IsOptional, @@ -11,10 +13,14 @@ import { } from 'class-validator'; /** - * A count-only wagon-transfer request. The requester picks source yard, wagon - * type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks - * those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that - * type currently in the source yard, and a reason is mandatory. + * A wagon-transfer request. The requester picks source yard, wagon type, + * destination yard and HOW MANY. The quantity may not exceed the AVAILABLE + * wagons of that type currently in the source yard (enforced in the service, + * which is the only layer that can count them), and a reason is mandatory. + * + * The requester may additionally name the specific wagons they want via + * `preferredWagonIds`. That is a preference recorded for OCC, not a + * reservation — the count still drives fulfilment. */ export class CreateTransferRequestDto { @IsUUID() @@ -37,6 +43,17 @@ export class CreateTransferRequestDto { @MaxLength(2000) reason!: string; + @ApiPropertyOptional({ + description: + 'Specific wagons the requester wants, if they picked any. A preference for OCC — the wagons are not reserved.', + type: [String], + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(1000) + @IsUUID('4', { each: true }) + preferredWagonIds?: string[]; + @ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' }) @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts index c39b12b9d..8b1922711 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts @@ -56,6 +56,14 @@ export class WagonTransferRequest extends BaseEntity { }) status!: WagonTransferRequestStatus; + /** + * The wagons the requester specifically asked for, when they picked any. A + * preference, not a reservation — the wagons stay AVAILABLE to everyone else, + * and OCC may still send different ones. Null/empty on a plain count request. + */ + @Column({ name: 'preferred_wagon_ids', type: 'uuid', array: true, nullable: true }) + preferredWagonIds?: string[] | null; + @Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true }) requestedByUserId?: string | null; diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts index 205d86450..74fb6d6d7 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts @@ -149,6 +149,19 @@ describe('WagonTransferRequestsService — partial fulfilment', () => { expect(result.skipped).toHaveLength(0); }); + it('auto-picks the wagons the requester named ahead of the rest', async () => { + // Asked for 2 and named w-3 — the auto-pick must take it even though + // wagon-number order would have sent w-0 and w-1. + build(request({ quantity: 2, preferredWagonIds: ['w-3'] })); + wagonRepo.find.mockResolvedValue(availableWagons(5)); + + await service.bulkFulfill(['req-1']); + + const [{ wagonIds }] = wagonsService.bulkTransfer.mock.calls[0]; + expect(wagonIds).toHaveLength(2); + expect(wagonIds[0]).toBe('w-3'); + }); + it('skips only when the yard has nothing to give', async () => { wagonRepo.find.mockResolvedValue([]); @@ -199,7 +212,7 @@ describe('WagonTransferRequestsService — partial fulfilment', () => { }); describe('createRequest', () => { - it('accepts a count larger than what the yard holds today', async () => { + it('accepts a count up to what the yard holds today', async () => { wagonRepo.count.mockResolvedValue(20); await service.createRequest( @@ -207,14 +220,128 @@ describe('WagonTransferRequestsService — partial fulfilment', () => { fromYardId: 'yard-a', toYardId: 'yard-b', wagonTypeId: 'type-1', - quantity: 50, + quantity: 20, reason: 'Grain campaign', }, 'user-1', ); expect(requestRepo.save).toHaveBeenCalled(); - expect(stored.quantity).toBe(50); + expect(stored.quantity).toBe(20); + }); + + it('refuses a count larger than what the yard holds today', async () => { + wagonRepo.count.mockResolvedValue(20); + + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 50, + reason: 'Grain campaign', + }, + 'user-1', + ), + ).rejects.toThrow(/only 20 wagon\(s\).*available/i); + expect(requestRepo.save).not.toHaveBeenCalled(); + }); + + it('refuses when the yard has nothing of that type available', async () => { + wagonRepo.count.mockResolvedValue(0); + + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 1, + reason: 'Grain campaign', + }, + 'user-1', + ), + ).rejects.toThrow(/no available wagons/i); + expect(requestRepo.save).not.toHaveBeenCalled(); + }); + + it('records the wagons the requester hand-picked', async () => { + wagonRepo.count.mockResolvedValue(20); + wagonRepo.find.mockResolvedValue(availableWagons(3)); + + await service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 3, + reason: 'Grain campaign', + preferredWagonIds: ['w-0', 'w-1', 'w-2'], + }, + 'user-1', + ); + + expect(stored.preferredWagonIds).toEqual(['w-0', 'w-1', 'w-2']); + }); + + it('leaves the picks null on a plain count request', async () => { + wagonRepo.count.mockResolvedValue(20); + + await service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 5, + reason: 'Grain campaign', + }, + 'user-1', + ); + + expect(stored.preferredWagonIds).toBeNull(); + }); + + it('refuses picks that are not available in the source yard', async () => { + wagonRepo.count.mockResolvedValue(20); + // Sitting in another yard — the requester's list is stale. + wagonRepo.find.mockResolvedValue([ + { ...availableWagons(1)[0], currentYardId: 'yard-z' }, + ]); + + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 1, + reason: 'Grain campaign', + preferredWagonIds: ['w-0'], + }, + 'user-1', + ), + ).rejects.toThrow(/no longer available in the source yard/i); + expect(requestRepo.save).not.toHaveBeenCalled(); + }); + + it('refuses more picks than the requested quantity', async () => { + wagonRepo.count.mockResolvedValue(20); + + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 2, + reason: 'Grain campaign', + preferredWagonIds: ['w-0', 'w-1', 'w-2'], + }, + 'user-1', + ), + ).rejects.toThrow(/picked 3 wagon\(s\) but are requesting 2/i); + expect(requestRepo.save).not.toHaveBeenCalled(); }); it('still refuses a same-yard move', async () => { diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts index 8d4159726..dc50a8742 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -27,6 +27,15 @@ import { WagonMovement } from './entities/wagon-movement.entity'; import { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; import { WagonsService } from './wagons.service'; +/** + * A request as sent to clients: the entity plus the resolved wagon numbers for + * whatever the requester hand-picked, so the desk can name them without a + * second round trip. + */ +export interface TransferRequestView extends WagonTransferRequest { + preferredWagons?: Array<{ id: string; wagonNumber: string }>; +} + /** Bundled per-user activity: requests they touched + wagons they moved. */ export interface TransferHistory { requests: WagonTransferRequest[]; @@ -75,10 +84,15 @@ export class WagonTransferRequestsService { ) {} /** - * Record a PENDING request. Count-only — no wagons are picked here, and the - * count is NOT capped by what the source yard holds today: OCC fulfils in - * instalments, so asking for 50 while only 20 sit there is a normal, useful - * request. A reason is mandatory and is shown on the OCC queue. + * Record a PENDING request. The count is capped by what the source yard can + * hand over right now: a request may not exceed the AVAILABLE, uncoupled + * wagons of that type in the source yard (the same number the yard desk + * shows). A reason is mandatory and is shown on the OCC queue. + * + * The requester may also name the wagons they want (`preferredWagonIds`). + * Those are validated against the source yard here so a bad pick is rejected + * at request time rather than surfacing at fulfilment, but they are only a + * preference — the wagons are not reserved and OCC may send others. */ async createRequest( dto: CreateTransferRequestDto, @@ -89,11 +103,29 @@ export class WagonTransferRequestsService { 'Source and destination yard must be different', ); } + const available = await this.countAvailable( + dto.fromYardId, + dto.wagonTypeId, + ); + if (available === 0) { + throw new BadRequestException( + 'No available wagons of this type in the source yard', + ); + } + if (dto.quantity > available) { + throw new BadRequestException( + `Only ${available} wagon(s) of this type are available in the source yard — cannot request ${dto.quantity}`, + ); + } + + const preferredWagonIds = await this.validatePreferredWagons(dto); + const request = this.requestRepo.create({ fromYardId: dto.fromYardId, toYardId: dto.toYardId, wagonTypeId: dto.wagonTypeId, quantity: dto.quantity, + preferredWagonIds, status: WagonTransferRequestStatus.Pending, requestedByUserId: userId ?? null, reason: dto.reason, @@ -103,6 +135,45 @@ export class WagonTransferRequestsService { return this.findById(saved.id); } + /** + * Check the requester's hand-picked wagons against the source yard: each must + * exist, sit in that yard, match the requested type, be AVAILABLE and be + * uncoupled — the same conditions fulfilment will apply. Returns the + * de-duplicated ids, or null when the requester picked nothing. + */ + private async validatePreferredWagons( + dto: CreateTransferRequestDto, + ): Promise { + const ids = [...new Set(dto.preferredWagonIds ?? [])]; + if (ids.length === 0) return null; + + if (ids.length > dto.quantity) { + throw new BadRequestException( + `You picked ${ids.length} wagon(s) but are requesting ${dto.quantity} — pick at most ${dto.quantity}`, + ); + } + + const wagons = await this.wagonRepo.find({ where: { id: In(ids) } }); + if (wagons.length !== ids.length) { + throw new NotFoundException('One or more selected wagons not found'); + } + const unusable = wagons.filter( + (w) => + w.currentYardId !== dto.fromYardId || + w.wagonTypeId !== dto.wagonTypeId || + w.status !== WagonStatus.Available || + w.trainId != null, + ); + if (unusable.length) { + throw new BadRequestException( + `These wagons are no longer available in the source yard: ${unusable + .map((w) => w.wagonNumber) + .join(', ')}`, + ); + } + return ids; + } + /** * AVAILABLE wagons of `wagonTypeId` currently in `yardId` — what OCC can move * right now. Shown on the desk beside the outstanding count so staff see at a @@ -164,16 +235,51 @@ export class WagonTransferRequestsService { : 'r.createdAt'; qb.orderBy(sortColumn, query.sortOrder ?? 'DESC'); - return paginateQuery(qb, { page: query.page, pageSize: query.pageSize }); + const page = await paginateQuery(qb, { + page: query.page, + pageSize: query.pageSize, + }); + return { ...page, items: await this.withPreferredWagons(page.items) }; } - async findById(id: string): Promise { + /** + * Resolve `preferredWagonIds` into wagon numbers for a page of requests in a + * single query, so the desk can show WHICH wagons were asked for. Ids that no + * longer resolve (purged wagon) simply drop out — the column carries no FK. + */ + private async withPreferredWagons( + requests: WagonTransferRequest[], + ): Promise { + const ids = [ + ...new Set(requests.flatMap((r) => r.preferredWagonIds ?? [])), + ]; + if (ids.length === 0) return requests; + + const wagons = await this.wagonRepo.find({ + where: { id: In(ids) }, + select: { id: true, wagonNumber: true }, + }); + const byId = new Map(wagons.map((w) => [w.id, w.wagonNumber])); + + return requests.map((r) => { + const picked = r.preferredWagonIds ?? []; + if (picked.length === 0) return r; + return Object.assign(r, { + preferredWagons: picked + .filter((id) => byId.has(id)) + .map((id) => ({ id, wagonNumber: byId.get(id)! })), + }); + }); + } + + async findById(id: string): Promise { const request = await this.requestRepo.findOne({ where: { id }, relations: REQUEST_RELATIONS, }); if (!request) throw new NotFoundException(`Transfer request ${id} not found`); - return request; + const [view] = await this.withPreferredWagons([request]); + return view; } /** Wagons still owed on an open request. */ @@ -396,7 +502,7 @@ export class WagonTransferRequestsService { continue; } const remaining = this.remainingOn(request); - const wagons = await this.wagonRepo.find({ + const candidates = await this.wagonRepo.find({ where: { currentYardId: request.fromYardId, wagonTypeId: request.wagonTypeId, @@ -404,8 +510,19 @@ export class WagonTransferRequestsService { trainId: IsNull(), }, order: { wagonNumber: 'ASC' }, - take: remaining, }); + // Honour the requester's picks first — any that are still available in + // the yard go out ahead of the plain wagon-number order, and the rest of + // the instalment is topped up from whatever else is on hand. + const preferred = new Set(request.preferredWagonIds ?? []); + const wagons = ( + preferred.size + ? [ + ...candidates.filter((w) => preferred.has(w.id)), + ...candidates.filter((w) => !preferred.has(w.id)), + ] + : candidates + ).slice(0, remaining); if (wagons.length === 0) { skipped.push({ id, @@ -464,7 +581,7 @@ export class WagonTransferRequestsService { }); return { - requests, + requests: await this.withPreferredWagons(requests), movements, meta: { page: page ?? 1, 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 2d8950fb4..f2c76e283 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -121,8 +121,46 @@ export class WagonsService { * (yard workspace, coupling pickers) walk the pages client-side — see * `wagonService.listAll` in the backoffice. */ - findAll(query: ListWagonsQueryDto = {}): Promise> { - return paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 }); + async findAll(query: ListWagonsQueryDto = {}): Promise> { + const page = await paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 }); + await this.attachStatusDates(page.items); + return page; + } + + /** + * Latest status-flip dates from the audit log, for the wagons desk columns: + * when the wagon last went to MAINTENANCE and when it last became AVAILABLE. + * One grouped query per page; null when the log has no such flip. + */ + private async attachStatusDates(wagons: Wagon[]): Promise { + if (!wagons.length) return; + const rows: Array<{ + wagonId: string; + lastMaintenanceAt: Date | null; + lastAvailableAt: Date | null; + }> = await this.dataSource + .getRepository(WagonStatusLog) + .createQueryBuilder('l') + .select('l.wagon_id', 'wagonId') + .addSelect( + `MAX(l.created_at) FILTER (WHERE l.to_status = '${WagonStatus.Maintenance}')`, + 'lastMaintenanceAt', + ) + .addSelect( + `MAX(l.created_at) FILTER (WHERE l.to_status = '${WagonStatus.Available}')`, + 'lastAvailableAt', + ) + .where('l.wagon_id IN (:...ids)', { ids: wagons.map((w) => w.id) }) + .groupBy('l.wagon_id') + .getRawMany(); + const byId = new Map(rows.map((r) => [r.wagonId, r])); + for (const w of wagons) { + const r = byId.get(w.id); + Object.assign(w, { + lastMaintenanceAt: r?.lastMaintenanceAt ?? null, + lastAvailableAt: r?.lastAvailableAt ?? null, + }); + } } async findById(id: string): Promise { @@ -304,8 +342,8 @@ export class WagonsService { async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise { const wagon = await this.findById(wagonId); - // Mirror train-builder attachWagons: only a truly free, available wagon in - // the train's own yard can be coupled, and never onto a dispatched train. + // Mirror train-builder attachWagons: only a truly free, available wagon + // (any yard) can be coupled, and never onto a dispatched train. if (wagon.trainId != null) { throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another train`); } @@ -320,11 +358,6 @@ export class WagonsService { `Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`, ); } - if (wagon.currentYardId !== train.currentYardId) { - throw new BadRequestException( - `Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`, - ); - } const maxSeq = await this.wagonRepo .createQueryBuilder('w') 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 459b5009e..19d3b4b7a 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 @@ -38,15 +38,21 @@ export class WarehouseInventoryController { @Get() @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'List warehouse inventory' }) - findAll(@Query() filter: FilterWarehouseInventoryDto) { - return this.inventoryService.findAll(filter); + findAll( + @Query() filter: FilterWarehouseInventoryDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.findAll(filter, user); } @Get('ready-for-loading') @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'List inventory ready for loading' }) - findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) { - return this.inventoryService.findReadyForLoading(filter); + findReadyForLoading( + @Query() filter: FilterWarehouseInventoryDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.findReadyForLoading(filter, user); } @Get('inquiry') 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 2f204bd62..126998bae 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 @@ -28,12 +28,20 @@ import type { InterchangeDocument } from '../interchange-documents/entities/inte import { LastMileService } from '../last-mile/last-mile.service'; import { UpdateLastMileDto } from '../last-mile/dto/update-last-mile.dto'; import { NotificationsService } from '../notifications/notifications.service'; -import { sendCompanyChannels } from '../notifications/notify-company.util'; +import { + sendCompanyChannels, + notifyCarriageAcceptanceReady as notifyCarriageAcceptanceReadyShared, +} from '../notifications/notify-company.util'; import { companyNotifyPhoneExpr, primaryContactUserJoin, } from '../notifications/resolve-company-phone.util'; import { SignaturesService } from '../signatures/signatures.service'; +import { StampSettingsService } from '../stamp-settings/stamp-settings.service'; +import { LogoSettingsService } from '../logo-settings/logo-settings.service'; +import { YardScopeService } from '../rule-engine/services/yard-scope.service'; +import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util'; +import { sealClass, sealImageCss, sealMarkup } from '../billing/documents/seal-markup.util'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; @@ -414,6 +422,9 @@ export class WarehouseInventoryService { private readonly handover: HandoverService, private readonly inbox: NotificationInboxService, private readonly events: EventEmitter2, + private readonly stampSettings: StampSettingsService, + private readonly logoSettings: LogoSettingsService, + private readonly yardScope: YardScopeService, ) {} /** @@ -969,7 +980,16 @@ export class WarehouseInventoryService { // ── Listing ──────────────────────────────────────────────────────────── - async findAll(filter: FilterWarehouseInventoryDto): Promise { + /** + * `user` drives yard access scoping: a desk mapped to yards sees only those + * yards' inventory. Optional so internal callers that are not serving a + * request (schedulers, other services) are unaffected — they pass nothing and + * get the unscoped list, which is what they had before. + */ + async findAll( + filter: FilterWarehouseInventoryDto, + user?: unknown, + ): Promise { const createdAt = filter.dateFrom && filter.dateTo ? Between(new Date(filter.dateFrom), new Date(filter.dateTo)) @@ -1011,6 +1031,32 @@ export class WarehouseInventoryService { }); } + // Yard scoping — applied to `base` before the search branch splits it, so + // both OR arms carry the constraint. A null result means "do not narrow". + // + // Scoped on `warehouse.stationId`, NOT on `inventory.yardId`: those are two + // different id spaces that share a name. `warehouse_inventory.yard_id` is a + // FK to `warehouse_yards` — a yard INSIDE a warehouse — while the desk↔yard + // mapping is against `freight.yards`, the network yard, which inventory + // reaches through `warehouses.station_id`. Filtering `yardId` against + // mapped network yards matches nothing and hides every row (observed: all + // 34 rows disappeared before this was corrected). + // + // `filter.yardId` is likewise a warehouse-yard id, so it is NOT passed as + // the requested yard here; `filter.facilityId` is the station-yard filter. + const scopedYardIds = await this.yardScope.listFilterYardIds( + user as never, + filter.facilityId, + 'warehouse-inventory list', + ); + if (scopedYardIds) { + if (!scopedYardIds.length) return []; + base.warehouse = { + ...((base.warehouse as FindOptionsWhere) ?? {}), + stationId: scopedYardIds.length === 1 ? scopedYardIds[0] : In(scopedYardIds), + }; + } + const search = filter.search?.trim(); const where: FindManyOptions['where'] = search ? [ @@ -1028,8 +1074,11 @@ export class WarehouseInventoryService { return items; } - findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise { - return this.findAll({ ...filter, status: 'READY_FOR_LOADING' }); + findReadyForLoading( + filter: FilterWarehouseInventoryDto, + user?: unknown, + ): Promise { + return this.findAll({ ...filter, status: 'READY_FOR_LOADING' }, user); } /** @@ -3744,6 +3793,8 @@ export class WarehouseInventoryService { const html = this.buildReleaseDocumentHtml({ reference, issuedAt, + stampImageUrl: await this.stampSettings.getStampImageUrl(), + logoImageUrl: await this.logoSettings.getLogoImageUrl(), bookingReference, bookingStatus: row?.bookingStatus ?? null, customerName: row?.customerName ?? null, @@ -4215,6 +4266,7 @@ export class WarehouseInventoryService { const html = this.buildGrnDocumentHtml({ grnNumber: row.grnNumber, + logoImageUrl: await this.logoSettings.getLogoImageUrl(), receivedAt: row.receivedAt ? new Date(row.receivedAt) : new Date(), bookingReference: row.bookingReference ?? row.bookingId ?? 'N/A', bookingStatus: row.bookingStatus ?? null, @@ -4798,6 +4850,8 @@ export class WarehouseInventoryService { const html = this.buildHandoverDocumentHtml({ reference, handedOverAt, + stampImageUrl: await this.stampSettings.getStampImageUrl(), + logoImageUrl: await this.logoSettings.getLogoImageUrl(), bookingReference, bookingStatus: row.bookingStatus ?? null, customerName: row.customerName ?? null, @@ -5147,6 +5201,7 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_DISPATCHED', description: 'Inventory dispatched', performedBy, + freeCapacity: true, }); } @@ -5431,6 +5486,8 @@ export class WarehouseInventoryService { description: string; performedBy?: string; preloaded?: WarehouseInventory; + /** Cargo physically leaves the warehouse on this transition — free up capacity (mirrors deliver()). */ + freeCapacity?: boolean; }, ): Promise { const item = opts.preloaded ?? (await this.findById(id)); @@ -5441,6 +5498,20 @@ export class WarehouseInventoryService { status: to, [opts.timestampField]: new Date(), }); + + if (opts.freeCapacity) { + const weight = Number(item.weight) || 0; + const volume = Number(item.volume) || 0; + const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; + await this.applyCapacityDelta( + manager, + { warehouseId: item.warehouseId, yardId: item.yardId, zoneId: item.zoneId }, + -weight, + -volume, + -containerCount, + ); + } + await this.activityLog.record( { activityType: opts.activityType, @@ -5479,6 +5550,8 @@ export class WarehouseInventoryService { zone: string | null; inventoryStatus: string | null; receiveSummary: string | null; + /** The one global company logo; null renders the plain text brand. */ + logoImageUrl?: string | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -5535,6 +5608,7 @@ export class WarehouseInventoryService { .ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; } .ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; } .rule { height: 3px; background: #0f766e; margin: 16px 0 22px; } + ${logoImageCss()} .notice { width: 76%; margin: 0 0 18px; padding: 13px 18px; background: #f0fdfa; border: 1px solid #5eead4; border-left: 5px solid #0f766e; font-size: 13px; line-height: 1.45; } .section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #0f766e; text-transform: uppercase; letter-spacing: .12em; } table { width: 100%; border-collapse: collapse; } @@ -5548,6 +5622,7 @@ export class WarehouseInventoryService {
+ ${logoMarkup(data.logoImageUrl)}
Ethio-Djibouti Railway S.C.

Goods Received Note

Warehouse receiving confirmation
@@ -5603,6 +5678,10 @@ export class WarehouseInventoryService { truckType?: string | null; truckGateOut?: string | null; truckWeightTons?: number | null; + /** The one global company stamp; null falls back to the drawn text seal. */ + stampImageUrl?: string | null; + /** The one global company logo; null renders the plain text brand. */ + logoImageUrl?: string | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -5689,12 +5768,15 @@ export class WarehouseInventoryService { .seal { width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; } .seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; } .seal span { position: relative; } + ${sealImageCss()} + ${logoImageCss()}
+ ${logoMarkup(data.logoImageUrl)}
Ethio-Djibouti Railway S.C.

Warehouse Release / Exit Paper

Official gate clearance and warehouse exit authorization
@@ -5722,7 +5804,7 @@ export class WarehouseInventoryService {
Officer in charge name / signature / date
-
EDR
Warehouse
Cleared
+
${sealMarkup(data.stampImageUrl, ['EDR', 'Warehouse', 'Cleared'])}
Customer or driver name / signature / date
@@ -5762,6 +5844,10 @@ export class WarehouseInventoryService { signerDisplayName: string; signatureImageUrl: string | null; } | null; + /** The one global company stamp; null falls back to the drawn text seal. */ + stampImageUrl?: string | null; + /** The one global company logo; null renders the plain text brand. */ + logoImageUrl?: string | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -5839,11 +5925,14 @@ export class WarehouseInventoryService { .seal { position: relative; width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; } .seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; } .seal span { position: relative; } + ${sealImageCss()} + ${logoImageCss()}
+ ${logoMarkup(data.logoImageUrl)}
Ethio-Djibouti Railway S.C.

Import Goods Handover Document

EDR to customer warehouse handover
@@ -5882,7 +5971,7 @@ export class WarehouseInventoryService {
Officer in charge name / signature / date
-
EDR
Warehouse
Handover
+
${sealMarkup(data.stampImageUrl, ['EDR', 'Warehouse', 'Handover'])}
${approval?.signatureImageUrl ? `` : ''}
${approval ? esc(approval.signerDisplayName) : 'Customer or driver name / signature / date'}
@@ -6156,26 +6245,13 @@ export class WarehouseInventoryService { * fires right after receive, not at marshalling. */ private async notifyCarriageAcceptanceReady(bookingId: string): Promise { - try { - const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query( - `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, - [bookingId], - ); - if (!b?.companyId) return; - const body = `Your carriage acceptance sheet for booking ${b.reference} is ready to download from the portal.`; - await this.inbox.notify({ - recipients: { companyId: b.companyId }, - audience: NotificationAudience.PORTAL, - type: NotificationType.DOCUMENT_ACTION, - title: 'Carriage acceptance sheet ready', - body, - link: `/bookings/${bookingId}`, - data: { bookingId, reference: b.reference }, - }); - await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body); - } catch (err) { - this.logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`); - } + await notifyCarriageAcceptanceReadyShared( + this.dataSource, + this.notifications, + this.inbox, + bookingId, + this.logger, + ); } private async notifyOwnerInventoryReceived(params: { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 6bf03c93d..558ffc1a9 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -99,7 +99,9 @@ interface InventoryContext { interface ViewSource { id: string; invoiceNumber: string; - companyId: string; + /** Nullable on the entity (shipping-line invoices have no company); every + * warehouse invoice is customer-billed, so in practice this is always set. */ + companyId: string | null; sourceId: string; type: string; status: Freight.InvoiceStatus | string; 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 9cf1ef0cd..174a32341 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 @@ -157,9 +157,6 @@ async function main() { email: 'negad-indode-demo@edr.local', contactPersonName: 'Marshalling Demo', contactPersonPhone: '251900000202', - generalManagerName: 'Demo Manager', - generalManagerEmail: 'negad-indode-demo@edr.local', - generalManagerPhone: '251900000202', }), )); diff --git a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts index 2e234bcf0..91848dff4 100644 --- a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts @@ -247,9 +247,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder { website: null, contactPersonName: 'First Last Mile Demo', contactPersonPhone: '251900000101', - generalManagerName: 'Demo Manager', - generalManagerEmail: COMPANY_EMAIL, - generalManagerPhone: '251900000101', }, { conflictPaths: { tin: true } }, ); 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 20d2a3f8b..4f259c28c 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -324,9 +324,6 @@ export class DemoBookingsSeeder { website: null, contactPersonName: "Train Scheduling", contactPersonPhone: "251900000001", - generalManagerName: "Demo Manager", - generalManagerEmail: COMPANY_EMAIL, - generalManagerPhone: "251900000001", }, { conflictPaths: { tin: true } }, ); diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index d90fe7b80..e4ab41954 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -191,6 +191,24 @@ const POSITION_TYPE_PERMISSIONS = [ }, ] as const; +/** + * Audit trail access. + * + * Read-only by design: `audit_logs` rows are written solely by + * `AuditInterceptor` and the module exposes no create/update/delete route, so + * there is deliberately no `:create` / `:update` / `:delete` twin to grant. + * The matching `:read` key is derived automatically by + * `deriveReadPermissions` below. + */ +const AUDIT_LOG_PERMISSIONS = [ + { + id: "52b2cdef-5313-4954-8c69-0f8c58ea2c1e", + key: "edr_freight_app:audit_log:view", + name: { am: "የኦዲት መዝገብ ይመልከቱ", en: "View audit logs" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + const EDR_FREIGHT_VIEWABLE_PERMISSIONS = [ ...EMPLOYEE_REGISTRATION_PERMISSIONS, ...ROLE_ASSIGNMENT_PERMISSIONS, @@ -198,6 +216,7 @@ const EDR_FREIGHT_VIEWABLE_PERMISSIONS = [ ...HIERARCHY_POSITION_PERMISSIONS, ...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS, ...POSITION_TYPE_PERMISSIONS, + ...AUDIT_LOG_PERMISSIONS, ...BOOKING_RULE_ENGINE_PERMISSIONS, ]; @@ -223,6 +242,7 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ "edr_freight_app:hierarchy_positions:view", "edr_freight_app:hierarchy_employee_assignment:view", "edr_freight_app:position_types:view", + "edr_freight_app:chat:view", ], }, { 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 7bcae753b..53f7ad2d2 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 @@ -1,6 +1,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { DataSource } from "typeorm"; +import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity"; import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity"; import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants"; @@ -37,7 +38,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 1, }, { @@ -49,7 +50,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 2, }, { @@ -60,7 +61,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 3, }, poaDelegationDefault(4), @@ -76,7 +77,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 1, }, { @@ -87,7 +88,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 2, }, { @@ -98,7 +99,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 3, }, { @@ -109,7 +110,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ isMultiple: false, maxFiles: 1, allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 4, }, poaDelegationDefault(5), @@ -127,7 +128,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ // isMultiple: false, // maxFiles: 1, // allowedExtensions: DOC_EXTENSIONS, -// maxSizeMb: 10, +// maxSizeMb: 50, // displayOrder: 1, // }, // { @@ -138,7 +139,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ // isMultiple: false, // maxFiles: 1, // allowedExtensions: DOC_EXTENSIONS, -// maxSizeMb: 10, +// maxSizeMb: 50, // displayOrder: 2, // }, // { @@ -149,11 +150,61 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ // isMultiple: false, // maxFiles: 1, // allowedExtensions: DOC_EXTENSIONS, -// maxSizeMb: 10, +// maxSizeMb: 50, // displayOrder: 3, // }, // ]; +/** + * Documents required from a co-operative union or farm — the third alternative + * to the two nationality sets, not an addition to them. A co-op is always + * registered in Ethiopia and has a TIN but no business licence, so its + * registration certificate stands in for the commercial registration every + * other Ethiopian company uploads. + * + * Admin-managed like every other onboarding set: what these members must + * actually produce is a backoffice decision, edited in the file-settings editor. + */ +const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [ + // First on purpose: only the first field of a new set is seeded, and this is + // the paper that distinguishes a co-operative from every other company. + { + fileKey: "cooperative_registration_certificate", + fileLabel: "Co-operative Union / Farm Registration Certificate", + helpText: + "Certificate issued by the co-operative promotion agency that registered the union or farm.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 50, + displayOrder: 1, + }, + { + fileKey: "tin_certificate", + fileLabel: "TIN Certificate", + helpText: "Verified against the TIN registry during registration.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 50, + displayOrder: 2, + }, + { + fileKey: "national_id", + fileLabel: "National ID", + helpText: "Verified against the National ID API during registration.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 50, + displayOrder: 3, + }, + poaDelegationDefault(4), +]; + interface OnboardingDocumentSetting { code: string; label: string; @@ -176,6 +227,14 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [ entity: "customer", fields: FOREIGN_ONBOARDING_FIELDS, }, + // The third set: a union or farm resolves here INSTEAD of a nationality set + // (it is always Ethiopian, and holds no business licence). + { + code: "company_onboarding_documents_cooperative", + label: "Co-operative union / farm onboarding documents", + entity: "customer", + fields: COOPERATIVE_ONBOARDING_FIELDS, + }, // Legacy per-company-type codes — removed, unused by any resolver or portal // lookup (only `company_onboarding_documents_ethiopian`/`_foreign` are live). // { @@ -232,7 +291,7 @@ function clearanceField( isMultiple: false, maxFiles: 1, allowedExtensions: opts?.extensions ?? DOC_EXTENSIONS, - maxSizeMb: 10, + maxSizeMb: 50, displayOrder, }; } @@ -556,7 +615,7 @@ const DRIVER_DOCUMENT_FIELDS: OnboardingField[] = [ isMultiple: true, maxFiles: 20, allowedExtensions: ["pdf", "jpg", "jpeg", "png", "doc", "docx"], - maxSizeMb: 10, + maxSizeMb: 50, displayOrder: 1, }, ]; @@ -584,6 +643,21 @@ const INTERCITY_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ }, ]; +// ── Shipping line booking documents ───────────────────────────────────────── +// Collected on a shipping line's booking right after it is initiated. Shipping +// lines book without a contract, so this set — not a contract — is what +// Operations reviews before the booking may be completed. Fields start empty +// and are configured in the backoffice file-settings editor, like the sets +// above. `entity: "booking"` puts it alongside the other per-booking sets. +const SHIPPING_LINE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ + { + code: "shipping_line_booking_documents", + label: "Shipping line booking documents", + entity: "booking", + fields: [], + }, +]; + // ── Hazardous cargo documents ─────────────────────────────────────────────── // Asked for in the contract wizard the moment the customer flags the cargo as // hazardous (ONE_TIME contracts only). Fields start empty and are configured in @@ -606,16 +680,15 @@ export class FileUploadSettingsSeeder { async run() { const settingRepository = this.dataSource.getRepository(FileUploadSetting); - // Seed only into an empty table: any existing rows (including - // soft-deleted ones, which would still conflict on the unique `code`) - // mean the data is admin-managed, so leave it untouched. - const existing = await settingRepository.count({ withDeleted: true }); - if (existing > 0) { - this.logger.log( - `file_upload_settings already has ${existing} rows — skipping seed`, - ); - return; - } + // Seed per CODE, not "only into an empty table". An existing row is + // admin-managed and never touched — including a soft-deleted one, which + // means the set was removed on purpose (and would still conflict on the + // unique `code`). What the table-wide check got wrong is the other half: a + // set added to this file after the first boot could never reach a database + // that already held the others, so it existed in code and nowhere else. + const existingCodes = new Set( + (await settingRepository.find({ withDeleted: true })).map((s) => s.code), + ); const allSettings: Array< OnboardingDocumentSetting & { description: string } @@ -643,22 +716,48 @@ export class FileUploadSettingsSeeder { description: "Intercity shipment documents — contract-level for ONE_TIME (after both signatures), per booking for GENERAL; reviewed by Operations.", })), + ...SHIPPING_LINE_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: + "Documents a shipping line uploads on a booking it initiated. Reviewed by Operations; the booking can only be completed once they are approved.", + })), ]; - // Insert setting rows only — no FileUploadField rows. Fields start empty - // and are configured from the backoffice file-settings editor; the field - // definitions above are kept as reference defaults. - await settingRepository.insert( - allSettings.map((documentSetting) => ({ - code: documentSetting.code, - label: documentSetting.label, - description: documentSetting.description, - entity: documentSetting.entity, - })), + const missing = allSettings.filter((s) => !existingCodes.has(s.code)); + if (missing.length === 0) { + this.logger.log("file upload settings up to date — nothing to seed"); + return; + } + + const inserted = await settingRepository.save( + missing.map((documentSetting) => + settingRepository.create({ + code: documentSetting.code, + label: documentSetting.label, + description: documentSetting.description, + entity: documentSetting.entity, + }), + ), ); + // A brand-new set gets exactly ONE field: its first reference default. The + // rest of the list above stays documentation — what a set actually asks for + // is a backoffice decision, edited in the file-settings editor. Seeding one + // means a set is never born empty (an empty set silently requires nothing), + // while leaving the admin a single row to extend rather than a list to prune. + const fieldRepository = this.dataSource.getRepository(FileUploadField); + const firstFields = inserted.flatMap((setting) => { + const reference = missing.find((s) => s.code === setting.code)?.fields[0]; + return reference + ? [fieldRepository.create({ ...reference, settingId: setting.id })] + : []; + }); + if (firstFields.length > 0) await fieldRepository.save(firstFields); + this.logger.log( - `Seeded ${allSettings.length} file upload settings with empty fields`, + `Seeded ${missing.length} file upload settings (${firstFields.length} with a default field): ${missing + .map((s) => s.code) + .join(", ")}`, ); } } 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 06511cb2b..c78dede1b 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -47,6 +47,64 @@ const perm = (id: string, key: string, en: string): FreightPermissionSeed => ({ applicationKey: EDR_FREIGHT_APP_KEY, }); +/** + * One entry per report definition (see modules/reports/definitions). Each + * gets its own permission, gated behind the `reports:view` master key that + * opens the Reports section itself. + * Keep new keys at the END: reportPermId derives ids from list index, so a + * mid-list insert would shift ids already seeded for later keys. + */ +export const REPORT_KEYS = [ + "bookings-list", + "revenue-by-customer", + "aging-receivables", + "contract-utilization", + "wagon-fleet-status", + "wagon-status-duration", + "wagon-requests", + "locomotive-fleet-status", + "booking-status-breakdown", + "train-schedule-status", + "train-turnaround", + "wagon-teu-utilization", + "loaded-capacity", + "global-logistics-wagons", + "customer-status", + "contract-lifecycle", + "customs-documents", + "invoicing-pipeline", + "first-last-mile-bookings", + "invoices-by-status", + "payments-by-status", + "revenue-summary", + "cargo-summary", + "revenue-by-category", + "revenue-transactions", + "revenue-by-period", + "revenue-by-route", + "revenue-top-customers", + "payment-classification", + "revenue-reconciliation", + "receivables-payables", + "revenue-anomalies", +] as const; + +export type ReportKey = (typeof REPORT_KEYS)[number]; + +export const reportPermissionKey = (key: ReportKey): string => + `edr_freight_app:reports:${key.replace(/-/g, "_")}:view`; + +const reportPermId = (index: number): string => + `a4f00002-0001-4000-8000-${(index + 1).toString(16).padStart(12, "0")}`; + +const titleCase = (slug: string): string => + slug.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join(" "); + +export const REPORT_PERMISSIONS: FreightPermissionSeed[] = REPORT_KEYS.map( + (key, index) => + perm(reportPermId(index), reportPermissionKey(key), `Report: ${titleCase(key)}`), +); + export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ perm( "a1000001-0001-4000-8000-000000000001", @@ -177,6 +235,14 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:bookings:wagon_cancellation_rebook", "Rebook cancelled wagons for a customer", ), + // Shared-wagon gate: two customers' cargo on one wagon is a commercial call, + // so it is signed off separately from the ordinary booking approvals — and + // never by the GL user who created the pairing. + perm( + "a1000001-0001-4000-8000-000000000029", + "edr_freight_app:bookings:approve_consolidation", + "Approve shared-wagon consolidation", + ), ]; /** @@ -410,6 +476,26 @@ export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +/** + * Yard access scoping. `yard_positions` maps desks to yards and the resolver + * (`YardScopeService`) narrows a caller to the yards their active position is + * mapped to. This key is the deliberate way out of that narrowing, for the HQ + * desks that are cross-yard by nature (OCC, CEO, rolling stock). Without it, + * "unmapped" would have to mean "sees everything", which is a bypass by + * accident rather than by grant. + * + * Editing the mapping itself needs no key of its own: it is yard configuration, + * so it rides on `rule_engine:yards:view` / `:update` like every other field on + * a yard. + */ +export const YARD_SCOPE_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "f4a00001-0001-4000-8000-000000000001", + "edr_freight_app:yards:view_all", + "Access every yard (bypass yard scoping)", + ), +]; + /** * Advanced backoffice resources — full CRUD + workflow-action keys. * See docs/rbac/freight-backoffice-permissions.md. Additive only: the existing @@ -450,6 +536,36 @@ export const CUSTOMER_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// C2. Shipping lines — carriers registered by staff (no self-signup). +export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "d1a00002-0001-4000-8000-000000000001", + "edr_freight_app:shipping_lines:view", + "View shipping lines", + ), + perm( + "d1a00002-0001-4000-8000-000000000002", + "edr_freight_app:shipping_lines:create", + "Register shipping line", + ), + perm( + "d1a00002-0001-4000-8000-000000000003", + "edr_freight_app:shipping_lines:update", + "Update shipping line", + ), + perm( + "d1a00002-0001-4000-8000-000000000004", + "edr_freight_app:shipping_lines:reset-password", + "Resend shipping line activation link", + ), +]; + +// Internal chat (Matrix/Element) — sidebar visibility + manual reconcile trigger. +export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [ + perm('c9a00001-0001-4000-8000-000000000001', 'edr_freight_app:chat:view', 'Open internal chat'), + perm('c9a00001-0001-4000-8000-000000000002', 'edr_freight_app:chat:sync', 'Re-run chat room/membership sync'), +]; + // D. Finance — payments + invoices export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -482,6 +598,28 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:invoices:eims_resolve", "Resolve a blocked MoR EIMS submission", ), + // Cancellation is a separate irreversible-at-MoR action from registration — its own grant, + // same reasoning as eims_register. + perm( + "d2b00001-0001-4000-8000-000000000008", + "edr_freight_app:invoices:eims_cancel", + "Cancel a registered invoice with MoR EIMS", + ), + // Covers both sales and withholding receipts — same risk profile (filing a document with + // MoR), no reason to split further. + perm( + "d2b00001-0001-4000-8000-000000000009", + "edr_freight_app:invoices:eims_receipt_register", + "Register a sales or withholding receipt with MoR EIMS", + ), + // Issuing a credit/debit memo is itself filing-equivalent — auto-submit picks it up like any + // other issued invoice — so it carries the same restricted grant as the eims_* actions above, + // not invoices:export. + perm( + "d2b00001-0001-4000-8000-00000000000a", + "edr_freight_app:invoices:memo_issue", + "Issue a credit or debit memo against a registered invoice", + ), // USD bookings are paid by bank transfer; Finance uploads the slip and settles // the invoice. Moves money state, so it is its own grant, not part of view. perm( @@ -489,6 +627,47 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:invoices:confirm_offline", "Confirm offline (bank transfer) invoice payment", ), + // Shipping lines consume services on credit and are invoiced after the fact, + // so what they owe is its own Finance surface, separate from invoices:view — + // an unbilled credit is not an invoice yet. + perm( + "d2c00001-0001-4000-8000-000000000001", + "edr_freight_app:shipping_line_credits:view", + "View shipping-line credits and outstanding balance", + ), + perm( + "d2c00001-0001-4000-8000-000000000002", + "edr_freight_app:shipping_line_credits:invoice", + "Generate an invoice from shipping-line credits", + ), + // Erases a debt outright, which is why it is not folded into :invoice. + perm( + "d2c00001-0001-4000-8000-000000000003", + "edr_freight_app:shipping_line_credits:cancel", + "Cancel (write off) an unbilled shipping-line credit", + ), + // Two-step manual actions on credit invoices: request grants per action, + // decision grants that apply to any pending request. + perm( + "d2c00001-0001-4000-8000-000000000004", + "edr_freight_app:shipping_line_credits:invoice_mark_paid", + "Request marking a shipping-line credit invoice paid (offline payment)", + ), + perm( + "d2c00001-0001-4000-8000-000000000005", + "edr_freight_app:shipping_line_credits:invoice_approve", + "Approve any pending shipping-line credit invoice request", + ), + perm( + "d2c00001-0001-4000-8000-000000000006", + "edr_freight_app:shipping_line_credits:invoice_cancel", + "Request cancelling a shipping-line credit invoice", + ), + perm( + "d2c00001-0001-4000-8000-000000000007", + "edr_freight_app:shipping_line_credits:invoice_reject", + "Reject any pending shipping-line credit invoice request", + ), ]; // E. First / last mile operations @@ -707,6 +886,34 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:trains:assign_wagons", "Assign wagons to train", ), + // Granular splits of trains:update / trains:delete for the train-builder + // detail page's Actions menu — each item gets its own grant instead of + // sharing the coarse update/delete keys. + perm( + "e1c00001-0001-4000-8000-000000000006", + "edr_freight_app:trains:change_locomotives", + "Change train locomotives", + ), + perm( + "e1c00001-0001-4000-8000-000000000007", + "edr_freight_app:trains:change_yard", + "Change train yard", + ), + perm( + "e1c00001-0001-4000-8000-000000000008", + "edr_freight_app:trains:toggle_active", + "Activate or deactivate train", + ), + perm( + "e1c00001-0001-4000-8000-000000000009", + "edr_freight_app:trains:disband", + "Disband train", + ), + perm( + "e1c00001-0001-4000-8000-000000000010", + "edr_freight_app:trains:change_wagon_yard", + "Change yard of a coupled wagon", + ), perm( "e1d00001-0001-4000-8000-000000000001", "edr_freight_app:routes:view", @@ -1164,6 +1371,42 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:dropdown:manage", "Manage dropdown settings", ), + perm( + "b4b00002-0001-4000-8000-000000000001", + "edr_freight_app:settings:stamp:view", + "View the company stamp", + ), + perm( + "b4b00002-0001-4000-8000-000000000002", + "edr_freight_app:settings:stamp:manage", + "Manage the company stamp", + ), + perm( + "b4b00004-0001-4000-8000-000000000001", + "edr_freight_app:settings:logo:view", + "View the company logo", + ), + perm( + "b4b00004-0001-4000-8000-000000000002", + "edr_freight_app:settings:logo:manage", + "Manage the company logo", + ), + // The per-officer approval teeter (ማህተም) — an individual's own stamp + + // signature, not the company seal. It used to ride on settings:stamp:*, which + // now gates the ONE company stamp; this key was split out when the two were + // untangled. `settings:invoice_stamp:*` retired at the same time: it gated the + // company stamp before the fold and is deliberately left orphaned in any DB + // that already seeded it (the seeder upserts by key and never deletes). + perm( + "b4b00003-0001-4000-8000-000000000001", + "edr_freight_app:settings:teeter:view", + "View own approval teeter and signature", + ), + perm( + "b4b00003-0001-4000-8000-000000000002", + "edr_freight_app:settings:teeter:manage", + "Manage own approval teeter and signature", + ), perm( "b4c00001-0001-4000-8000-000000000001", "edr_freight_app:audit:view", @@ -1270,6 +1513,16 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:exchange_rate:manage", "Set the USD-ETB fallback rate", ), + perm( + "b4d00001-0001-4000-8000-000000000003", + "edr_freight_app:settings:manual_payment:view", + "View the manual (offline) payment channel settings", + ), + perm( + "b4d00001-0001-4000-8000-000000000004", + "edr_freight_app:settings:manual_payment:manage", + "Enable or disable manual invoice settlement per currency", + ), perm( "b4e00001-0001-4000-8000-000000000001", "edr_freight_app:settings:contract_templates:view", @@ -1281,7 +1534,10 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [ "Edit contract templates & articles", ), // Granular split of contract-template access. `view` opens the sidebar page; - // `read` is API-read-only for other pages that display template data. + // `read` is API-read-only for other pages that display template data — and is + // NOT written out here: deriveReadPermissions mints the `:read` twin of every + // `:view` key, so a hand-written one duplicates the key (Postgres 21000 on the + // seeder's ON CONFLICT (key) insert) and carries a v4 id where twins are v5. perm( "b4e00001-0001-4000-8000-000000000003", "edr_freight_app:settings:contract_templates:create", @@ -1297,11 +1553,6 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:contract_templates:delete", "Delete bulk contract templates", ), - perm( - "b4e00001-0001-4000-8000-000000000006", - "edr_freight_app:settings:contract_templates:read", - "Read contract template data (API only)", - ), perm( "b4f00001-0001-4000-8000-000000000001", "edr_freight_app:settings:support_content:view", @@ -1445,7 +1696,10 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ ]; export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ + ...REPORT_PERMISSIONS, ...CUSTOMER_PERMISSIONS, + ...SHIPPING_LINE_PERMISSIONS, + ...CHAT_PERMISSIONS, ...FINANCE_PERMISSIONS, ...MILE_PERMISSIONS, ...FLEET_RAIL_PERMISSIONS, @@ -1465,6 +1719,7 @@ export const BOOKING_RULE_ENGINE_PERMISSIONS = [ ...CONTRACT_PERMISSIONS, ...RULE_ENGINE_PERMISSIONS, ...GAP_CONTROLLER_PERMISSIONS, + ...YARD_SCOPE_PERMISSIONS, ...ADVANCED_BACKOFFICE_PERMISSIONS, ]; @@ -1549,6 +1804,7 @@ export const FREIGHT_PERMS = { wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void", wagonCancellationRebook: "edr_freight_app:bookings:wagon_cancellation_rebook", + approveConsolidation: "edr_freight_app:bookings:approve_consolidation", // Notification selectors, not route guards — see NOTIFICATION_PERMISSIONS. getNotification: "edr_freight_app:bookings:get_notification", clearanceGetNotification: @@ -1604,11 +1860,24 @@ export const FREIGHT_PERMS = { dispatch: "edr_freight_app:train_scheduling:dispatch", markPaid: "edr_freight_app:train_scheduling:mark_paid", expireBooking: "edr_freight_app:train_scheduling:expire_booking", + /** + * Edit a schedule's operational run numbers (train + voyage) before + * dispatch. Separate from `update`: these numbers are what yards and + * customs quote, so changing them is narrower than general scheduling edits. + */ + editTrainNumber: "edr_freight_app:train_scheduling:edit_train_number", }, fleet: { view: "edr_freight_app:fleet:view", manage: "edr_freight_app:fleet:manage", }, + /** + * Audit trail. View-only: the module has no write routes, so this is the + * only key it needs — see AUDIT_LOG_PERMISSIONS in edr-freight.seed.ts. + */ + auditLog: { + view: "edr_freight_app:audit_log:view", + }, admin: "edr_freight_app:admin", ruleEngine: { view: (slug: RuleEngineResourceSlug) => @@ -1629,6 +1898,10 @@ export const FREIGHT_PERMS = { allocation: { manage: "edr_freight_app:allocation:manage", }, + yards: { + /** Bypasses yard scoping entirely — see YARD_SCOPE_PERMISSIONS. */ + viewAll: "edr_freight_app:yards:view_all", + }, customers: { view: "edr_freight_app:customers:view", create: "edr_freight_app:customers:create", @@ -1639,6 +1912,35 @@ export const FREIGHT_PERMS = { // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. getNotification: "edr_freight_app:customers:get_notification", }, + shippingLines: { + view: "edr_freight_app:shipping_lines:view", + create: "edr_freight_app:shipping_lines:create", + update: "edr_freight_app:shipping_lines:update", + resetPassword: "edr_freight_app:shipping_lines:reset-password", + }, + shippingLineCredits: { + view: "edr_freight_app:shipping_line_credits:view", + /** Turn a batch of unbilled credits into an invoice. */ + invoice: "edr_freight_app:shipping_line_credits:invoice", + /** Write off an unbilled credit — separate grant: it erases a debt. */ + cancel: "edr_freight_app:shipping_line_credits:cancel", + // Two-step manual actions on credit invoices, gated purely by permission: + // finance-level REQUEST grants (per action) and decision grants that apply + // to ANY pending request — including the holder's own. + /** Request recording an offline payment against a credit invoice. */ + invoiceMarkPaid: + "edr_freight_app:shipping_line_credits:invoice_mark_paid", + /** Request voiding a credit invoice (credits return to unbilled). */ + invoiceCancel: "edr_freight_app:shipping_line_credits:invoice_cancel", + /** Approve any pending invoice request (mark-paid or cancel). */ + invoiceApprove: "edr_freight_app:shipping_line_credits:invoice_approve", + /** Reject any pending invoice request. */ + invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject", + }, + chat: { + view: 'edr_freight_app:chat:view', + sync: 'edr_freight_app:chat:sync', + }, payments: { view: "edr_freight_app:payments:view", }, @@ -1647,6 +1949,9 @@ export const FREIGHT_PERMS = { export: "edr_freight_app:invoices:export", eimsRegister: "edr_freight_app:invoices:eims_register", eimsResolve: "edr_freight_app:invoices:eims_resolve", + eimsCancel: "edr_freight_app:invoices:eims_cancel", + eimsReceiptRegister: "edr_freight_app:invoices:eims_receipt_register", + memoIssue: "edr_freight_app:invoices:memo_issue", confirmOffline: "edr_freight_app:invoices:confirm_offline", }, firstMile: { @@ -1714,6 +2019,11 @@ export const FREIGHT_PERMS = { update: "edr_freight_app:trains:update", delete: "edr_freight_app:trains:delete", assignWagons: "edr_freight_app:trains:assign_wagons", + changeLocomotives: "edr_freight_app:trains:change_locomotives", + changeYard: "edr_freight_app:trains:change_yard", + changeWagonYard: "edr_freight_app:trains:change_wagon_yard", + toggleActive: "edr_freight_app:trains:toggle_active", + disband: "edr_freight_app:trains:disband", }, routes: { view: "edr_freight_app:routes:view", @@ -1849,10 +2159,37 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:dropdown:view", manage: "edr_freight_app:settings:dropdown:manage", }, + // The ONE company stamp/seal, applied to every generated document + // (invoices, receipts, warehouse papers, the EDR side of contracts). + stamp: { + view: "edr_freight_app:settings:stamp:view", + manage: "edr_freight_app:settings:stamp:manage", + }, + // The ONE company logo, applied to every generated document (invoices, + // receipts, contracts, warehouse papers, train-scheduling manifests). + logo: { + view: "edr_freight_app:settings:logo:view", + manage: "edr_freight_app:settings:logo:manage", + }, + // The per-officer approval teeter (ማህተም) + signature — genuinely per-person, + // and NOT the company seal above. Retired: `invoiceStamp`, which used to + // gate the company stamp before the two were untangled. + teeter: { + view: "edr_freight_app:settings:teeter:view", + manage: "edr_freight_app:settings:teeter:manage", + }, exchangeRate: { view: "edr_freight_app:settings:exchange_rate:view", manage: "edr_freight_app:settings:exchange_rate:manage", }, + // Whether Finance may settle invoices by hand, per currency. Split + // view/manage on purpose: Finance reads it (the worklist offers only the + // enabled currencies) but must not switch its own channel on — same + // maker-checker split as the other sensitive finance settings. + manualPayment: { + view: "edr_freight_app:settings:manual_payment:view", + manage: "edr_freight_app:settings:manual_payment:manage", + }, contractTemplates: { view: "edr_freight_app:settings:contract_templates:view", manage: "edr_freight_app:settings:contract_templates:manage", @@ -1867,9 +2204,6 @@ export const FREIGHT_PERMS = { manage: "edr_freight_app:settings:support_content:manage", }, }, - audit: { - view: "edr_freight_app:audit:view", - }, support: { agentView: "edr_freight_app:support:agent_view", agentSend: "edr_freight_app:support:agent_send", @@ -1897,6 +2231,7 @@ export const FREIGHT_PERMS = { }, reports: { view: "edr_freight_app:reports:view", + report: (key: ReportKey): string => reportPermissionKey(key), }, staff: { users: { @@ -2021,6 +2356,11 @@ const FLEET_GRANULAR_KEYS: string[] = [ FREIGHT_PERMS.trains.update, FREIGHT_PERMS.trains.delete, FREIGHT_PERMS.trains.assignWagons, + FREIGHT_PERMS.trains.changeLocomotives, + FREIGHT_PERMS.trains.changeYard, + FREIGHT_PERMS.trains.changeWagonYard, + FREIGHT_PERMS.trains.toggleActive, + FREIGHT_PERMS.trains.disband, FREIGHT_PERMS.routes.view, FREIGHT_PERMS.routes.create, FREIGHT_PERMS.routes.update, @@ -2037,11 +2377,17 @@ const FLEET_GRANULAR_KEYS: string[] = [ FREIGHT_PERMS.consignments.create, ]; +const allReportKeys = (): string[] => REPORT_KEYS.map((k) => reportPermissionKey(k)); + // Everyone who works the booking desk also opens the overview dashboard and // the canned reports — granted alongside bookings:view in every preset below. +// Each report also carries its own key (see REPORT_PERMISSIONS); spreading +// allReportKeys() here keeps every existing preset seeing every report, same +// as when reports:view alone gated the whole section. const STAFF_DASHBOARD_KEYS: string[] = [ FREIGHT_PERMS.overview.view, FREIGHT_PERMS.reports.view, + ...allReportKeys(), ]; // Notification desks — recipient selectors, not access. A preset gets a desk @@ -2069,6 +2415,10 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.reject, FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.rejectApproval, + // Shared-wagon gate: two customers' cargo on one wagon is a commercial + // call. Granted to the approver roles and NOT to GL — GL creates the + // pairing, so GL approving it would defeat the second pair of eyes. + FREIGHT_PERMS.bookings.approveConsolidation, FREIGHT_PERMS.bookings.cancel, FREIGHT_PERMS.bookings.wagonCancellationView, FREIGHT_PERMS.bookings.wagonCancellationVoid, @@ -2101,6 +2451,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.dispatch, FREIGHT_PERMS.trainScheduling.markPaid, FREIGHT_PERMS.trainScheduling.expireBooking, + FREIGHT_PERMS.trainScheduling.editTrainNumber, FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.fleet.manage, ...FLEET_GRANULAR_KEYS, @@ -2124,6 +2475,10 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.approveDirector, FREIGHT_PERMS.bookings.rejectApproval, + // Shared-wagon gate: two customers' cargo on one wagon is a commercial + // call. Granted to the approver roles and NOT to GL — GL creates the + // pairing, so GL approving it would defeat the second pair of eyes. + FREIGHT_PERMS.bookings.approveConsolidation, FREIGHT_PERMS.bookings.generateContract, FREIGHT_PERMS.contracts.view, FREIGHT_PERMS.contracts.approveDirector, @@ -2136,6 +2491,10 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.approveCeo, FREIGHT_PERMS.bookings.rejectApproval, + // Shared-wagon gate: two customers' cargo on one wagon is a commercial + // call. Granted to the approver roles and NOT to GL — GL creates the + // pairing, so GL approving it would defeat the second pair of eyes. + FREIGHT_PERMS.bookings.approveConsolidation, FREIGHT_PERMS.contracts.view, FREIGHT_PERMS.contracts.approveCeo, ...allRuleEngineViewKeys(), @@ -2146,12 +2505,26 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, - // Deliberately NOT granted here: invoices:eims_register and invoices:eims_resolve. - // Invoices are filed with MoR by the workflow, not by a person, so filing is not a - // Finance job function — the endpoints exist for controlled testing and exceptional - // operations, and are assigned to named admins rather than a role preset. + // Manual settlement (bank transfer / counter) of USD and ETB invoices. + FREIGHT_PERMS.invoices.confirmOffline, + // Read-only: the worklist offers whichever currencies are switched on. + // Flipping the switch is deliberately NOT here — see `manualPayment`. + FREIGHT_PERMS.settings.manualPayment.view, + // Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel, + // eims_receipt_register, eims:memo_issue. Automatic filing needs no human permission at all + // (the cron sweep runs as the system); these are the *manual* exceptional-operations + // endpoints, and stay off the general Finance role. They are granted to the `chief` position + // instead — see below — the same maker–checker split already used for shipping-line credit + // mark-paid/cancel (Finance raises, chief decides). FREIGHT_PERMS.payments.view, FREIGHT_PERMS.bookings.wagonCancellationView, + // Shipping-line credit ledger is a Finance surface: bill batches into + // invoices and RAISE manual invoice actions. Approval of those actions is + // deliberately absent — it sits with the chief (maker–checker). + FREIGHT_PERMS.shippingLineCredits.view, + FREIGHT_PERMS.shippingLineCredits.invoice, + FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid, + FREIGHT_PERMS.shippingLineCredits.invoiceCancel, ], // Global Logistics: manages ONLY the customs-clearance queue. Scoped out of // the general booking-request list (no bookings:view) — instead a dedicated @@ -2200,6 +2573,10 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.reject, FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.rejectApproval, + // Shared-wagon gate: two customers' cargo on one wagon is a commercial + // call. Granted to the approver roles and NOT to GL — GL creates the + // pairing, so GL approving it would defeat the second pair of eyes. + FREIGHT_PERMS.bookings.approveConsolidation, FREIGHT_PERMS.bookings.cancel, FREIGHT_PERMS.bookings.wagonCancellationView, FREIGHT_PERMS.bookings.wagonCancellationVoid, @@ -2218,6 +2595,12 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.suspend, FREIGHT_PERMS.contracts.editDocument, ...BOOKING_DESK_NOTIFICATION_KEYS, + // Marketing follows up with the customer when a reviewer sends profile + // changes back, so they sit on the customer desk: read-only on the customer + // record (no verify/deactivate — the decision stays with the chief) plus the + // desk key the change-request pings are addressed to. + FREIGHT_PERMS.customers.view, + FREIGHT_PERMS.customers.getNotification, ], orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; @@ -2253,10 +2636,24 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.governmentExpedite, FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, + // Manual MoR EIMS actions and credit/debit memo issuance: kept off the general Finance role + // (see that preset's comment) and granted here instead — the chief is already the decision + // side of every other sensitive finance action (mark-paid/cancel approval below), and these + // are irreversible-at-MoR or receivable-creating in the same way. + FREIGHT_PERMS.invoices.eimsCancel, + FREIGHT_PERMS.invoices.eimsResolve, + FREIGHT_PERMS.invoices.eimsReceiptRegister, + FREIGHT_PERMS.invoices.memoIssue, FREIGHT_PERMS.payments.view, + // Decision side of the credit-invoice two-step: finance raises + // mark-paid/cancel requests, the chief approves or rejects them. + FREIGHT_PERMS.shippingLineCredits.view, + FREIGHT_PERMS.shippingLineCredits.invoiceApprove, + FREIGHT_PERMS.shippingLineCredits.invoiceReject, ]), // Director additionally manages train scheduling + rail fleet (same block the - // operation officer/chief hold), on top of the approval-chain role preset. + // operation officer/chief hold), on top of the approval-chain role preset, + // and carries the same full warehouse authority the chief tier holds. director: dedupe([ ...ROLE_PERMISSION_PRESETS.director, FREIGHT_PERMS.trainScheduling.view, @@ -2265,9 +2662,22 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.cancel, FREIGHT_PERMS.trainScheduling.reschedule, FREIGHT_PERMS.trainScheduling.rulesManage, + FREIGHT_PERMS.trainScheduling.editTrainNumber, FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.fleet.manage, ...FLEET_GRANULAR_KEYS, + // Warehouse — full CRUD, matching the chief tier. Unlike the dispatcher, + // the director also owns the allocation and fee rules themselves. + FREIGHT_PERMS.warehouseDashboard.view, + ...Object.values(FREIGHT_PERMS.warehouses), + ...Object.values(FREIGHT_PERMS.warehouseYards), + ...Object.values(FREIGHT_PERMS.warehouseZones), + ...Object.values(FREIGHT_PERMS.warehouseAllocationRules), + ...Object.values(FREIGHT_PERMS.warehouseFeeRules), + ...Object.values(FREIGHT_PERMS.warehouseInventory), + ...Object.values(FREIGHT_PERMS.warehouseInspectionReports), + ...Object.values(FREIGHT_PERMS.interchangeDocuments), + ...Object.values(FREIGHT_PERMS.warehouseFeeInvoices), ]), ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]), ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]), diff --git a/apps/edr-freight-api/src/seed/freight-positions.seeder.ts b/apps/edr-freight-api/src/seed/freight-positions.seeder.ts index 09901b502..81df21a17 100644 --- a/apps/edr-freight-api/src/seed/freight-positions.seeder.ts +++ b/apps/edr-freight-api/src/seed/freight-positions.seeder.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { + Application, Organization, Permission, Position, @@ -8,7 +9,23 @@ import { } from '@tria-plc/iamapi-common'; import { DataSource, EntityManager, In } from 'typeorm'; -import { EDR_FREIGHT_POSITIONS } from './edr-freight.seed'; +import { EDR_FREIGHT_APPLICATION, EDR_FREIGHT_POSITIONS } from './edr-freight.seed'; + +/** + * Turn a permission key into a readable fallback name for a row this seeder has + * to mint itself: `edr_freight_app:train_scheduling:edit_train_number` becomes + * "Train scheduling: edit train number". Only used for keys absent from the + * catalog — a key that IS catalogued keeps its curated Amharic/English name. + */ +const nameForKey = (key: string): { en: string } => { + const [, ...rest] = key.split(':'); + const [resource, ...action] = rest; + const humanize = (s: string) => s.replace(/_/g, ' '); + const label = action.length + ? `${humanize(resource)}: ${humanize(action.join(' '))}` + : humanize(resource); + return { en: label.charAt(0).toUpperCase() + label.slice(1) }; +}; const SEED_FLAG = 'SEED_EDR_ORG'; const EDR_ORG_KEY = 'edr_freight'; @@ -83,7 +100,19 @@ export class FreightPositionsSeeder { ); } - /** Resolve every permission key referenced by any position to its id. */ + /** + * Resolve every permission key referenced by any position to its id, minting + * the rows that do not exist yet. + * + * The position presets draw from FREIGHT_PERMS (the registry), which is + * broader than the EDR_FREIGHT_PERMISSIONS catalog EdrOrgSeeder inserts — + * module keys like `train_scheduling:*` live only in the registry. So every + * newly-added preset key would otherwise abort boot with + * `missing_permissions:` until someone hand-inserted it. Ensuring them + * here keeps this seeder self-sufficient: it declares the keys it needs, so + * it is the one that guarantees they exist. Same approach, and the same + * id-less insert reasoning, as FreightNotificationPermissionsSeeder. + */ private async loadPermissionIds( manager: EntityManager, ): Promise> { @@ -91,16 +120,54 @@ export class FreightPositionsSeeder { ...new Set(EDR_FREIGHT_POSITIONS.flatMap((p) => p.permissionKeys)), ]; - const permissions = await manager.getRepository(Permission).find({ - where: { key: In(keys) }, - select: { id: true, key: true }, - }); - - const map = new Map(permissions.map((p) => [p.key, p.id as string])); + const read = async () => { + const rows = await manager.getRepository(Permission).find({ + where: { key: In(keys) }, + select: { id: true, key: true }, + }); + return new Map(rows.map((p) => [p.key, p.id as string])); + }; + let map = await read(); const missing = keys.filter((key) => !map.has(key)); - if (missing.length > 0) { - throw new Error(`missing_permissions:${missing.join(',')}`); + if (missing.length === 0) { + return map; + } + + const application = await manager.getRepository(Application).findOne({ + where: { key: EDR_FREIGHT_APPLICATION.key }, + select: { id: true }, + }); + if (!application?.id) { + throw new Error(`missing_application:${EDR_FREIGHT_APPLICATION.key}`); + } + + // Ids are left to the column default and never sent: iam.permissions has + // two unique columns (PK id, UQ key) and ON CONFLICT can only target one, + // so a hand-minted id already owned by a retired key would slip past + // ON CONFLICT (key) and die on the PK. + await manager + .createQueryBuilder() + .insert() + .into(Permission) + .values( + missing.map((key) => ({ + key, + name: nameForKey(key), + applicationId: application.id as string, + })), + ) + .orIgnore() + .execute(); + + this.logger.log( + `Seeded ${missing.length} permission(s) referenced by positions but absent from the catalog: ${missing.join(', ')}`, + ); + + map = await read(); + const stillMissing = keys.filter((key) => !map.has(key)); + if (stillMissing.length > 0) { + throw new Error(`missing_permissions:${stillMissing.join(',')}`); } return map; diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts index 654f4f77f..a658ec13d 100644 --- a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -175,9 +175,6 @@ export class PaidImportExportMileDemoSeeder { website: null, contactPersonName: 'Paid Mile Demo', contactPersonPhone: '251900000202', - generalManagerName: 'Demo Manager', - generalManagerEmail: COMPANY_EMAIL, - generalManagerPhone: '251900000202', }, { conflictPaths: { tin: true } }, ); diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 00d5e0ddb..f0e9c2254 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,4 +1,5 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Navigate, Outlet, @@ -10,6 +11,7 @@ import { } from "react-router-dom"; import { FreightDashboardLayout, type SidebarItem } from "@/components/layout"; +import { api } from "@/services/api"; import { useAuth } from "./auth/useAuth"; import LoadingScreen from "./components/LoadingScreen"; import LoginPage from "./pages/auth/LoginPage"; @@ -17,6 +19,7 @@ import ForgotPasswordPage from "./pages/auth/ForgotPasswordPage"; import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; +import ConsolidationApprovalsPage from "./pages/bookings/ConsolidationApprovalsPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; import WagonCancellationsPage from "./pages/bookings/WagonCancellationsPage"; import ContractRequestsPage from "./pages/contracts/ContractRequestsPage"; @@ -34,16 +37,18 @@ import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetai import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; +import ShippingLineCompaniesPage from "./pages/shipping-lines/ShippingLineCompaniesPage"; +import ShippingLineCreditsPage from "./pages/shipping-lines/ShippingLineCreditsPage"; import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage"; -import InvoicesPage from "./pages/invoices/InvoicesPage"; -import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage"; +import FinanceHubPage from "./pages/invoices/FinanceHubPage"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; import OverviewPage from "./pages/dashboard/OverviewPage"; -import ReportsHubPage from "./pages/reports/ReportsHubPage"; +import OverviewDomainPage from "./pages/dashboard/OverviewDomainPage"; +import { OVERVIEW_DOMAINS } from "./components/overview/overview-domains.config"; +import ReportsLandingPage from "./pages/reports/ReportsLandingPage"; import ReportPage from "./pages/reports/ReportPage"; +import AuditLogsPage from "./pages/AuditLogsPage"; import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage"; -import PaymentsPage from "./pages/payments/PaymentsPage"; -import AuditLogsPage from "./pages/audit/AuditLogsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import { RequirePermission } from "./components/auth/RequirePermission"; import { FREIGHT_PERMS } from "./lib/permissions"; @@ -51,6 +56,8 @@ import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing"; import NoAccessPage from "./pages/NoAccessPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; +import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage"; +import LogoSettingsPage from "./pages/settings/LogoSettingsPage"; import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import PortalContentPage from "./pages/portal_content/PortalContentPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; @@ -80,7 +87,7 @@ import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPa import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import TradeAccessPage from "./pages/configuration/TradeAccessPage"; import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard"; -import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage"; +import ManualPaymentSettingsCard from "./pages/settings/ManualPaymentSettingsCard"; import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; @@ -110,6 +117,7 @@ import FaydaCallbackPage from "./pages/FaydaCallbackPage"; import { UserManagementRoutes } from "./user-management/route"; import SetPassword from "./shared/components/SetPassword"; import SupportInboxPage from "./pages/support/SupportInboxPage"; +import ChatLaunchPage from "./pages/chat/ChatLaunchPage"; import { APP_TITLE, buildSidebarSections, @@ -117,6 +125,20 @@ import { findActiveSidebarLabel, } from "@/components/layout/sidebar-sections"; +/** + * The per-shipment clearance detail page is the shared destination of three + * hubs (Operations → Clearance, Clearance Documents, Self-Clearance Review), + * none of which are gated on `bookings:clearance_view`. Gating the detail on + * that key alone bounced reviewers back to their landing page (Bookings) the + * moment they opened a row, so accept any key that can reach a hub. + */ +const CLEARANCE_DETAIL_PERMS = [ + FREIGHT_PERMS.bookings.clearanceView, + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.opsClearanceReview, +]; + const DashboardShell = () => { const navigate = useNavigate(); const location = useLocation(); @@ -124,8 +146,18 @@ const DashboardShell = () => { const demoItems: SidebarItem[] = []; + const { data: reportCatalog } = useQuery(api.reports.catalog.queryOptions()); + const reportItems: SidebarItem[] = useMemo( + () => + (reportCatalog ?? []).map((report) => ({ + label: report.title, + href: `/dashboard/reports/${report.key}`, + })), + [reportCatalog], + ); + const sidebarSections = filterSidebarByPermission( - buildSidebarSections(demoItems), + buildSidebarSections(demoItems, reportItems), user, ); const displayName = user?.name?.en || user?.username || user?.email || "User"; @@ -184,11 +216,58 @@ const App = () => { {/* Landing is per-user: /dashboard/overview is gated on overview:view, so a fixed target strands anyone without that key on a blank page. */} } /> - } /> + } + /> }> - } /> - } /> - } /> + + + + } + /> + {/* One drill-down route per overview domain — the old per-tab charts, + now each on its own page. Single source of truth for the + permission gate is OVERVIEW_DOMAINS, shared with the summary + page's "View all" links. */} + {OVERVIEW_DOMAINS.map((domain) => ( + + + + } + /> + ))} + + + + } + /> + + + + } + /> + + + + } + /> {/* Dev/testing page for the mock AI booking assistant. */} { /> } /> - } /> - + + + + } + /> + {/* Payments used to be its own page; it's now the "payments" tab on + the merged Invoices hub. Old bookmarks/links still land there. */} + } + /> + + + + } + /> + + } /> - } /> { } /> + {/* Merged Invoices / Payments / USD Payments hub — tabs switch via + ?tab=invoices|payments|manual-payments (default invoices). Access is + OR'd across both keys so a user with just one still gets in; each + tab hides itself if the user lacks the permission it used to be + routed on. */} + + + + } + /> + + + + } + /> - + + } /> - - - } + element={} /> { } /> - } /> + + + + } + /> { } /> + {/* Shared-wagon gate: consolidated pairs wait for a human decision + before either half reaches Operations. */} + + + + } + /> { + } @@ -321,9 +461,7 @@ const App = () => { + } @@ -463,25 +601,195 @@ const App = () => { path="bookings/:id/milestones" element={} /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> { + } @@ -768,16 +1078,39 @@ const App = () => { + } /> + {/* + The ONE company stamp, for every generated document. The per-officer + teeter (ማህተም) that used to sit beside it at /dashboard/stamp-settings + now lives at /user-management/teeter-and-signature — it is a different + thing (an individual's approval stamp), and pairing the two here was + the duplication. + */} - + + + + } + /> + {/* Old URL kept alive so existing links/bookmarks do not 404. */} + } + /> + {/* The ONE company logo, shown in the header of every generated document. */} + + } /> @@ -837,10 +1170,24 @@ const App = () => { } /> + +
+ +
+ + } + /> +
@@ -908,4 +1255,3 @@ function LegacyGlEthiopiaClearanceRedirect() { } export default App; - diff --git a/apps/edr-freight-web/backoffice/src/complaints/services/etradeTinService.ts b/apps/edr-freight-web/backoffice/src/complaints/services/etradeTinService.ts index 3bdc653c4..8feb7cfb8 100644 --- a/apps/edr-freight-web/backoffice/src/complaints/services/etradeTinService.ts +++ b/apps/edr-freight-web/backoffice/src/complaints/services/etradeTinService.ts @@ -101,7 +101,8 @@ export function mapEtradeBusinessLicenses( const tradeName = String(business.TradesName ?? "").trim(); const tradeNameAmh = String(business.TradeNameAmh ?? "").trim(); const activities = (business.SubGroups ?? []) - .map((group) => String(group.Description ?? "").trim()) + // eTrade returns null entries in this array, not just a null array. + .map((group) => String(group?.Description ?? "").trim()) .filter(Boolean); const displayTradeName = diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx index b2d260e37..5b7de12d5 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -1,5 +1,5 @@ import { useNavigate } from "react-router-dom"; -import { ChevronRight, ExternalLink, MoreHorizontal } from "lucide-react"; +import { ExternalLink, MoreHorizontal } from "lucide-react"; import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core"; import { BookingConfirmDialog } from "./BookingConfirmDialog"; @@ -38,6 +38,7 @@ export function BookingActionsMenu({ reference: row.reference, schedulingStatus: row.schedulingStatus, customsClearingEnabled: row.customsClearingEnabled, + consolidationPartnerId: row.consolidationPartnerId, }; const flow = useBookingActionDialog(row.id, context); @@ -64,17 +65,9 @@ export function BookingActionsMenu({ const hasMenu = listRowHasActions(row, user); + // Row click already opens the detail page — no chevron affordance needed. if (!hasMenu && variant === "table") { - return ( - navigate(`/dashboard/booking-requests/${row.id}`)} - aria-label="View booking" - > - - - ); + return null; } // Toolbar: lay every action out as a button row. @@ -100,7 +93,13 @@ export function BookingActionsMenu({ ); })} - + ); } @@ -157,7 +156,13 @@ export function BookingActionsMenu({ - + ); } @@ -166,10 +171,14 @@ function ActionDialog({ flow, pendingAction, onSuppressRowClick, + consolidationPartnerId, + consolidationPartnerReference, }: { flow: ReturnType; pendingAction: ReturnType["pendingAction"]; onSuppressRowClick?: () => void; + consolidationPartnerId?: string | null; + consolidationPartnerReference?: string | null; }) { return ( ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx index 6b8e4b650..246704aca 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from "react"; +import { Link2 } from "lucide-react"; import { + Alert, Modal, Group, Stack, @@ -37,6 +39,12 @@ interface BookingConfirmDialogProps { isPending: boolean; confirmDisabled?: boolean; extra?: ReactNode; + /** + * Reference of the booking sharing this one's wagon. When set, the dialog + * warns that the decision lands on BOTH bookings — staff must not think they + * are acting on one. + */ + pairedWithReference?: string | null; } export function BookingConfirmDialog({ @@ -52,6 +60,7 @@ export function BookingConfirmDialog({ isPending, confirmDisabled = false, extra, + pairedWithReference = null, }: BookingConfirmDialogProps) { if (!action || !action.confirmTitle) return null; @@ -125,6 +134,21 @@ export function BookingConfirmDialog({ {action.confirmDescription} )} + {pairedWithReference && ( + } + > + + This applies to {pairedWithReference} as well — + the two bookings share a wagon and are decided together. If either + fails, neither changes. + + + )} {/* Body */} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx index 7d1fff022..fa21d0d9f 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx @@ -1,6 +1,7 @@ import { Badge, Group } from "@mantine/core"; import { Link2 } from "lucide-react"; import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config"; +import { humanize } from "@/lib/format"; const statusColorMap: Record = { DRAFT: "gray", @@ -40,7 +41,7 @@ export function BookingStatusBadge({ partnerReference, }: BookingStatusBadgeProps) { const style = BOOKING_STATUS_STYLES[status] ?? { - label: status, + label: humanize(status), color: "gray", }; const color = statusColorMap[status] ?? "gray"; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx index ecd903128..dc7286b5c 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx @@ -1,5 +1,5 @@ import { Package } from "lucide-react"; -import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core"; +import { SimpleGrid, Divider, Box, Group, Table, Text, Badge } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; import { cargoTonsAndItems } from "@/utils/cargoWeight"; @@ -16,22 +16,82 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { const containers = booking.bookingContainers ?? []; const { tons, items } = cargoTonsAndItems(booking); + // Booking-level flags OR any container line carrying a count — the flag can + // lag the lines (per-line opt-ins), so either alone must light the tile. + const isHazardous = + booking.isHazardous || + containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0); + const isReefer = + booking.isReefer || + containers.some((c) => Number(c.reeferQuantity ?? 0) > 0); + const showHandlingColumns = containers.some( + (c) => + Number(c.hazardousQuantity ?? 0) > 0 || Number(c.reeferQuantity ?? 0) > 0, + ); + + const isBulk = booking.freightType === "BULK"; + // Bulk: the commodity itself (Wheat, Steel…) is the headline. Containers: + // the freight kind, with the shipper's own description alongside. + const cargoHeadline = isBulk + ? (booking.cargoType?.label ?? booking.cargoType?.name ?? "Bulk cargo") + : "Containers"; + const cargoDescription = booking.cargoFreeText?.trim() || null; + return ( + + + {cargoHeadline} + + + {isBulk ? "Bulk" : "Container"} + + {cargoDescription ? ( + + — {cargoDescription} + + ) : null} + {items != null && } + + {/* Handling that changes how the yard treats the shipment is flagged + loudly, not buried in the grid. */} + {(isHazardous || isReefer) && ( + + {isHazardous && ( + + Hazardous cargo + + )} + {isReefer && ( + + Refrigerated cargo + + )} + + )} + {containers.length > 0 && ( <> @@ -42,6 +102,8 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { Container type Qty VGM / unit + {showHandlingColumns && Hazardous} + {showHandlingColumns && Reefer} @@ -54,6 +116,28 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { {c.quantity} {c.vgmPerUnitTons} t + {showHandlingColumns && ( + + {Number(c.hazardousQuantity ?? 0) > 0 ? ( + + {c.hazardousQuantity} + + ) : ( + "—" + )} + + )} + {showHandlingColumns && ( + + {Number(c.reeferQuantity ?? 0) > 0 ? ( + + {c.reeferQuantity} + + ) : ( + "—" + )} + + )} ))} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx index 06fafc099..92df02ab6 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx @@ -1,44 +1,16 @@ -import type { LucideIcon } from "lucide-react"; -import { - Building2, - FileCheck, - Mail, - MapPin, - Phone, - User, -} from "lucide-react"; -import { Group, Stack, Text, Divider } from "@mantine/core"; +import { Building2, FileCheck, Mail, MapPin, Phone, User } from "lucide-react"; +import { Text } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; +import { LinkedEntityCard } from "@/components/detail"; +import type { FieldRowProps } from "@/components/detail"; import { SectionCard } from "./SectionCard"; -interface InfoRowProps { - icon: LucideIcon; - label: string; - value?: string | null; -} - -function InfoRow({ icon: Icon, label, value }: InfoRowProps) { - return ( - - - - - {label} - - - - {value || "—"} - - - ); -} - export interface BookingCompanyCardProps { booking: BookingDetail; } -/** Customer (company) information for the booking. */ +/** Customer (company) quick info for the booking, linking to its detail page. */ export function BookingCompanyCard({ booking }: BookingCompanyCardProps) { const company = booking.company; @@ -46,11 +18,9 @@ export function BookingCompanyCard({ booking }: BookingCompanyCardProps) { if (!company && booking.isGovernment) { return ( - + + {booking.governmentInstitution ?? "Government"} + ); } @@ -67,36 +37,24 @@ export function BookingCompanyCard({ booking }: BookingCompanyCardProps) { const companyName = company.companyName ?? company.name ?? company.label; - const rows: InfoRowProps[] = [ + const rows: FieldRowProps[] = [ { icon: FileCheck, label: "TIN", value: company.tin }, { icon: Mail, label: "Email", value: company.email }, { icon: Phone, label: "Phone", value: company.phone }, { icon: MapPin, label: "Address", value: company.address }, { icon: User, label: "Contact person", value: company.contactPersonName }, { icon: Phone, label: "Contact phone", value: company.contactPersonPhone }, - ].filter((r) => r.value); + ]; return ( - - - {rows.length === 0 ? ( - - No additional company details available. - - ) : ( - rows.map((row, index) => ( -
- {index > 0 && } - -
- )) - )} -
-
+ rows={rows} + emptyMessage="No additional company details available." + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractCard.tsx new file mode 100644 index 000000000..f5fc3bf2a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractCard.tsx @@ -0,0 +1,49 @@ +import { Anchor as AnchorIcon } from "lucide-react"; +import { Code } from "@mantine/core"; + +import type { BookingDetail } from "@/types/booking"; +import { LinkedEntityCard } from "@/components/detail"; +import type { FieldRowProps } from "@/components/detail"; + +export interface BookingContractCardProps { + booking: BookingDetail; +} + +/** Parent contract quick info for the booking, linking to its detail page. */ +export function BookingContractCard({ booking }: BookingContractCardProps) { + if (!booking.contractId || !booking.contractReference) return null; + + const rows: FieldRowProps[] = [ + { + label: "Kind", + value: booking.contractKind === "GENERAL" ? "General" : "One-time", + }, + ]; + + return ( + + {booking.contractSummary} + + ) : undefined + } + /> + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx deleted file mode 100644 index 1e86a7d2a..000000000 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingContractSummaryCard.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Anchor } from "lucide-react"; -import { Code } from "@mantine/core"; - -import { SectionCard } from "./SectionCard"; - -export interface BookingContractSummaryCardProps { - summary: string; -} - -/** Generated contract terms, shown verbatim. */ -export function BookingContractSummaryCard({ summary }: BookingContractSummaryCardProps) { - return ( - - - {summary} - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx index 3236e5cae..d3bee348d 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx @@ -1,6 +1,10 @@ -import { Truck } from "lucide-react"; -import { SimpleGrid } from "@mantine/core"; +import type { ReactNode } from "react"; +import { Download, Truck } from "lucide-react"; +import { Button, Group, SimpleGrid, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { lastMileRequestsService } from "@/services/last-mile-requests.service"; import type { BookingDetail } from "@/types/booking"; import { SectionCard } from "./SectionCard"; @@ -8,24 +12,92 @@ import { MetricTile } from "./MetricTile"; export interface BookingMileServicesCardProps { booking: BookingDetail; + /** Export handover-mode control — how the cargo reaches the train. Lives + * here because it's the other "how does the cargo physically travel" fact; + * shown even when no mile address is set, since EXPORT bookings still need + * the choice made. */ + handoverSection?: ReactNode; } -/** First / last mile addresses. Renders nothing when neither is present. */ -export function BookingMileServicesCard({ booking }: BookingMileServicesCardProps) { - if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) { +/** + * First / last mile addresses, plus the export handover control and the + * stored last-mile contract reference (signed status + PDF download) for + * Truck & Machinery once a request on this booking is approved. Renders + * nothing when none of the three are present. + */ +export function BookingMileServicesCard({ + booking, + handoverSection, +}: BookingMileServicesCardProps) { + const hasAddresses = + Boolean(booking.firstMilePickupAddress) || Boolean(booking.lastMileDeliveryAddress); + + const { data: requestsResponse } = useQuery({ + queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.list({ bookingId: booking.id }), + queryFn: async () => + (await lastMileRequestsService.list({ bookingId: booking.id })).data, + enabled: Boolean(booking.lastMileDeliveryAddress), + }); + const approvedRequest = (requestsResponse?.data ?? []).find( + (r) => r.status === "APPROVED", + ); + + if (!hasAddresses && !handoverSection) { return null; } + const downloadContract = async () => { + if (!approvedRequest) return; + const blob = (await lastMileRequestsService.contractDocument(approvedRequest.id)).data; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `last-mile-contract-${booking.reference ?? booking.id}.pdf`; + a.click(); + URL.revokeObjectURL(url); + }; + return ( - - {booking.firstMilePickupAddress && ( - + + {hasAddresses && ( + + {booking.firstMilePickupAddress && ( + + )} + {booking.lastMileDeliveryAddress && ( + + )} + )} - {booking.lastMileDeliveryAddress && ( - + {approvedRequest && ( + + + + Last-mile contract + + + {approvedRequest.customerSignedAt + ? `Signed ${new Date(approvedRequest.customerSignedAt).toLocaleDateString()}${ + approvedRequest.signerDisplayName + ? ` by ${approvedRequest.signerDisplayName}` + : "" + }` + : "Awaiting customer signature"} + + + + )} - + {handoverSection} + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx deleted file mode 100644 index ed9802150..000000000 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import type { ReactNode } from "react"; -import { - ArrowLeft, - Building2, - Calendar, - Clock, - Container as ContainerIcon, - Flame, - RefreshCw, - Wallet, - Weight, -} from "lucide-react"; -import { - Button, - Group, - Paper, - Stack, - Text, - ThemeIcon, - Title, -} from "@mantine/core"; -import type { LucideIcon } from "lucide-react"; - -import type { BookingDetail } from "@/types/booking"; -import { cargoTonsAndItems } from "@/utils/cargoWeight"; -import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; -import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; -import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; -import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; -import { NextStepBanner } from "@/components/bookings/NextStepBanner"; - -import { formatDate } from "./booking-detail.styles"; - -export interface BookingRequestHeroProps { - booking: BookingDetail; - customerLabel: string; - onBack: () => void; - onRefresh: () => void; - isFetching?: boolean; -} - -/** Top hero for the request detail page: identity, status, next step, key figures. */ -export function BookingRequestHero({ - booking, - customerLabel, - onBack, - onRefresh, - isFetching, -}: BookingRequestHeroProps) { - const amount = Number(booking.totalAmount); - const containers = booking.bookingContainers ?? []; - const containerCount = containers.reduce( - (sum, c) => sum + Number(c.quantity ?? 0), - 0, - ); - const { tons: weight, items: itemCount } = cargoTonsAndItems(booking); - - return ( - - - - - - - - - - - Booking reference - - - - - {booking.reference} - - - - - - {booking.schedulingStatus ? ( - - ) : null} - - - {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( - - Hold expires {new Date(booking.holdExpiresAt).toLocaleString()} - - ) : null} - - - - - - - - - - {booking.nextStep ? ( - - - - ) : null} - - - - - - - - - - ); -} - -function MetaItem({ - icon: Icon, - text, - strong, -}: { - icon: LucideIcon; - text: ReactNode; - strong?: boolean; -}) { - return ( - - - - {text} - - - ); -} - -function HeroTile({ - icon: Icon, - label, - value, - hint, - accent = "edr-green", -}: { - icon: LucideIcon; - label: string; - value: ReactNode; - hint?: ReactNode; - accent?: string; -}) { - return ( - - - - - - - - {label} - - - {value} - - {hint ? ( - - {hint} - - ) : null} - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx new file mode 100644 index 000000000..7f5a4af23 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingSchedulingWindowCard.tsx @@ -0,0 +1,286 @@ +import { useEffect, useState } from "react"; +import { Badge, Box, Group, Stack, Text } from "@mantine/core"; +import { CalendarClock } from "lucide-react"; + +import type { BookingDetail } from "@/types/booking"; +import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; + +import { SectionCard } from "./SectionCard"; + +export interface BookingSchedulingWindowCardProps { + booking: BookingDetail; +} + +/** Full date + time — staff read these against the operating clock, so no time is dropped. */ +function formatStamp(iso: string | null | undefined): string | null { + if (!iso) return null; + const ms = new Date(iso).getTime(); + if (!Number.isFinite(ms)) return null; + return new Date(ms).toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** "in 2h 14m" / "12m ago" — the at-a-glance read next to an absolute stamp. */ +function formatRelative(iso: string, nowMs: number): string { + const diff = new Date(iso).getTime() - nowMs; + const past = diff < 0; + const totalMinutes = Math.floor(Math.abs(diff) / 60_000); + const days = Math.floor(totalMinutes / 1440); + const hours = Math.floor((totalMinutes % 1440) / 60); + const minutes = totalMinutes % 60; + + const parts: string[] = []; + if (days) parts.push(`${days}d`); + if (hours) parts.push(`${hours}h`); + // Keep minutes when they're the only unit, so sub-hour gaps never read "0". + if (minutes || parts.length === 0) parts.push(`${minutes}m`); + + const span = parts.slice(0, 2).join(" "); + return past ? `${span} ago` : `in ${span}`; +} + +/** + * Length of a window as "1h 30m" / "45m". Null unless both ends are real and + * ordered — the pay window is configurable per schedule, so this is read off the + * actual stamps rather than assuming any fixed duration. + */ +function formatDuration( + from: string | null | undefined, + to: string | null | undefined, +): string | null { + if (!from || !to) return null; + const fromMs = new Date(from).getTime(); + const toMs = new Date(to).getTime(); + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) return null; + const minutes = Math.round((toMs - fromMs) / 60_000); + if (minutes <= 0) return null; + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + if (!hours) return `${rest}m`; + return rest ? `${hours}h ${rest}m` : `${hours}h`; +} + +function Row({ + label, + value, + hint, + tone, +}: { + label: string; + value: string; + hint?: string | null; + tone?: "muted" | "warning" | "danger"; +}) { + const valueColor = + tone === "danger" ? "red.7" : tone === "warning" ? "orange.7" : "dark"; + return ( + + + {label} + + + + {value} + + {hint ? ( + + {hint} + + ) : null} + + + ); +} + +/** + * Backoffice-only staff view of the scheduling clock: which batch/train the + * booking is scheduled for, when its pay window closes, and the train's + * planned vs actual departure/arrival (i.e. when the run actually ended). + */ +export function BookingSchedulingWindowCard({ + booking, +}: BookingSchedulingWindowCardProps) { + const schedule = booking.trainScheduleSummary ?? null; + + // The pay-window end staff should quote is the drain end (a payment landing + // inside the drain still counts); fall back to the raw deadline if the API + // predates that field. + const payWindowEndsAt = booking.paymentDrainEndsAt ?? booking.paymentDeadline ?? null; + + // One shared ticking clock so every relative label in the card stays in sync. + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const interval = setInterval(() => setNowMs(Date.now()), 30_000); + return () => clearInterval(interval); + }, []); + + // How long the customer actually had to pay: start → the raw deadline, NOT the + // drain end (the drain is settlement grace, not payable time). + const windowDuration = formatDuration( + booking.selectedForBatchAt, + booking.paymentDeadline, + ); + + const hasAnything = + Boolean(schedule) || + Boolean(payWindowEndsAt) || + Boolean(booking.selectedForBatchAt) || + Boolean(booking.holdExpiresAt); + if (!hasAnything) return null; + + const payWindowClosed = payWindowEndsAt + ? new Date(payWindowEndsAt).getTime() <= nowMs + : false; + + const trainLabel = + schedule?.trainNumber ?? + schedule?.reference ?? + (schedule ? "Assigned train" : null); + + // Before the batch engine allocates, the only train on the booking is the one + // the customer picked at day-commit — staff review that during Operation + // Review, so it is labelled as a request, not as a confirmed allocation. + const isRequested = schedule?.isRequested === true; + const trainRowLabel = isRequested ? "Requested train" : "Scheduled on train"; + + // A train that has already left (or is past its planned departure) can no + // longer carry this booking, so accepting onto it would be wrong. Called out + // here because this card sits above the staff-actions toolbar. + const departureIso = + schedule?.actualDepartureAt ?? schedule?.scheduledDepartureDate ?? null; + const hasDeparted = schedule?.actualDepartureAt + ? true + : departureIso !== null && new Date(departureIso).getTime() <= nowMs; + + return ( + } + > + + {trainLabel ? ( + + ) : ( + + )} + + {isRequested && trainLabel ? ( + + {hasDeparted + ? "This train has already departed — accepting the operation will not place the booking on it." + : "Picked by the customer at day-commit. Accepting the operation releases the booking to the batch pool for this train."} + + ) : null} + + {schedule?.status ? ( + + + Train status + + + {schedule.windowPhase ? ( + + {schedule.windowPhase.replace(/_/g, " ")} + + ) : null} + + {schedule.status} + + + + ) : null} + + {booking.selectedForBatchAt ? ( + + ) : null} + + {payWindowEndsAt ? ( + + ) : null} + + {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( + + ) : null} + + {schedule ? ( + <> + + + + ) : null} + + + ); +} 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 501e074e2..291bc0d05 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 @@ -14,6 +14,7 @@ import { Text, Textarea, ThemeIcon, + Timeline, Tooltip, } from "@mantine/core"; import { @@ -24,6 +25,7 @@ import { FileCheck2, FileText, MessageSquareWarning, + RefreshCw, Upload, } from "lucide-react"; import toast from "react-hot-toast"; @@ -31,6 +33,7 @@ import type { Freight } from "@edr/types"; import { isViewable } from "@edr/ui-common"; import { SectionCard } from "./SectionCard"; +import { formatDateTime } from "@/lib/format"; import { bookingsService } from "@/services/bookings.service"; import { downloadBookingFile, @@ -457,6 +460,76 @@ export function ClearanceReviewSection({ ); } +const EVENT_META: Record< + Freight.ClearanceDocumentEvent["type"], + { color: string; icon: typeof Upload; label: (byName: string | null) => string } +> = { + UPLOADED: { + color: "blue", + icon: Upload, + label: (n) => `Uploaded by ${n ?? "customer"}`, + }, + RESUBMITTED: { + color: "blue", + icon: RefreshCw, + label: (n) => `Re-submitted by ${n ?? "customer"}`, + }, + QUERIED: { + color: "red", + icon: MessageSquareWarning, + label: (n) => `Query opened by ${n ?? "staff"}`, + }, + APPROVED: { + color: "edr-green", + icon: CheckCircle2, + label: (n) => `Approved by ${n ?? "staff"}`, + }, +}; + +/** Per-document audit trail: uploads, amendment responses, queries, approval. */ +function DocHistoryTimeline({ + history, +}: { + history: Freight.ClearanceDocumentEvent[]; +}) { + return ( + + {history.map((ev, i) => { + const meta = EVENT_META[ev.type]; + const Icon = meta.icon; + return ( + } + title={ + + {meta.label(ev.byName)} + + } + > + + {formatDateTime(ev.at)} + + {ev.note ? ( + + {ev.note} + + ) : null} + + ); + })} + + ); +} + function StatPill({ color, label, @@ -578,9 +651,33 @@ function DocReviewCard({ )} + {hasFile && ( + + + void downloadBookingFile(doc.file!.id, doc.file!.name) + } + c="edr-green" + style={{ + display: "flex", + background: "transparent", + border: "none", + cursor: "pointer", + }} + > + + + + )} + {(doc.history?.length ?? 0) > 0 && ( + + )} + {status === "QUERIED" && doc.note && ( = { + PENDING: "yellow", + APPROVED: "teal", + REJECTED: "red", +}; + +/** + * Audit trail for this booking's shared wagon: every approval request against + * it, who decided, when, and why. Rendered only for a booking that is actually + * consolidated — there is nothing to show otherwise. + */ +export function ConsolidationApprovalCard({ bookingId }: { bookingId: string }) { + const { data } = useQuery({ + queryKey: ["consolidation-approvals", "history", bookingId], + queryFn: () => bookingsService.consolidationApprovalHistory(bookingId), + enabled: Boolean(bookingId), + }); + + if (!data?.length) return null; + + return ( + + + {data.map((row) => ( + + + + {row.status} + + + {row.bookingReference ?? "—"} + {row.partnerBookingReference ?? "—"} + + + + + Requested {formatDateTime(row.requestedAt)} + {row.requestedBy ? ` by ${row.requestedBy}` : ""} + + + {row.decidedAt ? ( + + {row.status === "APPROVED" ? "Approved" : "Rejected"}{" "} + {formatDateTime(row.decidedAt)} + {row.decidedBy ? ` by ${row.decidedBy}` : ""} + + ) : ( + + Waiting for a decision — neither booking reaches Operations until + this is approved. + + )} + + {row.decisionNote ? ( + + “{row.decisionNote}” + + ) : null} + + ))} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index ecbb0488e..23f9b2bd8 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -16,9 +16,9 @@ export * from "./BookingPaymentCard"; export * from "./BookingPaymentCountdownCard"; export * from "./BookingFactsCard"; export * from "./BookingDocumentsCard"; -export * from "./BookingRequestHero"; export * from "./BookingRouteServiceCard"; export * from "./BookingMileServicesCard"; export * from "./BookingCargoCard"; -export * from "./BookingContractSummaryCard"; +export * from "./BookingContractCard"; export * from "./BookingCompanyCard"; +export * from "./BookingSchedulingWindowCard"; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts index be7111745..24398bdf9 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts @@ -14,6 +14,19 @@ function isValidValidityDays(value: string): boolean { return Number.isInteger(days) && days >= 1 && days <= 365; } +/** + * Decisions that must be applied to BOTH halves of a consolidated pair. The two + * bookings share one wagon: accepting one alone would put half a wagon into the + * approval chain, and cancelling one alone would strand the other on a wagon it + * can no longer fill. + */ +const PAIRED_DECISIONS = { + accept: "accept", + cancel: "cancel", + operationAccept: "operationAccept", + requestChanges: "requestChanges", +} as const; + export function useBookingActionDialog( bookingId: string, context: BookingActionContext, @@ -52,6 +65,30 @@ export function useBookingActionDialog( const onSuccess = () => closeDialog(); + // A booking on a shared wagon routes the four pairable decisions through the + // paired endpoint, which applies them to both halves all-or-nothing. Every + // other action stays per booking. + const pairedDecision = + PAIRED_DECISIONS[pendingAction.id as keyof typeof PAIRED_DECISIONS]; + if (context.consolidationPartnerId && pairedDecision) { + if (pairedDecision === "accept") { + const days = Number(inputValue.trim()); + if (!Number.isInteger(days) || days < 1 || days > 365) return; + mutations.pairedDecision.mutate( + { decision: "accept", validityDays: days }, + { onSuccess }, + ); + return; + } + mutations.pairedDecision.mutate( + pairedDecision === "cancel" + ? { decision: "cancel", reason: inputValue.trim() } + : { decision: pairedDecision, note: inputValue.trim() }, + { onSuccess }, + ); + return; + } + switch (pendingAction.id) { case "accept": { const days = Number(inputValue.trim()); diff --git a/apps/edr-freight-web/backoffice/src/components/common/FilterToggle.tsx b/apps/edr-freight-web/backoffice/src/components/common/FilterToggle.tsx new file mode 100644 index 000000000..b97f827d4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/common/FilterToggle.tsx @@ -0,0 +1,34 @@ +import { ActionIcon, Indicator } from "@mantine/core"; +import { Filter } from "lucide-react"; + +export interface FilterToggleProps { + /** Number of active advanced filters — shown as a badge on the button. */ + count: number; + expanded: boolean; + onClick: () => void; +} + +/** Toggle for the collapsible advanced-filters row on list pages. */ +export function FilterToggle({ count, expanded, onClick }: FilterToggleProps) { + return ( + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx b/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx index 92ab514ad..8ca604ba4 100644 --- a/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx +++ b/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx @@ -2,6 +2,7 @@ import { Button, Group, TextInput } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; import { Search, X } from "lucide-react"; import type { ReactNode } from "react"; +import { getDateRangePresets } from "./dateRangePresets"; export interface ListControlsProps { search: string; @@ -54,28 +55,19 @@ const ListControls = ({ )} {showDateRange && ( - <> - - - + { + onDateFromChange(from); + onDateToChange(to); + }} + presets={getDateRangePresets()} + clearable + w={230} + /> )} {children} diff --git a/apps/edr-freight-web/backoffice/src/components/common/dateRangePresets.ts b/apps/edr-freight-web/backoffice/src/components/common/dateRangePresets.ts new file mode 100644 index 000000000..e59e3bb84 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/common/dateRangePresets.ts @@ -0,0 +1,41 @@ +import { + format, + startOfDay, + endOfDay, + startOfMonth, + endOfMonth, + startOfYear, + subDays, + subMonths, +} from "date-fns"; +import type { DatePickerPreset } from "@mantine/dates"; + +const iso = (date: Date) => format(date, "yyyy-MM-dd"); + +/** + * Shared "Today / Last 7 days / …" presets for every Mantine + * `` in the app, + * so every from/to filter offers the same shortcuts. Computed fresh per call + * (not a module-level constant) so "Today" stays today. + */ +export function getDateRangePresets(): DatePickerPreset<"range">[] { + const today = new Date(); + return [ + { label: "Today", value: [iso(startOfDay(today)), iso(endOfDay(today))] }, + { + label: "Yesterday", + value: [iso(startOfDay(subDays(today, 1))), iso(endOfDay(subDays(today, 1)))], + }, + { label: "Last 7 days", value: [iso(startOfDay(subDays(today, 6))), iso(endOfDay(today))] }, + { label: "Last 30 days", value: [iso(startOfDay(subDays(today, 29))), iso(endOfDay(today))] }, + { label: "This month", value: [iso(startOfMonth(today)), iso(endOfDay(today))] }, + { + label: "Last month", + value: [ + iso(startOfMonth(subMonths(today, 1))), + iso(endOfMonth(subMonths(today, 1))), + ], + }, + { label: "Year to date", value: [iso(startOfYear(today)), iso(endOfDay(today))] }, + ]; +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/BookingRequestStatusBadge.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/BookingRequestStatusBadge.tsx new file mode 100644 index 000000000..dc742df4f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/BookingRequestStatusBadge.tsx @@ -0,0 +1,16 @@ +import { Badge } from "@mantine/core"; + +const STATUS_COLOR: Record = { + PENDING: "edr-green", + ACCEPTED: "blue", + REJECTED: "red", +}; + +/** Status of a customer-submitted shipment (booking) request against a contract. */ +export function BookingRequestStatusBadge({ status }: { status: string }) { + return ( + + {status} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx new file mode 100644 index 000000000..6f3fb530f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx @@ -0,0 +1,525 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Badge, + Box, + Button, + FileButton, + Group, + Loader, + NumberInput, + Paper, + Select, + Stack, + Text, + Tooltip, +} from "@mantine/core"; +import { + CheckCircle2, + Download, + Eye, + FileText, + Receipt, + Send, + Upload, +} from "lucide-react"; +import toast from "react-hot-toast"; +import type { Freight } from "@edr/types"; +import { isViewable } from "@edr/ui-common"; + +import { bookingsService } from "@/services/bookings.service"; +import { + downloadBookingFile, + fetchViewableFile, +} from "@/services/files.service"; +import { formatDateTime } from "@/lib/format"; +import { extractErrorMessage } from "@/utils/errorExtractor"; + +const CURRENCIES = ["ETB", "USD"]; + +const STATUS_META: Record< + Freight.ClearanceChargeStatus, + { label: string; color: string } +> = { + DOC_UPLOADED: { label: "Awaiting billing", color: "yellow" }, + BILLED: { label: "Ready to send", color: "blue" }, + SENT: { label: "Sent — unpaid", color: "orange" }, + PAID: { label: "Paid", color: "edr-green" }, +}; + +export interface ClearanceChargesTabProps { + bookingId: string; + /** DJ uploads the port document; ET bills, sends and creates miscellaneous. */ + roleMode: "ET" | "DJ"; + onViewFile: (file: { name: string; url: string }) => void; +} + +/** + * Post-finalization charges billed to the customer, two levels: port charges + * (document from GL Djibouti, billed by GL Ethiopia) then miscellaneous + * (created whole by GL Ethiopia once the port charge is paid). Each level + * issues its own payable invoice — ETB settles through the portal gateway + * (CBE), other currencies through Finance's manual settlement. + */ +export function ClearanceChargesTab({ + bookingId, + roleMode, + onViewFile, +}: ClearanceChargesTabProps) { + const qc = useQueryClient(); + const { data: charges, isLoading } = useQuery({ + queryKey: ["clearance-charges", bookingId], + queryFn: () => bookingsService.getClearanceCharges(bookingId), + }); + + const refresh = (next: Freight.ClearanceCharge[]) => + qc.setQueryData(["clearance-charges", bookingId], next); + const onError = (e: unknown) => + toast.error(extractErrorMessage(e, "Could not update the charge")); + + const uploadPort = useMutation({ + mutationFn: (file: File) => + bookingsService.uploadPortChargeDocument(bookingId, file), + onSuccess: (next) => { + toast.success("Port-charges document uploaded"); + refresh(next); + }, + onError, + }); + const bill = useMutation({ + mutationFn: (p: { chargeId: string; amount: number; currency: string }) => + bookingsService.billClearanceCharge(bookingId, p.chargeId, p), + onSuccess: (next) => { + toast.success("Charge amount saved"); + refresh(next); + }, + onError, + }); + const send = useMutation({ + mutationFn: (chargeId: string) => + bookingsService.sendClearanceCharge(bookingId, chargeId), + onSuccess: (next) => { + toast.success("Invoice sent to the customer"); + refresh(next); + }, + onError, + }); + const createMisc = useMutation({ + mutationFn: (p: { file: File; amount: number; currency: string }) => + bookingsService.createMiscellaneousCharge(bookingId, p.file, p), + onSuccess: (next) => { + toast.success("Miscellaneous charge created"); + refresh(next); + }, + onError, + }); + + if (isLoading) { + return ( + + + Loading charges… + + ); + } + + const port = (charges ?? []).find((c) => c.type === "PORT_CHARGES") ?? null; + const misc = (charges ?? []).find((c) => c.type === "MISCELLANEOUS") ?? null; + const busy = + uploadPort.isPending || bill.isPending || send.isPending || createMisc.isPending; + + const totals = new Map(); + for (const c of charges ?? []) { + if (c.amount != null && c.currency) + totals.set(c.currency, (totals.get(c.currency) ?? 0) + c.amount); + } + + return ( + + + port && bill.mutate({ chargeId: port.id, amount, currency }) + } + onSend={() => port && send.mutate(port.id)} + djUpload={ + roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? ( + f && uploadPort.mutate(f)} + accept="application/pdf,image/*" + disabled={busy} + > + {(props) => ( + + )} + + ) : null + } + /> + + + misc && bill.mutate({ chargeId: misc.id, amount, currency }) + } + onSend={() => misc && send.mutate(misc.id)} + etCreate={ + roleMode === "ET" && !misc && port?.status === "PAID" ? ( + + createMisc.mutate({ file, amount, currency }) + } + /> + ) : null + } + /> + + {totals.size > 0 && ( + + + + Total billed + + + {[...totals.entries()].map(([currency, amount]) => ( + + {amount.toLocaleString(undefined, { + minimumFractionDigits: 2, + })}{" "} + {currency} + + ))} + + + + )} + + ); +} + +function ChargeCard({ + title, + charge, + roleMode, + busy, + emptyHint, + onViewFile, + onBill, + onSend, + djUpload, + etCreate, +}: { + title: string; + charge: Freight.ClearanceCharge | null; + roleMode: "ET" | "DJ"; + busy: boolean; + emptyHint: string; + onViewFile: (file: { name: string; url: string }) => void; + onBill: (amount: number, currency: string) => void; + onSend: () => void; + djUpload?: React.ReactNode; + etCreate?: React.ReactNode; +}) { + const [editing, setEditing] = useState(false); + const [amount, setAmount] = useState(charge?.amount ?? ""); + const [currency, setCurrency] = useState(charge?.currency ?? "ETB"); + + const status = charge?.status ?? null; + const meta = status ? STATUS_META[status] : null; + // ET enters/revises the amount while the charge is unpaid. + const showBillForm = + roleMode === "ET" && + charge != null && + (charge.status === "DOC_UPLOADED" || editing); + + return ( + + + + + + + {title} + + {charge?.uploadedAt && ( + + Document uploaded + {charge.uploadedByName ? ` by ${charge.uploadedByName}` : ""} ·{" "} + {formatDateTime(charge.uploadedAt)} + + )} + {charge?.billedAt && ( + + Billed{charge.billedByName ? ` by ${charge.billedByName}` : ""} ·{" "} + {formatDateTime(charge.billedAt)} + + )} + {charge?.paidAt && ( + + Paid · {formatDateTime(charge.paidAt)} + {charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""} + + )} + + + + {charge?.amount != null && charge.currency && ( + + {charge.amount.toLocaleString(undefined, { + minimumFractionDigits: 2, + })}{" "} + {charge.currency} + + )} + {meta && ( + + {meta.label} + + )} + + + + {charge?.file && ( + + + + {charge.file.name} + + {isViewable({ name: charge.file.name, url: "" }) && ( + + + void fetchViewableFile( + charge.file!.id, + charge.file!.name, + ).then(onViewFile) + } + c="edr-green" + style={{ + display: "flex", + background: "transparent", + border: "none", + cursor: "pointer", + }} + > + + + + )} + + + void downloadBookingFile(charge.file!.id, charge.file!.name) + } + c="edr-green" + style={{ + display: "flex", + background: "transparent", + border: "none", + cursor: "pointer", + }} + > + + + + + )} + + {!charge && ( + + {emptyHint} + + )} + {djUpload && {djUpload}} + {etCreate && {etCreate}} + + {showBillForm && ( + + + v && setCurrency(v)} + w={100} + /> + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx index a61646d6f..95dc13d37 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceOpsTabs.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; import { Badge, Stack, Tabs, Text } from "@mantine/core"; -import { AlertTriangle, FileText, Share2, ShieldAlert } from "lucide-react"; +import { AlertTriangle, FileText, Receipt, Share2, ShieldAlert } from "lucide-react"; import type { Freight } from "@edr/types"; import { useAuth } from "@/auth/useAuth"; @@ -9,6 +9,7 @@ import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { AssignRiskCard } from "@/components/contracts/gl-actions/AssignRiskCard"; import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; +import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab"; import { GlExchangePanel } from "@/components/contracts/GlExchangePanel"; export interface ClearanceOpsTabsProps { @@ -68,6 +69,12 @@ export function ClearanceOpsTabs({ Boolean(exchangeEntityId) && (hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) || hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)); + // Post-finalization customer billing. This layout is only rendered on the ET + // clearance pages — the DJ page (GlClearanceDetailPage) mounts its own tab. + const showCharges = + Boolean(bookingId) && + Boolean(onViewFile) && + hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions); // Risk assignment + incident reporting hit bookings:operations endpoints. const canOps = hasPermission(user, FREIGHT_PERMS.bookings.operations); const hasTabs = (showOpsTabs && hasOps) || showDocuments || showExchange; @@ -100,6 +107,11 @@ export function ClearanceOpsTabs({ Document exchange ) : null} + {showCharges ? ( + }> + Customer charges + + ) : null} {showOpsTabs && canOps && riskMs ? ( }> Risk assignment @@ -131,6 +143,16 @@ export function ClearanceOpsTabs({ ) : null} + {showCharges ? ( + + + + ) : null} + {showOpsTabs && canOps && riskMs && bookingId ? ( diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx index 1bf9b55e7..3a3c71449 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx @@ -10,6 +10,7 @@ import { import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core"; import type { Freight } from "@edr/types"; +import { formatDate } from "@/lib/format"; import { CONTRACT_APPROVAL_ROLE_LABELS, HAZARDOUS_APPROVAL_ROLE_PERMISSION, @@ -54,10 +55,6 @@ function formatAgo(iso: string): string { return "just now"; } -function formatDate(iso: string): string { - return new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" }); -} - type MilestoneIcon = typeof Send; interface Milestone { diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractStatusTabs.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractStatusTabs.tsx deleted file mode 100644 index 862bc1562..000000000 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractStatusTabs.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { Badge, ScrollArea, Tabs } from "@mantine/core"; -import { - ClipboardCheck, - FileSignature, - Inbox, - LayoutGrid, - ShieldCheck, - Truck, - XCircle, -} from "lucide-react"; - -import "@/components/overview/overview.css"; -import { - CONTRACT_LIST_TABS, - type ContractStatusTabKey, -} from "@/features/contracts/contract-status.config"; - -const TAB_ICONS: Record = { - all: , - intake: , - in_approval: , - approved_contract: , - clearance: , - active: , - closed: , -}; - -interface ContractStatusTabsProps { - active: ContractStatusTabKey; - onChange: (tab: ContractStatusTabKey) => void; - counts?: Partial>; -} - -export function ContractStatusTabs({ - active, - onChange, - counts, -}: ContractStatusTabsProps) { - return ( - onChange((value as ContractStatusTabKey) ?? "all")} - variant="pills" - color="edr-green" - keepMounted={false} - classNames={{ list: "ov-tablist", tab: "ov-tab" }} - > - - - {CONTRACT_LIST_TABS.map((tab) => { - const isActive = active === tab.key; - const count = counts?.[tab.key]; - return ( - - {count} - - ) : undefined - } - > - {tab.label} - - ); - })} - - - - ); -} - -export type { ContractStatusTabKey }; 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 f665f388b..af68d0f55 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -1006,7 +1006,7 @@ export function ReleaseOrderCard({ clearance: ClearanceViewLike & { vesselDepartureDate?: string | null }; onChanged?: () => void; }) { - const [file, setFile] = useState(null); + const [files, setFiles] = useState([]); const [vesselDate, setVesselDate] = useState( clearance.vesselDepartureDate ? new Date(clearance.vesselDepartureDate) : null, ); @@ -1020,7 +1020,14 @@ export function ReleaseOrderCard({ Release Order - + { - if (!file || !vesselDate) return; + if (files.length === 0 || !vesselDate) return; setLoading(true); try { const iso = vesselDate.toISOString().slice(0, 10); const result = isBooking - ? await bookingsService.uploadReleaseOrder(entityId, file, iso) - : await contractsService.uploadReleaseOrder(entityId, file, iso); + ? await bookingsService.uploadReleaseOrder(entityId, files, iso) + : await contractsService.uploadReleaseOrder(entityId, files, iso); if (result.hold) { toast.error(result.holdReason ?? "Vessel date too soon"); } else { 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 dfe0bf0e4..95dbc787e 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx @@ -10,11 +10,10 @@ import { toIsoDate, useDoCollectionDates, } from "@/components/contracts/DoCollectionDateFields"; -import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone"; -import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow"; +import { PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone"; import { contractsService } from "@/services/contracts.service"; import { bookingsService } from "@/services/bookings.service"; -import type { Freight } from "@edr/types"; +import { isDeliveryOrderFileCode, isReleaseOrderFileCode, type Freight } from "@edr/types"; export type GlClearanceUploadKind = "do" | "ro"; @@ -44,9 +43,8 @@ export function GlClearanceUploadModal({ vesselArrivalDate, doCollectedDate, onSuccess, - onPreview, }: GlClearanceUploadModalProps) { - const [file, setFile] = useState(null); + const [files, setFiles] = useState([]); const [vesselDate, setVesselDate] = useState( vesselDepartureDate ? new Date(vesselDepartureDate) : null, ); @@ -66,16 +64,16 @@ export function GlClearanceUploadModal({ const isDo = kind === "do"; const isRo = kind === "ro"; const replaceMode = isDo - ? Boolean(findWorkflowFile(workflowFiles, "delivery_order")) - : Boolean(findWorkflowFile(workflowFiles, "release_order")); + ? workflowFiles.some((f) => isDeliveryOrderFileCode(f.code) && f.file) + : workflowFiles.some((f) => isReleaseOrderFileCode(f.code) && f.file); const close = () => { - setFile(null); + setFiles([]); onClose(); }; const submit = async () => { - if (!file || !kind) return; + if (files.length === 0 || !kind) return; if (isRo && !vesselDate) { toast.error("Vessel departure date is required."); return; @@ -93,23 +91,23 @@ export function GlClearanceUploadModal({ doCollectedDate: toIsoDate(doDates.doCollected)!, }; if (isBooking) { - await bookingsService.uploadDeliveryOrder(entityId, file, dates); + await bookingsService.uploadDeliveryOrder(entityId, files, dates); } else { - await contractsService.uploadDeliveryOrder(entityId, file, dates); + await contractsService.uploadDeliveryOrder(entityId, files, dates); } toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded"); } else { const iso = vesselDate!.toISOString().slice(0, 10); const result = isBooking - ? await bookingsService.uploadReleaseOrder(entityId, file, iso) - : await contractsService.uploadReleaseOrder(entityId, file, iso); + ? await bookingsService.uploadReleaseOrder(entityId, files, iso) + : await contractsService.uploadReleaseOrder(entityId, files, iso); if (result.hold) { toast.error(result.holdReason ?? "Vessel date too soon"); } else { toast.success(replaceMode ? "Release Order updated" : "Release Order uploaded"); } } - setFile(null); + setFiles([]); onSuccess?.(); close(); } catch (e) { @@ -152,14 +150,15 @@ export function GlClearanceUploadModal({ )} - @@ -170,7 +169,7 @@ export function GlClearanceUploadModal({ color="edr-green" loading={loading} disabled={ - !file || + files.length === 0 || (isRo && !vesselDate) || (isDo && !doDatesComplete(doDates)) } 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 67e84a09f..06ae71384 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -11,6 +11,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { ActionIcon, Alert, + Badge, Box, Button, Center, @@ -40,6 +41,7 @@ import { FileText, FileUp, Flame, + Link2, MapPin, Package, Receipt, @@ -57,7 +59,10 @@ import { import { api } from "@/services/api"; import { PageContainer } from "@/components/page"; import { PageHeader } from "@/components/page/PageHeader"; -import { contractsService } from "@/services/contracts.service"; +import { + contractsService, + type ConsolidationCandidate, +} from "@/services/contracts.service"; import { bookingsService } from "@/services/bookings.service"; import { useContractCapacity, @@ -80,6 +85,18 @@ import { StepHeader, StepLabel, } from "./gl-booking-form/form-ui"; +import { + ConsolidationPartnerPanel, + emptyPartnerLine, +} from "./gl-booking-form/ConsolidationPartnerPanel"; +import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker"; + +/** + * Container sizes offered on the parent-booking panel. Fixed rather than taken + * from this contract's scope: the parent booking is a different customer on a + * different contract, so its sizes are its own. + */ +const PARTNER_SIZES = ["20ft", "40ft"]; /** All booking-window times are communicated in East Africa Time. */ const EAT_TZ = "Africa/Addis_Ababa"; @@ -240,6 +257,14 @@ export default function GlCreateBookingForm() { enabled: Boolean(copyFromParam), }); + // The booking being completed — used to name the customer on the price + // confirmation when a second booking's price is shown beside it. + const { data: completeBooking } = useQuery({ + queryKey: ["gl-complete-booking", completeBookingId], + queryFn: () => bookingsService.getById(completeBookingId!), + enabled: Boolean(completeBookingId), + }); + // Same window-gating the customer sees: booking is only allowed while a // window is OPEN for one of the contract's routes. Intercity contracts are // never window-gated — the shipment rides a passing train staff pick later. @@ -290,6 +315,18 @@ export default function GlCreateBookingForm() { const [withReturn, setWithReturn] = useState(false); const [prefilled, setPrefilled] = useState(false); const [priceOpen, setPriceOpen] = useState(false); + // ── Odd-20ft shared wagon (customs / Path B) ────────────────────────────── + // An odd 20ft total leaves one container unpaired. On a customs contract GL + // resolves that here by linking a second booking that is also odd — two odd + // counts always sum to even — completing both together onto the shared wagon. + const [consolidateOdd, setConsolidateOdd] = useState(false); + // Set once GL flips the toggle by hand, so the auto-on effect below never + // re-opens a panel GL deliberately closed. + const consolidateTouchedRef = useRef(false); + const [partnerPickerOpen, setPartnerPickerOpen] = useState(false); + const [partner, setPartner] = useState(null); + const [partnerLines, setPartnerLines] = useState([]); + const [partnerCargoDescription, setPartnerCargoDescription] = useState(""); const seededRef = useRef(false); const returnSeededRef = useRef(false); @@ -834,6 +871,53 @@ export default function GlCreateBookingForm() { }, [isContainer, containerLines]); const hasOdd20ft = ft20Total % 2 === 1; + // Only a customs (Path B) instance being COMPLETED by GL can use the shared + // wagon: it is GL, not the customer, who links the two bookings. Anything else + // keeps the historical hard block on odd 20ft. + // + // Switched OFF for now: consolidation is built end to end (toggle, parent + // picker, split entry, paired pricing, approval gate) but not in use, so an + // odd 20ft total is rejected outright instead of offering the shared wagon. + // Drop the `false &&` to bring the whole flow back. + const oddConsolidationAvailable = + false && + Boolean(completeBookingId && isContainer && contract?.customsClearingEnabled); + + // Auto-on: entering an odd 20ft total opens the consolidation panel by itself, + // once. GL can still switch it off — then odd is blocked exactly as before. + useEffect(() => { + if (!oddConsolidationAvailable) return; + if (consolidateTouchedRef.current) return; + if (hasOdd20ft) setConsolidateOdd(true); + }, [oddConsolidationAvailable, hasOdd20ft]); + + // Clear the partner as soon as the panel closes or stops applying, so a + // leftover selection can never ride along into a plain single-booking submit. + useEffect(() => { + if (consolidateOdd && oddConsolidationAvailable) return; + setPartner(null); + setPartnerLines([]); + setPartnerCargoDescription(""); + }, [consolidateOdd, oddConsolidationAvailable]); + + const consolidationActive = + oddConsolidationAvailable && consolidateOdd && hasOdd20ft; + + // Once a parent booking is linked, each booking's cargo is entered under its + // own labelled heading so it is clear which containers belong to whom. + const splitView = Boolean(consolidationActive && partner); + + const candidatesQuery = useQuery({ + queryKey: ["consolidation-candidates", id, completeBookingId], + queryFn: () => + contractsService.listConsolidationCandidates( + id ?? "", + completeBookingId ?? "", + ), + enabled: + partnerPickerOpen && Boolean(id) && Boolean(completeBookingId), + }); + const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON"; const bulkErrors = useMemo(() => { @@ -886,7 +970,65 @@ export default function GlCreateBookingForm() { !cargoDescriptionError : !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer; - const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError; + // Consolidation (sharing the wagon with another customer's odd booking) is + // built but switched off for now, so an odd 20ft total always blocks — the + // shared wagon no longer resolves the unpaired container. Flip this back to + // `hasOdd20ft && !consolidationActive` to re-enable the shared-wagon path. + const oddBlocksSubmit = hasOdd20ft; + + // Partner side: a linked partner must be picked, carry an odd 20ft count of + // its own (odd + odd = even fills the wagon) and have complete unit details. + const partnerFt20Total = useMemo(() => { + if (!consolidationActive) return 0; + return partnerLines + .filter((l) => parseInt(l.containerSize, 10) === 20) + .reduce((sum, l) => sum + Number(l.quantity || 0), 0); + }, [consolidationActive, partnerLines]); + + const partnerError = useMemo(() => { + if (!consolidationActive) return undefined; + if (!partner) return "Select the booking that shares this wagon."; + const totalQty = partnerLines.reduce( + (sum, l) => sum + Math.max(0, Number(l.quantity) || 0), + 0, + ); + if (totalQty < 1) { + return `Enter the containers for ${partner.reference}.`; + } + if (partnerFt20Total % 2 === 0) { + return `${partner.reference} must also carry an odd number of 20ft containers so the two bookings fill whole wagons together (it has ${partnerFt20Total}).`; + } + const incomplete = partnerLines.some((line) => { + const qty = Number(line.quantity || 0); + return qty >= 1 && line.units.length < qty; + }); + if (incomplete) { + return `Enter the container details for all of ${partner.reference}'s containers.`; + } + const badUnit = partnerLines.some((line) => + line.units.some( + (u) => + !ISO_CONTAINER_NUMBER_REGEX.test(u.containerNumber.trim().toUpperCase()) || + !(Number(u.vgmTons) > 0), + ), + ); + if (badUnit) { + return `Every ${partner.reference} container needs a valid container number and a VGM above 0.`; + } + if (!partnerCargoDescription.trim()) { + return `Describe the cargo carried in ${partner.reference}'s containers.`; + } + return undefined; + }, [ + consolidationActive, + partner, + partnerLines, + partnerFt20Total, + partnerCargoDescription, + ]); + + const formValid = + cargoValid && !oddBlocksSubmit && !dateError && !routeError && !partnerError; /** The create-booking DTO from the current form state — shared by the * authoritative price preview and the actual submit so what GL confirms is @@ -953,6 +1095,44 @@ export default function GlCreateBookingForm() { return payload; }; + /** + * Completion DTO for the partner half of a shared wagon. Route, day and train + * are deliberately copied from THIS booking: the two bookings ride the same + * wagon, so they must ride the same train on the same day. Only the cargo and + * the billing currency belong to the partner. + */ + const buildPartnerPayload = (): Freight.CreateBookingUnderContractDto | null => { + if (!partner || !consolidationActive) return null; + + const payload: Freight.CreateBookingUnderContractDto = { + paymentCurrency, + ...(scheduledDate + ? { scheduledDate: new Date(scheduledDate).toISOString() } + : {}), + ...(trainScheduleId ? { trainScheduleId } : {}), + ...(partnerCargoDescription.trim() + ? { cargoFreeText: partnerCargoDescription.trim() } + : {}), + containers: partnerLines + .filter((l) => Number(l.quantity) >= 1) + .map((l) => ({ + containerSize: l.containerSize, + quantity: Number(l.quantity), + hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined, + reeferQuantity: Number(l.reeferQuantity || 0) || undefined, + units: l.units.map((u) => ({ + containerNumber: u.containerNumber.trim().toUpperCase(), + ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), + vgmTons: Number(u.vgmTons) || 0, + isHazardous: Boolean(u.isHazardous), + isReefer: Boolean(u.isReefer), + })), + })), + }; + + return payload; + }; + // Authoritative price preview (same pricing pass the booking persists at // create): rail freight + first/last mile + overweight + every surcharge, // plus the hard-block checks (20ft pairing, max capacity, container numbers @@ -964,6 +1144,22 @@ export default function GlCreateBookingForm() { }); const validation = validateShipmentMutation.data ?? null; + // The partner is priced against ITS OWN contract, so the two totals shown in + // the confirm modal are each customer's real bill — nobody pays for the other. + const validatePartnerMutation = useMutation({ + mutationFn: (input: { + contractId: string; + bookingId: string; + dto: Freight.CreateBookingUnderContractDto; + }) => + contractsService.validateShipment( + input.contractId, + input.dto, + input.bookingId, + ), + }); + const partnerValidation = validatePartnerMutation.data ?? null; + const serverTotal = useMemo(() => { const items = validation?.lineItems; if (!items?.length) return null; @@ -1010,8 +1206,52 @@ export default function GlCreateBookingForm() { }; }, [serverTotal, priceTotal, overweightSurchargeAmount]); + const partnerTotal = useMemo(() => { + const items = partnerValidation?.lineItems; + if (!items?.length) return null; + return { + currency: partnerValidation?.currency ?? "ETB", + lines: items.map((li) => ({ + label: li.description, + unitPrice: li.unitAmount, + unit: li.unit.toLowerCase(), + quantity: li.quantity, + amount: li.amount, + })), + total: + partnerValidation?.totalAmount ?? items.reduce((s, l) => s + l.amount, 0), + }; + }, [partnerValidation]); + + // The partner half must clear the same hard blocks as this one — the pair is + // booked all-or-nothing, so a block on either side blocks both. + const partnerBlockers = useMemo(() => { + if (!consolidationActive || !partnerValidation) return []; + return [ + ...(partnerValidation.pairingErrors ?? []), + ...(partnerValidation.capacityErrors ?? []), + ...(partnerValidation.containerClashErrors ?? []), + ...(partnerValidation.spaceErrors ?? []), + ]; + }, [consolidationActive, partnerValidation]); + + const completePairMutation = useMutation({ + mutationFn: (input: { + payload: Freight.CreateBookingUnderContractDto; + partnerPayload: Freight.CreateBookingUnderContractDto; + partnerBookingId: string; + }) => + contractsService.completeConsolidatedPair(id ?? "", completeBookingId ?? "", { + partnerBookingId: input.partnerBookingId, + booking: input.payload, + partner: input.partnerPayload, + }), + }); + const submitPending = - mutations.createBooking.isPending || mutations.completeBooking.isPending; + mutations.createBooking.isPending || + mutations.completeBooking.isPending || + completePairMutation.isPending; // Block confirm until the authoritative server price is in hand — the client // estimate is display-only; booking on it would confirm an un-validated, @@ -1023,7 +1263,13 @@ export default function GlCreateBookingForm() { capacityErrors.length > 0 || containerClashErrors.length > 0 || spaceErrors.length > 0 || - !serverTotal; + !serverTotal || + // Same bar for the shared-wagon partner: its authoritative price must be in + // hand and its own hard blocks clear before either booking is confirmed. + (consolidationActive && + (validatePartnerMutation.isPending || + !partnerTotal || + partnerBlockers.length > 0)); const openPriceModal = () => { // Surface the per-field errors (portal-parity validation) instead of @@ -1039,6 +1285,15 @@ export default function GlCreateBookingForm() { validateShipmentMutation.reset(); validateShipmentMutation.mutate(payload); } + validatePartnerMutation.reset(); + const partnerPayload = buildPartnerPayload(); + if (partnerPayload && partner?.contractId) { + validatePartnerMutation.mutate({ + contractId: partner.contractId, + bookingId: partner.id, + dto: partnerPayload, + }); + } }; const handleSubmit = () => { @@ -1054,6 +1309,25 @@ export default function GlCreateBookingForm() { const payload = buildPayload(); if (!payload) return; + // Shared wagon: both halves complete together, all-or-nothing on the server. + if (consolidationActive && partner && completeBookingId) { + // A hard block on the partner's own price preview blocks the pair. + if (partnerBlockers.length > 0) return; + const partnerPayload = buildPartnerPayload(); + if (!partnerPayload) return; + completePairMutation.mutate( + { + payload, + partnerPayload, + partnerBookingId: partner.id, + }, + { + onSuccess: () => navigate(`/dashboard/clearance/${completeBookingId}`), + }, + ); + return; + } + if (completeBookingId) { // Completion mode: cargo + day land on the already-cleared instance — // the request was linked and accepted at submission time. @@ -1347,6 +1621,18 @@ export default function GlCreateBookingForm() { maxRows={4} styles={fieldStyles} /> + {/* With a parent booking linked, each booking's containers are + entered in its own labelled section, one after the other. */} + {splitView ? ( + + + {completeBooking?.reference ?? "This booking"} + + + {completeBooking?.company?.name ?? "—"} + + + ) : null} {containerLines.length === 0 ? ( This contract has no container sizes in scope. @@ -1526,7 +1812,71 @@ export default function GlCreateBookingForm() { )) )} - {hasOdd20ft ? ( + {hasOdd20ft && oddConsolidationAvailable ? ( + } + title={`Odd number of 20ft containers (${ft20Total})`} + > + + + 20ft containers travel two per wagon, so one container here + is unpaired. On a customs booking you can pair it with + another customer's odd booking and complete both onto the + shared wagon — each booking is still priced and invoiced + separately. + + { + consolidateTouchedRef.current = true; + setConsolidateOdd(e.currentTarget.checked); + }} + /> + {consolidateOdd ? ( + + + {partner ? ( + + ) : null} + + ) : ( + + With sharing off, book an even number of 20ft containers + — add one more or remove one (e.g. {ft20Total + 1} or{" "} + {ft20Total - 1} instead of {ft20Total}). + + )} + + + ) : hasOdd20ft ? ( ) : null} + + {splitView && partner ? ( + <> + + + + {partner.reference} + + + {partner.companyName ?? "—"} + + + + Parent booking — ships on the same day and train, billed to + its own customer. + + + + ) : null} ) : ( @@ -1796,7 +2174,23 @@ export default function GlCreateBookingForm() { }} > - {showErrors && !formValid ? ( + {/* The review button is disabled on an odd 20ft total, so the click + that would surface the errors never lands — state the reason here + rather than leaving it in a tooltip nobody hovers. */} + {oddBlocksSubmit ? ( + } + mb="sm" + title={`Odd number of 20ft containers (${ft20Total})`} + > + 20ft containers travel two per wagon, so they must be booked in + even numbers. Add one more 20ft container or remove one — book{" "} + {ft20Total + 1} or {ft20Total - 1} instead of {ft20Total}. + + ) : showErrors && !formValid ? ( Fix the highlighted fields before reviewing the price. + ) : partnerError ? ( + // The review button is disabled while the parent booking is + // incomplete, so the click that would reveal the errors never + // lands — say what is outstanding without waiting for it. + } + mb="sm" + > + {partnerError} + ) : null} {/* Mantine tooltips get no pointer events from a disabled button, so the wrapper carries the hover target. */} @@ -1821,9 +2234,11 @@ export default function GlCreateBookingForm() { radius="md" leftSection={} onClick={openPriceModal} - // Same hard block the customer portal applies at review time — - // an unpaired 20ft can never be planned onto a wagon. - disabled={hasOdd20ft} + // An unpaired 20ft can never be planned onto a wagon — unless + // a parent booking is linked to share it, which is what + // oddBlocksSubmit accounts for. The parent's own cargo must be + // complete too, or there is nothing to price. + disabled={oddBlocksSubmit || Boolean(partnerError)} > Review price & book @@ -1833,6 +2248,24 @@ export default function GlCreateBookingForm() { + setPartnerPickerOpen(false)} + candidates={candidatesQuery.data ?? []} + isLoading={candidatesQuery.isLoading} + isError={candidatesQuery.isError} + onSelect={(candidate) => { + setPartner(candidate); + // Seed a 20ft and a 40ft line. The parent booking sits on its OWN + // contract, whose size scope need not match this one's, so the panel + // offers both sizes rather than mirroring this contract's scope; a + // size the parent does not ship is simply left at 0. + setPartnerLines(PARTNER_SIZES.map(emptyPartnerLine)); + setPartnerCargoDescription(""); + setPartnerPickerOpen(false); + }} + /> + { @@ -1984,6 +2417,18 @@ export default function GlCreateBookingForm() { )} + {/* Whose bill this is. Only worth naming when a second booking is + on screen — on a lone booking there is nothing to confuse it with. */} + {consolidationActive && partner ? ( + + + {completeBooking?.reference ?? "This booking"} + + + {completeBooking?.company?.name ?? contract.company?.name ?? "—"} + + + ) : null} {displayTotal.lines.map((line, i) => ( @@ -2028,6 +2473,123 @@ export default function GlCreateBookingForm() { + {consolidationActive && partner ? ( + + + + {partner.reference} + + + {partner.companyName ?? "—"} + + + + {validatePartnerMutation.isPending ? ( + + + + Pricing the partner booking… + + + ) : partnerBlockers.length > 0 ? ( + } + title={`Cannot book ${partner.reference}`} + > + + {partnerBlockers.map((msg, i) => ( + + {msg} + + ))} + + Both bookings are confirmed together, so this must be + fixed before either can be booked. + + + + ) : partnerTotal ? ( + <> + + {partnerTotal.lines.map((line, i) => ( + + + + {line.label} + + + {line.quantity.toLocaleString()} ×{" "} + {line.unitPrice.toLocaleString()}{" "} + {partnerTotal.currency} ·{" "} + {formatRateUnit(line.unit)} + + + + {line.amount.toLocaleString()}{" "} + {partnerTotal.currency} + + + ))} + + + + + Total + + + {partnerTotal.total.toLocaleString()}{" "} + + {partnerTotal.currency} + + + + + ) : ( + + No price yet for the partner booking. + + )} + + ) : null} + + {consolidationActive && partner ? ( + } + > + + These two bookings share one wagon but stay separate: each is + invoiced to its own customer and paid separately. Confirming + books both together — if either fails, neither is booked. + + + ) : null} + diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/LogoUpload.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/LogoUpload.tsx new file mode 100644 index 000000000..f8ffe0fd0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/LogoUpload.tsx @@ -0,0 +1,172 @@ +import { useRef, useState } from "react"; +import { Box, Button, Group, Image, Paper, Stack, Text } from "@mantine/core"; +import { ImageIcon, RefreshCw, X } from "lucide-react"; + +const MAX_LOGO_MB = 10; + +export interface LogoUploadProps { + /** Logo image as a data URL, or null when none is attached yet. */ + value: string | null; + onChange: (dataUrl: string | null) => void; + label?: string; + description?: string; +} + +/** + * Company logo picker — reads the picked image straight into a data URL, + * same transport as {@link StampUpload}. Kept as its own component (not a + * generalized image-upload) matching how stamp/teeter are already separate + * files here despite the near-identical shape. + */ +export function LogoUpload({ + value, + onChange, + label = "Company logo", + description = "Attach the official company logo.", +}: LogoUploadProps) { + const inputRef = useRef(null); + const [dragging, setDragging] = useState(false); + const [error, setError] = useState(null); + const [fileName, setFileName] = useState(null); + + const readFile = (file: File | undefined | null) => { + if (!file) return; + if (!file.type.startsWith("image/")) { + setError("The logo must be an image file (PNG or JPG)."); + return; + } + if (file.size > MAX_LOGO_MB * 1024 * 1024) { + setError(`The logo image must be under ${MAX_LOGO_MB} MB.`); + return; + } + const reader = new FileReader(); + reader.onload = () => { + setError(null); + setFileName(file.name); + onChange(typeof reader.result === "string" ? reader.result : null); + }; + reader.onerror = () => setError("Could not read that file. Try another."); + reader.readAsDataURL(file); + }; + + const openPicker = () => inputRef.current?.click(); + + const clear = () => { + setFileName(null); + setError(null); + onChange(null); + if (inputRef.current) inputRef.current.value = ""; + }; + + return ( + + + {label} + + + readFile(e.currentTarget.files?.[0])} + /> + + {value ? ( + + + + Company logo + + + + {fileName ?? "Logo attached"} + + + Shown in the header of every generated document. + + + + + + + + + ) : ( + { + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + readFile(e.dataTransfer.files?.[0]); + }} + style={{ + borderColor: dragging + ? "var(--mantine-color-edr-green-6)" + : undefined, + borderStyle: "dashed", + backgroundColor: dragging + ? "var(--mantine-color-edr-green-0)" + : undefined, + cursor: "pointer", + }} + > + + + + Upload company logo + + + {description} Drop an image here or click to browse — PNG or JPG, + up to {MAX_LOGO_MB} MB. + + + + )} + + {error && ( + + {error} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 926637db2..4b44edda6 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -34,12 +34,15 @@ import { Truck, Upload, } from "lucide-react"; -import type { Freight } from "@edr/types"; +import { + deliveryOrderFileLabel, + isDeliveryOrderFileCode, + type Freight, +} from "@edr/types"; import toast from "react-hot-toast"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { ExportClearanceStepper } from "@/components/contracts/ExportClearanceStepper"; -import { PhasedDocumentUploadField } from "@/components/contracts/PhasedDocumentUploadField"; import { DoCollectionDateFields, doDatesComplete, @@ -2133,56 +2136,82 @@ function DeliveryOrderStep({ onViewFile?: (file: { name: string; url: string }) => void; onDownloadFile?: (file: { id: string; name: string }) => void; }) { - const [files, setFiles] = useState>({ - delivery_order: null, - }); + const [files, setFiles] = useState([]); const [doDates, setDoDates] = useDoCollectionDates({ vesselArrivalDate, doCollectedDate, }); const [loading, setLoading] = useState(false); - const hasFile = Boolean(files.delivery_order); + + const submit = async () => { + if (files.length === 0 || !doDatesComplete(doDates)) return; + const dates = { + vesselArrivalDate: toIsoDate(doDates.vesselArrival)!, + doCollectedDate: toIsoDate(doDates.doCollected)!, + }; + setLoading(true); + try { + if (isBooking) { + await bookingsService.uploadDeliveryOrder(entityId, files, dates); + } else { + await contractsService.uploadDeliveryOrder(entityId, files, dates); + } + setFiles([]); + toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded"); + onChanged?.(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Upload failed"); + } finally { + setLoading(false); + } + }; return ( - setFiles((prev) => ({ ...prev, [key]: file }))} - workflowFiles={workflowFiles} - replaceMode={replaceMode} - loading={loading} - disabled={!hasFile || !doDatesComplete(doDates)} - helperText="Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the DO was collected." - submitLabel={replaceMode ? "Replace DO" : "Upload DO"} - extraFields={ - - } - onViewFile={onViewFile} - onDownloadFile={onDownloadFile} - onSubmit={async () => { - const file = files.delivery_order; - if (!file || !doDatesComplete(doDates)) return; - const dates = { - vesselArrivalDate: toIsoDate(doDates.vesselArrival)!, - doCollectedDate: toIsoDate(doDates.doCollected)!, - }; - setLoading(true); - try { - if (isBooking) { - await bookingsService.uploadDeliveryOrder(entityId, file, dates); - } else { - await contractsService.uploadDeliveryOrder(entityId, file, dates); - } - setFiles({ delivery_order: null }); - toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded"); - onChanged?.(); - } catch (e) { - toast.error(e instanceof Error ? e.message : "Upload failed"); - } finally { - setLoading(false); + + + Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the + DO was collected. Add as many files as needed. + + + {workflowFiles + .filter((wf) => isDeliveryOrderFileCode(wf.code) && wf.file) + .map((wf, index) => ( + + ))} + + + + + accept="*/*" + value={files} + onChange={setFiles} + replaceMode={replaceMode} + disabled={loading} + /> + + + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx index de8ff1953..5d875d98e 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/detail/ContractDetailTabCards.tsx @@ -30,6 +30,7 @@ import { clearanceWorkflowFileLabel } from "@edr/types"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { detailStyles } from "@/components/bookings/detail/booking-detail.styles"; +import { LinkedEntityCard } from "@/components/detail"; import { customersService } from "@/services/customers.service"; type ContractFile = NonNullable[number]; @@ -141,25 +142,23 @@ export function ContractCustomerCard({ return ( - - - + rows={[ + { icon: FileCheck, label: "TIN", value: company.tin }, + { icon: Hash, label: "VAT number", value: company.vatNumber }, + { icon: ShieldCheck, label: "FAN number", value: company.fanNumber }, + { icon: Globe, label: "Country", value: company.country }, + { icon: Mail, label: "Email", value: company.email }, + { icon: Phone, label: "Phone", value: company.phone }, + { icon: MapPin, label: "Address", value: company.address }, + { icon: Globe, label: "Website", value: company.website }, + ]} + /> - + {/* Whoever the eTrade licence names as the business's manager. */} + diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx index d8c5f819a..1469e06e3 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx @@ -12,56 +12,14 @@ import { User, Warehouse, } from "lucide-react"; -import { Badge, Box, Divider, Group, Stack, Text } from "@mantine/core"; +import { Badge, Box, Group, Stack, Text } from "@mantine/core"; import type { Freight } from "@edr/types"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { LinkedEntityCard } from "@/components/detail"; type ReqContract = NonNullable; -interface InfoRowProps { - icon: LucideIcon; - label: string; - value?: string | null; -} - -function InfoRow({ icon: Icon, label, value }: InfoRowProps) { - return ( - - - - - {label} - - - - {value || "—"} - - - ); -} - -function InfoRows({ rows }: { rows: InfoRowProps[] }) { - const visible = rows.filter((r) => r.value); - if (visible.length === 0) { - return ( - - No details available. - - ); - } - return ( - - {visible.map((row, i) => ( -
- {i > 0 && } - -
- ))} -
- ); -} - /** Customer (company) on the request's contract. */ export function RequestCustomerCard({ contract }: { contract?: ReqContract | null }) { const company = contract?.company; @@ -75,23 +33,21 @@ export function RequestCustomerCard({ contract }: { contract?: ReqContract | nul ); } return ( - - - + rows={[ + { icon: FileCheck, label: "TIN", value: company.tin }, + { icon: Mail, label: "Email", value: company.email }, + { icon: Phone, label: "Phone", value: company.phone }, + { icon: MapPin, label: "Address", value: company.address }, + { icon: User, label: "Contact", value: company.contactPersonName }, + { icon: Phone, label: "Contact phone", value: company.contactPersonPhone }, + ]} + /> ); } @@ -119,43 +75,41 @@ export function RequestContractSummaryCard({ }) { if (!contract) return null; return ( - - - + rows={[ + { + icon: FileText, + label: "Kind", + value: contract.contractKind === "GENERAL" ? "General" : "One-time", + }, + { + icon: Package, + label: "Cargo", + value: contract.freightType === "CONTAINER" ? "Container" : "Bulk", + }, + { icon: Ship, label: "Trade", value: titleCase(contract.tradeDirection) }, + { icon: FileCheck, label: "Currency", value: contract.paymentCurrency }, + { + icon: FileCheck, + label: "Customs", + value: contract.customsClearingEnabled + ? "Included (Global Logistics)" + : "Not included", + }, + { + icon: FileText, + label: "Valid until", + value: contract.contractValidUntil + ? fmtDate(contract.contractValidUntil) + : "Not active yet", + }, + ]} + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx new file mode 100644 index 000000000..e52cce097 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx @@ -0,0 +1,255 @@ +import { type KeyboardEvent } from "react"; +import { + Box, + Checkbox, + Group, + Stack, + Text, + TextInput, +} from "@mantine/core"; + +/** + * Container editor for the PARTNER half of a shared wagon. Deliberately a + * reduced version of the main form's editor: the partner contributes only cargo + * — route, shipment day and train are inherited from the booking it shares the + * wagon with, and hazardous/reefer/return counts are derived from the per-unit + * ticks rather than typed line totals. + */ + +export interface PartnerUnitDraft { + containerNumber: string; + sealNumber: string; + vgmTons: string; + isHazardous: boolean; + isReefer: boolean; + isReturn: boolean; +} + +export interface PartnerLineDraft { + containerSize: string; + quantity: string; + hazardousQuantity: string; + reeferQuantity: string; + returnQuantity: string; + units: PartnerUnitDraft[]; +} + +export function emptyPartnerUnit(): PartnerUnitDraft { + return { + containerNumber: "", + sealNumber: "", + vgmTons: "", + isHazardous: false, + isReefer: false, + isReturn: false, + }; +} + +export function emptyPartnerLine(size: string): PartnerLineDraft { + return { + containerSize: size, + quantity: "0", + hazardousQuantity: "0", + reeferQuantity: "0", + returnQuantity: "0", + units: [], + }; +} + +/** Quantities are magnitudes — swallow the minus key before it reaches the field. */ +const blockNegative = (event: KeyboardEvent) => { + if (event.key === "-") event.preventDefault(); +}; + +/** Grow or shrink a line's unit rows to match its quantity. */ +function syncUnits(line: PartnerLineDraft, quantity: number): PartnerLineDraft { + const target = Math.max(0, Math.floor(quantity) || 0); + const units = [...line.units]; + while (units.length < target) units.push(emptyPartnerUnit()); + units.length = target; + return { + ...line, + units, + hazardousQuantity: String(units.filter((u) => u.isHazardous).length), + reeferQuantity: String(units.filter((u) => u.isReefer).length), + }; +} + +interface Props { + lines: PartnerLineDraft[]; + onLinesChange: (lines: PartnerLineDraft[]) => void; + cargoDescription: string; + onCargoDescriptionChange: (value: string) => void; + /** Whether per-container hazardous / refrigerated ticks apply. */ + showHazardous: boolean; + showReefer: boolean; + /** Surface field errors only after the operator tried to continue. */ + showErrors: boolean; + error?: string; +} + +export function ConsolidationPartnerPanel({ + lines, + onLinesChange, + cargoDescription, + onCargoDescriptionChange, + showHazardous, + showReefer, + showErrors, + error, +}: Props) { + const patchLine = (index: number, patch: Partial) => { + onLinesChange( + lines.map((line, i) => (i === index ? { ...line, ...patch } : line)), + ); + }; + + const patchUnit = ( + lineIndex: number, + unitIndex: number, + patch: Partial, + ) => { + onLinesChange( + lines.map((line, i) => { + if (i !== lineIndex) return line; + const units = line.units.map((unit, u) => + u === unitIndex ? { ...unit, ...patch } : unit, + ); + return { + ...line, + units, + hazardousQuantity: String(units.filter((u) => u.isHazardous).length), + reeferQuantity: String(units.filter((u) => u.isReefer).length), + }; + }), + ); + }; + + return ( + + {error && showErrors ? ( + + {error} + + ) : null} + + {lines.map((line, lineIdx) => ( + + + {line.containerSize} containers + + + patchLine(lineIdx, { quantity: e.currentTarget.value })} + // Sync off the typed value, not the captured `line` — that snapshot + // still holds the pre-edit quantity and would write it back. + onBlur={(e) => { + const typed = e.currentTarget.value; + patchLine(lineIdx, { + ...syncUnits({ ...line, quantity: typed }, Number(typed || 0)), + quantity: typed, + }); + }} + mb={12} + /> + + {line.units.map((unit, unitIdx) => ( + + + Container {unitIdx + 1} + + + + patchUnit(lineIdx, unitIdx, { + containerNumber: e.currentTarget.value.toUpperCase(), + }) + } + /> + + patchUnit(lineIdx, unitIdx, { + sealNumber: e.currentTarget.value, + }) + } + /> + 0) + ? "Required." + : undefined + } + onChange={(e) => + patchUnit(lineIdx, unitIdx, { vgmTons: e.currentTarget.value }) + } + /> + + {showHazardous || showReefer ? ( + + {showHazardous ? ( + + patchUnit(lineIdx, unitIdx, { + isHazardous: e.currentTarget.checked, + }) + } + /> + ) : null} + {showReefer ? ( + + patchUnit(lineIdx, unitIdx, { + isReefer: e.currentTarget.checked, + }) + } + /> + ) : null} + + ) : null} + + ))} + + ))} + + onCargoDescriptionChange(e.currentTarget.value)} + /> + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx new file mode 100644 index 000000000..c20c08994 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPicker.tsx @@ -0,0 +1,137 @@ +import { + Alert, + Badge, + Box, + Button, + Center, + Group, + Loader, + Modal, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; +import { AlertCircle, Link2 } from "lucide-react"; + +import type { ConsolidationCandidate } from "@/services/contracts.service"; + +/** + * Picker for the booking that shares this booking's wagon. The server has + * already narrowed the list to bookings that can legally pair — same route and + * direction, customs clearing, an odd 20ft count of their own and not already + * linked to someone else — so every row here is a valid choice. + */ +interface Props { + opened: boolean; + onClose: () => void; + candidates: ConsolidationCandidate[]; + isLoading: boolean; + isError: boolean; + onSelect: (candidate: ConsolidationCandidate) => void; +} + +export function ConsolidationPartnerPicker({ + opened, + onClose, + candidates, + isLoading, + isError, + onSelect, +}: Props) { + return ( + + + + + + + Pick the parent booking + + + Customs bookings on the same route that also carry an odd number of + 20ft containers. + + + + } + > + {isLoading ? ( +
+ +
+ ) : isError ? ( + } + > + Could not load the candidate bookings. Close this and try again. + + ) : candidates.length === 0 ? ( + } + title="No booking available to share this wagon" + > + + No other customs booking on this route currently carries an odd + number of 20ft containers. Either wait for one, or switch the + shared-wagon option off and book an even number of 20ft containers. + + + ) : ( + + {candidates.map((candidate) => ( + + + + + + {candidate.reference} + + + {candidate.status.replaceAll("_", " ")} + + + + {candidate.companyName ?? "—"} + {candidate.tradeDirection + ? ` · ${candidate.tradeDirection}` + : ""} + {" · "} + {candidate.hasCargo + ? `${candidate.ft20Quantity} × 20ft` + : "cargo not entered yet"} + + + + + + ))} + + )} +
+ ); +} 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 d86e0079c..7f4cf30ce 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -20,11 +20,10 @@ import { FileX2, } from "lucide-react"; import { useState } from "react"; -import { useFileViewer } from "@edr/ui-common"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; -import { fetchViewableFile } from "@/services/files.service"; +import { openFileInNewTab } from "@/services/files.service"; import { api } from "@/services/api"; import type { Company } from "@/types/customer"; import { formatDate, humanize } from "./format"; @@ -43,9 +42,17 @@ export const FIELD_LABELS: Record = { contactPersonPosition: "Contact position", contactPersonEmail: "Contact email", contactPersonPhone: "Contact phone", - generalManagerName: "General manager", - generalManagerEmail: "GM email", - generalManagerPhone: "GM phone", + ownerName: "Owner name", + ownerEmail: "Owner email", + ownerPhone: "Owner phone", + poaDeclared: "Has a Power of Attorney", + poaPassportNumber: "PoA passport number", + // Nothing writes these any more — the general manager was removed — but + // change requests filed before that still carry them, and without a label + // the reviewer sees a raw attribute key. + generalManagerName: "General manager (retired)", + generalManagerEmail: "GM email (retired)", + generalManagerPhone: "GM phone (retired)", poaName: "PoA name", poaPhone: "PoA phone", poaEmail: "PoA email", @@ -79,9 +86,9 @@ export function currentValue(company: Company, key: string): string { nationality: c.nationality, contactPersonName: c.contactPersonName ?? attrs.contactPersonName, contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone, - generalManagerName: c.generalManagerName ?? attrs.generalManagerName, - generalManagerEmail: c.generalManagerEmail ?? attrs.generalManagerEmail, - generalManagerPhone: c.generalManagerPhone ?? attrs.generalManagerPhone, + ownerName: c.ownerName ?? attrs.ownerName, + ownerEmail: c.ownerEmail ?? attrs.ownerEmail, + ownerPhone: c.ownerPhone ?? attrs.ownerPhone, }; const v = key in map ? map[key] : (c[key] ?? attrs[key]); return v === null || v === undefined || v === "" ? "—" : String(v); @@ -219,7 +226,6 @@ export function ChangeRequestReview({ company }: { company: Company }) { api.customers.requestChangeRequestChanges.mutationOptions(), ); - const { view, viewer } = useFileViewer(); const [actionTarget, setActionTarget] = useState<{ id: string; kind: "reject" | "request-changes"; @@ -342,10 +348,10 @@ export function ChangeRequestReview({ company }: { company: Company }) { type="button" size="sm" onClick={() => - void fetchViewableFile( + openFileInNewTab( c.fileId, c.fileName ?? humanize(c.code), - ).then(view) + ) } style={{ textDecoration: @@ -374,12 +380,7 @@ export function ChangeRequestReview({ company }: { company: Company }) { component="button" type="button" size="sm" - onClick={() => - void fetchViewableFile( - fileId, - `Document ${i + 1}`, - ).then(view) - } + onClick={() => openFileInNewTab(fileId, `Document ${i + 1}`)} > Document {i + 1} @@ -413,10 +414,10 @@ export function ChangeRequestReview({ company }: { company: Company }) { type="button" size="sm" onClick={() => - void fetchViewableFile( + openFileInNewTab( c.fileId, c.fileName ?? "License document", - ).then(view) + ) } style={{ textDecoration: @@ -524,8 +525,6 @@ export function ChangeRequestReview({ company }: { company: Company }) {
- - {viewer} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx index cd421d9bd..8469e4031 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/CompanyTimeline.tsx @@ -1,9 +1,8 @@ import { Alert, Anchor, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { FilePlus2, FileX2, History } from "lucide-react"; -import { useFileViewer } from "@edr/ui-common"; -import { fetchViewableFile } from "@/services/files.service"; +import { openFileInNewTab } from "@/services/files.service"; import { api } from "@/services/api"; import type { Company, @@ -36,6 +35,12 @@ interface TimelineEntry { at: string; note?: string | null; summary?: string; + /** Who filed the change (the customer, or staff editing during onboarding). */ + requestedBy?: string | null; + /** When they filed it — the "asked" half of the ask/decide pair below. */ + requestedAt?: string | null; + /** Who decided (approved / rejected / sent it back to marketing). */ + decidedBy?: string | null; fieldDiffs: FieldDiff[]; docDiffs: DocDiff[]; } @@ -43,10 +48,18 @@ interface TimelineEntry { const KIND_BADGE: Record = { approved: { label: "Approved", color: "edr-green" }, rejected: { label: "Rejected", color: "red" }, - changes_requested: { label: "Changes requested", color: "yellow" }, + // Sending a request back is what "reverted to marketing" means here: the + // request stays open and marketing owns the follow-up with the customer. + changes_requested: { label: "Sent back to marketing", color: "yellow" }, revision: { label: "Recorded", color: "blue" }, }; +/** "Requested by X" / "Reviewed by X", with the id-less case reading sanely. */ +function actorLine(verb: string, who?: string | null, when?: string | null) { + if (!who && !when) return null; + return `${verb}${who ? ` by ${who}` : ""}${when ? ` · ${formatDate(when)}` : ""}`; +} + /** * Pair adjacent remove-then-add intents into one before/after doc diff — a * "replace" is always staged as `[{op:'remove'}, {op:'add'}]` pushed together @@ -132,6 +145,9 @@ function fromChangeRequest( kind: r.status as TimelineEntry["kind"], at: r.reviewedAt ?? r.updatedAt, note: r.note, + requestedBy: r.submittedByName, + requestedAt: r.submittedAt ?? r.createdAt, + decidedBy: r.reviewedByName, fieldDiffs, docDiffs, }; @@ -156,6 +172,7 @@ function fromRevision(rev: CompanyRevision): TimelineEntry { kind: "revision", at: rev.createdAt, summary: rev.summary, + requestedBy: rev.actorName, fieldDiffs, docDiffs, }; @@ -170,7 +187,6 @@ function fromRevision(rev: CompanyRevision): TimelineEntry { * single answer instead of two places to check. */ export function CompanyTimeline({ company }: { company: Company }) { - const { view, viewer } = useFileViewer(); const changeRequestsQuery = useQuery( api.customers.changeRequests.queryOptions({ input: { id: company.id } }), ); @@ -186,7 +202,7 @@ export function CompanyTimeline({ company }: { company: Company }) { ].sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()); const openFile = (file: { id: string; name: string }) => - void fetchViewableFile(file.id, file.name).then(view); + openFileInNewTab(file.id, file.name); if (entries.length === 0) { return ( @@ -205,6 +221,20 @@ export function CompanyTimeline({ company }: { company: Company }) { {entries.map((entry) => { const badge = KIND_BADGE[entry.kind]; + const requestedLine = actorLine( + entry.kind === "revision" ? "Edited" : "Requested", + entry.requestedBy, + entry.requestedAt, + ); + const decidedLine = actorLine( + entry.kind === "changes_requested" + ? "Sent back to marketing" + : entry.kind === "rejected" + ? "Rejected" + : "Approved", + entry.decidedBy, + entry.kind === "revision" ? null : entry.at, + ); return ( @@ -224,10 +254,33 @@ export function CompanyTimeline({ company }: { company: Company }) { + {/* Who asked, and who decided. Without this the feed said what + changed and when, but never named a person — the first thing + anyone auditing a returned request needs. */} + {(requestedLine || decidedLine) && ( + + {requestedLine && ( + + {requestedLine} + + )} + {decidedLine && ( + + {decidedLine} + + )} + + )} + {entry.note && ( - Note: {entry.note} + + {entry.kind === "changes_requested" + ? "What was asked for:" + : "Note:"} + {" "} + {entry.note} )} @@ -293,7 +346,6 @@ export function CompanyTimeline({ company }: { company: Company }) { ); })} - {viewer} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/PersonCard.tsx b/apps/edr-freight-web/backoffice/src/components/customers/PersonCard.tsx new file mode 100644 index 000000000..b4a201fd1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/PersonCard.tsx @@ -0,0 +1,112 @@ +import { Badge, Card, Divider, Group, Stack, Text } from "@mantine/core"; +import type { ReactNode } from "react"; + +export interface PersonField { + label: string; + value?: string | null; +} + +export interface PersonCardProps { + /** OWNER / POA / CONTACT PERSON — the person's role, not their name. */ + title: string; + icon?: ReactNode; + /** + * Identity state. `undefined` = this person has no identity check at all + * (contact person), so no badge is rendered rather than a misleading "not + * verified" one. + */ + verified?: boolean; + /** Extra pills after the verification badge (e.g. "Verifies for this company"). */ + badges?: ReactNode; + /** Rendered between the header and the fields — alerts, match warnings. */ + notice?: ReactNode; + fields: PersonField[]; + /** Shown when the API returned nothing for every field. */ + emptyMessage: string; + /** Attachments or anything else that belongs to this person. */ + children?: ReactNode; +} + +/** + * One person in the customer's people column: owner, power of attorney, contact + * person. Empty fields are dropped rather than rendered as "—", so a field the + * API stops sending simply disappears instead of leaving a dead row behind. + */ +export function PersonCard({ + title, + icon, + verified, + badges, + notice, + fields, + emptyMessage, + children, +}: PersonCardProps) { + const filled = fields.filter( + (f) => f.value != null && String(f.value).trim(), + ); + + return ( + + + + {icon} + + {title} + + {verified !== undefined && + (verified ? ( + + Fayda verified + + ) : ( + + Not verified + + ))} + {badges} + + + {notice} + + {filled.length > 0 ? ( + + {filled.map((f) => ( + + + {f.label} + + + {f.value} + + + ))} + + ) : ( + + {emptyMessage} + + )} + + {children && ( + <> + + {children} + + )} + + + ); +} + +export default PersonCard; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx deleted file mode 100644 index f0e9b266a..000000000 --- a/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { Alert, Button, Loader, Modal, Radio, Stack, Text } from "@mantine/core"; -import { useMutation, useQuery } 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 single-use link to the customer's - * primary contact; the customer opens it and 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 allowed = hasPermission(user, FREIGHT_PERMS.customers.resetPassword); - - // The destination is the primary contact's IAM account, not the company - // record — those are different fields and routinely hold different values, so - // showing `company.phone` here would tell staff the wrong number. Only fetched - // once the modal is open. - const targetQuery = useQuery( - api.customers.resetTarget.queryOptions({ - input: { companyId: company.id }, - enabled: allowed && opened, - }), - ); - const target = targetQuery.data; - - const { mutate, isPending } = useMutation( - api.customers.resetPassword.mutationOptions({ - onSuccess: (result) => { - setOpened(false); - toast({ - title: "Reset link sent", - description: `The customer can set a new password using the link sent to ${result.maskedTarget}. It expires in 24 hours.`, - }); - }, - onError: (error) => { - toast({ - title: "Could not send reset link", - description: error.message, - variant: "destructive", - }); - }, - }), - ); - - if (!allowed) return null; - - // SMS is domestic-only: a foreign number counts as unavailable, same as a - // missing one, so staff can't send a link that will never arrive. - const phoneUsable = !!target?.phone && target.phoneIsDomestic !== false; - const channelMissing = - !!target && (channel === "email" ? !target.email : !phoneUsable); - - return ( - <> - - - setOpened(false)} - title="Send a password-reset link" - centered - > - - - We'll send a single-use link to this customer's primary - contact. They choose their own new password — you will not see it. - The link expires in 24 hours. - - - {targetQuery.isLoading ? ( - - - - ) : targetQuery.isError ? ( - - {targetQuery.error.message} - - ) : target ? ( - <> - setChannel(v as ResetChannel)} - label={`Send the link to ${target.name || "the primary contact"} via`} - > - - - - - - - - These are the primary contact's own login details, which may - differ from the company contact details on the profile. - - - - - ) : null} - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index edcc4a517..3127a39f8 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -114,10 +114,41 @@ export function CompanyNationalityBadge({ ); } +/** + * The company's registration was typed, not fetched from eTrade — nothing in it + * has been checked against a licence. Loud on purpose: it is the one thing a + * reviewer must not miss about this customer. Two kinds of company land here + * for different reasons, and the badge names which. + */ +export function ManualRegistrationBadge({ + cooperative, + investorLicence, +}: { + cooperative?: boolean | null; + investorLicence?: boolean | null; +}) { + if (!cooperative && !investorLicence) return null; + return ( + + {cooperative + ? "Manual entry · co-operative" + : "Manual entry · investment licence"} + + ); +} + /** * Profile chips for a company row: one chip per role (Importer / Exporter / …) - * carrying its reference code. Caps at three (a company has at most three - * profiles); any extra collapse into a `+N` chip. + * carrying its reference code, colored by the profile's status (green active, + * amber pending, red rejected/blacklisted). Caps at three (a company has at + * most three profiles); any extra collapse into a `+N` chip. */ export function ProfileChips({ profiles, @@ -152,14 +183,15 @@ export function ProfileChips({ withArrow > - {humanize(profile.type)} · {profile.reference} + {humanize(profile.type)} + {profile.reference ? ` · ${profile.reference}` : ""} ))} @@ -306,9 +338,11 @@ export function InvoiceStatusBadge({ /** * Inline approval action buttons for a profile row. - * Transitions: pending → approve / reject-with-note | rejected → approve (override) | + * Transitions: pending → approve / reject-with-note | rejected → undo-rejection (→ pending) | * active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate. - * Rejecting captures a note the customer sees so they can fix and reapply. + * Rejecting captures a note the customer sees so they can fix and reapply — a + * rejected role is theirs to resubmit, so it cannot be approved from here until + * they do (the API refuses it); undoing the rejection is the only way back. * * `locked` (customer hasn't submitted onboarding) withholds the review decision * only — there's no application to judge yet, and the API rejects the call @@ -494,18 +528,33 @@ export function ProfileApprovalActions({ } if (status === "rejected") { - if (!canSet("active")) return null; + // No Approve here: the role is waiting on the customer to fix what was + // flagged and resubmit it, and the API refuses rejected → active outright. + // All that's left is undoing a rejection that shouldn't have happened, + // which puts the role back in the queue rather than into service. + if (!canSet("pending")) return null; return ( - + + + Awaiting customer resubmission + + + + + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/customers/format.ts b/apps/edr-freight-web/backoffice/src/components/customers/format.ts index 0397c1cee..341170931 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/format.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/format.ts @@ -1,38 +1,2 @@ -/** Shared formatting helpers for the customer-management pages. */ - -/** snake_case / SCREAMING_CASE → Title Case. */ -export function humanize(value: string): string { - return value - .toLowerCase() - .split(/[_\s]+/) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -export function formatDate(value: string | null | undefined): string { - if (!value) return "—"; - const d = new Date(value); - return Number.isNaN(d.getTime()) - ? "—" - : d.toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - }); -} - -export function formatMoney(amount: number, currency: string): string { - return new Intl.NumberFormat(undefined, { - style: "currency", - currency, - maximumFractionDigits: 0, - }).format(amount); -} - -export function formatBytes(bytes: number): string { - if (!bytes) return "0 B"; - const units = ["B", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - const value = bytes / Math.pow(1024, i); - return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; -} +/** @deprecated import from "@/lib/format" (or ../../lib/format) instead. */ +export { humanize, formatDate, formatDateTime, formatMoney, formatBytes } from "../../lib/format"; 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 81f25fcb2..864a89ae6 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -4,6 +4,7 @@ export { CompanyStatusBadge, CompanyTypeBadge, InvoiceStatusBadge, + ManualRegistrationBadge, PaymentStatusBadge, ProfileApprovalActions, ProfileChips, @@ -19,9 +20,10 @@ export { RequestDocumentChangeModal, type RequestDocumentChangeModalProps, } from "./RequestDocumentChangeModal"; -export { - default as ResetPasswordAction, - type ResetPasswordActionProps, -} from "./ResetPasswordAction"; export { formatBytes, formatDate, formatMoney, humanize } from "./format"; +export { + PersonCard, + type PersonCardProps, + type PersonField, +} from "./PersonCard"; export { TableCard, type TableCardProps } from "./TableCard"; diff --git a/apps/edr-freight-web/backoffice/src/components/detail/EntityLink.tsx b/apps/edr-freight-web/backoffice/src/components/detail/EntityLink.tsx new file mode 100644 index 000000000..497c59d6c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/detail/EntityLink.tsx @@ -0,0 +1,63 @@ +import type { ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { ArrowUpRight } from "lucide-react"; +import { Anchor, Group, Text } from "@mantine/core"; +import { Link } from "react-router-dom"; + +export interface EntityLinkProps { + /** Route to the related record's detail page. Renders nothing if falsy — a + * link with no id would be a dead one (e.g. a government booking with no + * company). */ + to?: string | null; + label: ReactNode; + icon?: LucideIcon; + /** Monospace label — for references/codes (e.g. "CT-2024-0117"). */ + mono?: boolean; + size?: "xs" | "sm" | "md"; + fw?: number; + className?: string; +} + +/** + * Inline link to another record's detail page, with a small "go to" glyph so + * it reads as navigation rather than plain emphasis. `stopPropagation` matters + * wherever this sits inside a clickable table row (booking/invoice rows + * navigate on click) — without it a nested link races the row handler. + */ +export function EntityLink({ + to, + label, + icon: Icon, + mono, + size = "sm", + fw = 600, + className, +}: EntityLinkProps) { + if (!to) { + return ( + + {label} + + ); + } + + return ( + e.stopPropagation()} + underline="hover" + c="edr-green" + fw={fw} + fz={size} + ff={mono ? "monospace" : undefined} + className={className} + > + + {Icon ? : null} + {label} + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/detail/Field.tsx b/apps/edr-freight-web/backoffice/src/components/detail/Field.tsx new file mode 100644 index 000000000..7300005b9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/detail/Field.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { Group, Stack, Text } from "@mantine/core"; + +export interface FieldProps { + label: string; + value?: ReactNode; +} + +/** + * Stacked label-over-value pair — uppercase dimmed label, value below. Used in + * grids of facts (e.g. an invoice summary, a contract's key figures). + */ +export function Field({ label, value }: FieldProps) { + const isEmpty = value === undefined || value === null || value === ""; + return ( + + + {label} + + + {isEmpty ? "—" : value} + + + ); +} + +export interface FieldRowProps { + icon?: LucideIcon; + label: string; + value?: ReactNode; +} + +/** + * Left icon+label / right bold value row, divider-separated when stacked in a + * list. Used inside quick-info cards (see `LinkedEntityCard`). + */ +export function FieldRow({ icon: Icon, label, value }: FieldRowProps) { + const isEmpty = value === undefined || value === null || value === ""; + return ( + + + {Icon ? : null} + + {label} + + + + {isEmpty ? "—" : value} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/detail/LinkedEntityCard.tsx b/apps/edr-freight-web/backoffice/src/components/detail/LinkedEntityCard.tsx new file mode 100644 index 000000000..931932fb5 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/detail/LinkedEntityCard.tsx @@ -0,0 +1,67 @@ +import type { ReactNode } from "react"; +import type { LucideIcon } from "lucide-react"; +import { Divider, Stack, Text } from "@mantine/core"; + +import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { FieldRow, type FieldRowProps } from "./Field"; +import { EntityLink } from "./EntityLink"; + +export interface LinkedEntityCardProps { + icon: LucideIcon; + /** Card title, e.g. "Customer" or "Contract". */ + title: string; + /** The entity's own name/reference, rendered as the linked subtitle. */ + name: ReactNode; + /** Route to the entity's detail page. Omit when there's nothing to link to + * (e.g. a government booking with no company) — the name renders as plain + * dimmed text instead of a dead link. */ + to?: string | null; + accent?: string; + /** Quick-info rows shown below the linked name — empty ones are dropped. */ + rows?: FieldRowProps[]; + /** Extra content under the rows (e.g. a summary paragraph, an action). */ + footer?: ReactNode; + /** Shown instead of rows/footer when there's nothing to display at all. */ + emptyMessage?: string; +} + +/** + * "Customer at a glance" / "Contract at a glance" card for a detail page's + * sticky rail: a linked title plus a handful of quick-info rows, so the + * related record's essentials are visible without navigating away. + */ +export function LinkedEntityCard({ + icon, + title, + name, + to, + accent = "blue", + rows = [], + footer, + emptyMessage, +}: LinkedEntityCardProps) { + const visibleRows = rows.filter((r) => r.value !== undefined && r.value !== null && r.value !== ""); + + return ( + + + + {visibleRows.length > 0 ? ( + + {visibleRows.map((row, index) => ( +
+ {index > 0 && } + +
+ ))} +
+ ) : emptyMessage ? ( + + {emptyMessage} + + ) : null} + {footer} +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/detail/index.ts new file mode 100644 index 000000000..15e379099 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/detail/index.ts @@ -0,0 +1,14 @@ +export { Field, FieldRow } from "./Field"; +export type { FieldProps, FieldRowProps } from "./Field"; +export { EntityLink } from "./EntityLink"; +export type { EntityLinkProps } from "./EntityLink"; +export { LinkedEntityCard } from "./LinkedEntityCard"; +export type { LinkedEntityCardProps } from "./LinkedEntityCard"; + +// Re-exported so pages under this restructure have one import path for both +// the new quick-info primitives and the existing section-card shell. Imported +// from the file directly (not the bookings/detail barrel) — that barrel also +// re-exports cards that import from this module, and going through it would +// create a circular import. +export { SectionCard } from "@/components/bookings/detail/SectionCard"; +export type { SectionCardProps } from "@/components/bookings/detail/SectionCard"; diff --git a/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx b/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx new file mode 100644 index 000000000..e42b70252 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/FilterBar.tsx @@ -0,0 +1,160 @@ +import { useState, type ReactNode } from "react"; +import { Anchor, Divider, Group, TextInput } from "@mantine/core"; +import { Search, Trash2 } from "lucide-react"; + +import type { FilterDef, SortOption } from "./types"; +import type { UseFilters } from "./useFilters"; +import { FilterPill } from "./FilterPill"; +import { MoreFiltersMenu } from "./MoreFiltersMenu"; +import { SaveViewButton } from "./SaveViewButton"; +import { SavedViewCards } from "./SavedViewCards"; +import { SortControl } from "./SortControl"; +import { useSavedViews } from "./useSavedViews"; + +export interface FilterBarProps { + defs: FilterDef[]; + controls: UseFilters; + searchPlaceholder?: string; + showSearch?: boolean; + /** value already "field:DIR" — the page's existing SORT_OPTIONS, moved not rewritten. */ + sortOptions?: SortOption[]; + /** localStorage namespace for saved views. Omit to hide the control. */ + viewId?: string; + /** Escape hatch: tabs, row count, a "New" button — rendered at the far right. */ + children?: ReactNode; +} + +export function FilterBar({ + defs, + controls, + searchPlaceholder = "Search…", + showSearch = true, + sortOptions, + viewId, + children, +}: FilterBarProps) { + // Filters just picked from "More filters" render as an already-open pill + // until the popover closes, then fall back to the ordinary pinned/active split. + const [justPicked, setJustPicked] = useState([]); + + const pinned = defs.filter((d) => !d.secondary || controls.values[d.key] || justPicked.includes(d.key)); + const secondary = defs.filter((d) => !pinned.includes(d)); + // Applied filters read first, left to right — a stable partition keeps + // each group in its original def order rather than resorting on every apply. + const orderedPinned = [ + ...pinned.filter((d) => controls.values[d.key]), + ...pinned.filter((d) => !controls.values[d.key]), + ]; + + // Unconditional call (rules of hooks) — viewId is a per-page constant, and + // the hook is a no-op storage key when saved views aren't wired up. + const savedViews = useSavedViews(viewId ?? "__unset__"); + const activeQuery = controls.currentQueryString(); + const hasMatchingView = savedViews.views.some((v) => v.query === activeQuery); + const canSaveView = Boolean(viewId) && activeQuery.length > 0 && !hasMatchingView; + + return ( +
+ {viewId && ( + + )} + + {/* + Two independent zones on wide screens — left (search + pills + more + filters + clear) wraps to as many lines as it needs, right (sort + + save) stays pinned on the first line via `sm:flex-nowrap` + + `sm:shrink-0`. `nowrap` unconditionally (the old inline style) forced + that same two-column layout on a phone too: neither zone had room and + both got squeezed/clipped. Below the `sm` breakpoint this stacks to a + single column instead — full-width left row, full-width right row. + */} +
+ + {showSearch && ( + } + value={controls.searchText} + onChange={(e) => controls.setSearchText(e.currentTarget.value)} + size="xs" + radius="lg" + // Regular weight (not the Button-driven 600 the rest of the bar + // uses) and a solid, fully-opaque border/text — same "opaque, not + // faint" fix the inactive pill trigger got. + styles={{ + input: { + fontWeight: 400, + borderColor: "var(--mantine-color-gray-6)", + color: "var(--mantine-color-gray-9)", + }, + }} + style={{ minWidth: 160, flex: "1 1 160px" }} + /> + )} + + {orderedPinned.map((def) => ( + controls.setFilter(def.key, v)} + autoOpen={justPicked.includes(def.key)} + /> + ))} + + setJustPicked((prev) => [...prev, key])} + /> + + {controls.activeCount > 0 && ( + { + controls.clearFilters(); + setJustPicked([]); + }} + style={{ display: "inline-flex", alignItems: "center", gap: 4 }} + > + + Clear + + )} + + + {/* Sorting is a different kind of control (view order, not scope) — + cut off from the filter pills by a vertical divider and pinned to + the right, independent of how the left side wraps. */} + {/* + Plain div, not : Group's `wrap` prop sets an inline + flex-wrap style, which always beats a Tailwind class regardless of + breakpoint — `sm:flex-nowrap` would never win against `wrap="wrap"`. + Wrap on mobile (own row, room is tight), pinned nowrap from `sm` up. + */} +
+ {children} + {sortOptions && sortOptions.length > 0 && ( + <> + + + + )} + {canSaveView && ( + <> + + + + )} +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx b/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx new file mode 100644 index 000000000..af97d2fcf --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/FilterPill.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { ActionIcon, Button, Popover } from "@mantine/core"; +import { ChevronDown, X } from "lucide-react"; + +import type { FilterDef, FilterValue } from "./types"; +import { formatFilterValue } from "./format"; +import { BooleanBody } from "./bodies/BooleanBody"; +import { DateBody } from "./bodies/DateBody"; +import { EnumBody } from "./bodies/EnumBody"; +import { NumberBody } from "./bodies/NumberBody"; +import { RouteBody } from "./bodies/RouteBody"; +import { TextBody } from "./bodies/TextBody"; + +const BODIES: Record> = { + text: TextBody, + enum: EnumBody, + date: DateBody, + number: NumberBody, + boolean: BooleanBody, + route: RouteBody, +}; + +// Most bodies fit a narrow popover; a date range needs room for the presets +// sidebar next to the calendar, so it gets a wider minimum. +const DROPDOWN_WIDTH: Partial> = { date: 340 }; + +export interface FilterPillProps { + def: FilterDef; + value: FilterValue | undefined; + onChange: (v: FilterValue | undefined) => void; + /** Opened immediately (used when picked from "More filters"). */ + autoOpen?: boolean; +} + +export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps) { + const [opened, setOpened] = useState(Boolean(autoOpen)); + const Body = BODIES[def.type]; + const active = Boolean(value); + + return ( + + + + + + setOpened(false)} /> + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/MoreFiltersMenu.tsx b/apps/edr-freight-web/backoffice/src/components/filters/MoreFiltersMenu.tsx new file mode 100644 index 000000000..c557bbbc3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/MoreFiltersMenu.tsx @@ -0,0 +1,83 @@ +import { useMemo, useState } from "react"; +import { Button, Popover, ScrollArea, Stack, Text, TextInput, UnstyledButton } from "@mantine/core"; +import { Plus, Search } from "lucide-react"; + +import type { FilterDef } from "./types"; + +export interface MoreFiltersMenuProps { + defs: FilterDef[]; + /** Called with the picked def's key — the caller pins it and opens its popover. */ + onPick: (key: string) => void; +} + +/** Searchable list over the page's secondary/inactive filters. Plain filter + list, + * not cmdk — a handful of static strings doesn't need a Combobox store. */ +export function MoreFiltersMenu({ defs, onPick }: MoreFiltersMenuProps) { + const [opened, setOpened] = useState(false); + const [query, setQuery] = useState(""); + + const visible = useMemo( + () => defs.filter((d) => d.label.toLowerCase().includes(query.toLowerCase())), + [defs, query], + ); + + if (defs.length === 0) return null; + + return ( + + + + + + + } + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + size="sm" + autoFocus + /> + + + {visible.map((d) => ( + { + setOpened(false); + setQuery(""); + onPick(d.key); + }} + > + + + {d.label} + + + ))} + {visible.length === 0 && ( + + No matching filters + + )} + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/OperatorSelect.tsx b/apps/edr-freight-web/backoffice/src/components/filters/OperatorSelect.tsx new file mode 100644 index 000000000..a769ebf93 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/OperatorSelect.tsx @@ -0,0 +1,25 @@ +import { SegmentedControl } from "@mantine/core"; +import { DEFAULT_OP, OPERATOR_LABELS, type FilterDef, type Operator } from "./types"; + +export interface OperatorSelectProps { + def: FilterDef; + value: Operator; + onChange: (op: Operator) => void; +} + +/** Renders nothing when a def has <= 1 operator — most defs, by design: type-aware + * operators are a capability, not a dropdown forced into every popover. */ +export function OperatorSelect({ def, value, onChange }: OperatorSelectProps) { + const operators = def.operators ?? [DEFAULT_OP[def.type]]; + if (operators.length <= 1) return null; + return ( + onChange(v as Operator)} + data={operators.map((op) => ({ value: op, label: OPERATOR_LABELS[op] }))} + mb="xs" + /> + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/SaveViewButton.tsx b/apps/edr-freight-web/backoffice/src/components/filters/SaveViewButton.tsx new file mode 100644 index 000000000..54db1da70 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/SaveViewButton.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import { Button } from "@mantine/core"; +import { Check, Save } from "lucide-react"; + +import { useToast } from "@/hooks/use-toast"; +import type { FilterDef } from "./types"; +import { describeQuery } from "./format"; +import type { SavedView } from "./useSavedViews"; + +export interface SaveViewButtonProps { + defs: FilterDef[]; + query: string; + onSave: (query: string) => SavedView; +} + +/** Filled, not outline — this is the one action-y button in the bar (every + * other control here is a filter), so it needs to actually look like a + * button. One click, no name prompt: the card grid's label is generated + * from the active filters (see `describeQuery`). */ +export function SaveViewButton({ defs, query, onSave }: SaveViewButtonProps) { + const { toast } = useToast(); + const [justSaved, setJustSaved] = useState(false); + + const handleSave = () => { + onSave(query); + toast({ title: "View saved", description: describeQuery(defs, query), duration: 4000 }); + // The toast is in the corner; this flash is right where the eye already + // is — the actual confirmation that "the saving" registered. + setJustSaved(true); + setTimeout(() => setJustSaved(false), 1500); + }; + + return ( + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/SavedViewCards.tsx b/apps/edr-freight-web/backoffice/src/components/filters/SavedViewCards.tsx new file mode 100644 index 000000000..9cf8c40be --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/SavedViewCards.tsx @@ -0,0 +1,70 @@ +import { ActionIcon, Card, SimpleGrid, Text } from "@mantine/core"; +import { Trash2 } from "lucide-react"; + +import { useToast } from "@/hooks/use-toast"; +import type { FilterDef } from "./types"; +import { describeQuery } from "./format"; +import type { SavedView } from "./useSavedViews"; + +export interface SavedViewCardsProps { + defs: FilterDef[]; + views: SavedView[]; + activeQuery: string; + applyQueryString: (query: string) => void; + onRemove: (id: string) => void; +} + +/** Saved views up front as a grid of cards — not one more item buried in a + * dropdown nobody opens. Renders nothing until there's at least one saved. */ +export function SavedViewCards({ defs, views, activeQuery, applyQueryString, onRemove }: SavedViewCardsProps) { + const { toast } = useToast(); + if (views.length === 0) return null; + + return ( + // base: 1 — a phone-width viewport forcing 2 columns is what clipped + // card text and overflowed the row; one full-width card per row until + // there's actually room for more. + + {views.map((v) => { + const active = v.query === activeQuery; + const label = describeQuery(defs, v.query); + return ( + { + applyQueryString(v.query); + toast({ title: `Switched to "${label}"` }); + }} + style={{ + cursor: "pointer", + borderColor: active ? "var(--mantine-color-edr-green-6)" : undefined, + borderWidth: active ? 2 : 1, + backgroundColor: active ? "var(--mantine-color-edr-green-0)" : undefined, + }} + > +
+ + {label} + + { + e.stopPropagation(); + onRemove(v.id); + toast({ title: "View deleted", description: label, variant: "destructive" }); + }} + > + + +
+
+ ); + })} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/SortControl.tsx b/apps/edr-freight-web/backoffice/src/components/filters/SortControl.tsx new file mode 100644 index 000000000..cdb9dde33 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/SortControl.tsx @@ -0,0 +1,40 @@ +import { Button, Menu } from "@mantine/core"; +import { ArrowUpDown, Check } from "lucide-react"; + +import type { SortOption } from "./types"; + +export interface SortControlProps { + options: SortOption[]; + value: string; + onChange: (value: string) => void; +} + +/** A control, not a form field — Menu (not Select) gives the check-mark + + * trigger-label read Stripe's sort control has. Rendered only when a page + * passes sortOptions; inventing options for an endpoint without sortBy + * support would ship a control that silently does nothing. */ +export function SortControl({ options, value, onChange }: SortControlProps) { + if (options.length === 0) return null; + const current = options.find((o) => o.value === value); + + return ( + + + + + + {options.map((o) => ( + : } + onClick={() => onChange(o.value)} + > + {o.label} + + ))} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/bodies/BooleanBody.tsx b/apps/edr-freight-web/backoffice/src/components/filters/bodies/BooleanBody.tsx new file mode 100644 index 000000000..cd9f8ed11 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/bodies/BooleanBody.tsx @@ -0,0 +1,32 @@ +import { useState } from "react"; +import { Radio, Stack } from "@mantine/core"; + +import { DEFAULT_OP } from "../types"; +import type { BooleanFilterDef, Operator } from "../types"; +import { OperatorSelect } from "../OperatorSelect"; +import type { FilterBodyProps } from "./TextBody"; + +export function BooleanBody({ def, value, onChange, onClose }: FilterBodyProps) { + const [op, setOp] = useState(value?.op ?? DEFAULT_OP.boolean); + const [v, setV] = useState(value?.v[0] ?? ""); + + // Two mutually-exclusive options — apply the moment one is picked, same as + // EnumBody's single-select radio. No Apply button needed. + const pick = (next: string) => { + setV(next); + onChange({ op, v: [next] }); + onClose(); + }; + + return ( + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/bodies/DateBody.tsx b/apps/edr-freight-web/backoffice/src/components/filters/bodies/DateBody.tsx new file mode 100644 index 000000000..32970abef --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/bodies/DateBody.tsx @@ -0,0 +1,96 @@ +import { useState } from "react"; +import { Button, Stack } from "@mantine/core"; +import { DatePickerInput } from "@mantine/dates"; +import { CalendarDays } from "lucide-react"; + +import { getDateRangePresets } from "@/components/common/dateRangePresets"; +import { startOfDayIso, endOfDayIso, parseDateStr } from "../dates"; +import { DEFAULT_OP } from "../types"; +import type { DateFilterDef, Operator } from "../types"; +import { OperatorSelect } from "../OperatorSelect"; +import type { FilterBodyProps } from "./TextBody"; + +// ponytail: Gregorian only. Record-management pages need the Ethiopian +// calendar (see shared/common/form/fields/AmharicDatePicker.tsx) — add an +// i18n.language !== "en" branch here when this body is first wired into a +// record-management page (Phase 4 of the filter-bar rollout). +export function DateBody({ def, value, onChange, onClose }: FilterBodyProps) { + // DEFAULT_OP.date is always "between" — a def restricted to a single + // non-default operator (e.g. `operators: ["before"]` for an exact-date + // filter) would otherwise open on the range UI with no way to switch off + // it, since OperatorSelect hides itself when there's only one choice. + const [op, setOp] = useState(value?.op ?? def.operators?.[0] ?? DEFAULT_OP.date); + // Mantine 9's date inputs speak `YYYY-MM-DD` strings, not Date objects. + const [from, setFrom] = useState(value?.v[0]?.slice(0, 10) ?? null); + const [to, setTo] = useState(value?.v[1]?.slice(0, 10) ?? null); + + const apply = () => { + if (op === "between") { + onChange( + from && to + ? { op, v: [startOfDayIso(parseDateStr(from)), endOfDayIso(parseDateStr(to))] } + : undefined, + ); + } else { + onChange( + from + ? { + op, + v: [ + op === "before" + ? startOfDayIso(parseDateStr(from)) + : endOfDayIso(parseDateStr(from)), + ], + } + : undefined, + ); + } + onClose(); + }; + + // This popover already lives inside FilterPill's own Popover. Mantine's + // DatePickerInput opens ITS calendar in a separate portal by default, so a + // click on a day registers as "outside" the outer Popover and closes the + // whole filter before the range can be picked (or Apply reached) — the + // reported "date picker doesn't work". Keeping the calendar un-portalled + // renders it inside the outer popover's own DOM subtree instead, so + // outside-click detection sees it as inside. + const nestedPopoverProps = { withinPortal: false } as const; + + return ( + + + {op === "between" ? ( + } + placeholder="Any" + value={[from, to]} + onChange={([f, t]) => { + setFrom(f); + setTo(t); + }} + presets={getDateRangePresets()} + popoverProps={nestedPopoverProps} + clearable + autoFocus + /> + ) : ( + } + placeholder="Any" + value={from} + onChange={setFrom} + popoverProps={nestedPopoverProps} + clearable + autoFocus + /> + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/filters/bodies/EnumBody.tsx b/apps/edr-freight-web/backoffice/src/components/filters/bodies/EnumBody.tsx new file mode 100644 index 000000000..3bc352dcb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/filters/bodies/EnumBody.tsx @@ -0,0 +1,139 @@ +import { useMemo, useState } from "react"; +import { Button, Checkbox, Group, Radio, Stack, Text, TextInput, UnstyledButton } from "@mantine/core"; +import { Search } from "lucide-react"; + +import { DEFAULT_OP } from "../types"; +import type { EnumFilterDef, Operator } from "../types"; +import { OperatorSelect } from "../OperatorSelect"; +import type { FilterBodyProps } from "./TextBody"; + +/** How many options before a search box appears above the list. */ +const SEARCH_THRESHOLD = 8; + +/** + * Stretches the Checkbox/Radio's native