Merge pull request #607 from Tria-plc/dev

merge dev to main
This commit is contained in:
Sennay
2026-07-10 21:05:44 +03:00
committed by GitHub
1533 changed files with 269152 additions and 3778 deletions

View File

@@ -0,0 +1,42 @@
---
name: edr-db
description: Query, EXPLAIN-validate, and inspect the remote EDR freight dev database. Use whenever you need to check data, verify a raw SQL statement before shipping it, list a table's columns, check schema drift, or see which migrations are recorded. psql is NOT installed on this machine — this runner is the sanctioned path. Triggers - "check the db", "query edr_dev", "does column X exist", "validate this SQL", "is migration recorded", "seed check", diagnosing a 400/500 whose cause may be data or schema.
---
# EDR dev-DB runner
One script, runs from anywhere in the repo (resolves `pg` from `apps/edr-freight-api`):
```bash
node .claude/skills/edr-db/query.cjs "SELECT ... " # run SQL, console.table output
node .claude/skills/edr-db/query.cjs explain "SELECT ..." # EXPLAIN-validate only (no rows touched)
node .claude/skills/edr-db/query.cjs columns <table> # freight.<table> column list
node .claude/skills/edr-db/query.cjs migrations [like] # public.migrations rows (newest first)
node .claude/skills/edr-db/query.cjs drift <table> # bare column names, for diffing vs the entity
```
Connection comes from `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME`,
defaulting to the shared dev database (`edr_dev`).
## Rules that go with it
- **HARD RULE: every raw SQL statement you write into a service must pass
`explain` here before you ship it.** A typo'd column is a runtime 500 the
type-checker cannot catch.
- Never assume a recorded migration applied — check `migrations <name>` **and**
`columns <table>` together. Recorded-but-absent = schema drift; fix with a
NEW repair migration (idempotent DDL, no-op `down()`), never by editing the
recorded one.
- Writes to dev data are fine for seeding/diagnosis but keep them idempotent
(`WHERE NOT EXISTS` guards) — watch-mode API instances race `migrationsRun`,
and non-idempotent statements have double-run here before.
- Timestamps for new migrations: must be unique across `src/migrations/` AND
higher than `SELECT max(timestamp) FROM public.migrations`.
## Diagnosing a pasted 400/500 (the recurring loop)
1. Find the route: grep the path segment in `apps/edr-freight-api/src/modules/*/**.controller.ts`.
2. Read the service method — list its guard `throw`s. Most "bugs" are a guard
working as designed (handover unsigned, fee unpaid, not PAID, wrong direction).
3. Check the actual DB state for that record with this runner.
4. Only then decide: guard doing its job (fix the UI affordance) vs real defect.

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* Dev-DB runner for the EDR freight database. psql is NOT installed on this
* machine; this is the sanctioned way to query, EXPLAIN-validate, and inspect
* the remote dev DB. Resolves `pg` from apps/edr-freight-api so it runs from
* anywhere in the repo.
*
* node .claude/skills/edr-db/query.cjs "SELECT ... " run SQL (console.table)
* node .claude/skills/edr-db/query.cjs explain "SELECT..." EXPLAIN-validate only
* node .claude/skills/edr-db/query.cjs columns <table> list freight.<table> columns
* node .claude/skills/edr-db/query.cjs migrations [like] public.migrations rows
* node .claude/skills/edr-db/query.cjs drift <table> columns vs entity check helper
*
* Connection: DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME env vars, falling
* back to the shared dev database.
*/
const path = require('path');
const { createRequire } = require('module');
const repoRoot = path.resolve(__dirname, '..', '..', '..');
const apiRequire = createRequire(
path.join(repoRoot, 'apps', 'edr-freight-api', 'package.json'),
);
const { Client } = apiRequire('pg');
const cfg = {
host: process.env.DB_HOST ?? '10.18.7.207',
port: parseInt(process.env.DB_PORT ?? '5432', 10),
user: process.env.DB_USER ?? 'postgres',
password: process.env.DB_PASSWORD ?? 'dcba@1234',
database: process.env.DB_NAME ?? 'edr_dev',
};
const [, , first, ...rest] = process.argv;
async function main() {
if (!first) {
console.error('usage: query.cjs "<sql>" | explain "<sql>" | columns <table> | migrations [like] | drift <table>');
process.exit(2);
}
const c = new Client(cfg);
await c.connect();
try {
if (first === 'columns') {
const r = await c.query(
`SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema='freight' AND table_name=$1
ORDER BY ordinal_position`,
[rest[0]],
);
console.table(r.rows);
} else if (first === 'migrations') {
const like = rest[0] ? `%${rest[0]}%` : '%';
const r = await c.query(
`SELECT id, timestamp, name FROM public.migrations
WHERE name ILIKE $1 ORDER BY id DESC LIMIT 40`,
[like],
);
console.table(r.rows);
} else if (first === 'drift') {
// Quick drift signal: DB columns for the table. Compare by eye against
// the entity's @Column names; a recorded-but-absent column = drift.
const r = await c.query(
`SELECT column_name FROM information_schema.columns
WHERE table_schema='freight' AND table_name=$1 ORDER BY column_name`,
[rest[0]],
);
console.log(r.rows.map((x) => x.column_name).join('\n'));
} else if (first === 'explain') {
await c.query('EXPLAIN ' + rest.join(' '));
console.log('OK — statement is valid against', cfg.database);
} else {
const sql = [first, ...rest].join(' ');
const started = Date.now();
const r = await c.query(sql);
if (Array.isArray(r.rows) && r.rows.length) console.table(r.rows);
console.log(`${r.rowCount ?? 0} row(s), ${Date.now() - started}ms`);
}
} finally {
await c.end();
}
}
main().catch((e) => {
console.error('FAIL:', e.message);
process.exit(1);
});

View File

@@ -0,0 +1,40 @@
---
name: standup
description: Produce the work report Hagernesh asks for - "what have I done today", "tasks of yesterday and today", daily/period summaries for tickets or timesheets. Builds the answer from git history plus uncommitted work, never from memory alone.
---
# Work report (standup / ticket summary)
Ground every line in git. Do not reconstruct from conversation memory — commits
are the record.
## Gather
```bash
# Commits in the window (adjust dates; author matches "Hagernesh")
git log --since="YYYY-MM-DD 00:00" --until="YYYY-MM-DD 00:00" --author=Hagernesh \
--pretty=format:"%h|%ad|%s" --date=short
# What each commit actually contains (subjects lie sometimes)
git show --stat --pretty=format:"%s" <hash> | head -12
# In-flight work = part of "today" even if uncommitted
git status --short
git log origin/dev..HEAD --oneline # branch commits not yet in dev
```
## Known pitfalls in this repo
- **Check subjects against contents.** Commit titles here sometimes mismatch the
diff (e.g. a commit titled "unload export" that actually contained ISO
container validation). Use `git show --stat` before reporting a title as fact.
- A day with no commits usually still has uncommitted/in-flight work — report it
as its own section with per-item status (done / uncommitted / blocked).
- Merge commits from other authors are noise; filter with `--author`.
## Output format
One table per day: `# | Task (plain language, not the commit subject verbatim) |
Commit / Status`. Follow with a short "carry-over / blocked" list naming what
blocks each item. Keep it ticket-ready: no jargon that needs the repo open to
decode.

View File

@@ -0,0 +1,59 @@
---
name: verify
description: Project definition-of-done runner for the EDR platform. Use before calling any code change finished, before committing, and whenever asked "is it done / does it work". Runs the targeted checks that actually catch this repo's failure modes - type-check with turbo filters, @edr/types dist rebuild, raw-SQL EXPLAIN validation, migration safety, and honest test reporting.
---
# Verify a change (EDR definition of done)
Run these in order. Report which you ran and what each said — never call
unverified work done.
## 1. Type-check exactly what you touched
```bash
pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice --filter=@edr/freight-portal
```
Drop filters you didn't touch; whole-repo runs waste minutes. **If you edited
`packages/types`, rebuild it FIRST** — consumers read its `dist/`, not `src/`:
```bash
pnpm turbo build --filter=@edr/types
```
## 2. Validate every raw SQL statement
Each new/edited `dataSource.query` / `manager.query` string must pass:
```bash
node .claude/skills/edr-db/query.cjs explain "<the statement with dummy params>"
```
## 3. Migration checklist (if you added one)
- Timestamp unique in `src/migrations/` **and** greater than
`node .claude/skills/edr-db/query.cjs "SELECT max(timestamp) FROM public.migrations"`.
- DDL idempotent (`IF NOT EXISTS`, guarded backfills).
- Watch-mode reload does NOT run migrations — apply the SQL to the dev DB
yourself or fully restart the API, then confirm with
`query.cjs columns <table>`.
## 4. Tests — honest bar
`pnpm test` for `@edr/freight-api` is currently red on `dev`, so a green suite
is not the bar. The bar: run the specs nearest what you touched and introduce
**no new failure**. If you touched a service constructor, update its `.spec.ts`
mocks (constructor-arity breaks are this repo's most common test regression).
## 5. Observe the behaviour
Compiling is not working. Hit the endpoint, drive the UI flow, or query the
resulting rows. If you genuinely could not observe it, say so explicitly in the
summary — do not imply it was seen working.
## 6. Before commit
- Conventional message (`fix(warehouses): …`). Git hooks do NOT run in this
repo (husky shims exist but no user hooks) — nothing will catch it for you.
- Lint the files you touched if in doubt: `pnpm turbo lint --filter=<pkg>`.
- Do not commit or push unless the user asked.

View File

@@ -18,7 +18,7 @@ jobs:
matrix: ${{ steps.filter.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v4e
with:
fetch-depth: 2

1
.gitignore vendored
View File

@@ -28,3 +28,4 @@ coverage/
*~
\#*\#
.\#*
docker-compose.override.yml

294
CLAUDE_NEW.md Normal file
View File

@@ -0,0 +1,294 @@
# EDR Platform — Developer Guide
> This file is the contract. If something here contradicts the code, the code is the
> truth and this file is a bug — fix it in the same PR.
## Overview
Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight
Management and Passenger Management applications, a payment microservice, plus shared
types, NestJS utilities, and React component libraries.
The freight domain is the largest and most active area. Its core flow is:
**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload
→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.**
Fees (storage, demurrage, double handling, truck detention) and allocation rules
(warehouse/yard/zone) hang off the warehouse stage.
## Apps
| App | Package name | Purpose | Default port |
| ------------------------------ | --------------------------- | -------------------------------------------------- | ------------ |
| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 |
| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 |
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 |
| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 |
| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 |
| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 |
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 |
`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages.
Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace
packages (see `pnpm-workspace.yaml`).
`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace
package and is not built, linted, or type-checked. Leave it alone unless asked.
## Packages
| Package | Purpose |
| ---------------------- | ---------------------------------------------------------------------------------- |
| `@edr/types` | Shared TypeScript interfaces and enums |
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
| `@edr/ui-common` | Shared React components and theme |
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
| `@edr/tsconfig` | Shared TypeScript configurations |
| `@edr/prettier-config` | Shared Prettier configuration |
**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a
type in `packages/types/src` changes nothing for consumers until you rebuild:
```bash
pnpm turbo build --filter=@edr/types
```
If a type-check fails on a field you just added to `@edr/types`, this is why.
## Commands
| Command | Description |
| --------------------------- | ---------------------------------------- |
| `pnpm install` | Install all workspace dependencies |
| `pnpm dev` | Run every app in dev mode |
| `pnpm dev:freight` | Freight API + portal + backoffice |
| `pnpm dev:freight:api` | Freight API only |
| `pnpm dev:freight:portal` | Freight portal only |
| `pnpm dev:freight:backoffice` | Freight backoffice only |
| `pnpm dev:passenger` | Passenger API + web |
| `pnpm dev:payment` | Payment API |
| `pnpm build` | Build every package and app |
| `pnpm test` | Run all tests (turbo) |
| `pnpm lint` | Lint everything |
| `pnpm type-check` | Type-check every package |
| `pnpm format` | Format all files with Prettier |
Prefer targeted turbo filters over whole-repo runs — they are minutes faster:
```bash
pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice
```
`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains,
gate-pass scenarios). Read the script before running one; several write real rows.
## Environment & database
- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and
no port `5433`/`5434` is published anywhere in the repo.
- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`,
`DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a
remote database.
- The connection sits behind a **connection pooler**. Do **not** pass
`extra.options: '-c search_path=…'` — the pooler rejects it with
`08P01 unsupported startup parameter in options: search_path`. `search_path` is applied
per-connection in a pool `connect` handler instead. See
`apps/edr-freight-api/src/config/database.config.ts` before touching connection options.
- Each app owns its own database. **No cross-database joins**; cross-domain data flows
through API calls or message queues.
- `psql` is not installed on the dev machine. To query the database, write a short Node
script using the `pg` client and run it from `apps/edr-freight-api` (where `pg` resolves).
## Hard rules
These are non-negotiable. Everything else is a strong default.
- **pnpm only.** Never run `npm install` or `yarn`.
- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not
reach for `any` to make an error go away.
- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false`
in every config and it has already corrupted this database twice (see *Migrations*).
All schema changes go through TypeORM migrations.
- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
- **All entities** extend `BaseEntity` from `@edr/api-common``createdAt`, `updatedAt`,
`deletedAt` (soft delete).
- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`);
TypeScript properties are `camelCase`.
- **Controllers contain no business logic.** They validate, delegate, and shape the response.
- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`.
- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands.
- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and
offer the safe version.
## Architecture
### NestJS module shape
`module → controller → service → repository`, with `entities/` and `dto/` alongside.
### Data access — the real model
There are two sanctioned ways to read and write, and you must pick the right one:
1. **Entity CRUD → the custom repository class.** Extends `BaseRepository<Entity>` from
`@edr/api-common`. Services inject the repository class, never `Repository<T>` directly.
2. **Read projections, queue endpoints, cross-table reports → raw SQL** via
`this.dataSource.query(...)` or `manager.query(...)` inside a transaction.
Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it.
It carries one obligation:
> **HARD RULE — validate every raw SQL statement against a real database before you ship it.**
> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through
> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*).
Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository,
so they join the caller's transaction.
**Never do slow I/O inside a database transaction.** Queue the work and fan it out after
commit. An SMS awaited inside a transaction once held capacity locks open for the whole
gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to
no timeout and will wait forever.
### Migrations
Migrations are the most dangerous surface in this repo. Two production-grade incidents have
already come from it.
- `migrationsRun: true`**migrations run automatically on API boot**, with
`migrationsTransactionMode: 'each'`.
- Consequences you must design for:
- Running several `nest start --watch` instances races `migrationsRun`. A non-idempotent
data migration can execute twice. Keep one instance.
- A watch-mode hot reload does **not** re-run migrations. If you add a column that new
code reads, apply it to the dev database yourself (idempotently) or fully restart.
- **Give every migration a unique timestamp.** 34 timestamps are currently shared by two or
more migrations. TypeORM orders by timestamp and breaks ties non-deterministically. Before
adding one, check the filename prefix is unused *and* higher than the newest recorded row.
- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and
backfills guarded by `WHERE col IS NULL`.
- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory`
was recorded in `migrations` while its column was absent — it had been dropped out of band.
TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*.
- **A repair migration's `down()` should be a no-op.** Reverting a repair must not
re-introduce the outage it fixed.
### Auth
Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own.
- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub.
- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`.
- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS.<area>.<action>)`.
- 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/<feature>/` with `entities/`, `dto/`, and the four
`<feature>.{module,controller,service,repository}.ts` files.
2. The entity extends `BaseEntity` from `@edr/api-common`.
3. The repository extends `BaseRepository<Entity>` from `@edr/api-common`.
4. The service injects the repository class (not `Repository<T>` 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/<Name>/<Name>.tsx` and `src/components/<Name>/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=<each touched package>` 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.

View File

@@ -0,0 +1,74 @@
# Priority & Batch Window Flow (Import, Freight)
Export = no batch, no priority. Pure first-come-first-served (`booking-batch.service.ts:462-467, 625-628`). Everything below is import only.
## Step by step
**1. Booking submitted → priority score computed**
`booking-transition.service.ts:110-111,196-197``booking-pricing.service.ts:403-407` `computeSubmitPriorityScore()``rule-engine.service.ts:118`.
- Government booking: `+50,000` (`government-priority.constants.ts:2`, applied `rule-engine.service.ts:201`)
- Plus cargo/weight modifiers
- Stored on `booking.priorityScore`
**2. Window opens (PRE_WINDOW → OPEN)**
Cron tick every 10s: `booking-window.service.ts:63``advanceImport``booking-window.service.ts:232-251`.
Times computed by `computeImportWindowTimes` (`batch-window.util.ts:248-283`).
**3. Customers book during OPEN**
Booking lands as:
- Commercial → `FULLY_EXECUTED`
- Government → `APPROVED/PAID` (skips contract flow)
**4. Window closes (OPEN → DOC_REVIEW)**
`booking-window.service.ts:254-268`. Staff review docs for `docReviewMinutes`.
**5. Doc review ends**
Staff `completeDocReview()` (`booking-window.service.ts:124-159`) or timeout → `booking-window.service.ts:270-295`.
Before batch runs: `expireUnacceptedForRouteDay` (`booking-batch.service.ts:1853-1883`) kills never-accepted bookings so they can't compete.
**6. Batch fill runs**
`processRouteDay``fillRouteDay` (`booking-batch.service.ts:1138-1319`), or single-schedule `fillSchedule` (`:1018-1128`).
- Pool pulled pre-sorted: `findBatchPool`/`findBatchPoolByCorridorDay` (`bookings.repository.ts:991-1008, 1055-1083`)
`ORDER BY is_government DESC, priority_score DESC, fully_executed_at ASC, created_at ASC`
- Consolidated pairs grouped as one atomic unit: `groupConsolidatedPool` (`:1962-1987`) — never split.
- Greedy placement, earliest-departing fitting train first: loop at `:1218-1306`.
- No fit + government booking → `preemptForGovernment` (`:1891-1910`): bumps lowest-`priorityScore` commercial victim first, only if legs overlap (`:1920`).
- No fit + commercial import (GENERAL/ONE_TIME) → maybe partial "split" offer: `maybeOfferPartial`/`isSplitEligible` (`:1326-1370`).
- Still no fit → stays pooled, `notifier.unplaced` (`:1278-1280`).
**7. Placed bookings get reserved/allocated**
- Commercial: `reserve()` (`:1673-1703`) → `SELECTED_FOR_BATCH`, payment deadline set, DOC_REVIEW→PAYMENT (`booking-window.service.ts:275-294`).
- Government: `allocate()` directly (`:1706-1746`), no payment step.
**8. Payment phase ends**
`booking-window.service.ts:297-309``settleDueReservations``settleReserved` (`:1437-1491`):
- paid → allocated
- unpaid → expired, capacity freed
Then `concludeCycle` (`:315-373`):
- Train full → `DONE` + auto-finalize (`:320-329`)
- Not full → reopen same/next day (`nextCycleOpensAt` / office hours, `:331-372`, `batch-window.util.ts:217-224`) or `DONE` if no cycle fits before departure.
**9. Backstop**
`settleOverdueReservations` (`booking-window.service.ts:388-406`) catches any reservation whose deadline passed outside the normal tick.
## Phase enum
`PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → (reopen PRE_WINDOW | DONE)`
(`booking-window.config.ts:27-34`)
## What decides priority
1. `is_government` — always first, both in SQL sort and `compareSchedulingPriority` util (`compare-scheduling-priority.util.ts:9-23`)
2. `priority_score` DESC (rule engine: government bonus + cargo/weight modifiers)
3. `fully_executed_at` ASC (earlier wins)
4. `created_at` ASC
## Edge cases
- Government preemption only bumps if legs overlap; picks lowest-priority victim first.
- Consolidated pairs are both-or-neither, never split (`:1326-1334, 1793`).
- Only GENERAL/ONE_TIME import bookings are eligible for partial "split" offers.
- Per-unit try/catch around reserve — one failure can't cause silent trickle/stagger allocation (comment at `:1283-1288`).
- Each train freezes its own rule snapshot at window-open time, not live config (`booking-window.service.ts:85-93`).

View File

@@ -41,6 +41,7 @@ import { NotificationsModule } from "./modules/notifications/notifications.modul
import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
import { OtpModule } from "./modules/otp/otp.module";
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
@@ -158,6 +159,7 @@ import { LoggerMiddleware } from "./logger.middleware";
NotificationInboxModule,
FileUploadSettingsModule,
DropdownSettingsModule,
ContractTemplatesModule,
OtpModule,
RuleEngineModule,
BackofficeModule,

View File

@@ -0,0 +1,63 @@
import Handlebars from 'handlebars';
/** One numbered clause of a dynamic article, with optional nested bullets. */
export interface RenderedClause {
text: string;
bullets: string[];
}
/** A dynamic article ready for the Handlebars template. */
export interface RenderedArticle {
number: number;
title: string;
/** Set (instead of clauses) when the body is a single plain paragraph. */
paragraph?: string;
clauses: RenderedClause[];
}
/**
* Parse a template article body into clauses. Format: one clause per line;
* lines prefixed with "- " become bullets nested under the preceding clause.
* A body that reduces to a single clause without bullets renders as a plain
* paragraph rather than a numbered list of one.
*/
export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph' | 'clauses'> {
const lines = (body ?? '')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
const clauses: RenderedClause[] = [];
for (const line of lines) {
if (line.startsWith('- ')) {
const bullet = line.slice(2).trim();
if (clauses.length === 0) {
clauses.push({ text: bullet, bullets: [] });
} else {
clauses[clauses.length - 1].bullets.push(bullet);
}
} else {
clauses.push({ text: line, bullets: [] });
}
}
if (clauses.length === 1 && clauses[0].bullets.length === 0) {
return { paragraph: clauses[0].text, clauses: [] };
}
return { clauses };
}
/**
* Interpolate Handlebars placeholders ({{client.companyName}}, {{contractDate}},
* …) inside admin-authored template text against the contract view model.
* Malformed placeholders must never break document generation — fall back to
* the raw text.
*/
export function interpolateTemplateText(text: string, context: unknown): string {
if (!text || !text.includes('{{')) return text ?? '';
try {
return Handlebars.compile(text)(context);
} catch {
return text;
}
}

View File

@@ -8,6 +8,7 @@ import {
ContractSignerRole,
} from '../modules/contracts/entities/contract-signature.entity';
import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing.service';
import { ContractTemplatesService } from '../modules/contract-templates/contract-templates.service';
import { ContractTemplateResolver } from './contract-template.resolver';
import { getTemplateMeta } from './contract-template.registry';
import { ContractViewModel } from './contract-view-model.builder';
@@ -74,6 +75,7 @@ export class ContractDocumentViewModelBuilder {
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly templateResolver: ContractTemplateResolver,
private readonly contractTemplates: ContractTemplatesService,
) {}
async build(
@@ -86,7 +88,32 @@ export class ContractDocumentViewModelBuilder {
const templateKey =
contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract));
const template = getTemplateMeta(templateKey);
let template = getTemplateMeta(templateKey);
// Prefer the admin-editable DB template matching the contract's
// direction/freight pair; fall back to the code-defined generic layout
// when none is active.
const dynamicSource = await this.contractTemplates.findActiveForContract(
contract.tradeDirection,
contract.freightType,
);
const dynamicTemplate = dynamicSource
? {
code: dynamicSource.code,
name: dynamicSource.name,
documentTitle: dynamicSource.documentTitle,
whereasClauses: dynamicSource.whereasClauses ?? [],
articles: dynamicSource.articles ?? [],
}
: undefined;
if (dynamicTemplate) {
template = {
...template,
title: dynamicTemplate.name,
templateFile: 'edr-dynamic.hbs',
};
}
const pricing = this.buildPricing(contract);
const signatures = await this.loadSignatures(contractId);
@@ -139,6 +166,7 @@ export class ContractDocumentViewModelBuilder {
hasContractDocument: hasContractFile,
hasCustomerSignature: hasCustomer,
hasStaffSignature: hasStaff,
dynamicTemplate,
};
return { contract, view };

View File

@@ -0,0 +1,151 @@
import { parseArticleBody, interpolateTemplateText } from './contract-article.util';
import { ContractRendererService } from './contract-renderer.service';
import { getTemplateMeta } from './contract-template.registry';
import type { ContractViewModel } from './contract-view-model.builder';
describe('parseArticleBody', () => {
it('numbers each non-empty line as a clause', () => {
const parsed = parseArticleBody('First clause.\nSecond clause.\n\nThird clause.');
expect(parsed.paragraph).toBeUndefined();
expect(parsed.clauses.map((c) => c.text)).toEqual([
'First clause.',
'Second clause.',
'Third clause.',
]);
});
it('nests "- " lines as bullets under the previous clause', () => {
const parsed = parseArticleBody('Rates are:\n- USD 10 per ton\n- USD 20 per wagon\nPayment in advance.');
expect(parsed.clauses).toHaveLength(2);
expect(parsed.clauses[0].bullets).toEqual(['USD 10 per ton', 'USD 20 per wagon']);
expect(parsed.clauses[1].text).toBe('Payment in advance.');
});
it('renders a single bare line as a paragraph', () => {
const parsed = parseArticleBody('This Agreement becomes effective when signed.');
expect(parsed.paragraph).toBe('This Agreement becomes effective when signed.');
expect(parsed.clauses).toEqual([]);
});
});
describe('interpolateTemplateText', () => {
it('fills placeholders from the view model', () => {
expect(
interpolateTemplateText('Valid until August 31, {{contractYear}}.', {
contractYear: 2026,
}),
).toBe('Valid until August 31, 2026.');
});
it('falls back to raw text on malformed placeholders', () => {
expect(interpolateTemplateText('Broken {{#if}} tag', {})).toBe('Broken {{#if}} tag');
});
});
describe('dynamic template rendering (edr-dynamic.hbs)', () => {
const renderer = new ContractRendererService();
renderer.onModuleInit();
function dynamicView(): ContractViewModel {
const meta = getTemplateMeta('IMP_BULK_USD_FORWARDING');
return {
bookingId: 'test-id',
reference: 'EDR/CT/2026/0042',
status: 'CONTRACT_READY',
templateKey: 'IMP_BULK_USD_FORWARDING',
template: { ...meta, title: 'Bulk Import Contract', templateFile: 'edr-dynamic.hbs' },
contractDate: '1 January 2026',
contractYear: 2026,
client: {
companyName: 'Abyssinia Trading PLC',
companyAddress: 'Bole Sub-city, Addis Ababa',
companyLocation: 'Ethiopia',
phone: '+251900000000',
email: 'test@example.com',
tinNumber: '1234567890',
vatNumber: 'VAT-001',
fanNumber: 'FAN-001',
businessLicense: 'BL-001',
},
provider: {
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
address: 'Nifas Silk Lafto Sub City, Addis Ababa, Ethiopia',
phone: '+251 11 872 0000',
email: 'info@edr.gov.et',
tinNumber: '—',
},
schedule: {
originLabel: 'Nagad',
destinationLabel: 'Galaan Multipurpose Port',
tradeDirection: 'IMPORT',
freightType: 'BULK',
serviceType: 'Rail + clearance',
scheduledDate: '—',
contractType: 'GENERAL',
cargoDescription: 'Steel billets',
totalWeightVgm: '—',
equipmentReturn: '—',
hazardousLabel: 'No',
firstMilePickupAddress: '—',
lastMileDeliveryAddress: '—',
},
pricing: {
displayMode: 'UNIT_RATES',
unitRates: [
{ label: 'Rail transport', unitPrice: 59.4, unit: 'ton', currency: 'USD' },
],
currency: 'USD',
equipmentReturn: '—',
originLabel: 'Nagad',
destinationLabel: 'Galaan Multipurpose Port',
} as unknown as ContractViewModel['pricing'],
signatures: [],
canSignCustomer: false,
canSignStaff: false,
hasContractDocument: false,
hasCustomerSignature: false,
hasStaffSignature: false,
dynamicTemplate: {
code: 'IMPORT_BULK',
name: 'Bulk Import Contract',
documentTitle: 'Bulk Cargo Transportation and Customs Clearance Services',
whereasClauses: ['The Client has agreed to engage the Service Provider.'],
articles: [
{
id: 'objective',
title: 'Objective of the Services',
body: 'Integrated logistics services including:\n- Rail transport to GMP\n- Customs clearance',
order: 1,
},
{
id: 'duration',
title: 'Duration',
body: 'Valid until August 31, {{contractYear}}.',
order: 2,
},
],
},
};
}
it('renders numbered dynamic articles with bullets and interpolation', () => {
const html = renderer.render(dynamicView());
expect(html).toContain('Bulk Cargo Transportation and Customs Clearance Services');
expect(html).toContain('Article 1');
expect(html).toContain('Objective of the Services');
expect(html).toContain('Rail transport to GMP');
expect(html).toContain('Valid until August 31, 2026.');
expect(html).toContain('Abyssinia Trading PLC');
expect(html).toContain('Annex A — Commercial Schedule');
// Greenish theme marker from styles.hbs
expect(html).toContain('#1b9e7a');
});
it('keeps the generic layout when no dynamic template is attached', () => {
const view = dynamicView();
delete view.dynamicTemplate;
view.template = getTemplateMeta('IMP_BULK_USD_FORWARDING');
const html = renderer.render(view);
expect(html).toContain('Article 5: Contract Price');
});
});

View File

@@ -3,6 +3,11 @@ import * as fs from 'fs';
import * as path from 'path';
import Handlebars from 'handlebars';
import {
interpolateTemplateText,
parseArticleBody,
RenderedArticle,
} from './contract-article.util';
import { ContractViewModel } from './contract-view-model.builder';
@Injectable()
@@ -31,9 +36,40 @@ export class ContractRendererService implements OnModuleInit {
return template({
...view,
paymentArticle: view.pricing.currency === 'ETB' ? 'ETB' : 'USD',
...this.buildDynamicSections(view),
});
}
/**
* Turn the DB-backed dynamic template (when present) into render-ready data:
* interpolate placeholders against the view model, then parse each article
* body into numbered clauses with nested bullets.
*/
private buildDynamicSections(view: ContractViewModel): {
dynamicDocumentTitle?: string;
dynamicWhereas?: string[];
dynamicArticles?: RenderedArticle[];
} {
const dyn = view.dynamicTemplate;
if (!dyn || dyn.articles.length === 0) return {};
const articles = [...dyn.articles]
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((article, index) => ({
number: index + 1,
title: interpolateTemplateText(article.title, view),
...parseArticleBody(interpolateTemplateText(article.body, view)),
}));
return {
dynamicDocumentTitle: interpolateTemplateText(dyn.documentTitle, view),
dynamicWhereas: dyn.whereasClauses.map((clause) =>
interpolateTemplateText(clause, view),
),
dynamicArticles: articles,
};
}
private getCompiled(fileName: string): Handlebars.TemplateDelegate {
const cached = this.compiled.get(fileName);
if (cached) return cached;

View File

@@ -17,6 +17,21 @@ export interface ContractSignatureView {
signatureImageUrl?: string | null;
}
/**
* DB-backed contract template (freight.contract_templates) attached to the
* view model when an active template matches the contract's direction/freight
* pair. The renderer turns its articles into numbered clauses and switches to
* the dedicated edr-dynamic.hbs layout; absent, the legacy generic layout with
* code-defined clause packs is used.
*/
export interface ContractDynamicTemplateView {
code: string;
name: string;
documentTitle: string;
whereasClauses: string[];
articles: Array<{ id: string; title: string; body: string; order: number }>;
}
export interface ContractViewModel {
bookingId: string;
reference: string;
@@ -65,6 +80,7 @@ export interface ContractViewModel {
hasContractDocument: boolean;
hasCustomerSignature: boolean;
hasStaffSignature: boolean;
dynamicTemplate?: ContractDynamicTemplateView;
}
@Injectable()

View File

@@ -0,0 +1,23 @@
{{#each dynamicArticles}}
<section class="article">
<h2 class="article-heading"><span class="article-no">Article {{number}}</span><span class="article-name">{{title}}</span></h2>
{{#if paragraph}}
<p class="article-paragraph">{{paragraph}}</p>
{{else}}
<ol class="clauses">
{{#each clauses}}
<li>
{{text}}
{{#if bullets.length}}
<ul class="clause-bullets">
{{#each bullets}}
<li>{{this}}</li>
{{/each}}
</ul>
{{/if}}
</li>
{{/each}}
</ol>
{{/if}}
</section>
{{/each}}

View File

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

View File

@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{{dynamicDocumentTitle}}{{reference}}</title>
{{> styles}}
</head>
<body>
<main class="contract">
{{!-- ─────────────────────────── Cover page ─────────────────────────── --}}
<section class="cover page-section">
<div class="brand-row">
<div class="logo-mark">EDR</div>
<div>
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
<p class="muted">Freight Transport Services</p>
</div>
</div>
<div class="cover-title">
<p class="document-label">Contract Agreement</p>
<div class="cover-rule"></div>
<p class="cover-for">for</p>
<h1>{{dynamicDocumentTitle}}</h1>
<p class="cover-between">between</p>
<p class="cover-party">Ethio-Djibouti Standard Gauge Railway Share Company</p>
<p class="cover-between">and</p>
<p class="cover-party">{{client.companyName}}</p>
<div class="cover-rule"></div>
</div>
<table class="meta-grid">
<tr>
<th>Contract Ref No.</th>
<td>{{reference}}</td>
<th>Contract Date</th>
<td>{{contractDate}}</td>
</tr>
<tr>
<th>Trade Direction</th>
<td>{{schedule.tradeDirection}}</td>
<th>Freight Type</th>
<td>{{schedule.freightType}}</td>
</tr>
</table>
<p class="cover-year">{{contractYear}}</p>
</section>
{{!-- ──────────────────────────── Preamble ──────────────────────────── --}}
<section class="page-section">
<h2>Parties to the Agreement</h2>
<p class="lead">
This Contract Agreement is made on <strong>{{contractDate}}</strong>.
</p>
<p class="between-label">Between</p>
<p>
<strong>Ethio-Djibouti Standard Gauge Railway Share Company (EDR)</strong>, a share company
incorporated under the laws of the Federal Democratic Republic of Ethiopia (FDRE), having its
principal place of business at {{provider.address}} (hereinafter referred to as the
<strong>"Service Provider"</strong>);
</p>
<p class="between-label">And</p>
<p>
<strong>{{client.companyName}}</strong>, an organization incorporated under the laws of the
Federal Democratic Republic of Ethiopia (FDRE), having its principal place of business at
{{client.companyAddress}} (hereinafter referred to as the <strong>"Client"</strong>).
</p>
<div class="party-grid">
<div class="party-card">
<h3>Service Provider</h3>
<p class="party-name">{{provider.name}}</p>
<dl>
<dt>Address</dt><dd>{{provider.address}}</dd>
<dt>Phone</dt><dd>{{provider.phone}}</dd>
<dt>Email</dt><dd>{{provider.email}}</dd>
<dt>TIN</dt><dd>{{provider.tinNumber}}</dd>
</dl>
</div>
<div class="party-card">
<h3>Client</h3>
<p class="party-name">{{client.companyName}}</p>
<dl>
<dt>Address</dt><dd>{{client.companyAddress}}</dd>
<dt>Location</dt><dd>{{client.companyLocation}}</dd>
<dt>Phone</dt><dd>{{client.phone}}</dd>
<dt>Email</dt><dd>{{client.email}}</dd>
<dt>TIN</dt><dd>{{client.tinNumber}}</dd>
<dt>VAT</dt><dd>{{client.vatNumber}}</dd>
<dt>Business license</dt><dd>{{client.businessLicense}}</dd>
</dl>
</div>
</div>
</section>
{{#if dynamicWhereas.length}}
<section class="page-section">
<h2>Recitals</h2>
{{#each dynamicWhereas}}
<p><span class="whereas-label">Whereas</span> {{this}}</p>
{{/each}}
<p class="now-therefore">Now, therefore, the parties agree as follows:</p>
</section>
{{/if}}
{{!-- ──────────────────────── Dynamic articles ──────────────────────── --}}
{{> dynamic_articles}}
{{!-- ─────────────────── Commercial schedule (annex) ─────────────────── --}}
<section class="page-section annex">
<h2>Annex A — Commercial Schedule</h2>
<table class="details-table">
<tbody>
<tr>
<th>Route</th>
<td>{{schedule.originLabel}}{{schedule.destinationLabel}}</td>
<th>Service type</th>
<td>{{schedule.serviceType}}</td>
</tr>
<tr>
<th>Cargo</th>
<td>{{schedule.cargoDescription}}</td>
<th>Hazardous cargo</th>
<td>{{schedule.hazardousLabel}}</td>
</tr>
<tr>
<th>Equipment return</th>
<td>{{schedule.equipmentReturn}}</td>
<th>Payment currency</th>
<td>{{paymentArticle}}</td>
</tr>
</tbody>
</table>
{{#if pricing.unitRates.length}}
<h3>Agreed Unit Rates</h3>
<p class="muted-note">
The rates below are the frozen unit prices applicable to this contract. Quantities and resulting
totals are determined per shipment at booking time.
</p>
<table class="schedule">
<thead>
<tr><th>Item</th><th>Unit price</th></tr>
</thead>
<tbody>
{{#each pricing.unitRates}}
<tr>
<td>{{label}}</td>
<td>{{currency}} {{unitPrice}} / {{unit}}</td>
</tr>
{{/each}}
</tbody>
</table>
{{/if}}
</section>
{{!-- ────────────────────────── Signatures ───────────────────────────── --}}
<section class="page-section">
<h2>Execution</h2>
<p>
In witness whereof, the parties hereto have caused this contract to be signed in their respective
names as of the day and year first above written. The signatories confirm that they are fully
authorized to sign and execute this Contract Agreement.
</p>
{{> signatures_block}}
<div class="witnesses">
<p class="sig-title">Witnesses</p>
<table class="witness-table">
<thead>
<tr><th></th><th>Name</th><th>Signature</th><th>Date</th></tr>
</thead>
<tbody>
<tr><td>1.</td><td></td><td></td><td></td></tr>
<tr><td>2.</td><td></td><td></td><td></td></tr>
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds locomotives.overage_tolerance_tons / overage_tolerance_meters: an
* optional per-locomotive deviation allowance above max_pull_weight_tons /
* max_train_length_meters. Nullable, defaults to no tolerance so existing
* strict-cap behavior is unchanged until staff sets a value.
*/
export class AddLocomotiveOverageTolerance2040000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.locomotives
ADD COLUMN IF NOT EXISTS overage_tolerance_tons NUMERIC(10, 3),
ADD COLUMN IF NOT EXISTS overage_tolerance_meters NUMERIC(10, 3);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.locomotives
DROP COLUMN IF EXISTS overage_tolerance_tons,
DROP COLUMN IF EXISTS overage_tolerance_meters;
`);
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds customer_truck_containers.loaded_at so an assignment (customer planning
* which containers ride which truck) is distinct from the container actually
* being loaded. Stage LOADED now requires loaded_at; customer assignment alone
* keeps the container at its prior stage (RECEIVED/GRN) with its planned truck
* shown. Backfills containers on already-departed trucks (they left loaded).
*/
export class AddCustomerTruckContainerLoadedAt2050000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.customer_truck_containers
ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ;
`);
await queryRunner.query(`
UPDATE freight.customer_truck_containers ctc
SET loaded_at = a.departed_at
FROM freight.customer_truck_assignments a
WHERE a.id = ctc.assignment_id
AND a.departed_at IS NOT NULL
AND ctc.deleted_at IS NULL
AND ctc.loaded_at IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.customer_truck_containers DROP COLUMN IF EXISTS loaded_at;
`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Drops wagon_types.max_wagons_per_train. Train wagon-count caps are already
* derived from locomotive + wagon length/weight (train-capacity.util.ts) and
* the global train_scheduling_global_rules row — this per-wagon-type override
* was unused by that derivation and only added a confusing "Max / train"
* field to the wagon type form.
*/
export class DropWagonTypeMaxWagonsPerTrain2050000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_types
DROP COLUMN IF EXISTS max_wagons_per_train;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_types
ADD COLUMN IF NOT EXISTS max_wagons_per_train INT;
`);
}
}

View File

@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Upserts the 10 real EDR wagon types (code, name, capacity, length, tare
* weight) by code. Overwrites any existing row with the same code so
* previously-seeded demo values (e.g. NW5/PW2/CW3 from demo-bookings.seeder)
* are replaced with the real spec.
*/
export class SeedRailWagonTypes2060000000000 implements MigrationInterface {
private readonly wagonTypes = [
{ code: 'NW7', name: 'Double deck sedan wagon', capacityTons: 22, lengthMeters: 26.066, tareWeightTons: 37.1 },
{ code: 'NW5', name: 'Flat wagon', capacityTons: 70, lengthMeters: 13.966, tareWeightTons: 22.4 },
{ code: 'PW2', name: 'Box wagon', capacityTons: 70, lengthMeters: 17.066, tareWeightTons: 25.2 },
{ code: 'GW2', name: 'Tank wagon', capacityTons: 70, lengthMeters: 12.228, tareWeightTons: 23 },
{ code: 'CW4', name: 'Gondola covered wagon', capacityTons: 70, lengthMeters: 13.976, tareWeightTons: 24.8 },
{ code: 'CW3', name: 'Gondola open wagon', capacityTons: 70, lengthMeters: 13.976, tareWeightTons: 23.4 },
{ code: 'KW2', name: 'Hopper covered wagon', capacityTons: 69, lengthMeters: 16.466, tareWeightTons: 25.2 },
{ code: 'KW3', name: 'Hopper wagon open', capacityTons: 70, lengthMeters: 14.4, tareWeightTons: 24 },
{ code: 'NW6', name: 'Flat wagon (long)', capacityTons: 70, lengthMeters: 18.56, tareWeightTons: 25.3 },
{ code: 'BW1', name: 'Refrigerated wagon', capacityTons: 38, lengthMeters: 21.996, tareWeightTons: 32.1 },
];
public async up(queryRunner: QueryRunner): Promise<void> {
for (const wt of this.wagonTypes) {
await queryRunner.query(
`
INSERT INTO freight.wagon_types (code, name, capacity_tons, length_meters, tare_weight_tons, is_active)
VALUES ($1, $2, $3, $4, $5, true)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
capacity_tons = EXCLUDED.capacity_tons,
length_meters = EXCLUDED.length_meters,
tare_weight_tons = EXCLUDED.tare_weight_tons;
`,
[wt.code, wt.name, wt.capacityTons, wt.lengthMeters, wt.tareWeightTons],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM freight.wagon_types WHERE code = ANY($1);`,
[this.wagonTypes.map((wt) => wt.code)],
);
}
}

View File

@@ -0,0 +1,63 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Tare weight becomes mandatory on a wagon type.
*
* The locomotive's pull limit is a GROSS limit — it drags the wagon as well as
* the cargo — so capacity math cannot run without a tare. A NULL tare silently
* read as zero and let trains overbook by the tare fraction (~27% on a PW2
* consist), so the column is now NOT NULL.
*
* Any row still missing a tare predates 2060000000000-SeedRailWagonTypes (which
* upserts the ten real EDR types). Backfill those by code first, and give any
* remaining custom/demo type the NW5 flat-wagon tare rather than fail the
* migration — a wrong-but-plausible tare is recoverable in the admin UI; a
* blocked deploy is not.
*/
export class MakeWagonTypeTareWeightRequired2070000000000 implements MigrationInterface {
private readonly tareByCode: Array<[string, number]> = [
['NW7', 37.1],
['NW5', 22.4],
['PW2', 25.2],
['GW2', 23],
['CW4', 24.8],
['CW3', 23.4],
['KW2', 25.2],
['KW3', 24],
['NW6', 25.3],
['BW1', 32.1],
];
/** NW5 flat wagon — the commonest type in the fleet (550 of 1100). */
private readonly fallbackTareTons = 22.4;
public async up(queryRunner: QueryRunner): Promise<void> {
for (const [code, tareWeightTons] of this.tareByCode) {
await queryRunner.query(
`UPDATE freight.wagon_types
SET tare_weight_tons = $2
WHERE code = $1 AND tare_weight_tons IS NULL;`,
[code, tareWeightTons],
);
}
await queryRunner.query(
`UPDATE freight.wagon_types
SET tare_weight_tons = $1
WHERE tare_weight_tons IS NULL;`,
[this.fallbackTareTons],
);
await queryRunner.query(
`ALTER TABLE freight.wagon_types
ALTER COLUMN tare_weight_tons SET NOT NULL;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.wagon_types
ALTER COLUMN tare_weight_tons DROP NOT NULL;`,
);
}
}

View File

@@ -0,0 +1,53 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Wagon spec belongs to the wagon TYPE, not to each physical wagon.
*
* `wagons.tare_weight` and `wagons.max_payload_weight` duplicated
* `wagon_types.tare_weight_tons` / `wagon_types.capacity_tons` on all 1100 rows,
* with nothing keeping them in step. They had drifted completely: every wagon
* disagreed with its type's tare (seeded ~20T against a real 22.4T NW5), and a
* third disagreed on payload (NW5 wagons claiming 22T70T against a flat 70T).
* None of those numbers came from the railway.
*
* Nothing reads them for capacity — that math resolves tare and capacity through
* `wagon_type_id` — so dropping them removes a source of fiction rather than a
* source of truth. `wagon_type_id` is NOT NULL with no orphans, so the type is
* always reachable.
*
* A wagon re-tared after repair would need a nullable override column on
* `wagons` falling back to the type; deliberately not added, since no such
* per-wagon value exists today.
*/
export class DropWagonSpecColumns2080000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagons
DROP COLUMN IF EXISTS tare_weight,
DROP COLUMN IF EXISTS max_payload_weight;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Re-add nullable, backfill from the owning type, then restore NOT NULL.
// The pre-drop values were drifted seed data and are not recoverable — the
// type's spec is what they should always have held.
await queryRunner.query(`
ALTER TABLE freight.wagons
ADD COLUMN IF NOT EXISTS tare_weight NUMERIC(10, 2),
ADD COLUMN IF NOT EXISTS max_payload_weight NUMERIC(10, 2);
`);
await queryRunner.query(`
UPDATE freight.wagons w
SET tare_weight = t.tare_weight_tons,
max_payload_weight = t.capacity_tons
FROM freight.wagon_types t
WHERE t.id = w.wagon_type_id;
`);
await queryRunner.query(`
ALTER TABLE freight.wagons
ALTER COLUMN tare_weight SET NOT NULL,
ALTER COLUMN max_payload_weight SET NOT NULL;
`);
}
}

View File

@@ -0,0 +1,58 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
/**
* Creates freight.contract_templates — the six editable contract document
* templates (direction × freight type) whose dynamic articles drive the
* generated contract PDF — and seeds them from the EDR reference contract
* documents. Seeding is idempotent (ON CONFLICT (code) DO NOTHING) so admin
* edits are never overwritten by redeploys.
*/
export class CreateContractTemplates2090000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.contract_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(40) NOT NULL,
name VARCHAR(200) NOT NULL,
description TEXT,
document_title VARCHAR(300) NOT NULL,
whereas_clauses JSONB NOT NULL DEFAULT '[]',
articles JSONB NOT NULL DEFAULT '[]',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
CONSTRAINT uq_contract_templates_code UNIQUE (code)
);
`);
for (const seed of CONTRACT_TEMPLATE_DEFAULTS) {
const articles = seed.articles.map((article, index) => ({
...article,
order: index + 1,
}));
await queryRunner.query(
`
INSERT INTO freight.contract_templates
(code, name, description, document_title, whereas_clauses, articles)
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb)
ON CONFLICT (code) DO NOTHING;
`,
[
seed.code,
seed.name,
seed.description,
seed.documentTitle,
JSON.stringify(seed.whereasClauses),
JSON.stringify(articles),
],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_templates;`);
}
}

View File

@@ -0,0 +1,55 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Repairs `freight.warehouse_inventory.grn_number`.
*
* AddGrnNumberToWarehouseInventory1828000000000 is recorded in `migrations` but
* the column is absent on at least one environment - it was added, then dropped
* out-of-band (a stray `synchronize: true`, same class of damage that
* RepairSynchronizeDrift1870000000000 already had to undo). Because TypeORM has
* the original recorded, it will never re-run it.
*
* Without the column, everything that reads or writes a GRN fails with
* `column ... grn_number does not exist`:
* - bulkReceive() -> INSERT names grn_number (receive to warehouse)
* - importQueueByStatuses() -> Unloaded + Dispatch queues
* - exportInventoryByStatus() -> Received / Ready-To-Load / Loaded tabs
* - grnDocument() -> GRN PDF
*
* Idempotent: a no-op on environments where the column survived.
*/
export class RepairGrnNumberColumn2090000000000 implements MigrationInterface {
name = 'RepairGrnNumberColumn2090000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
ADD COLUMN IF NOT EXISTS grn_number VARCHAR(100) NULL
`);
// Recover the GRN for rows received before the column existed: it was also
// written into the receive note as "GRN Number: <value>".
await queryRunner.query(`
UPDATE freight.warehouse_inventory
SET grn_number = substring(notes FROM 'GRN Number: ([^\\n\\r]+)')
WHERE grn_number IS NULL
AND notes IS NOT NULL
AND notes ~ 'GRN Number: '
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_grn_number
ON freight.warehouse_inventory(grn_number)
WHERE grn_number IS NOT NULL
`);
}
/**
* Deliberately a no-op. Dropping the column is what broke these environments
* in the first place, and the original 1828 migration already owns its own
* down(). Reverting this repair must not re-introduce the outage.
*/
public async down(): Promise<void> {
// intentionally empty
}
}

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* `company_profiles.status` defaulted to 'active', so any insert that omitted
* the column produced an operational role that was approved without ever being
* reviewed. Every live write path already passes 'pending' explicitly; this
* closes the hole at the schema level.
*
* Deliberately no data backfill. A role approved through setCompanyProfileStatus
* always stamps `reviewed_at`, so `status = 'active' AND reviewed_at IS NULL`
* flags a role that skipped review — but it also matches rows approved before
* `reviewed_at` existed (migration 2000000000001). Auditing that set is a
* judgement call about real customers, not something to automate here.
*/
export class CompanyProfileDefaultPending2100000000000
implements MigrationInterface
{
name = 'CompanyProfileDefaultPending2100000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."company_profiles" ALTER COLUMN "status" SET DEFAULT 'pending'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."company_profiles" ALTER COLUMN "status" SET DEFAULT 'active'`,
);
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Auto-load onto a selected train: a warehouse_loadings row now records WHICH
* train the item was loaded onto (train_schedule_id), and wagon_id becomes
* nullable because a schedule-level load may not resolve to a single wagon.
*/
export class WarehouseLoadingTrainAssociation2100000000000 implements MigrationInterface {
name = 'WarehouseLoadingTrainAssociation2100000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_loadings
ADD COLUMN IF NOT EXISTS train_schedule_id UUID NULL
`);
await queryRunner.query(`
ALTER TABLE freight.warehouse_loadings
ALTER COLUMN wagon_id DROP NOT NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_train_schedule
ON freight.warehouse_loadings(train_schedule_id)
WHERE train_schedule_id IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_loadings_train_schedule`);
await queryRunner.query(`
ALTER TABLE freight.warehouse_loadings DROP COLUMN IF EXISTS train_schedule_id
`);
// wagon_id stays nullable on revert: restoring NOT NULL would fail on rows
// recorded without a wagon and re-introduce the outage this fixes.
}
}

View File

@@ -0,0 +1,48 @@
import {
Body,
Controller,
NotFoundException,
Param,
ParseUUIDPipe,
Post,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
import { CustomerResetService } from "./customer-reset.service";
/**
* Staff-triggered password reset. The customer receives the code and sets their
* own password — staff never see or handle a credential.
*/
@ApiTags("backoffice")
@Controller("backoffice/customers")
@ApiBearerAuth()
export class CustomerResetController {
constructor(private readonly customerResetService: CustomerResetService) {}
@Post(":companyId/reset-password")
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
@ApiOperation({
summary: "Send a password-reset code to a customer's primary contact",
})
async resetPassword(
@Param("companyId", ParseUUIDPipe) companyId: string,
@Body() dto: BackofficeResetPasswordDto,
) {
const maskedTarget = await this.customerResetService.sendResetToCustomer(
companyId,
dto.channel,
);
if (!maskedTarget) {
throw new NotFoundException(
`No active primary contact with ${dto.channel === "email" ? "an email address" : "a phone number"} for this customer`,
);
}
return { channel: dto.channel, maskedTarget };
}
}

View File

@@ -0,0 +1,59 @@
import { Injectable, Logger } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { ExternalProfile } from "../companies/entities/external-profile.entity";
import { ResetChannel } from "./dto/forgot-password.dto";
import { ForgotPasswordService } from "./forgot-password.service";
@Injectable()
export class CustomerResetService {
private readonly logger = new Logger(CustomerResetService.name);
constructor(
@InjectRepository(ExternalProfile)
private readonly externalProfileRepository: Repository<ExternalProfile>,
private readonly forgotPasswordService: ForgotPasswordService,
) {}
/**
* Send a reset code to the company's primary contact. Returns the masked
* destination, or null when there is no eligible account for that channel.
*
* Unlike the public flow this reports failure honestly — the caller is an
* authenticated staff member, so there is nothing to enumerate.
*/
async sendResetToCustomer(
companyId: string,
channel: ResetChannel,
): Promise<string | null> {
const profile = await this.externalProfileRepository.findOne({
where: { companyId, isPrimaryContact: true },
});
if (!profile) {
this.logger.warn(`Company ${companyId} has no primary contact profile`);
return null;
}
// Resolve through the same active-account gate the public flow uses, so a
// suspended customer cannot be reactivated by a staff-triggered reset.
const user = await this.forgotPasswordService.resolveActiveUserById(
profile.userId,
);
if (!user) {
this.logger.warn(
`Primary contact ${profile.userId} of company ${companyId} is not an active account`,
);
return null;
}
const target = await this.forgotPasswordService.requestReset(user, channel);
if (!target) return null;
this.logger.log(
`Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`,
);
return this.forgotPasswordService.maskTarget(target);
}
}

View File

@@ -0,0 +1,35 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum, IsNotEmpty, IsString } from "class-validator";
/** The channel the reset code is delivered over. */
export enum ResetChannel {
Email = "email",
Phone = "phone",
}
export class ForgotPasswordRequestDto {
@ApiProperty({
description: "Email, username, or phone number of the account to reset",
example: "name@company.com",
})
@IsString()
@IsNotEmpty()
identifier!: string;
@ApiProperty({ enum: ResetChannel })
@IsEnum(ResetChannel)
channel!: ResetChannel;
}
export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto {
@ApiProperty({ description: "The 6-digit code sent to the chosen channel" })
@IsString()
@IsNotEmpty()
otp!: string;
}
export class BackofficeResetPasswordDto {
@ApiProperty({ enum: ResetChannel })
@IsEnum(ResetChannel)
channel!: ResetChannel;
}

View File

@@ -0,0 +1,69 @@
import { Body, Controller, Logger, Post } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Public } from "@edr/api-common";
import {
ForgotPasswordRequestDto,
ForgotPasswordVerifyDto,
} from "./dto/forgot-password.dto";
import { ForgotPasswordService, ResetTicket } from "./forgot-password.service";
/**
* Freight-owned reset flow. IAM ships a `forgot-password` route, but it only
* ever SMSes a magic link (no email channel, and it needs `FE_BASE_URL`, which
* this API does not set). These routes drive freight's own email-or-phone OTP
* service instead, then hand back a ticket for IAM's public `set-password`.
*/
@ApiTags("auth")
@Controller("auth")
@Public()
export class ForgotPasswordController {
private readonly logger = new Logger(ForgotPasswordController.name);
constructor(private readonly forgotPasswordService: ForgotPasswordService) {}
@Post("forgot-password/request")
@ApiOperation({
summary: "Send a password-reset code over email or SMS",
description:
"Always reports success. An unknown, inactive, or channel-less account is " +
"indistinguishable from a real one, so this cannot be used to enumerate accounts.",
})
async request(@Body() dto: ForgotPasswordRequestDto): Promise<{ success: true }> {
const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier);
if (user) {
try {
await this.forgotPasswordService.requestReset(user, dto.channel);
} catch (error) {
// A delivery failure must not change the response shape either — log it
// and let the caller sit on the OTP screen.
this.logger.error(
`Reset code delivery failed for user ${user.id}: ${
error instanceof Error ? error.message : String(error)
}`,
error instanceof Error ? error.stack : undefined,
);
}
} else {
this.logger.log("Reset requested for an unknown or inactive account");
}
return { success: true };
}
@Post("forgot-password/verify")
@ApiOperation({
summary: "Exchange a valid reset code for a single-use set-password ticket",
description:
"The returned { userId, verificationCode } is the body for PATCH /api/auth/set-password, " +
"alongside the same identifier and the new password.",
})
verify(@Body() dto: ForgotPasswordVerifyDto): Promise<ResetTicket> {
return this.forgotPasswordService.verifyAndMintTicket(
dto.identifier,
dto.channel,
dto.otp,
);
}
}

View File

@@ -0,0 +1,169 @@
import { randomBytes } from "node:crypto";
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
import { DataSource, Repository } from "typeorm";
import { hashPassword } from "@tria-plc/api-common/utils/argon";
import { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user-verification.entity";
import { OtpService, OtpTarget } from "../otp/otp.service";
import { ResetChannel } from "./dto/forgot-password.dto";
/**
* How long the reset ticket minted for `PATCH /api/auth/set-password` stays
* valid. The IAM `setPassword` handler enforces this via `expiresAt`.
*/
const RESET_TICKET_TTL_MS = 10 * 60 * 1000;
/** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */
const RESET_OTP_TTL_MS = 10 * 60 * 1000;
export interface ResetTicket {
userId: string;
verificationCode: string;
}
@Injectable()
export class ForgotPasswordService {
private readonly logger = new Logger(ForgotPasswordService.name);
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly otpService: OtpService,
) {}
/**
* Resolve an account that is actually eligible for a password reset.
*
* IAM's `set-password` handler flips `isActive: true` on the user as a side
* effect, so a reset on a deactivated account would silently resurrect it.
* Gating here — rather than at the set-password call — is what keeps that
* from being reachable. Mirrors IAM's own login lookup: match on any of
* email / username / phone, and require an active credential row.
*/
async resolveActiveUser(identifier: string): Promise<User | null> {
const id = identifier.trim();
if (!id) return null;
return await this.activeUserQuery()
.andWhere(
"(LOWER(u.email) = LOWER(:id) OR u.username = :id OR u.phoneNumber = :id)",
{ id },
)
.getOne();
}
/** Same eligibility gate as {@link resolveActiveUser}, keyed by IAM user id. */
async resolveActiveUserById(userId: string): Promise<User | null> {
if (!userId) return null;
return await this.activeUserQuery()
.andWhere("u.id = :userId", { userId })
.getOne();
}
/**
* Base query for accounts eligible to reset. `.where()` is claimed here so
* callers must use `.andWhere()` — TypeORM's `.where()` resets the clause,
* which would silently drop the `isActive` gate.
*/
private activeUserQuery() {
return this.userRepository
.createQueryBuilder("u")
.innerJoin("u.userCredentials", "uc", "uc.isActive = true")
.where("u.isActive = true")
.orderBy("u.createdAt", "DESC");
}
/** The address the code goes to, taken from the account — never from input. */
private targetFor(user: User, channel: ResetChannel): OtpTarget | null {
if (channel === ResetChannel.Email) {
return user.email ? { email: user.email } : null;
}
return user.phoneNumber ? { phone: user.phoneNumber } : null;
}
/**
* Send a reset code to the account's own email/phone. Returns the target so
* authenticated (backoffice) callers can echo a masked version; unauthenticated
* callers must discard it.
*
* Note: `otp_verifications` keys rows by a unique phone/email, and `sendOtp`
* upserts. A reset request therefore overwrites any pending signup code for
* the same address — last code sent wins. That is the pre-existing behaviour
* between any two flows sharing this table.
*/
async requestReset(
user: User,
channel: ResetChannel,
): Promise<OtpTarget | null> {
const target = this.targetFor(user, channel);
if (!target) return null;
await this.otpService.sendOtp(target);
return target;
}
/**
* Prove possession of the OTP, then mint an IAM reset ticket the caller can
* spend on the public `PATCH /api/auth/set-password`.
*
* Minting a `UserVerification` row rather than writing `UserCredential`
* ourselves keeps IAM as the single owner of the password write path (old
* credential deactivation, argon hashing, changed-at bookkeeping).
*/
async verifyAndMintTicket(
identifier: string,
channel: ResetChannel,
otp: string,
): Promise<ResetTicket> {
const user = await this.resolveActiveUser(identifier);
const target = user && this.targetFor(user, channel);
if (!user?.id || !target) {
// Same shape as a wrong code: a caller probing for accounts learns nothing
// beyond what the request step already (deliberately) refuses to tell them.
throw new BadRequestException("Invalid verification code");
}
await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS);
const code = randomBytes(24).toString("base64url");
const verificationCode = await hashPassword(code);
const userId = user.id;
await this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(UserVerification);
// Retire any outstanding codes so only the ticket we just minted can be
// spent — `findVerificationForPrimaryReset` reads the newest row.
await repo.update({ userId }, { isUsed: true });
await repo.insert({
userId,
otpType: EOtpType.RESET_PASSWORD,
verificationCode,
expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS),
isUsed: false,
attemptCount: 0,
});
});
this.logger.log(`Reset ticket minted for user ${userId}`);
return { userId, verificationCode: code };
}
/** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
maskTarget(target: OtpTarget): string {
if (target.email) {
const [local, domain] = target.email.split("@");
const head = local.slice(0, 1);
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
}
const phone = target.phone ?? "";
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
}
}

View File

@@ -2,15 +2,35 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity';
import { ExternalProfile } from '../companies/entities/external-profile.entity';
import { OtpModule } from '../otp/otp.module';
import { CheckAvailabilityController } from './check-availability.controller';
import { CheckAvailabilityService } from './check-availability.service';
import { CustomerResetController } from './customer-reset.controller';
import { CustomerResetService } from './customer-reset.service';
import { ForgotPasswordController } from './forgot-password.controller';
import { ForgotPasswordService } from './forgot-password.service';
import { FreightMeController } from './freight-me.controller';
import { FreightMeService } from './freight-me.service';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [FreightMeController, CheckAvailabilityController],
providers: [FreightMeService, CheckAvailabilityService],
imports: [
TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]),
OtpModule,
],
controllers: [
FreightMeController,
CheckAvailabilityController,
ForgotPasswordController,
CustomerResetController,
],
providers: [
FreightMeService,
CheckAvailabilityService,
ForgotPasswordService,
CustomerResetService,
],
})
export class FreightAuthModule {}

View File

@@ -143,6 +143,20 @@ export function htmlToText(html: string): string {
.trim();
}
/**
* Large rotated light-gray copy label (e.g. "Copy 1: Port Operations Copy"),
* drawn FIRST so the page content sits on top of it. 30-degree rotation via a
* text matrix; roughly centered on the page.
*/
export function watermarkOp(text: string, page: { width: number; height: number }): string {
const label = clipText(text, 46);
const size = 34;
const w = textWidth(label, size);
const x = page.width / 2 - (w * 0.866) / 2;
const y = page.height / 2 - (w * 0.5) / 2;
return `q BT 0.93 0.93 0.93 rg /F2 ${size} Tf 0.866 0.5 -0.5 0.866 ${x.toFixed(1)} ${y.toFixed(1)} Tm (${escapePdfText(label)}) Tj ET Q`;
}
/**
* Parse a "summary tiles + one <table> + notice + signature lines" document (the
* marshalling / load-list layout the train-scheduling builders emit) and draw it as a
@@ -150,11 +164,26 @@ export function htmlToText(html: string): string {
* document, not a flat text dump. Switches to landscape when the table is wide.
*/
export function buildTabularFallbackPdf(html: string): Buffer {
// Documents printed in duplicate wrap each copy in <section class="copy">
// (freight order: Port Operations copy + Gate Security copy). Render one
// page per copy, each with its own watermark and tile set — parsing the
// whole HTML at once would merge both copies' tiles and drop the watermarks.
const copies = [...html.matchAll(/<section class="copy">([\s\S]*?)<\/section>/gi)].map((m) => m[1]);
const fragments = copies.length ? copies : [html];
return assemblePdf(fragments.flatMap((fragment) => buildTabularPageOps(fragment)));
}
function buildTabularPageOps(
html: string,
): Array<{ ops: string[]; page: { width: number; height: number } }> {
const pick = (re: RegExp) => html.match(re)?.[1];
const title = htmlToText(pick(/<h1[^>]*>([\s\S]*?)<\/h1>/i) ?? "Document");
const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
const metaRef = htmlToText(pick(/class="meta"[\s\S]*?<strong>([\s\S]*?)<\/strong>/i) ?? "");
const metaLabel =
htmlToText(pick(/class="meta"[^>]*>([\s\S]*?)<strong/i) ?? "").toUpperCase() || "REFERENCE";
const generated = htmlToText(pick(/Generated:\s*([^<]+)/i) ?? "");
const watermark = htmlToText(pick(/class="watermark"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
const tiles: Array<[string, string]> = [];
for (const m of html.matchAll(
@@ -180,24 +209,49 @@ export function buildTabularFallbackPdf(html: string): Buffer {
const M = 32;
const contentW = page.width - M * 2;
const right = page.width - M;
const ops: string[] = [];
const MAX_PAGES = 12;
// Header
ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray));
ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark));
if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
if (metaRef) {
ops.push(textOpRight("TRAIN / SCHEDULE", right, page.height - 42, 7.5, "F2", PdfColor.gray));
ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
}
if (generated) {
ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray));
}
ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
const pagesOut: Array<{ ops: string[]; page: { width: number; height: number } }> = [];
let ops: string[] = [];
let y = 0;
// Summary tiles
let y = page.height - 100;
const drawFullHeader = () => {
ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray));
ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark));
if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
if (metaRef) {
ops.push(textOpRight(clipText(metaLabel, 26), right, page.height - 42, 7.5, "F2", PdfColor.gray));
ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
}
if (generated) {
ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray));
}
ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
y = page.height - 100;
};
const drawContinuationHeader = (pageNo: number) => {
ops.push(lineOp(M, page.height - 24, right, page.height - 24, PdfColor.teal, 1.6));
ops.push(
textOp(clipText(`${title} (continued — page ${pageNo})`, landscape ? 100 : 68), M, page.height - 42, 11, "F2", PdfColor.dark),
);
if (metaRef) ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 42, 10, "F2", PdfColor.gray));
y = page.height - 54;
};
const startPage = (first: boolean) => {
ops = [];
if (watermark) ops.push(watermarkOp(watermark, page));
if (first) drawFullHeader();
else drawContinuationHeader(pagesOut.length + 1);
};
const finishPage = () => pagesOut.push({ ops, page });
startPage(true);
// Summary tiles (first page only)
if (tiles.length) {
const cols = landscape ? 6 : 4;
const tileW = contentW / cols;
@@ -213,21 +267,34 @@ export function buildTabularFallbackPdf(html: string): Buffer {
y -= tileH + 12;
}
// Table
// Table, paginated across as many pages as the rows need.
if (headers.length) {
const colW = contentW / headers.length;
const headerH = 16;
const rowH = 14;
const cellChars = Math.max(4, Math.floor(colW / 3.9));
ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
headers.forEach((h, c) =>
ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
);
y -= headerH;
const bottomReserve = 46; // keep clear of the page edge on row-only pages
let shown = 0;
for (const row of rows) {
if (y < 96) break;
const drawTableHeader = () => {
ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
headers.forEach((h, c) =>
ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
);
y -= headerH;
};
drawTableHeader();
let truncated = 0;
for (const [index, row] of rows.entries()) {
if (y - rowH < bottomReserve) {
if (pagesOut.length + 1 >= MAX_PAGES) {
truncated = rows.length - index;
break;
}
finishPage();
startPage(false);
drawTableHeader();
}
ops.push(rectOp(M, y - rowH, contentW, rowH, "1 1 1", PdfColor.line, 0.4));
headers.forEach((_h, c) => {
if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3));
@@ -235,30 +302,33 @@ export function buildTabularFallbackPdf(html: string): Buffer {
if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark));
});
y -= rowH;
shown += 1;
}
if (shown < rows.length) {
ops.push(textOp(`... ${rows.length - shown} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
if (truncated > 0) {
ops.push(textOp(`... ${truncated} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
}
}
// Notice (verification clause)
// Notice + signatures live on the final page; give them a fresh page when the
// rows ran too deep for the fixed bottom band.
if (y < 110 && (notice || signatures.length)) {
finishPage();
startPage(false);
}
if (notice) {
ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2));
wrapText(notice, landscape ? 155 : 104)
.slice(0, 2)
.forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray)));
}
// Signatures
const sigW = contentW / signatures.length;
signatures.forEach((s, i) => {
signatures.forEach((sig, i) => {
const x = M + i * sigW;
ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7));
ops.push(textOp(clipText(s, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
ops.push(textOp(clipText(sig, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray));
});
finishPage();
return assembleSinglePagePdf(ops, page);
return pagesOut;
}
/** Greedy word-wrap to a maximum character width. */
@@ -320,3 +390,41 @@ export function assembleSinglePagePdf(
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "latin1");
}
/** Assemble a multi-page PDF; one content stream per page, shared Helvetica fonts. */
export function assemblePdf(
pages: Array<{ ops: string[]; page: { width: number; height: number } }>,
): Buffer {
const kids = pages.map((_, i) => `${5 + i * 2} 0 R`).join(" ");
const objects: string[] = [
"<< /Type /Catalog /Pages 2 0 R >>",
`<< /Type /Pages /Kids [${kids}] /Count ${pages.length} >>`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
];
for (const [i, p] of pages.entries()) {
const stream = p.ops.join("\n");
objects.push(
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${p.page.width} ${p.page.height}] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${6 + i * 2} 0 R >>`,
);
objects.push(`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`);
}
let pdf = "%PDF-1.4\n";
const offsets: number[] = [0];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(pdf, "latin1"));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) {
pdf += "% fallback padding\n";
}
const xrefOffset = Buffer.byteLength(pdf, "latin1");
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += "0000000000 65535 f \n";
for (const offset of offsets.slice(1)) {
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
return Buffer.from(pdf, "latin1");
}

View File

@@ -400,8 +400,18 @@ export class BookingPricingService {
* All three components are produced by RuleEngineService.evaluate, so submit
* simply re-runs the engine — there is no extra submit-time inflation.
*/
async computeSubmitPriorityScore(booking: Booking): Promise<number> {
async computeSubmitPriorityScore(
booking: Booking,
totalWagonsOverride?: number,
): Promise<number> {
const evalInput = await this.buildEvalInputForBooking(booking);
// BULK bookings have no container lines, so buildEvalInputForBooking yields
// totalWagons = 0 and every wagon-range priority config misses. The batch
// engine derives a bulk booking's wagon footprint from tonnage vs. live
// wagon capacity and passes it here to score the booking properly.
if (totalWagonsOverride != null && totalWagonsOverride > 0) {
evalInput.totalWagons = totalWagonsOverride;
}
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
return ruleResult.priorityScore;
}

View File

@@ -68,6 +68,8 @@ import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto';
import { CustomerTruckService } from './customer-truck.service';
import { FirstMileService } from '../first-mile/first-mile.service';
import { LastMileService } from '../last-mile/last-mile.service';
import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service';
import { SignContractDto } from './dto/sign-contract.dto';
@@ -81,6 +83,60 @@ import {
hasFreightPermission,
} from "../../common/freight-permission.util";
interface MileVehicleSummary {
plate: string | null;
code: string | null;
driverName: string | null;
containerNumber: string | null;
distanceKm: number | null;
}
interface MileLegSummary {
status: string;
exactKm: number | null;
remainingPayment: number | null;
currency: string;
invoiced: boolean;
vehicles: MileVehicleSummary[];
}
/** Trim a first/last-mile record down to a customer-safe operational summary. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function summarizeMileLeg(rec?: Record<string, any>): MileLegSummary | null {
if (!rec) return null;
const num = (v: unknown) => (v == null ? null : Number(v));
const assignments: Array<Record<string, any>> = rec.vehicleAssignments ?? []; // eslint-disable-line @typescript-eslint/no-explicit-any
const currency =
rec.vehicle?.currency ??
assignments[0]?.vehicle?.currency ??
rec.booking?.paymentCurrency ??
'ETB';
const vehicles: MileVehicleSummary[] = assignments.map((a) => ({
plate: a.vehicle?.plateNumber ?? null,
code: a.vehicle?.code ?? null,
driverName: a.vehicle?.assignedDriverName ?? null,
containerNumber: a.containerNumber ?? null,
distanceKm: num(a.distanceKm),
}));
if (!vehicles.length && rec.vehicle) {
vehicles.push({
plate: rec.vehicle.plateNumber ?? null,
code: rec.vehicle.code ?? null,
driverName: rec.vehicle.assignedDriverName ?? null,
containerNumber: null,
distanceKm: num(rec.exactKm),
});
}
return {
status: rec.status ?? '',
exactKm: num(rec.exactKm),
remainingPayment: num(rec.remainingPayment),
currency,
invoiced: Boolean(rec.invoice),
vehicles,
};
}
@ApiTags("bookings")
@Controller("bookings")
@ApiBearerAuth()
@@ -94,6 +150,8 @@ export class BookingsController {
private readonly bookingClearanceService: BookingClearanceService,
private readonly customerTruckService: CustomerTruckService,
private readonly containerReceiptService: ContainerReceiptService,
private readonly firstMileService: FirstMileService,
private readonly lastMileService: LastMileService,
) {}
@Post()
@@ -290,6 +348,33 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/mile-summary')
@ApiOperation({
summary: 'First/last-mile operational summary for a booking (customer-safe)',
})
async mileSummary(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
// Customers may only see their own booking's mile summary.
const booking = await this.bookingsService.findById(id);
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const [first, last] = await Promise.all([
this.firstMileService.findAll({ bookingId: id, pageSize: 1 }),
this.lastMileService.findAll({ bookingId: id, pageSize: 1 }),
]);
return {
firstMile: summarizeMileLeg(first.data[0]),
lastMile: summarizeMileLeg(last.data[0]),
};
}
@Post(':id/customer-truck-assignment')
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
async assignCustomerTruck(

View File

@@ -11,7 +11,9 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { BillingModule } from '../billing/billing.module';
import { DocumentsModule } from '../billing/documents/documents.module';
import { FirstMileModule } from '../first-mile/first-mile.module';
import { LastMileModule } from '../last-mile/last-mile.module';
import { BookingContractService } from './booking-contract.service';
import { BookingInvoiceService } from './booking-invoice.service';
// import { BookingPaymentController } from './booking-payment.controller';
@@ -67,9 +69,11 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
CustomerTruckContainer,
]),
BillingModule,
DocumentsModule,
NotificationsModule,
NotificationInboxModule,
forwardRef(() => FirstMileModule),
forwardRef(() => LastMileModule),
forwardRef(() => TrainSchedulingModule),
forwardRef(() => ContractsModule),
forwardRef(() => ContractsModule),
@@ -114,6 +118,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingPricingService,
BookingInvoiceService,
BookingLifecycleNotifierService,
BookingTransitionService,
ConsolidationService,
CustomerTruckService,
ContainerReceiptService,

View File

@@ -44,6 +44,11 @@ export interface BookingListFilterOptions {
customsClearingEnabled?: boolean;
createdFrom?: string;
createdTo?: string;
scheduledFrom?: string;
scheduledTo?: string;
originYardId?: string;
destinationYardId?: string;
isGovernment?: 'true' | 'false';
consolidationPaired?: string;
}
@@ -546,6 +551,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
if (!statuses.length) return [];
return this.repository.find({
where: { status: In(statuses) },
relations: {
company: true,
originYard: true,
destinationYard: true,
},
order: { createdAt: 'DESC' },
});
}
@@ -794,9 +804,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
});
}
if (options.bookingType) {
qb.andWhere('booking.bookingType = :bookingType', {
bookingType: options.bookingType,
});
// The stored booking_type column is 'ONE_TIME' for every row (contract
// drawdowns included — see contract-booking.service create), so the
// one-time vs general split keys on the denormalized contract_kind:
// GENERAL_CONTRACT tab = bookings under a GENERAL contract, ONE_TIME tab
// = everything else (ONE_TIME contracts and legacy contract-less rows).
if (options.bookingType === 'GENERAL_CONTRACT') {
qb.andWhere("booking.contract_kind = 'GENERAL'");
} else {
qb.andWhere("booking.contract_kind IS DISTINCT FROM 'GENERAL'");
}
}
if (options.createdFrom) {
qb.andWhere('booking.created_at >= :createdFrom', {
@@ -809,6 +826,32 @@ export class BookingsRepository extends BaseRepository<Booking> {
createdTo: options.createdTo,
});
}
if (options.scheduledFrom) {
qb.andWhere('booking.scheduled_date >= :scheduledFrom', {
scheduledFrom: options.scheduledFrom,
});
}
if (options.scheduledTo) {
// Inclusive end-of-day: callers pass a date; include the whole day.
qb.andWhere('booking.scheduled_date <= :scheduledTo', {
scheduledTo: options.scheduledTo,
});
}
if (options.originYardId) {
qb.andWhere('booking.origin_yard_id = :originYardId', {
originYardId: options.originYardId,
});
}
if (options.destinationYardId) {
qb.andWhere('booking.destination_yard_id = :destinationYardId', {
destinationYardId: options.destinationYardId,
});
}
if (options.isGovernment === 'true') {
qb.andWhere('booking.is_government = TRUE');
} else if (options.isGovernment === 'false') {
qb.andWhere('booking.is_government = FALSE');
}
if (options.tradeDirection) {
qb.andWhere('booking.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection,
@@ -993,6 +1036,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.andWhere('sb.id IS NULL')
@@ -1023,6 +1068,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.origin_yard_id = :originYardId', { originYardId })
.andWhere('booking.destination_yard_id = :destinationYardId', {
@@ -1061,6 +1108,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
.andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
@@ -1125,6 +1174,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
@@ -1138,6 +1189,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.getMany();
@@ -1167,6 +1220,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
.innerJoin(
TrainScheduleBooking,
'sb',

View File

@@ -50,7 +50,8 @@ import { Booking } from './entities/booking.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { FileRecord } from '../files/entities/file.entity';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { PdfRenderService } from '../billing/documents/pdf-render.service';
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
export interface PaginatedBookings {
@@ -99,7 +100,7 @@ export class BookingsService {
private readonly containerTypesService: ContainerTypesService,
private readonly consolidationService: ConsolidationService,
private readonly vehiclesService: VehiclesService,
private readonly contractPdfService: ContractPdfService,
private readonly pdfRender: PdfRenderService,
private readonly events: EventEmitter2,
) {}
@@ -170,7 +171,12 @@ export class BookingsService {
);
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks);
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
// Chromium when available; otherwise the styled tabular fallback (never the
// generic text dump — the freight order is an outward-facing gate document).
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
label: 'freight order',
fallback: (prepared) => buildTabularFallbackPdf(prepared),
});
return {
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer,
@@ -262,21 +268,10 @@ export class BookingsService {
containers: string | null;
}>,
): string {
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
const assignedAt = booking.customerTruckAssignedAt
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
: '-';
const bookingRows: Array<[string, string | null | undefined]> = [
['Booking Reference', booking.reference],
['Client Name', booking.company?.name],
['Client ID', booking.companyId],
['Trade Direction', booking.tradeDirection],
['Freight Type', booking.freightType],
['Assigned At', assignedAt],
['Booking Status', booking.status],
];
const bookingRowHtml = bookingRows
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
.join('');
// Fall back to the legacy single-truck booking columns when there are no
// multi-truck rows (bookings assigned before the multi-truck feature).
@@ -297,44 +292,63 @@ export class BookingsService {
]
: [];
const truckBlocks = truckList
.map((t, i) => {
const rows: Array<[string, string | null | undefined]> = [
['Truck Plate Number', t.plateNumber],
['Driver Name', t.driverName],
['Truck Type', t.truckType],
['Containers Loaded', t.containers],
[
'Arrival',
t.arrivedAt ? new Date(t.arrivedAt).toLocaleString('en-GB') : 'Awaiting arrival',
],
];
const html = rows
.map(
([label, value]) =>
`<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`,
)
.join('');
return `<div class="truck"><h2>Truck ${i + 1}</h2><table>${html}</table></div>`;
})
const truckRows = truckList
.map(
(t, i) => `<tr>
<td class="num">${i + 1}</td>
<td>${esc(t.plateNumber)}</td>
<td>${esc(t.driverName)}</td>
<td>${esc(t.truckType)}</td>
<td>${esc(t.containers)}</td>
<td>${t.arrivedAt ? esc(new Date(t.arrivedAt).toLocaleString('en-GB')) : 'Awaiting arrival'}</td>
</tr>`,
)
.join('');
const copy = (watermark: string) => `
<section class="copy">
<div class="watermark">${this.escapeHtml(watermark)}</div>
<header>
<div class="watermark">${esc(watermark)}</div>
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Freight Order</h1>
<p>Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</p>
<div class="subtitle">Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</div>
</div>
<strong>${this.escapeHtml(booking.reference)}</strong>
</header>
<table>${bookingRowHtml}</table>
${truckBlocks}
<div class="meta">
Booking
<strong>${esc(booking.reference)}</strong>
Generated: ${esc(new Date().toLocaleString('en-GB'))}
</div>
</div>
<div class="summary">
<div class="tile"><span>Client</span><strong>${esc(booking.company?.name)}</strong></div>
<div class="tile"><span>Client ID</span><strong>${esc(booking.companyId)}</strong></div>
<div class="tile"><span>Trade direction</span><strong>${esc(booking.tradeDirection)}</strong></div>
<div class="tile"><span>Freight type</span><strong>${esc(booking.freightType)}</strong></div>
<div class="tile"><span>Assigned at</span><strong>${esc(assignedAt)}</strong></div>
<div class="tile"><span>Booking status</span><strong>${esc(booking.status)}</strong></div>
</div>
<table>
<thead>
<tr>
<th class="num">#</th>
<th>Truck plate</th>
<th>Driver</th>
<th>Truck type</th>
<th>Containers loaded</th>
<th>Arrival</th>
</tr>
</thead>
<tbody>${truckRows}</tbody>
</table>
<div class="notice">
Present this freight order at the warehouse gate. Each truck may only collect the
containers listed against it; the handover must be signed before any truck leaves.
</div>
<div class="signatures">
<div>Customer / Carrier Signature</div>
<div>Port Operations Verification</div>
<div>Gate Security Verification</div>
<div class="line">Customer / Carrier signature — date</div>
<div class="line">Port operations verification — date</div>
<div class="line">Gate security verification — date</div>
</div>
</section>`;
@@ -342,21 +356,30 @@ export class BookingsService {
<html>
<head>
<meta charset="utf-8" />
<title>Freight Order</title>
<style>
body { font-family: Arial, sans-serif; color: #10202f; margin: 0; }
.copy { position: relative; min-height: 46vh; padding: 28px 32px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; }
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; }
h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
h2 { margin: 18px 0 8px; font-size: 14px; color: #0a6f4d; }
p { margin: 4px 0 0; color: #64748b; }
strong { font-size: 16px; color: #0a9f6a; }
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; margin-bottom: 6px; }
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
th { width: 34%; background: #f1f5f9; }
.truck { page-break-inside: avoid; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; }
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
@page { size: A4 portrait; margin: 10mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
.copy { position: relative; padding: 24px 28px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 30px; font-weight: 800; color: rgba(15, 23, 42, 0.07); transform: rotate(-18deg); pointer-events: none; }
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
.subtitle { margin-top: 4px; color: #64748b; font-size: 12px; }
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
.meta strong { display: block; margin: 4px 0; color: #0f172a; font-size: 15px; }
.summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 14px 0; }
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 48px; }
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 6px 7px; font-size: 10.5px; vertical-align: top; }
.num { text-align: right; width: 26px; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 30px; position: relative; z-index: 1; }
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 30px; }
</style>
</head>
<body>
@@ -1125,6 +1148,30 @@ export class BookingsService {
);
}
/**
* Batched version of the findById flag: marks each page item whose booking
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
* dashboard) can show "Approve delivery" for exactly the generated→signed
* window. One query for the whole page.
*/
private async attachHandoverFlags(bookings: Booking[]): Promise<void> {
const ids = bookings.map((b) => b.id);
if (!ids.length) return;
const rows: Array<{ bookingId: string }> = await this.dataSource.query(
`SELECT DISTINCT booking_id AS "bookingId"
FROM freight.booking_handovers
WHERE booking_id = ANY($1::uuid[])
AND signed_at IS NULL AND deleted_at IS NULL
AND mile_type = 'SELF_HAUL'`,
[ids],
);
const pending = new Set(rows.map((r) => r.bookingId));
for (const b of bookings) {
(b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
pending.has(b.id);
}
}
async findAll(
filter: FilterBookingDto,
forceCompanyId?: string,
@@ -1135,7 +1182,7 @@ export class BookingsService {
const statusFilter = this.parseStatusFilter(filter);
const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);
return this.bookingsRepository.findAllPaginated({
const result = await this.bookingsRepository.findAllPaginated({
page,
pageSize,
...statusFilter,
@@ -1158,10 +1205,17 @@ export class BookingsService {
paymentStatus: filter.paymentStatus,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
scheduledFrom: filter.scheduledFrom,
scheduledTo: filter.scheduledTo,
originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment,
consolidationPaired: filter.consolidationPaired,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
await this.attachHandoverFlags(result.items ?? []);
return result;
}
/** Booking statuses at which a customer can pay (mirrors booking-payment.service). */
@@ -1371,11 +1425,17 @@ export class BookingsService {
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
scheduledFrom: filter.scheduledFrom,
scheduledTo: filter.scheduledTo,
originYardId: filter.originYardId,
destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment,
consolidationPaired: filter.consolidationPaired,
};
@@ -1424,12 +1484,14 @@ export class BookingsService {
schedule?.status ?? null;
}
// A generated-but-unsigned handover means the customer must approve delivery.
// Surfaced so the portal shows "Approve delivery" as soon as the handover
// exists, independent of the truck-arrival flag.
// A generated-but-unsigned SELF_HAUL handover means the customer must approve
// delivery from the portal (booking-based, one per booking). EDR last-mile
// handovers are per delivering truck and signed by the receiver at the door,
// so they never surface the portal "Approve delivery" action.
const [pendingHandover] = await this.dataSource.query(
`SELECT 1 FROM freight.booking_handovers
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
AND mile_type = 'SELF_HAUL'
LIMIT 1`,
[id],
);

View File

@@ -312,6 +312,13 @@ export class CustomerTruckService {
if (assignment.departedAt) {
throw new ConflictException('This truck has already left — its load is locked');
}
// Containers can only be loaded after the truck has physically arrived at the
// warehouse (arrival weighing recorded). Assignment alone is just planning.
if (!assignment.arrivedAt) {
throw new BadRequestException(
'Record the truck arrival before loading — containers can only be loaded onto an arrived truck',
);
}
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (!requested.length) {
@@ -333,12 +340,16 @@ export class CustomerTruckService {
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
// Operator loading the truck: stamp loaded_at so these containers move to
// the LOADED stage (customer assignment alone leaves loaded_at null).
const loadedAt = new Date();
await manager.getRepository(CustomerTruckContainer).save(
requested.map((containerNumber) =>
manager.getRepository(CustomerTruckContainer).create({
assignmentId,
bookingId,
containerNumber,
loadedAt,
}),
),
);

View File

@@ -81,6 +81,31 @@ export class FilterBookingDto {
@IsDateString()
createdTo?: string;
@ApiPropertyOptional({ description: 'Filter bookings scheduled on/after this date (ISO)' })
@IsOptional()
@IsDateString()
scheduledFrom?: string;
@ApiPropertyOptional({ description: 'Filter bookings scheduled on/before this date (ISO)' })
@IsOptional()
@IsDateString()
scheduledTo?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Filter by origin yard' })
@IsOptional()
@IsUUID()
originYardId?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Filter by destination yard' })
@IsOptional()
@IsUUID()
destinationYardId?: string;
@ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter government vs private bookings' })
@IsOptional()
@IsIn(['true', 'false'])
isGovernment?: 'true' | 'false';
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
@IsOptional()
@IsIn([...TRADE_DIRECTIONS])

View File

@@ -23,4 +23,12 @@ export class CustomerTruckContainer extends BaseEntity {
@Column({ name: 'container_number', type: 'varchar', length: 64 })
containerNumber!: string;
/**
* When the container was actually loaded onto the truck by the operator.
* Null = customer-assigned (planned) but not yet loaded. Stage LOADED requires
* this to be set, so customer assignment alone does not mark a container loaded.
*/
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
loadedAt?: Date | null;
}

View File

@@ -34,7 +34,10 @@ import {
ResponseCompanyDto,
ResponseCompanyProfileDto,
} from "./dto/response-company.dto";
import { ProfileLicenseFileView } from "./entities/company-profile.entity";
import {
CompanyDocumentFileView,
ProfileLicenseFileView,
} from "./entities/company-profile.entity";
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
@@ -218,7 +221,7 @@ export class CompaniesController {
@Post("company-profile")
@ApiOperation({
summary:
"Create a single operational profile for the current user's company and make it the active mode",
"Create a single operational profile for the current user's company. The role starts pending and does not become the active mode",
})
async createCompanyProfile(
@CurrentUser() user: CurrentIamUser,
@@ -306,6 +309,49 @@ export class CompaniesController {
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
}
@Get("poa-delegation")
@ApiOperation({
summary:
"List the Power of Attorney delegation letter (with review state) for the current user's company",
})
async listPoaDelegation(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyDocumentFileView[]> {
return this.companiesService.listPoaDelegationFiles(user.id);
}
@Post("poa-delegation")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"Upload the Power of Attorney delegation letter, replacing any existing one. " +
"For an approved company the upload is staged for backoffice review; during " +
"onboarding it goes live.",
})
async uploadPoaDelegation(
@CurrentUser() user: CurrentIamUser,
@UploadedFiles() files: Array<Express.Multer.File>,
): Promise<CompanyDocumentFileView[]> {
const file = files?.[0];
if (!file) {
throw new BadRequestException("A delegation letter file is required");
}
return this.companiesService.uploadPoaDelegationLetter(user.id, file);
}
@Delete("poa-delegation/:fileId")
@ApiOperation({
summary:
"Remove the Power of Attorney delegation letter (staged for review on an approved company).",
})
async removePoaDelegation(
@CurrentUser() user: CurrentIamUser,
@Param("fileId", ParseUUIDPipe) fileId: string,
): Promise<CompanyDocumentFileView[]> {
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
}
@Patch("active-mode")
@ApiOperation({
summary: "Switch the current user's active operational mode (importer/exporter)",

View File

@@ -1,9 +1,11 @@
import { Module } from "@nestjs/common";
import { Module, forwardRef } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { HttpModule } from "@nestjs/axios";
import { FilesModule } from "../files/files.module";
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
import { MinioModule } from "../minio/minio.module";
import { NotificationsModule } from "../notifications/notifications.module";
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
import { CompaniesController } from "./companies.controller";
import { CompaniesService } from "./companies.service";
import { CompaniesRepository } from "./companies.repository";
@@ -17,6 +19,7 @@ import { Booking } from "../bookings/entities/booking.entity";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
@Module({
imports: [
@@ -31,6 +34,10 @@ import { ETradeService } from "./services/etrade.service";
FilesModule,
FileUploadSettingsModule,
MinioModule,
// Account-status notifications (CompanyNotifierService). The inbox module
// imports this module back for portal recipient targeting, hence forwardRef.
NotificationsModule,
forwardRef(() => NotificationInboxModule),
],
controllers: [CompaniesController],
providers: [
@@ -41,6 +48,7 @@ import { ETradeService } from "./services/etrade.service";
CompanyChangeRequestRepository,
CompanyDashboardRepository,
ETradeService,
CompanyNotifierService,
],
exports: [
CompaniesService,

View File

@@ -17,6 +17,7 @@ import { FilesService } from "../files/files.service";
import { FileRecord } from "../files/entities/file.entity";
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import { CreateCompanyDto } from "./dto/create-company.dto";
@@ -37,6 +38,7 @@ import {
import { ExternalProfile } from "./entities/external-profile.entity";
import {
BusinessLicenseFile,
CompanyDocumentFileView,
CompanyProfile,
ProfileLicenseFileView,
ProfileType,
@@ -45,6 +47,7 @@ import {
import {
ChangeRequestStatus,
CompanyChangeRequest,
DocumentChangeIntent,
LicenseChangeIntent,
} from "./entities/company-change-request.entity";
@@ -54,6 +57,27 @@ const LICENSE_CODE = "business_license";
/** Code for a license file staged in an open change request (not yet live). */
const LICENSE_PENDING_CODE = "business_license_pending";
/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */
const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
/** Code for a PoA letter staged in an open change request (not yet live). */
const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
/** FileRecord resource that company-level documents are stored under. */
const COMPANY_RESOURCE = "companies";
/** company.attributes keys that together mean "a PoA was entered". */
const POA_ATTRIBUTES = [
"poaName",
"poaPhone",
"poaEmail",
"poaLocation",
"poaAddress",
] as const;
/** Mandatory once the company operates as a freight forwarder. */
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
{ key: "poaName", label: "PoA name" },
{ key: "poaEmail", label: "PoA email" },
{ key: "poaPhone", label: "PoA phone" },
];
export interface UserIdentity {
userId: string;
firstName: string;
@@ -73,6 +97,7 @@ export class CompaniesService {
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
private readonly etradeService: ETradeService,
private readonly companyNotifier: CompanyNotifierService,
) { }
/**
@@ -562,9 +587,13 @@ export class CompaniesService {
}
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
await this.findCompanyById(id);
const before = await this.findCompanyById(id);
const updated = await this.companiesRepo.update(id, dto);
if (!updated) throw new NotFoundException(`Company ${id} not found`);
// Suspending or blacklisting locks the customer out, so they must be told.
// This is the only path that writes those statuses.
this.companyNotifier.statusChanged(updated, before.status);
return updated;
}
@@ -765,6 +794,7 @@ export class CompaniesService {
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
await this.companiesRepo.update(company.id, companyUpdates);
await this.applyLicenseChanges(request);
await this.applyDocumentChanges(request);
return (
(await this.changeRequestRepo.update(id, {
@@ -817,7 +847,12 @@ export class CompaniesService {
if (existing) {
const prev = existing.documents?.documentFileIds ?? [];
await this.changeRequestRepo.update(existing.id, {
documents: { documentFileIds: [...prev, ...fileIds] },
// Spread the existing documents blob: a bare object would drop any
// licenseChanges/documentChanges already staged on this request.
documents: {
...existing.documents,
documentFileIds: [...prev, ...fileIds],
},
submittedBy: submittedBy ?? existing.submittedBy ?? null,
submittedAt: now,
note: null,
@@ -849,12 +884,17 @@ export class CompaniesService {
);
}
await this.discardLicenseChanges(request);
await this.discardDocumentChanges(request);
return (
(await this.changeRequestRepo.update(id, {
status: ChangeRequestStatus.Rejected,
// Staged license uploads were just discarded; drop their intents so an
// amended resubmit never re-references deleted files.
documents: { ...request.documents, licenseChanges: [] },
// Staged license/document uploads were just discarded; drop their intents
// so an amended resubmit never re-references deleted files.
documents: {
...request.documents,
licenseChanges: [],
documentChanges: [],
},
note,
reviewedBy: reviewerId ?? null,
reviewedAt: new Date(),
@@ -1017,13 +1057,12 @@ export class CompaniesService {
);
}
const reference = await this.companyProfilesRepo.generateReference(type);
// No reference is minted here: it is issued by setCompanyProfileStatus when
// a reviewer approves the role. Creating it Active would bypass that review.
return this.companyProfilesRepo.create({
companyId,
type,
reference,
status: ProfileStatus.Active,
status: ProfileStatus.Pending,
});
}
@@ -1097,9 +1136,11 @@ export class CompaniesService {
}
/**
* Create a single operational profile for the current user's company and
* make it the active mode in the same call. Powers the header "Switch to
* Exporter/Importer" flow when the target profile doesn't exist yet.
* Create a single operational profile for the current user's company. The new
* role starts Pending, so it deliberately does NOT become the active mode:
* switching onto an unapproved profile would strip the user of `canBook` and
* block them from creating contracts under the role they already had approved.
* Callers switch explicitly via {@link setActiveMode} once the role is Active.
*/
async createCompanyProfileForUser(
userId: string,
@@ -1122,8 +1163,7 @@ export class CompaniesService {
let created = await this.companyProfilesRepo.findByType(companyId, type);
if (!created) {
// New self-service roles start Pending (awaiting backoffice approval) and
// carry no reference until approved. The customer can select this mode but
// can't book under it until it's cleared.
// carry no reference until approved.
created = await this.companyProfilesRepo.create({
companyId,
type,
@@ -1132,8 +1172,6 @@ export class CompaniesService {
});
}
await this.profilesRepo.update(profile.id, { activeProfileType: type });
return created;
}
@@ -1240,6 +1278,29 @@ export class CompaniesService {
);
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
// 4. Power of Attorney. Optional in general, but a freight forwarder acts on
// other companies' behalf so its PoA is mandatory. Either way, a PoA that
// has been entered must be evidenced by the delegation letter.
const poaRequired = (company.companyProfiles ?? []).some(
(p) => p.type === ProfileType.freightForwarder,
);
const poaProvided = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(),
);
const missingPoaFields = poaRequired
? REQUIRED_POA_FIELDS.filter(
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
)
: [];
// Only gate on the letter once the document set actually carries the field.
const delegationField = (setting?.fields ?? []).find(
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
);
const missingDelegation =
Boolean(delegationField) &&
(poaRequired || poaProvided) &&
!uploadedCodes.has(POA_DELEGATION_FILE_KEY);
const outstanding = [
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
@@ -1247,18 +1308,31 @@ export class CompaniesService {
(p) =>
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
),
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
...(missingDelegation
? ["Upload the delegation letter for your Power of Attorney"]
: []),
];
// Progress spans every required item the user has to satisfy: company-info
// fields, required documents and one license per operational profile.
// fields, required documents, one license per operational profile, and the
// PoA details/letter whenever those are mandatory.
const requiredDocCount = documents.filter((d) => d.isRequired).length;
const poaItemCount =
(poaRequired ? REQUIRED_POA_FIELDS.length : 0) +
(delegationField && (poaRequired || poaProvided) ? 1 : 0);
const total =
this.REQUIRED_COMPANY_INFO.length +
requiredDocCount +
licenseProfiles.length;
licenseProfiles.length +
poaItemCount;
const completed =
total -
(missingInfo.length + missingDocs.length + missingLicenses.length);
(missingInfo.length +
missingDocs.length +
missingLicenses.length +
missingPoaFields.length +
(missingDelegation ? 1 : 0));
return new OnboardingRequirementsResponseDto({
documentSettingCode,
@@ -1266,6 +1340,13 @@ export class CompaniesService {
companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo },
documents,
licenseProfiles,
poa: {
required: poaRequired,
provided: poaProvided,
delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY),
missingFields: missingPoaFields,
complete: missingPoaFields.length === 0 && !missingDelegation,
},
progress: { completed, total },
isComplete: outstanding.length === 0,
onboardingCompleted: profile.onboardingCompleted,
@@ -1365,10 +1446,12 @@ export class CompaniesService {
// browser (which fails on the internal bucket endpoint).
/**
* Upload business-license file(s) for one of the user's profiles. During
* onboarding (company not yet Active) they go live immediately; for an Active
* company they're staged under the pending code and recorded as `add` intents
* on a pending change request for backoffice review. Returns the updated view.
* Upload business-license file(s) for one of the user's profiles. For a role
* not yet approved (a fresh onboarding profile, or a newly added service on an
* already-active company) they go live immediately and are reviewed together
* with the role itself. Only for an already-approved role are they staged under
* the pending code and recorded as `add` intents on a pending change request —
* a licence swap on a live role is a change; a licence on a new role is not.
*/
async addProfileLicenseFiles(
userId: string,
@@ -1377,7 +1460,7 @@ export class CompaniesService {
): Promise<ProfileLicenseFileView[]> {
const profile = await this.resolveOwnedProfile(userId, profileId);
const company = await this.findCompanyById(profile.companyId);
const gated = company.status === CompanyStatus.Active;
const gated = profile.status === ProfileStatus.Active;
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
const uploaded = await Promise.all(
@@ -1409,9 +1492,9 @@ export class CompaniesService {
/**
* Remove a license file. A staged (pending) file is withdrawn outright
* (soft-deleted, its `add` intent dropped). A live file on an Active company
* is kept and recorded as a `remove` intent for review; during onboarding it
* is deleted immediately.
* (soft-deleted, its `add` intent dropped). A live file on an already-approved
* role is kept and recorded as a `remove` intent for review; on a role still
* awaiting approval it is deleted immediately.
*/
async removeProfileLicenseFile(
userId: string,
@@ -1427,7 +1510,7 @@ export class CompaniesService {
throw new NotFoundException(`License file ${fileId} not found`);
}
const company = await this.findCompanyById(profile.companyId);
const gated = company.status === CompanyStatus.Active;
const gated = profile.status === ProfileStatus.Active;
if (record.code === LICENSE_PENDING_CODE) {
// Withdraw a not-yet-approved upload: delete it and drop its add intent.
@@ -1449,7 +1532,7 @@ export class CompaniesService {
/**
* Replace a live license file with a freshly uploaded one — recorded as a
* `remove` of the old file plus an `add` of the new, so approval swaps them
* atomically. During onboarding the swap is applied immediately.
* atomically. On a role still awaiting approval the swap is applied immediately.
*/
async replaceProfileLicenseFile(
userId: string,
@@ -1463,7 +1546,7 @@ export class CompaniesService {
throw new NotFoundException(`License file ${fileId} not found`);
}
const company = await this.findCompanyById(profile.companyId);
const gated = company.status === CompanyStatus.Active;
const gated = profile.status === ProfileStatus.Active;
const created = await this.filesService.upload({
resourceId: profileId,
@@ -1671,6 +1754,254 @@ export class CompaniesService {
}
}
// ---------------------------------------------------------------------------
// Power of Attorney delegation letter
//
// A company-level document that follows the same staged-review model as the
// business license: on an approved (Active) company an upload lands under the
// pending code and the live letter is flagged for removal, so the reviewer
// sees both and approval swaps them atomically. During onboarding it goes live.
// ---------------------------------------------------------------------------
/** The company's PoA letter(s), with each file's review status resolved. */
async listPoaDelegationFiles(
userId: string,
): Promise<CompanyDocumentFileView[]> {
const { company } = await this.getCompanyInfoByUserId(userId);
return this.getPoaDelegationView(company.id);
}
/**
* Upload the PoA delegation letter, replacing whatever is already on file.
* On an Active company this stages an `add` for the new file plus a `remove`
* for each live one; a letter still awaiting approval is withdrawn outright
* rather than stacking a second pending upload.
*/
async uploadPoaDelegationLetter(
userId: string,
file: Express.Multer.File,
): Promise<CompanyDocumentFileView[]> {
const { company } = await this.getCompanyInfoByUserId(userId);
const gated = company.status === CompanyStatus.Active;
const records = await this.filesService.findByResource(
company.id,
COMPANY_RESOURCE,
);
const live = records.filter((r) => r.code === POA_DELEGATION_FILE_KEY);
const staged = records.filter(
(r) => r.code === POA_DELEGATION_PENDING_CODE,
);
// Supersede an unreviewed upload instead of queueing another one.
for (const r of staged) {
await this.filesService.remove(r.id);
await this.withdrawDocumentIntent(company.id, r.id);
}
const created = await this.filesService.upload({
resourceId: company.id,
resource: COMPANY_RESOURCE,
code: gated ? POA_DELEGATION_PENDING_CODE : POA_DELEGATION_FILE_KEY,
file,
});
if (gated) {
await this.stageDocumentIntent(
company.id,
[
...live.map((r) => ({
op: "remove" as const,
fileId: r.id,
code: POA_DELEGATION_FILE_KEY,
fileName: r.name,
})),
{
op: "add" as const,
fileId: created.id,
code: POA_DELEGATION_FILE_KEY,
fileName: created.name,
},
],
userId,
);
} else {
// Onboarding: no review, so the old letter is simply replaced.
for (const r of live) await this.filesService.remove(r.id);
}
return this.getPoaDelegationView(company.id);
}
/**
* Remove the PoA letter. A staged upload is withdrawn outright; a live file on
* an Active company is kept and flagged for deletion on approval; during
* onboarding it is deleted immediately.
*/
async removePoaDelegationLetter(
userId: string,
fileId: string,
): Promise<CompanyDocumentFileView[]> {
const { company } = await this.getCompanyInfoByUserId(userId);
const record = await this.filesService.findById(fileId);
if (
record.resource !== COMPANY_RESOURCE ||
record.resourceId !== company.id ||
(record.code !== POA_DELEGATION_FILE_KEY &&
record.code !== POA_DELEGATION_PENDING_CODE)
) {
throw new NotFoundException(`Delegation letter ${fileId} not found`);
}
if (record.code === POA_DELEGATION_PENDING_CODE) {
await this.filesService.remove(fileId);
await this.withdrawDocumentIntent(company.id, fileId);
} else if (company.status === CompanyStatus.Active) {
await this.stageDocumentIntent(
company.id,
[
{
op: "remove",
fileId,
code: POA_DELEGATION_FILE_KEY,
fileName: record.name,
},
],
userId,
);
} else {
await this.filesService.remove(fileId);
}
return this.getPoaDelegationView(company.id);
}
private async getPoaDelegationView(
companyId: string,
): Promise<CompanyDocumentFileView[]> {
const pending =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
const removeIds = new Set(
(pending?.documents?.documentChanges ?? [])
.filter((c) => c.op === "remove")
.map((c) => c.fileId),
);
const records = await this.filesService.findByResource(
companyId,
COMPANY_RESOURCE,
);
return records
.filter(
(r) =>
r.code === POA_DELEGATION_FILE_KEY ||
r.code === POA_DELEGATION_PENDING_CODE,
)
.map((r) => ({
id: r.id,
name: r.name,
size: r.size,
mimeType: r.mimeType,
status:
r.code === POA_DELEGATION_PENDING_CODE
? ("pending_add" as const)
: removeIds.has(r.id)
? ("pending_remove" as const)
: ("live" as const),
}));
}
/** Open or append a pending change request recording document add/remove intents. */
private async stageDocumentIntent(
companyId: string,
changes: DocumentChangeIntent[],
submittedBy?: string,
): Promise<void> {
if (changes.length === 0) return;
const now = new Date();
const existing =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
if (existing) {
const prev = existing.documents?.documentChanges ?? [];
// Re-uploading twice before review would otherwise stage a second `remove`
// for the same live file, and the duplicate would fail on approval.
const seen = new Set(prev.map((c) => `${c.op}:${c.fileId}`));
const fresh = changes.filter((c) => !seen.has(`${c.op}:${c.fileId}`));
if (fresh.length === 0) return;
await this.changeRequestRepo.update(existing.id, {
documents: {
...existing.documents,
documentChanges: [...prev, ...fresh],
},
submittedBy: submittedBy ?? existing.submittedBy ?? null,
submittedAt: now,
note: null,
});
} else {
await this.changeRequestRepo.create({
companyId,
snapshot: {},
documents: { documentChanges: changes },
status: ChangeRequestStatus.Pending,
submittedBy: submittedBy ?? null,
submittedAt: now,
});
}
}
/**
* Drop a staged document intent referencing `fileId`. If that empties the
* request entirely, delete it so the customer's settings page unlocks.
*/
private async withdrawDocumentIntent(
companyId: string,
fileId: string,
): Promise<void> {
const existing =
await this.changeRequestRepo.findPendingByCompanyId(companyId);
if (!existing) return;
const remaining = (existing.documents?.documentChanges ?? []).filter(
(c) => c.fileId !== fileId,
);
const docs = existing.documents ?? {};
const stillHasWork =
remaining.length > 0 ||
(docs.licenseChanges?.length ?? 0) > 0 ||
(docs.documentFileIds?.length ?? 0) > 0 ||
Object.keys(existing.snapshot ?? {}).length > 0;
if (stillHasWork) {
await this.changeRequestRepo.update(existing.id, {
documents: { ...docs, documentChanges: remaining },
});
} else {
await this.changeRequestRepo.softDelete(existing.id);
}
}
/** Apply a request's staged document changes: promote adds, delete removes. */
private async applyDocumentChanges(
request: CompanyChangeRequest,
): Promise<void> {
for (const change of request.documents?.documentChanges ?? []) {
if (change.op === "add") {
await this.filesService.setCode(change.fileId, change.code);
} else {
await this.filesService.remove(change.fileId);
}
}
}
/** Discard a rejected request's staged document uploads (adds only). */
private async discardDocumentChanges(
request: CompanyChangeRequest,
): Promise<void> {
for (const change of request.documents?.documentChanges ?? []) {
if (change.op === "add") {
await this.filesService.remove(change.fileId);
}
}
}
/**
* Resolve which company_profile a new booking belongs to, from the company
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
@@ -1719,13 +2050,17 @@ export class CompaniesService {
}
async fetchETradeData(tin: string) {
const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
const { businessInfo, companyInfo } =
await this.etradeService.resolveCompanyData(tin);
if (!businessInfo) {
throw new BadRequestException(
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
);
}
const registrationData = this.etradeService.extractRegistrationData(businessInfo);
const registrationData = this.etradeService.extractRegistrationData(
businessInfo,
companyInfo,
);
const tinTaken = await this.companiesRepo.existsByTin(tin);
return { ...registrationData, tinTaken };
}

View File

@@ -0,0 +1,88 @@
import { Repository } from "typeorm";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import {
ChangeRequestStatus,
CompanyChangeRequest,
} from "./entities/company-change-request.entity";
type Row = Pick<CompanyChangeRequest, "id" | "status"> & { createdAt: Date };
const COMPANY_ID = "company-1";
/**
* Stands in for the TypeORM repository over a fixed set of rows, honouring the
* `where.status` filter and the `createdAt DESC` ordering findOne relies on.
*/
function mockRepositoryOver(rows: Row[]) {
return {
findOne: jest.fn(
({ where }: { where: Partial<Row> & { companyId: string } }) =>
Promise.resolve(
rows
.filter(
(row) =>
where.companyId === COMPANY_ID &&
(where.status === undefined || row.status === where.status),
)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ??
null,
),
),
} as unknown as Repository<CompanyChangeRequest>;
}
function subject(rows: Row[]) {
return new CompanyChangeRequestRepository(mockRepositoryOver(rows));
}
describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => {
const rejected: Row = {
id: "rejected",
status: ChangeRequestStatus.Rejected,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
};
it("returns the pending request when one is open", async () => {
const pending: Row = {
id: "pending",
status: ChangeRequestStatus.Pending,
createdAt: new Date("2026-01-02T00:00:00.000Z"),
};
const result = await subject([rejected, pending]).findLatestOpenByCompanyId(
COMPANY_ID,
);
expect(result?.id).toBe("pending");
});
it("returns the latest rejected request when nothing is pending", async () => {
const result = await subject([rejected]).findLatestOpenByCompanyId(
COMPANY_ID,
);
expect(result?.id).toBe("rejected");
});
it("returns null once a resubmit of a rejected request is approved", async () => {
const approved: Row = {
id: "approved",
status: ChangeRequestStatus.Approved,
createdAt: new Date("2026-01-02T00:00:00.000Z"),
};
const result = await subject([
rejected,
approved,
]).findLatestOpenByCompanyId(COMPANY_ID);
expect(result).toBeNull();
});
it("returns null when the company has no requests", async () => {
const result = await subject([]).findLatestOpenByCompanyId(COMPANY_ID);
expect(result).toBeNull();
});
});

View File

@@ -28,18 +28,23 @@ export class CompanyChangeRequestRepository extends BaseRepository<CompanyChange
/**
* The company's latest "open" request — pending (locks the customer) or the
* most recent rejected one (drives the reapply banner + prefill). Approved
* requests are terminal and ignored here.
* most recent rejected one (drives the reapply banner + prefill).
*
* Only the company's newest request may be open. A rejection is superseded the
* moment the customer resubmits: that resubmit opens a *new* request, so once
* it is approved the newest request is terminal and nothing is open — even
* though the older rejected row still sits in the table as history.
*/
async findLatestOpenByCompanyId(
companyId: string,
): Promise<CompanyChangeRequest | null> {
const pending = await this.findPendingByCompanyId(companyId);
if (pending) return pending;
return this.repository.findOne({
where: { companyId, status: ChangeRequestStatus.Rejected },
const latest = await this.repository.findOne({
where: { companyId },
order: { createdAt: "DESC" },
});
return latest?.status === ChangeRequestStatus.Rejected ? latest : null;
}
async findById(id: string): Promise<CompanyChangeRequest | null> {

View File

@@ -0,0 +1,86 @@
import { Injectable, Logger } from "@nestjs/common";
import {
NotificationAudience,
NotificationPriority,
NotificationType,
} from "@edr/types";
import { Company, CompanyStatus } from "./entities/company.entity";
import { NotificationsService } from "../notifications/notifications.service";
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
/** Account statuses that lock the customer out and therefore must be told to them. */
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
CompanyStatus.Suspended,
CompanyStatus.Blacklisted,
];
/**
* Customer notifications for company account-status changes. Mirrors
* {@link ContractNotifierService}: SMS + email direct to the company contact,
* plus a persisted in-app item. Every send is fire-and-forget and never throws —
* a notification failure must not roll back the status change itself.
*/
@Injectable()
export class CompanyNotifierService {
private readonly logger = new Logger(CompanyNotifierService.name);
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
) {}
/** Send SMS + email to the company contact; log-only on failure. */
private async notifyContact(company: Company, message: string): Promise<void> {
const phone = company.contactPersonPhone ?? company.phone ?? null;
const email = company.email ?? company.generalManagerEmail ?? null;
if (phone) {
try {
await this.notifications.directSend("sms", phone, message);
} catch (err) {
this.logger.warn(`SMS failed for ${company.id}: ${(err as Error).message}`);
}
}
if (email) {
try {
await this.notifications.directSend("email", email, message);
} catch (err) {
this.logger.warn(`Email failed for ${company.id}: ${(err as Error).message}`);
}
}
if (!phone && !email) {
this.logger.warn(`No contact on file for ${company.id} — not notified`);
}
}
/**
* Tell the customer their account was suspended or blacklisted. Called only on
* a real transition into one of those statuses; other status writes are silent.
*/
statusChanged(company: Company, previous: CompanyStatus): void {
const status = company.status;
if (status === previous) return;
if (!PUNITIVE_STATUSES.includes(status)) return;
const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted";
const title = `Account ${label}`;
const body =
`Your company account has been ${label}. ` +
`You will not be able to submit new contracts or bookings. ` +
`Please contact EDR support for assistance.`;
this.logger.log(`ACCOUNT_${label.toUpperCase()}${company.id}`);
void this.notifyContact(company, `${title}. ${body}`);
void this.inbox.notify({
recipients: { companyId: company.id },
audience: NotificationAudience.PORTAL,
type: NotificationType.ACCOUNT_STATUS,
title,
body,
link: "/settings",
data: { companyId: company.id, status },
priority: NotificationPriority.HIGH,
});
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable } from "@nestjs/common";
import { Injectable, InternalServerErrorException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
@@ -20,6 +20,11 @@ const PREFIX_MAP: Record<ProfileType, string> = {
[ProfileType.transporter]: "TR",
};
const SERIES_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
/** Numbers per series letter: A00001..A99999, then B00001. */
const SERIES_SIZE = 99_999;
@Injectable()
export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
constructor(
@@ -38,9 +43,20 @@ export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
const result = await this.repository.query(
`SELECT nextval('${seqName}') AS next_id`,
);
const nextId = result[0].next_id as number;
const nextId = Number(result[0].next_id);
const offset = nextId - 1;
const seriesIndex = Math.floor(offset / SERIES_SIZE);
if (seriesIndex >= SERIES_LETTERS.length) {
throw new InternalServerErrorException(
`Company profile reference series exhausted for type "${type}"`,
);
}
const letter = SERIES_LETTERS[seriesIndex];
const number = (offset % SERIES_SIZE) + 1;
const prefix = PREFIX_MAP[type];
return `${prefix}-${String(nextId).padStart(5, "0")}`;
return `${prefix}-${letter}${String(number).padStart(5, "0")}`;
}
async findByCompanyId(companyId: string): Promise<CompanyProfile[]> {

View File

@@ -1,6 +1,7 @@
import {
ChangeRequestStatus,
CompanyChangeRequest,
DocumentChangeIntent,
LicenseChangeIntent,
} from "../entities/company-change-request.entity";
@@ -18,6 +19,8 @@ export class ChangeRequestResponseDto {
documentFileIds: string[];
/** Staged business-license add/remove intents attached to this request. */
licenseChanges: LicenseChangeIntent[];
/** Staged company-document add/remove intents (e.g. the PoA letter). */
documentChanges: DocumentChangeIntent[];
note: string | null;
submittedBy: string | null;
submittedAt: Date | null;
@@ -33,6 +36,7 @@ export class ChangeRequestResponseDto {
this.snapshot = req.snapshot ?? {};
this.documentFileIds = req.documents?.documentFileIds ?? [];
this.licenseChanges = req.documents?.licenseChanges ?? [];
this.documentChanges = req.documents?.documentChanges ?? [];
this.note = req.note ?? null;
this.submittedBy = req.submittedBy ?? null;
this.submittedAt = req.submittedAt ?? null;

View File

@@ -1,6 +1,7 @@
import { CompanyRegistrationData } from "@edr/types";
export class ETradeResponseDto implements CompanyRegistrationData {
companyName!: string;
licenceNumber!: string;
statusDescription!: string;
dateRegistered!: string;
@@ -20,6 +21,7 @@ export class ETradeResponseDto implements CompanyRegistrationData {
tinTaken?: boolean;
constructor(data: CompanyRegistrationData) {
this.companyName = data.companyName;
this.licenceNumber = data.licenceNumber;
this.statusDescription = data.statusDescription;
this.dateRegistered = data.dateRegistered;

View File

@@ -35,6 +35,19 @@ export interface OnboardingLicenseProfile {
uploaded: boolean;
}
export interface OnboardingPoaState {
/** True when the company operates as a freight forwarder — PoA is mandatory. */
required: boolean;
/** True once any PoA detail has been entered. */
provided: boolean;
/** True when the delegation letter is stored for the company. */
delegationLetterUploaded: boolean;
/** PoA details still missing (only populated when `required`). */
missingFields: OnboardingInfoField[];
/** False while the PoA step still owes details or a delegation letter. */
complete: boolean;
}
export class OnboardingRequirementsResponseDto {
/** Resolved document setting code (by nationality) the docs were drawn from. */
documentSettingCode: string;
@@ -52,6 +65,9 @@ export class OnboardingRequirementsResponseDto {
/** Per-operational-profile business-license requirements. */
licenseProfiles: OnboardingLicenseProfile[];
/** Power of Attorney state, so the wizard needn't re-derive the rule. */
poa: OnboardingPoaState;
/** Overall setup progress across fields + documents + licenses. */
progress: { completed: number; total: number };
@@ -70,6 +86,7 @@ export class OnboardingRequirementsResponseDto {
this.companyInfo = init.companyInfo;
this.documents = init.documents;
this.licenseProfiles = init.licenseProfiles;
this.poa = init.poa;
this.progress = init.progress;
this.isComplete = init.isComplete;
this.onboardingCompleted = init.onboardingCompleted;

View File

@@ -30,12 +30,34 @@ export interface LicenseChangeIntent {
fileName?: string;
}
/**
* A staged change to a company-level document, awaiting review. Same semantics
* as {@link LicenseChangeIntent} but keyed by the document's FileRecord `code`
* (e.g. `poa_delegation_letter`) rather than a profile: `add` → uploaded under
* the pending code, promoted to `code` on approval; `remove` → a live file that
* is deleted on approval. A replace is a `remove` plus an `add`.
*/
export interface DocumentChangeIntent {
op: "add" | "remove";
fileId: string;
/** The live FileRecord code this op targets (the upload setting's fileKey). */
code: string;
/** File name, snapshotted for the backoffice review screen. */
fileName?: string;
}
/** File references staged alongside a change request (documents/licenses). */
export interface ChangeRequestDocuments {
/** FileRecord ids uploaded against the company while this request was open. */
/**
* FileRecord ids uploaded against the company while this request was open.
* These go live immediately — only their ids are recorded, for the reviewer.
* Contrast `documentChanges`, which stages the file behind the pending code.
*/
documentFileIds?: string[];
/** Staged per-profile business-license add/remove intents. */
licenseChanges?: LicenseChangeIntent[];
/** Staged company-level document add/remove intents (e.g. the PoA letter). */
documentChanges?: DocumentChangeIntent[];
}
@Entity({ schema: "freight", name: "company_change_request" })

View File

@@ -31,17 +31,28 @@ export interface BusinessLicenseFile {
mimeType?: string;
}
/**
* `live` — approved & in effect; `pending_add` — uploaded, awaiting approval;
* `pending_remove` — live but flagged for deletion on approval.
*/
export type StagedFileStatus = "live" | "pending_add" | "pending_remove";
/** A business-license file plus its change-review state, surfaced to clients. */
export interface ProfileLicenseFileView {
id: string;
name: string;
size: number;
mimeType: string;
/**
* `live` — approved & in effect; `pending_add` — uploaded, awaiting approval;
* `pending_remove` — live but flagged for deletion on approval.
*/
status: "live" | "pending_add" | "pending_remove";
status: StagedFileStatus;
}
/** A company-level document (e.g. the PoA letter) with its change-review state. */
export interface CompanyDocumentFileView {
id: string;
name: string;
size: number;
mimeType: string;
status: StagedFileStatus;
}
@Entity({ schema: "freight", name: "company_profiles" })
@@ -60,7 +71,7 @@ export class CompanyProfile extends BaseEntity {
type!: ProfileType;
/**
* Official profile reference (e.g. "EX-00001"). Minted only when the profile
* Official profile reference (e.g. "EX-A00001"). Minted only when the profile
* is approved (status → Active); pending/unapproved profiles carry NULL.
* The unique index tolerates this because Postgres treats NULLs as distinct.
* API responses surface it as "" when absent — see ResponseCompanyProfileDto.
@@ -73,11 +84,17 @@ export class CompanyProfile extends BaseEntity {
})
reference!: string | null;
/**
* A newly requested operational role is unreviewed, so it defaults to Pending.
* Only {@link CompaniesService.setCompanyProfileStatus} may promote it to
* Active — an approved-by-default role would let a customer self-grant a
* service (e.g. importer) without any documentation review.
*/
@Column({
name: "status",
type: "varchar",
length: 32,
default: ProfileStatus.Active,
default: ProfileStatus.Pending,
})
status!: ProfileStatus;

View File

@@ -87,12 +87,21 @@ export class ETradeService {
}
}
/**
* `companyInfo` carries the registered organization name (`BusinessName`);
* `businessInfo` only carries the licence's `TradeName`. Pass both so the
* company name resolves to the legal entity rather than the trade name — and
* never to `ManagerNameEng`, which is the manager's personal name.
*/
extractRegistrationData(
businessInfo: ETradeBusinessInfo,
companyInfo?: ETradeCompanyInfo,
): CompanyRegistrationData {
const primaryManager = businessInfo.AssociateShortInfos?.[0];
return {
companyName:
companyInfo?.BusinessName?.trim() || businessInfo.TradeName?.trim() || "",
licenceNumber: businessInfo.LicenceNumber,
statusDescription: businessInfo.StatusDescription,
dateRegistered: businessInfo.DateRegistered,

View File

@@ -0,0 +1,97 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { ContractTemplatesService } from "./contract-templates.service";
import {
CreateArticleDto,
PreviewContractTemplateDto,
ReplaceArticlesDto,
UpdateArticleDto,
UpdateContractTemplateDto,
} from "./dto/contract-template.dto";
@ApiTags("contract-templates")
@Controller("contract-templates")
export class ContractTemplatesController {
constructor(private readonly service: ContractTemplatesService) {}
// Reads stay open to authenticated staff (the backoffice Templates tab);
// writes are admin-guarded like other freight configuration resources.
@Get()
@ApiOperation({ summary: "List the six contract document templates" })
list() {
return this.service.list();
}
@Get(":code")
@ApiOperation({ summary: "Get one contract template by code" })
getByCode(@Param("code") code: string) {
return this.service.getByCode(code);
}
@Patch(":code")
@FreightAdmin()
@ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" })
update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) {
return this.service.update(code, dto);
}
@Post(":code/preview")
@ApiOperation({
summary: "Render an HTML preview of the template against mock contract data",
})
preview(
@Param("code") code: string,
@Body() dto: PreviewContractTemplateDto,
) {
return this.service.preview(code, dto);
}
/* ------------------------- article routes ------------------------- */
@Put(":code/articles")
@FreightAdmin()
@ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" })
replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) {
return this.service.replaceArticles(code, dto.articles);
}
@Post(":code/articles")
@FreightAdmin()
@ApiOperation({ summary: "Add an article to the template" })
addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) {
return this.service.addArticle(code, dto);
}
@Patch(":code/articles/:articleId")
@FreightAdmin()
@ApiOperation({ summary: "Update an article's title or body" })
updateArticle(
@Param("code") code: string,
@Param("articleId") articleId: string,
@Body() dto: UpdateArticleDto,
) {
return this.service.updateArticle(code, articleId, dto);
}
@Delete(":code/articles/:articleId")
@FreightAdmin()
@ApiOperation({ summary: "Remove an article from the template" })
removeArticle(
@Param("code") code: string,
@Param("articleId") articleId: string,
) {
return this.service.removeArticle(code, articleId);
}
}

View File

@@ -0,0 +1,21 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { ContractRendererService } from "../../contracts/contract-renderer.service";
import { ContractTemplatesController } from "./contract-templates.controller";
import { ContractTemplatesRepository } from "./contract-templates.repository";
import { ContractTemplatesService } from "./contract-templates.service";
import { ContractTemplate } from "./entities/contract-template.entity";
@Module({
imports: [TypeOrmModule.forFeature([ContractTemplate])],
controllers: [ContractTemplatesController],
providers: [
ContractTemplatesRepository,
ContractTemplatesService,
// Stateless Handlebars renderer reused from src/contracts for previews.
ContractRendererService,
],
exports: [ContractTemplatesService],
})
export class ContractTemplatesModule {}

View File

@@ -0,0 +1,31 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import {
ContractTemplate,
ContractTemplateCode,
} from "./entities/contract-template.entity";
@Injectable()
export class ContractTemplatesRepository extends BaseRepository<ContractTemplate> {
constructor(
@InjectRepository(ContractTemplate)
repository: Repository<ContractTemplate>,
) {
super(repository);
}
findByCode(code: ContractTemplateCode): Promise<ContractTemplate | null> {
return this.repository.findOne({ where: { code } });
}
override findAll(): Promise<ContractTemplate[]> {
return this.repository.find({ order: { code: "ASC" } });
}
async saveTemplate(template: ContractTemplate): Promise<ContractTemplate> {
return this.repository.save(template);
}
}

View File

@@ -0,0 +1,66 @@
import { ContractRendererService } from "../../contracts/contract-renderer.service";
import { CONTRACT_TEMPLATE_DEFAULTS } from "../../seed/data/contract-template-defaults";
import { ContractTemplatesService } from "./contract-templates.service";
import { ContractTemplatesRepository } from "./contract-templates.repository";
import {
ContractTemplate,
contractTemplateCodeFor,
} from "./entities/contract-template.entity";
function seededTemplate(code: string): ContractTemplate {
const seed = CONTRACT_TEMPLATE_DEFAULTS.find((t) => t.code === code)!;
return {
id: "00000000-0000-0000-0000-000000000001",
code: seed.code,
name: seed.name,
description: seed.description,
documentTitle: seed.documentTitle,
whereasClauses: seed.whereasClauses,
articles: seed.articles.map((article, index) => ({ ...article, order: index + 1 })),
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as ContractTemplate;
}
describe("contractTemplateCodeFor", () => {
it("maps every direction/freight pair to one of the six codes", () => {
expect(contractTemplateCodeFor("IMPORT", "BULK")).toBe("IMPORT_BULK");
expect(contractTemplateCodeFor("EXPORT", "CONTAINER")).toBe("EXPORT_CONTAINER");
expect(contractTemplateCodeFor("DOMESTIC", "CONTAINER")).toBe("INTERCITY_CONTAINER");
expect(contractTemplateCodeFor("DOMESTIC", "BULK")).toBe("INTERCITY_BULK");
expect(contractTemplateCodeFor(null, null)).toBe("INTERCITY_CONTAINER");
});
});
describe("ContractTemplatesService.preview", () => {
const renderer = new ContractRendererService();
renderer.onModuleInit();
const repository = {
findByCode: jest.fn((code: string) => Promise.resolve(seededTemplate(code))),
} as unknown as ContractTemplatesRepository;
const service = new ContractTemplatesService(repository, renderer);
it.each(CONTRACT_TEMPLATE_DEFAULTS.map((t) => [t.code] as const))(
"renders a complete mock preview for %s",
async (code) => {
const { html } = await service.preview(code);
expect(html).toContain("Article 1");
expect(html).toContain("Article 13");
expect(html).toContain("Abyssinia Trading PLC");
expect(html).toContain("Annex A — Commercial Schedule");
// No unrendered handlebars placeholders may leak into the document.
expect(html).not.toContain("{{");
// Greenish theme applied.
expect(html).toContain("#1b9e7a");
},
);
it("interpolates {{contractYear}} inside seeded article bodies", async () => {
const { html } = await service.preview("IMPORT_BULK");
expect(html).toContain(`August 31, ${new Date().getFullYear()}`);
});
});

View File

@@ -0,0 +1,276 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { randomUUID } from "node:crypto";
import { ContractRendererService } from "../../contracts/contract-renderer.service";
import { getTemplateMeta } from "../../contracts/contract-template.registry";
import {
ContractDynamicTemplateView,
ContractViewModel,
} from "../../contracts/contract-view-model.builder";
import { ContractTemplatesRepository } from "./contract-templates.repository";
import {
CreateArticleDto,
PreviewContractTemplateDto,
ReplaceArticleDto,
UpdateArticleDto,
UpdateContractTemplateDto,
} from "./dto/contract-template.dto";
import {
CONTRACT_TEMPLATE_CODES,
ContractTemplate,
ContractTemplateArticle,
ContractTemplateCode,
contractTemplateCodeFor,
} from "./entities/contract-template.entity";
/** Registry keys used to derive labels for the mock preview per template code. */
const PREVIEW_TEMPLATE_KEYS: Record<ContractTemplateCode, string> = {
IMPORT_BULK: "IMP_BULK_USD_FORWARDING",
EXPORT_BULK: "EXP_BULK_USD_TRANSPORT_ONLY",
INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY",
IMPORT_CONTAINER: "IMP_CON_USD_TRANSPORT_ONLY",
EXPORT_CONTAINER: "EXP_CON_USD_FORWARDING",
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
};
@Injectable()
export class ContractTemplatesService {
constructor(
private readonly repository: ContractTemplatesRepository,
private readonly renderer: ContractRendererService,
) {}
async list(): Promise<ContractTemplate[]> {
const templates = await this.repository.findAll();
const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const));
return templates.sort(
(a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99),
);
}
async getByCode(code: string): Promise<ContractTemplate> {
const template = await this.repository.findByCode(this.assertCode(code));
if (!template) {
throw new NotFoundException(`Contract template ${code} not found`);
}
return template;
}
/**
* The active template used when generating a contract document for the given
* direction/freight pair; null when missing or deactivated (the renderer then
* falls back to the built-in generic layout).
*/
async findActiveForContract(
tradeDirection?: string | null,
freightType?: string | null,
): Promise<ContractTemplate | null> {
const code = contractTemplateCodeFor(tradeDirection, freightType);
const template = await this.repository.findByCode(code);
return template?.isActive ? template : null;
}
async update(code: string, dto: UpdateContractTemplateDto): Promise<ContractTemplate> {
const template = await this.getByCode(code);
if (dto.name !== undefined) template.name = dto.name;
if (dto.description !== undefined) template.description = dto.description;
if (dto.documentTitle !== undefined) template.documentTitle = dto.documentTitle;
if (dto.whereasClauses !== undefined) template.whereasClauses = dto.whereasClauses;
if (dto.isActive !== undefined) template.isActive = dto.isActive;
return this.repository.saveTemplate(template);
}
async addArticle(code: string, dto: CreateArticleDto): Promise<ContractTemplate> {
const template = await this.getByCode(code);
const articles = this.sorted(template.articles);
const article: ContractTemplateArticle = {
id: randomUUID(),
title: dto.title,
body: dto.body,
order: 0,
};
const index =
dto.position && dto.position <= articles.length ? dto.position - 1 : articles.length;
articles.splice(index, 0, article);
template.articles = this.renumber(articles);
return this.repository.saveTemplate(template);
}
async updateArticle(
code: string,
articleId: string,
dto: UpdateArticleDto,
): Promise<ContractTemplate> {
const template = await this.getByCode(code);
const article = template.articles.find((item) => item.id === articleId);
if (!article) {
throw new NotFoundException(`Article ${articleId} not found on template ${code}`);
}
if (dto.title !== undefined) article.title = dto.title;
if (dto.body !== undefined) article.body = dto.body;
template.articles = this.renumber(this.sorted(template.articles));
return this.repository.saveTemplate(template);
}
async removeArticle(code: string, articleId: string): Promise<ContractTemplate> {
const template = await this.getByCode(code);
const remaining = template.articles.filter((item) => item.id !== articleId);
if (remaining.length === template.articles.length) {
throw new NotFoundException(`Article ${articleId} not found on template ${code}`);
}
template.articles = this.renumber(this.sorted(remaining));
return this.repository.saveTemplate(template);
}
/** Replace the full ordered article list (also how the editor reorders). */
async replaceArticles(
code: string,
articles: ReplaceArticleDto[],
): Promise<ContractTemplate> {
const template = await this.getByCode(code);
template.articles = this.renumber(
articles.map((item) => ({
id: item.id ?? randomUUID(),
title: item.title,
body: item.body,
order: 0,
})),
);
return this.repository.saveTemplate(template);
}
/**
* Render the template against a representative mock contract so admins can
* see the final document without touching a real contract. Draft overrides
* allow previewing unsaved editor state.
*/
async preview(
code: string,
overrides?: PreviewContractTemplateDto,
): Promise<{ html: string }> {
const template = await this.getByCode(code);
const dynamicTemplate: ContractDynamicTemplateView = {
code: template.code,
name: overrides?.name ?? template.name,
documentTitle: overrides?.documentTitle ?? template.documentTitle,
whereasClauses: overrides?.whereasClauses ?? template.whereasClauses,
articles: overrides?.articles
? overrides.articles.map((item, index) => ({
id: item.id ?? randomUUID(),
title: item.title,
body: item.body,
order: index + 1,
}))
: this.sorted(template.articles),
};
const view = this.buildMockView(template.code, dynamicTemplate);
return { html: this.renderer.render(view) };
}
private buildMockView(
code: ContractTemplateCode,
dynamicTemplate: ContractDynamicTemplateView,
): ContractViewModel {
const meta = getTemplateMeta(PREVIEW_TEMPLATE_KEYS[code]);
const isBulk = code.endsWith("_BULK");
const now = new Date();
const unitRates = isBulk
? [
{ label: "Rail transport — per metric ton", unitPrice: 59.4, unit: "ton", currency: "USD" },
{ label: "Origin handling and documentation", unitPrice: 18, unit: "ton", currency: "USD" },
{ label: "Lashing material (when provided by EDR)", unitPrice: 150, unit: "unit", currency: "USD" },
]
: [
{ label: "Rail transport — 40ft container", unitPrice: 1916, unit: "container", currency: "USD" },
{ label: "Rail transport — 2 × 20ft containers", unitPrice: 1944, unit: "container", currency: "USD" },
{ label: "Excess tonnage surcharge", unitPrice: 10, unit: "ton", currency: "USD" },
];
return {
bookingId: "00000000-0000-0000-0000-000000000000",
reference: "EDR/CT/2026/0042",
status: "CONTRACT_READY",
templateKey: PREVIEW_TEMPLATE_KEYS[code],
template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" },
contractDate: now.toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
}),
contractYear: now.getFullYear(),
client: {
companyName: "Abyssinia Trading PLC",
companyAddress: "Bole Sub-city, Woreda 03, H.No 1234, Addis Ababa",
companyLocation: "Ethiopia",
phone: "+251 91 123 4567",
email: "logistics@abyssiniatrading.et",
tinNumber: "0011223344",
vatNumber: "VAT-556677",
fanNumber: "FAN-889900",
businessLicense: "BL/AA/12/345678",
},
provider: {
name: "Ethio-Djibouti Standard Gauge Railway Share Company",
address: "Nifas Silk Lafto Sub City, Addis Ababa, Ethiopia",
phone: "+251 11 872 0000",
email: "info@edr.gov.et",
tinNumber: "—",
},
schedule: {
originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station",
destinationLabel: "Galaan Multipurpose Port (GMP)",
tradeDirection: code.startsWith("IMPORT")
? "IMPORT"
: code.startsWith("EXPORT")
? "EXPORT"
: "DOMESTIC",
freightType: isBulk ? "BULK" : "CONTAINER",
serviceType: "Rail transport and customs clearance",
scheduledDate: "—",
contractType: "GENERAL",
cargoDescription: isBulk ? "Steel billets — 2,800 MT" : "40ft containers — FMCG cargo",
totalWeightVgm: "—",
equipmentReturn: isBulk ? "—" : "With empty return",
hazardousLabel: "No",
firstMilePickupAddress: "—",
lastMileDeliveryAddress: "—",
},
pricing: {
displayMode: "UNIT_RATES",
unitRates,
currency: "USD",
equipmentReturn: isBulk ? "—" : "With empty return",
originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station",
destinationLabel: "Galaan Multipurpose Port (GMP)",
} as unknown as ContractViewModel["pricing"],
signatures: [],
canSignCustomer: false,
canSignStaff: false,
hasContractDocument: false,
hasCustomerSignature: false,
hasStaffSignature: false,
dynamicTemplate,
};
}
private assertCode(code: string): ContractTemplateCode {
const upper = code?.toUpperCase() as ContractTemplateCode;
if (!CONTRACT_TEMPLATE_CODES.includes(upper)) {
throw new BadRequestException(
`Unknown contract template code "${code}". Valid codes: ${CONTRACT_TEMPLATE_CODES.join(", ")}`,
);
}
return upper;
}
private sorted(articles: ContractTemplateArticle[]): ContractTemplateArticle[] {
return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
private renumber(articles: ContractTemplateArticle[]): ContractTemplateArticle[] {
return articles.map((article, index) => ({ ...article, order: index + 1 }));
}
}

View File

@@ -0,0 +1,134 @@
import { ApiPropertyOptional, ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsArray,
IsBoolean,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateNested,
} from "class-validator";
export class UpdateContractTemplateDto {
@ApiPropertyOptional({ description: "Display name of the template" })
@IsOptional()
@IsString()
@MinLength(3)
@MaxLength(200)
name?: string;
@ApiPropertyOptional({ description: "Short description shown on the template card" })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: "Cover-page service title of the generated document" })
@IsOptional()
@IsString()
@MinLength(3)
@MaxLength(300)
documentTitle?: string;
@ApiPropertyOptional({ description: "WHEREAS recitals", type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
whereasClauses?: string[];
@ApiPropertyOptional({ description: "Whether the template is used for generation" })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class CreateArticleDto {
@ApiProperty({ description: "Article heading (without the Article N prefix)" })
@IsString()
@MinLength(2)
@MaxLength(200)
title!: string;
@ApiProperty({
description:
'Article body. One clause per line; prefix a line with "- " to nest it as a bullet under the previous clause.',
})
@IsString()
@MinLength(2)
body!: string;
@ApiPropertyOptional({ description: "1-based position to insert at (appends when omitted)" })
@IsOptional()
@IsInt()
@Min(1)
position?: number;
}
export class UpdateArticleDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(200)
title?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MinLength(2)
body?: string;
}
export class ReplaceArticleDto {
@ApiPropertyOptional({ description: "Existing article id (new id assigned when omitted)" })
@IsOptional()
@IsString()
id?: string;
@ApiProperty()
@IsString()
@MinLength(2)
@MaxLength(200)
title!: string;
@ApiProperty()
@IsString()
@MinLength(2)
body!: string;
}
export class ReplaceArticlesDto {
@ApiProperty({ type: [ReplaceArticleDto], description: "Full ordered article list" })
@IsArray()
@ValidateNested({ each: true })
@Type(() => ReplaceArticleDto)
articles!: ReplaceArticleDto[];
}
/** Optional draft overrides so the editor can preview unsaved changes. */
export class PreviewContractTemplateDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
documentTitle?: string;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
whereasClauses?: string[];
@ApiPropertyOptional({ type: [ReplaceArticleDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ReplaceArticleDto)
articles?: ReplaceArticleDto[];
}

View File

@@ -0,0 +1,77 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index } from "typeorm";
/**
* The six canonical contract document templates, one per
* (trade direction × freight type) combination. Contracts store DOMESTIC for
* intercity movements; the template layer labels those INTERCITY to match the
* commercial vocabulary used on the printed documents.
*/
export const CONTRACT_TEMPLATE_CODES = [
"IMPORT_BULK",
"EXPORT_BULK",
"INTERCITY_BULK",
"IMPORT_CONTAINER",
"EXPORT_CONTAINER",
"INTERCITY_CONTAINER",
] as const;
export type ContractTemplateCode = (typeof CONTRACT_TEMPLATE_CODES)[number];
/**
* One dynamic article on a contract template. `body` is plain multiline text:
* each non-empty line renders as a numbered clause; lines prefixed with "- "
* render as bullet points nested under the preceding clause. A single-line
* body renders as an unnumbered paragraph. Handlebars placeholders (e.g.
* {{client.companyName}}, {{contractDate}}, {{contractYear}}, {{reference}})
* are interpolated against the contract view model at render time.
*/
export interface ContractTemplateArticle {
id: string;
title: string;
body: string;
order: number;
}
/** Map a contract's stored direction/freight pair onto a template code. */
export function contractTemplateCodeFor(
tradeDirection?: string | null,
freightType?: string | null,
): ContractTemplateCode {
const direction =
tradeDirection === "IMPORT"
? "IMPORT"
: tradeDirection === "EXPORT"
? "EXPORT"
: "INTERCITY";
const freight =
(freightType ?? "").toUpperCase().includes("BULK") ? "BULK" : "CONTAINER";
return `${direction}_${freight}` as ContractTemplateCode;
}
@Entity({ schema: "freight", name: "contract_templates" })
@Index(["code"], { unique: true })
export class ContractTemplate extends BaseEntity {
@Column({ name: "code", type: "varchar", length: 40, unique: true })
code!: ContractTemplateCode;
@Column({ name: "name", type: "varchar", length: 200 })
name!: string;
@Column({ name: "description", type: "text", nullable: true })
description?: string | null;
/** Cover-page service line, e.g. "Steel Billet Transportation and Customs Clearance Services". */
@Column({ name: "document_title", type: "varchar", length: 300 })
documentTitle!: string;
/** WHEREAS recitals rendered between the parties block and the articles. */
@Column({ name: "whereas_clauses", type: "jsonb", default: () => "'[]'" })
whereasClauses!: string[];
@Column({ name: "articles", type: "jsonb", default: () => "'[]'" })
articles!: ContractTemplateArticle[];
@Column({ name: "is_active", type: "boolean", default: true })
isActive!: boolean;
}

View File

@@ -22,12 +22,15 @@ export class BookingRequestRepository extends BaseRepository<BookingRequest> {
});
}
/** GL queue: pending requests across all contracts, oldest first. */
async findPending(): Promise<BookingRequest[]> {
/**
* GL queue: every request across all contracts, newest first. The queue page
* filters by status client-side (pending work vs accepted/rejected history),
* and surfaces the customer — so the contract's company rides along.
*/
async findQueue(): Promise<BookingRequest[]> {
return this.repository.find({
where: { status: 'PENDING' },
order: { createdAt: 'ASC' },
relations: { contract: true },
order: { createdAt: 'DESC' },
relations: { contract: { company: true } },
});
}

View File

@@ -51,6 +51,11 @@ export class BookingRequestService {
const contract = await this.contractsService.findById(contractId);
await this.contractsService.assertCustomerCanAccessContract(userId, contract);
this.assertGeneralCustoms(contract);
if (contract.status === 'CONTRACT_CLOSED') {
throw new ConflictException(
'This contract is completed — the full contracted quantity has been booked.',
);
}
if (contract.status !== 'CONTRACT_ACTIVE') {
throw new ConflictException(
'The contract must be active before requesting a shipment.',
@@ -134,7 +139,7 @@ export class BookingRequestService {
}
queue(): Promise<BookingRequest[]> {
return this.repo.findPending();
return this.repo.findQueue();
}
private async findPending(requestId: string): Promise<BookingRequest> {

View File

@@ -0,0 +1,68 @@
import { BadRequestException } from '@nestjs/common';
import type { DataSource } from 'typeorm';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import type { ClearanceMilestone } from './entities/clearance-milestone.entity';
type Status = 'PENDING' | 'COMPLETED' | 'SKIPPED';
/**
* Risk assignment is gated on the T1 being closed (catalog order
* T1_CLOSED → RISK_ASSIGNED): customs cannot rate cargo still under transit.
*/
function makeService(t1Status: Status | 'MISSING') {
const rows = new Map<string, ClearanceMilestone>();
if (t1Status !== 'MISSING') {
rows.set('T1_CLOSED', { milestoneCode: 'T1_CLOSED', status: t1Status } as ClearanceMilestone);
}
const risk = { milestoneCode: 'RISK_ASSIGNED', status: 'PENDING' } as ClearanceMilestone;
rows.set('RISK_ASSIGNED', risk);
const repo = {
findOne: jest.fn(({ where }: { where: { milestoneCode: string } }) =>
Promise.resolve(rows.get(where.milestoneCode) ?? null),
),
save: jest.fn((m: ClearanceMilestone) => Promise.resolve(m)),
};
const dataSource = { getRepository: () => repo } as unknown as DataSource;
return { service: new ClearanceMilestoneService(dataSource), repo, risk };
}
describe('ClearanceMilestoneService.assignRisk', () => {
it('rejects the assignment while the T1 is still open', async () => {
const { service, repo } = makeService('PENDING');
await expect(service.assignRisk('b-1', 'GREEN')).rejects.toBeInstanceOf(
BadRequestException,
);
expect(repo.save).not.toHaveBeenCalled();
});
it('rejects the assignment when the booking has no T1_CLOSED milestone', async () => {
const { service, repo } = makeService('MISSING');
await expect(service.assignRisk('b-1', 'GREEN')).rejects.toBeInstanceOf(
BadRequestException,
);
expect(repo.save).not.toHaveBeenCalled();
});
it('assigns the risk level once the T1 is closed', async () => {
const { service, risk } = makeService('COMPLETED');
const saved = await service.assignRisk('b-1', 'RED', 'user-1');
expect(saved.status).toBe('COMPLETED');
expect(saved.metadata?.riskLevel).toBe('RED');
expect(risk.triggeredByUserId).toBe('user-1');
});
it('assigns the risk level when the T1 step was skipped', async () => {
const { service } = makeService('SKIPPED');
const saved = await service.assignRisk('b-1', 'YELLOW');
expect(saved.status).toBe('COMPLETED');
expect(saved.metadata?.riskLevel).toBe('YELLOW');
});
});

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import {
@@ -181,6 +181,10 @@ export class ClearanceMilestoneService {
* Assign a customs risk level (GREEN/YELLOW/RED) and complete the RISK_ASSIGNED
* milestone on a booking (GL Import US-04 / §11.3 #19). Stores the level in the
* milestone metadata so the timeline shows it.
*
* Customs cannot risk-rate cargo still moving under transit: the T1 must be
* closed (accepted by GL Ethiopia after the train arrives) first, which is the
* catalog order T1_CLOSED → RISK_ASSIGNED.
*/
async assignRisk(
bookingId: string,
@@ -188,9 +192,22 @@ export class ClearanceMilestoneService {
userId?: string,
note?: string,
): Promise<ClearanceMilestone> {
await this.assertT1Closed(bookingId);
return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note);
}
/** Guard: the booking's T1 must be closed before customs risk can be assigned. */
private async assertT1Closed(bookingId: string): Promise<void> {
const t1 = await this.repo.findOne({
where: { bookingId, milestoneCode: 'T1_CLOSED' },
});
if (t1?.status !== 'COMPLETED' && t1?.status !== 'SKIPPED') {
throw new BadRequestException(
'The T1 must be closed before a customs risk level can be assigned.',
);
}
}
/**
* Advise duty & tax (amount + declaration serial) and complete the
* DUTY_TAXES_ADVISED milestone (§11.3 #6). The customer then uploads the

View File

@@ -0,0 +1,150 @@
import { BadRequestException } from '@nestjs/common';
import { ContractBookingService } from './contract-booking.service';
import { Contract } from './entities/contract.entity';
/**
* Contract auto-completion by quantity cap. Once a GENERAL contract's capped
* scope is fully consumed (e.g. a split remainder rebooked), the contract moves
* to CONTRACT_CLOSED even inside its validity window, and further bookings are
* blocked — including while a booking window is open. Released capacity
* (cancelled/expired booking) reopens the contract on the next attempt.
*/
describe('ContractBookingService — quantity-cap completion', () => {
function makeService() {
const contractsRepository = {
findByIdWithRelations: jest.fn(),
update: jest.fn().mockResolvedValue(undefined),
};
const service = new ContractBookingService(
contractsRepository as never,
{} as never, // bookingsRepository
{} as never, // bookingPricingService
{} as never, // consolidationService
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
{} as never, // workflowService
{} as never, // invoiceService
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingTransitionService
);
return { service, contractsRepository };
}
type WithPrivate = {
maybeCompleteContract: (c: Contract) => Promise<void>;
};
const generalContract = (status: string): Contract =>
({
id: 'c-1',
reference: 'CTR-1',
contractKind: 'GENERAL',
status,
}) as Contract;
it('closes a GENERAL contract when every capped line is exhausted', async () => {
const { service, contractsRepository } = makeService();
jest.spyOn(service, 'computeCapacity').mockResolvedValue([
{ containerSize: '20FT', cap: 10, booked: 10, remaining: 0 },
{ containerSize: '40FT', cap: 4, booked: 4, remaining: 0 },
]);
await (service as never as WithPrivate).maybeCompleteContract(
generalContract('CONTRACT_ACTIVE'),
);
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
status: 'CONTRACT_CLOSED',
});
});
it('absorbs bulk-ton float dust when judging exhaustion', async () => {
const { service, contractsRepository } = makeService();
jest
.spyOn(service, 'computeCapacity')
.mockResolvedValue([{ cap: 100, booked: 99.9995, remaining: 0.0005 }]);
await (service as never as WithPrivate).maybeCompleteContract(
generalContract('FULLY_EXECUTED'),
);
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
status: 'CONTRACT_CLOSED',
});
});
it('keeps the contract open while any capped line has capacity left', async () => {
const { service, contractsRepository } = makeService();
jest.spyOn(service, 'computeCapacity').mockResolvedValue([
{ containerSize: '20FT', cap: 10, booked: 10, remaining: 0 },
{ containerSize: '40FT', cap: 4, booked: 3, remaining: 1 },
]);
await (service as never as WithPrivate).maybeCompleteContract(
generalContract('CONTRACT_ACTIVE'),
);
expect(contractsRepository.update).not.toHaveBeenCalled();
});
it('never closes an uncapped contract', async () => {
const { service, contractsRepository } = makeService();
jest.spyOn(service, 'computeCapacity').mockResolvedValue([]);
await (service as never as WithPrivate).maybeCompleteContract(
generalContract('CONTRACT_ACTIVE'),
);
expect(contractsRepository.update).not.toHaveBeenCalled();
});
it('never closes a ONE_TIME contract (single-slot rule governs it)', async () => {
const { service, contractsRepository } = makeService();
const spy = jest.spyOn(service, 'computeCapacity');
await (service as never as WithPrivate).maybeCompleteContract({
id: 'c-1',
contractKind: 'ONE_TIME',
status: 'FULLY_EXECUTED',
} as Contract);
expect(spy).not.toHaveBeenCalled();
expect(contractsRepository.update).not.toHaveBeenCalled();
});
it('rejects a new booking on a completed contract even inside an open window', async () => {
const { service, contractsRepository } = makeService();
contractsRepository.findByIdWithRelations.mockResolvedValue(
generalContract('CONTRACT_CLOSED'),
);
jest
.spyOn(service, 'computeCapacity')
.mockResolvedValue([{ cap: 10, booked: 10, remaining: 0 }]);
await expect(
service.createUnderContract('c-1', {} as never, null, null),
).rejects.toThrow(BadRequestException);
expect(contractsRepository.update).not.toHaveBeenCalled();
});
it('reopens a completed contract when capacity was released', async () => {
const { service, contractsRepository } = makeService();
contractsRepository.findByIdWithRelations.mockResolvedValue(
generalContract('CONTRACT_CLOSED'),
);
jest
.spyOn(service, 'computeCapacity')
.mockResolvedValue([{ cap: 10, booked: 8, remaining: 2 }]);
// The create path continues past the gate and dies later on the bare mocks —
// only the reopen transition is under test here.
await service.createUnderContract('c-1', {} as never, null, null).catch(() => undefined);
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
status: 'CONTRACT_ACTIVE',
});
});
});

View File

@@ -58,6 +58,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
invoiceService as never,
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingTransitionService
);
return {
service,

View File

@@ -16,6 +16,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingTransitionService } from '../bookings/booking-transition.service';
import { ConsolidationService } from '../bookings/consolidation.service';
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
@@ -72,6 +73,8 @@ export class ContractBookingService {
private readonly dataSource: DataSource,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
@Inject(forwardRef(() => BookingTransitionService))
private readonly bookingTransitionService: BookingTransitionService,
) {}
async createUnderContract(
@@ -83,6 +86,24 @@ export class ContractBookingService {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
// A contract whose quantity cap was fully booked is completed — no further
// bookings, even while contract validity and a booking window are still
// open. Capacity released after closure (a cancelled/expired booking)
// reopens the contract on the next booking attempt.
if (contract.status === 'CONTRACT_CLOSED') {
const capacity = await this.computeCapacity(contract);
const hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0);
if (!hasRoom) {
throw new BadRequestException(
'This contract is completed — the full contracted quantity has been booked.',
);
}
await this.contractsRepository.update(contract.id, {
status: 'CONTRACT_ACTIVE',
} as never);
contract.status = 'CONTRACT_ACTIVE';
}
// GL Ethiopia is identified by the dedicated contract create-booking permission
// (granted to the edr_gl_ethiopia preset).
const isGlActor =
@@ -122,6 +143,15 @@ export class ContractBookingService {
const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
// GENERAL without customs (Path A) ALSO clears per booking: the customer
// uploads his own clearance proof on each booking and Operations reviews it
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
// requestOperation machine). DOMESTIC has no border, so no gate.
const generalSelfClear =
contract.contractKind === 'GENERAL' &&
!contract.customsClearingEnabled &&
contract.tradeDirection !== 'DOMESTIC';
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
// there is no window and no date — staff accept them onto a train at
// finalize time, so both the window gate and scheduledDate are skipped.
@@ -140,9 +170,10 @@ export class ContractBookingService {
// Booking-window gate (config-driven): an operations booking may only be
// created while the route's booking window is open — import: the day's window
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
// export: within exportBookingLeadHours of departure. Customs Path B bookings
// enter clearance first and are scheduled later, so they are not gated here.
if (!generalCustoms && !isIntercity) {
// export: within exportBookingLeadHours of departure. Bookings that enter the
// clearance gate first (Path B customs AND Path A per-booking self-clearance)
// are scheduled later, so they are not gated here.
if (!generalCustoms && !generalSelfClear && !isIntercity) {
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
@@ -174,7 +205,10 @@ export class ContractBookingService {
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
status: generalCustoms ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING',
status:
generalCustoms || generalSelfClear
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING',
bookingType: 'ONE_TIME',
contractId: contract.id,
contractRouteId: route?.id ?? null,
@@ -187,7 +221,7 @@ export class ContractBookingService {
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? null,
equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
equipmentReturn: dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN',
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
tradeDirection: contract.tradeDirection,
@@ -259,9 +293,10 @@ export class ContractBookingService {
const withContainers = await this.bookingsRepository.findByIdWithFiles(
booking.id,
);
const intendedStatus = generalCustoms
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING';
const intendedStatus =
generalCustoms || generalSelfClear
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING';
if (
withContainers &&
freightType === 'CONTAINER' &&
@@ -277,6 +312,9 @@ export class ContractBookingService {
if (!parked.paired) {
// Waiting for a partner — stop here. The booking sits in
// PENDING_CONSOLIDATION, unbilled and unscheduled, until it pairs.
// A parked booking still holds contract capacity, so the cap may
// already be exhausted by it.
await this.maybeCompleteContract(contract);
const pendingResult = await this.bookingsRepository.findByIdWithFiles(
booking.id,
);
@@ -290,10 +328,233 @@ export class ContractBookingService {
generalCustoms,
);
await this.maybeCompleteContract(contract);
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
return { booking: result ?? booking, warnings };
}
/**
* Initiate a BARE booking instance under a GENERAL non-customs contract
* (Path A per-booking self-clearance). One click, zero input: no schedule
* date, no cargo, no window check, no pricing. The instance starts in the
* clearance gate (AWAITING_DOCUMENTS); the customer uploads clearance docs,
* Operations reviews and finalizes, and only then does the customer complete
* the booking (cargo + binding day + window check) via
* {@link completeUnderContract} — the same machinery a one-time shipment uses.
*/
async initiateUnderContract(
contractId: string,
dto: Pick<CreateBookingUnderContractDto, 'contractRouteId'>,
user?: { id?: string } | null,
actorPermissions?: unknown,
): Promise<CreateBookingUnderContractResult> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
const generalSelfClear =
contract.contractKind === 'GENERAL' &&
!contract.customsClearingEnabled &&
contract.tradeDirection !== 'DOMESTIC';
if (!generalSelfClear) {
throw new BadRequestException(
'Initiate booking applies only to general import/export contracts without customs clearing.',
);
}
if (contract.status === 'CONTRACT_CLOSED') {
throw new BadRequestException(
'This contract is completed — the full contracted quantity has been booked.',
);
}
const isGlActor =
actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
const createdByRole = await this.assertGate(contract, isGlActor);
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
const route = await this.resolveRoute(contract, dto.contractRouteId);
// Bare instance: no cargo, no date, no price. Draws no contract capacity
// until the customer completes it after clearance.
const booking = await insertWithGeneratedReference(
() => this.generateReference(),
(reference) =>
this.bookingsRepository.create({
reference,
companyId: contract.companyId ?? null,
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
status: 'AWAITING_DOCUMENTS',
bookingType: 'ONE_TIME',
contractId: contract.id,
contractRouteId: route?.id ?? null,
contractKind: contract.contractKind,
createdByRole,
createdByUserId: user?.id ?? null,
scheduledDate: null,
serviceTypeId: contract.serviceTypeId,
paymentCurrency: contract.paymentCurrency,
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? null,
equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
tradeDirection: contract.tradeDirection,
freightType: contract.freightType,
cargoTypeId: this.resolveCargoTypeId(contract, {}),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
cargoTotalWeightVgm: 0,
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
firstMilePickupLat: contract.firstMilePickupLat ?? null,
firstMilePickupLng: contract.firstMilePickupLng ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
} as never),
);
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
return { booking: result ?? booking, warnings: [] };
}
/**
* Complete a bare initiated booking after Operations finalized its per-booking
* clearance (CLEARANCE_READY) or returned it for changes
* (OPERATION_CHANGES_REQUESTED). This is the deferred half of
* {@link createUnderContract}: cargo lines, quantity-cap drawdown, booking
* window + open-departure checks, pricing, consolidation and invoicing all run
* here — the same gates a one-time shipment passes at creation.
*/
async completeUnderContract(
contractId: string,
bookingId: string,
dto: CreateBookingUnderContractDto,
): Promise<CreateBookingUnderContractResult> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (!booking || booking.contractId !== contract.id) {
throw new NotFoundException(`Booking ${bookingId} not found on this contract`);
}
if (!['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED'].includes(booking.status)) {
throw new BadRequestException(
'Clearance must be finalized before the booking can be completed.',
);
}
if (!dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required');
}
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
const freightType = contract.freightType;
const hasCargo =
(booking.bookingContainers?.length ?? 0) > 0 ||
Number(booking.cargoTotalWeightVgm) > 0;
const warnings: string[] = [];
// First completion persists cargo and draws contract capacity; a resubmit
// after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks
// the shipment day.
if (!hasCargo) {
await this.assertWithinQuantityCap(contract, dto);
if (freightType === 'CONTAINER') {
await this.assertWithinMaxCapacity(contract, dto);
await this.assert20ftPairableAtCreate(dto);
await this.persistContainers(booking.id, contract, dto);
}
await this.bookingsRepository.update(booking.id, {
cargoTypeId: this.resolveCargoTypeId(contract, dto),
cargoTotalWeightVgm: this.resolveBulkTons(dto),
...(dto.equipmentReturn ? { equipmentReturn: dto.equipmentReturn } : {}),
} as never);
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
if (loaded) {
if (freightType === 'CONTAINER') {
await this.applyWeightResults(loaded);
}
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
// A zero price means no contract rate matches — roll the cargo back so
// the instance stays CLEARANCE_READY and can be completed again once
// the contract rates are fixed (the clearance work is not lost).
if (!(computed.totalAmount > 0)) {
await this.bookingsRepository.deleteContainers(booking.id);
await this.bookingsRepository.update(booking.id, {
cargoTotalWeightVgm: 0,
} as never);
throw new BadRequestException(
'Booking price came out as 0 — no contract rate matches this ' +
'route/cargo. Set the contract rate and try again.',
);
}
await this.bookingsRepository.update(booking.id, {
totalAmount: computed.totalAmount,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
await this.bookingPricingService.createPricingSnapshots(
booking.id,
computed.usedRates,
computed.appliedModifiers,
);
warnings.push(...computed.warnings);
}
// Wagon consolidation gate — a partial-wagon 20ft set parks for a partner
// exactly like a drawdown created with cargo does. The shipment day is
// stored first so the pairing event can resume straight into the
// operations queue.
const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id);
if (
withContainers &&
freightType === 'CONTAINER' &&
(await this.consolidationService.needsConsolidationFromBooking(withContainers))
) {
await this.bookingsRepository.update(booking.id, {
scheduledDate: new Date(dto.scheduledDate),
} as never);
const parked = await this.consolidateDrawdown(
withContainers,
'OPERATION_REQUEST_PENDING',
);
warnings.push(parked.message);
if (!parked.paired) {
await this.maybeCompleteContract(contract);
const pendingResult = await this.bookingsRepository.findByIdWithFiles(booking.id);
return { booking: pendingResult ?? booking, warnings };
}
}
// Invoice the now-priced booking (idempotent, non-blocking).
await this.finalizeContractBooking(booking.id, contract, false);
await this.maybeCompleteContract(contract);
}
// Binding day + open-departure validation, status OPERATION_REQUEST_PENDING
// and the staff notification — the exact machine a one-time booking uses.
const completed = await this.bookingTransitionService.requestOperation(
booking.id,
dto.scheduledDate,
);
return { booking: completed, warnings };
}
/**
* Search for a complementary partner for a parked-eligible drawdown, pair it or
* park it in PENDING_CONSOLIDATION with the resume status it should return to.
@@ -592,6 +853,44 @@ export class ContractBookingService {
});
}
/**
* Complete the contract once its quantity cap is fully consumed. Runs after
* every booking created under a GENERAL contract (including a split remainder
* being rebooked): when no capped scope line has capacity left, the contract
* moves to CONTRACT_CLOSED even though its validity window is still open —
* blocking further bookings and shipment requests, including inside an open
* booking window. Never throws: a status hiccup must not undo the booking
* that was just created.
*/
private async maybeCompleteContract(contract: Contract): Promise<void> {
try {
// ONE_TIME contracts are governed by the single-active-booking slot (and
// are promoted to GENERAL on split), so only GENERAL completes by cap.
if (contract.contractKind !== 'GENERAL') return;
if (!['CONTRACT_ACTIVE', 'FULLY_EXECUTED'].includes(contract.status)) return;
const capacity = await this.computeCapacity(contract);
if (capacity.length === 0) return; // uncapped — completes only by expiry
// 0.001 tolerance absorbs bulk-ton float rounding (split weights round to
// 3 decimals); container caps are integers and unaffected.
const exhausted = capacity.every(
(c) => c.remaining != null && c.remaining <= 0.001,
);
if (!exhausted) return;
await this.contractsRepository.update(contract.id, {
status: 'CONTRACT_CLOSED',
} as never);
this.logger.log(
`Contract ${contract.reference} quantity cap fully booked — completed; no further bookings within validity.`,
);
} catch (err) {
this.logger.error(
`Could not evaluate completion for contract ${contract.id}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
/**
* Quantities already booked under a contract that still hold capacity. Excludes
* bookings that never shipped (CANCELLED / REJECTED / EXPIRED).

View File

@@ -350,6 +350,17 @@ export class ContractTransitionService {
const updated = await this.contractsService.findById(contractId);
if (allDone) {
this.notifier.approved(updated);
// Final approval step also generates the contract document from the
// template matching the contract's direction/freight pair. Best-effort:
// a rendering hiccup must not roll back the approval — the document can
// still be generated manually or lazily on view/download.
try {
return await this.generateContract(contractId);
} catch (err) {
this.logger.warn(
`Auto contract generation after final approval failed for ${updated.reference}: ${err}`,
);
}
}
return updated;
}
@@ -582,7 +593,7 @@ export class ContractTransitionService {
if (!dto.otpPhone || !dto.otp) {
throw new BadRequestException('OTP verification is required to sign the contract');
}
await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp);
await this.otpService.verifyOtpForAction({ phone: dto.otpPhone }, dto.otp);
await this.applySignature(contract, dto, options);
await this.contractsRepository.update(contractId, {
status: 'SIGNED_CUSTOMER',
@@ -632,16 +643,15 @@ export class ContractTransitionService {
contract.customsClearingEnabled ?? false,
);
// GENERAL + customs (Path B) runs clearance PER BOOKING, not at the contract
// level: there is no contract clearance cycle. The contract just becomes
// active; the customer then files shipment requests and GL books + clears
// each one. ONE_TIME customs and Path A self-clearance keep the contract
// cycle below.
const isGeneralCustoms =
contract.contractKind === 'GENERAL' &&
Boolean(contract.customsClearingEnabled);
// GENERAL contracts run clearance PER BOOKING, not at the contract level —
// both paths. Customs (Path B): the customer files shipment requests, GL
// books each one and the booking carries its own clearance. Self-clearance
// (Path A): the customer books, then uploads the clearance docs on that
// booking for Operations to review. Only ONE_TIME contracts keep the
// contract-level cycle below.
const isGeneral = contract.contractKind === 'GENERAL';
if (clearanceCode && !isGeneralCustoms) {
if (clearanceCode && !isGeneral) {
// Open a clearance cycle, seed the pre-booking milestones, and route the
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
// distinction is enforced at the review/finalize endpoints, not here.
@@ -652,8 +662,8 @@ export class ContractTransitionService {
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
updates.clearanceCycleNumber = cycleNumber;
} else {
// No contract-level clearance gate — DOMESTIC, or GENERAL+customs (which
// clears per booking). Ready for shipment requests / direct booking.
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
// (which clears per booking). Ready for shipment requests / direct booking.
updates.status =
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
updates.clearanceStatus = 'NOT_APPLICABLE';

View File

@@ -107,7 +107,7 @@ export class ContractsController {
@Get('booking-requests/queue')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({ summary: 'GL queue: pending shipment requests across contracts' })
@ApiOperation({ summary: 'GL queue: shipment requests across contracts (all statuses, newest first)' })
bookingRequestQueue() {
return this.bookingRequestService.queue();
}
@@ -799,6 +799,37 @@ export class ContractsController {
);
}
@Post(':id/bookings/initiate')
@ApiOperation({
summary:
'Initiate a bare booking instance under a GENERAL non-customs contract — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS).',
})
initiateBooking(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateBookingUnderContractDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.contractBookingService.initiateUnderContract(
id,
{ contractRouteId: dto?.contractRouteId },
{ id: user?.id ?? user?.sub },
user,
);
}
@Post(':id/bookings/:bookingId/complete')
@ApiOperation({
summary:
'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.',
})
completeBooking(
@Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: CreateBookingUnderContractDto,
) {
return this.contractBookingService.completeUnderContract(id, bookingId, dto);
}
@Post(':id/validate-shipment')
@ApiOperation({
summary:

View File

@@ -16,6 +16,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { BookingsModule } from '../bookings/bookings.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { ContractTemplatesModule } from '../contract-templates/contract-templates.module';
import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
@@ -81,6 +82,9 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
NotificationsModule,
NotificationInboxModule,
CompaniesModule,
// Provides the admin-editable contract document templates consumed by
// ContractDocumentViewModelBuilder when rendering contract PDFs.
ContractTemplatesModule,
// BookingsModule provides BookingsRepository/BookingPricingService used by the
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
forwardRef(() => BookingsModule),

View File

@@ -265,10 +265,11 @@ export class ContractsService {
}
}
// Attach the company profile's onboarding / business-license documents to the
// contract by reference. The separate "Documents" intake step was removed —
// the profile documents are simply carried onto every contract automatically.
await this.attachProfileDocuments(contract.id, companyProfileId);
// Attach the company's onboarding documents (TIN, licenses, IDs) and the
// profile's business-license documents to the contract by reference. The
// separate "Documents" intake step was removed — the profile documents are
// simply carried onto every contract automatically.
await this.attachProfileDocuments(contract.id, companyId ?? null, companyProfileId);
return { contract: await this.findById(contract.id), warnings };
}
@@ -316,51 +317,95 @@ export class ContractsService {
}
/**
* Copy a company profile's stored business-license / onboarding documents onto
* a contract by reference (no byte re-upload). Codes are slugged from each
* document name so they group under "Profile documents" on the contract detail
* page. No-op when the contract has no profile or the profile has no documents.
* Copy the company's onboarding documents (TIN certificate, commercial /
* investment license, national ID, passport — resource "companies", coded by
* the upload-setting fileKey) and the company profile's business-license
* documents (resource "company_profiles") onto a contract by reference (no
* byte re-upload). Idempotent: codes already present on the contract — user
* uploads or an earlier carry — are never duplicated or overwritten, so it is
* safe to run on every create and update. No-op when there is nothing to copy.
*/
private async attachProfileDocuments(
contractId: string,
companyId: string | null,
companyProfileId: string | null,
): Promise<void> {
if (!companyProfileId) return;
// Business-license files are FileRecords (resource "company_profiles"); carry
// the live ones by reference. Staged/pending uploads are excluded by code.
const records = await this.filesService.findByResource(
companyProfileId,
'company_profiles',
if (!companyId && !companyProfileId) return;
const existingCodes = new Set(
(await this.filesService.findByResource(contractId, 'contracts')).map(
(r) => r.code,
),
);
const docs = records
.filter((r) => r.code === 'business_license')
.map((r) => ({
name: r.name,
url: r.url,
size: r.size,
mimeType: r.mimeType,
}));
const docs: Array<{
code: string;
name: string;
url: string;
size: number;
mimeType?: string;
}> = [];
if (companyId) {
// Company onboarding documents keep their fileKey codes (tin_certificate,
// commercial_license, …) so the portal can match them against the
// onboarding upload-setting fields. Re-uploads append rows, so keep only
// the newest record per code.
const companyRecords = await this.filesService.findByResource(
companyId,
'companies',
);
const latestByCode = new Map<string, (typeof companyRecords)[number]>();
for (const r of companyRecords) {
const prev = latestByCode.get(r.code);
if (!prev || r.createdAt > prev.createdAt) latestByCode.set(r.code, r);
}
for (const r of latestByCode.values()) {
if (existingCodes.has(r.code)) continue;
docs.push({
code: r.code,
name: r.name,
url: r.url,
size: r.size,
mimeType: r.mimeType,
});
}
}
if (companyProfileId) {
// Business-license files are FileRecords (resource "company_profiles");
// carry the live ones by reference. Staged/pending uploads are excluded by
// code. Codes are slugged from each document name so they group under
// "Profile documents" on the contract detail page.
const records = await this.filesService.findByResource(
companyProfileId,
'company_profiles',
);
const slug = (name: string) =>
name
.toLowerCase()
.replace(/\.[a-z0-9]+$/, '')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '') || 'profile_document';
records
.filter((r) => r.code === 'business_license')
.forEach((r, i) => {
const code = `${slug(r.name)}_${i + 1}`;
if (existingCodes.has(code)) return;
docs.push({
code,
name: r.name,
url: r.url,
size: r.size,
mimeType: r.mimeType,
});
});
}
if (docs.length === 0) return;
const slug = (name: string) =>
name
.toLowerCase()
.replace(/\.[a-z0-9]+$/, '')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '') || 'profile_document';
try {
await this.filesService.attachExistingFiles(
contractId,
'contracts',
docs.map((d, i) => ({
code: `${slug(d.name)}_${i + 1}`,
name: d.name,
url: d.url,
size: d.size,
mimeType: d.mimeType,
})),
);
await this.filesService.attachExistingFiles(contractId, 'contracts', docs);
} catch {
// Non-fatal — the contract is still valid without the carried documents.
}
@@ -481,6 +526,15 @@ export class ContractsService {
await this.filesService.uploadMany(id, 'contracts', files);
}
// Re-carry any company/profile document that is still missing from the
// contract (runs after the upload so fresh replacements keep their slot).
// Backfills contracts created before profile documents were carried over.
await this.attachProfileDocuments(
id,
existing.companyId ?? null,
existing.companyProfileId ?? null,
);
return { contract: await this.findById(id), warnings };
}

View File

@@ -4,19 +4,28 @@ import {
IsArray,
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
Matches,
Min,
ValidateNested,
} from 'class-validator';
/** Per-shipment equipment return — "NA" stays contract-level only. */
const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
/** One physical container under a booking line — entered at booking time. */
export class CreateContainerUnitDto {
@ApiProperty()
@ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' })
@IsString()
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toUpperCase() : value))
@Matches(/^[A-Z]{4}\d{7}$/, {
message: 'containerNumber must match ISO container format, e.g. ABCD1234567',
})
containerNumber!: string;
@ApiPropertyOptional()
@@ -129,6 +138,15 @@ export class CreateBookingUnderContractDto {
@IsDateString()
scheduledDate?: string;
@ApiPropertyOptional({
enum: SHIPMENT_EQUIPMENT_RETURNS,
description:
'Per-shipment equipment return override; omitted → the contract default applies.',
})
@IsOptional()
@IsIn([...SHIPMENT_EQUIPMENT_RETURNS])
equipmentReturn?: string;
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
@IsOptional()
@IsArray()

View File

@@ -0,0 +1,150 @@
# GPS Tracking (GT06) — Operations & Device Configuration
GT06 trackers speak a **raw TCP binary protocol**, not HTTP/HTTPS. This shapes
everything about how the service is deployed and how devices are pointed at it.
---
## 1. Why GPS needs its own dedicated TCP port
- **Not HTTP.** GT06 devices send binary frames
(`0x78 0x78 | len | protocol | payload | serial | CRC16 | 0x0D 0x0A`).
An HTTP server receiving these answers `400 Bad Request` and closes.
- **Dedicated port required.** A listening socket is keyed on `(IP, port)`; two
listeners on the same pair collide (`EADDRINUSE`). The REST API already owns
its port, so GPS traffic needs a separate one.
- **No hostname routing.** GT06 frames carry no `Host` header and no TLS SNI, so
L7 proxies (Nginx `http`, AWS ALB, Cloudflare proxy) cannot route them by
domain. Routing must happen at **Layer 4 (TCP)** by port.
- **DNS carries no port.** An A record maps a name to an IP only. The tracker
config must state the port explicitly (e.g. `gps.example.com:5023`).
### Operational requirements
| Item | Value |
| --- | --- |
| Protocol | Raw TCP (not HTTP, not TLS) |
| Default port | `5023` (configurable via `GT06_TCP_PORT`) |
| Listener bind | `0.0.0.0` inside the `freight-gps` container |
| Edge terminator | **L4** — AWS NLB or Nginx `stream {}`. **Not** ALB / Cloudflare proxy. |
---
## 2. Port configuration
`5023` is only this project's default — **not** a GT06 protocol requirement. The
listener binds whatever `GT06_TCP_PORT` says, as long as trackers are configured
with the same number.
Host and container ports are decoupled in `docker-compose.yaml`:
```yaml
freight-gps:
ports:
- "${GT06_TCP_PORT:-5023}:5023" # host is configurable; container fixed
environment:
GT06_TCP_PORT: "5023" # pinned inside the container
```
- The **container** always listens on `5023`.
- The **host/public** port is configurable (443, 5023, 9000, …) via the root
`.env`'s `GT06_TCP_PORT`.
- This split is required because the image runs as a **non-root** user
(`nestjs`, uid 1001), which cannot bind ports `<1024`. Docker (root) binds the
host port and forwards to `5023` inside.
- Running **outside Docker** (`pnpm dev:gps`, systemd), `GT06_TCP_PORT` is the
actual bind port, so `<1024` needs root or `CAP_NET_BIND_SERVICE`.
- **443 is allowed but risky:** GT06 stays raw TCP, not TLS. Middleboxes that
expect a TLS handshake on 443 may drop the connection.
---
## 3. Deployment topology
The GT06 listener runs as its own process (`dist/main.gps.js`, module
`GpsIngestModule`) — DB + GPS only, no HTTP server. It shares the `edr_freight`
DB with the API; the DB is the seam (ingester writes `gps_devices` /
`gps_positions`, API reads them).
```
freight-api HTTP :3001 GT06_TCP_PORT=0 (listener off, applies migrations)
freight-gps TCP :5023 DB_MIGRATIONS_RUN=false (owns the tracker socket)
```
`DB_MIGRATIONS_RUN=false` keeps the second process from racing migrations.
Horizontal scale: each tracker holds one long-lived TCP connection with
per-socket session state, so N `freight-gps` replicas can run behind an L4 LB —
each device sticks to one replica. `ensureDevice` is safe under concurrency
(unique IMEI).
---
## 4. Device configuration (GT06 side)
Config is done by **SMS to the tracker's SIM**. Commands below are the canonical
Concox/GT06 set — **verify against your unit's sheet**, syntax varies by firmware.
Default command password is usually `123456`.
Prep: data-enabled SIM, SMS on, **SIM PIN off**, know your carrier APN.
```
STATUS# # 1. sanity check — returns GSM/GPS/batt/GPRS
APN,<apn># # 2. carrier data APN (add ,user,pass if needed)
SERVER,1,gps.example.com,5023,0# # 3. point at server (1=domain). Port MUST match GT06_TCP_PORT
GPRSON,1# # 4. enable data
GPSON,1# # enable GPS
TIMER,10# # 5. upload interval, seconds (some use UPLOAD,10#)
RESET# # 6. reboot so it reconnects (many cache DNS until reboot)
```
Raw-IP variant of step 3: `SERVER,0,203.0.113.50,5023,0#`
Custom host port (e.g. 443): `SERVER,1,gps.example.com,443,0#`
### Verify from the server
```bash
docker compose logs -f freight-gps | grep -Ei "login|Auto-registering|ingester up"
nc -vz gps.example.com 5023
curl -H "Authorization: Bearer <token>" https://api.example.com/api/gps/positions/latest
```
First login packet **auto-registers** the IMEI (no manual step). `online:true`
only when `lastSeenAt` < 5 min (computed at read time).
### Link a tracker to a vehicle (optional)
Auto-register leaves `vehicleId` null. Attach it (needs `tracking.manage`):
```
PATCH /api/gps/devices/:id { "vehicleId": "<uuid>", "name": "Truck 03-ET" }
```
### Failure map
| Symptom | Cause |
| --- | --- |
| No SMS reply | SIM PIN on / no signal / wrong number |
| Replies but never connects | APN wrong, or `SERVER` port `GT06_TCP_PORT` |
| Connects then drops | server not ACKing, or middlebox on 443 expecting TLS |
| Registered but `online:false` | packets blocked by firewall open inbound TCP |
| Wrong location / `positioned:false` | no GPS fix yet open sky, cold start ~12 min |
---
## 5. Security
- GT06 authenticates with **IMEI only**, which is **spoofable**. Anyone who can
reach the port can inject fake positions.
- **Do not** expose the port to `0.0.0.0/0`. Restrict at the firewall / security
group to the SIM provider's **APN / IP range**.
- Trackers must use the same host+port as the server:
`SERVER,1,gps.example.com,<port>,0#`.
---
## 6. Edge (L4) termination
See [`infrastructure/nginx/gps-stream.conf`](../../../../../infrastructure/nginx/gps-stream.conf)
for an Nginx `stream {}` example, and the AWS NLB notes in the same file.
Reminder: **L4 only** an HTTP proxy cannot route GT06.

View File

@@ -11,14 +11,15 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { GpsTrackingService } from './gps-tracking.service';
import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto';
@ApiTags('gps-tracking')
@ApiBearerAuth()
@Controller('gps')
@FleetView()
@BookingStaff(FREIGHT_PERMS.tracking.view)
export class GpsTrackingController {
constructor(private readonly gps: GpsTrackingService) {}
@@ -44,21 +45,21 @@ export class GpsTrackingController {
}
@Post('devices')
@FleetManage()
@BookingStaff(FREIGHT_PERMS.tracking.manage)
@ApiOperation({ summary: 'Register a GPS tracker' })
register(@Body() dto: RegisterDeviceDto) {
return this.gps.registerDevice(dto);
}
@Patch('devices/:id')
@FleetManage()
@BookingStaff(FREIGHT_PERMS.tracking.manage)
@ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) {
return this.gps.updateDevice(id, dto);
}
@Delete('devices/:id')
@FleetManage()
@BookingStaff(FREIGHT_PERMS.tracking.manage)
@ApiOperation({ summary: 'Delete a GPS tracker' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.gps.removeDevice(id);

View File

@@ -49,6 +49,23 @@ export class CreateLocomotiveDto {
@Min(0)
maxTrainLengthMeters!: number;
// Allowed deviation above maxPullWeightTons before scheduling blocks the train
// (e.g. 90 lets a 3,500T-rated locomotive pull up to 3,590T). Omit/0 = strict cap.
@ApiPropertyOptional({ example: 90 })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
@IsNumber()
@Min(0)
overageToleranceTons?: number;
// Allowed deviation above maxTrainLengthMeters before scheduling blocks the train.
@ApiPropertyOptional({ example: 0 })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
@IsNumber()
@Min(0)
overageToleranceMeters?: number;
@ApiPropertyOptional({ example: 4200 })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))

View File

@@ -39,6 +39,26 @@ export class Locomotive extends BaseEntity {
@Column({ name: 'max_train_length_meters', type: 'numeric', precision: 10, scale: 3, default: 760 })
maxTrainLengthMeters!: number;
/** Allowed deviation above maxPullWeightTons before a train is blocked (e.g. the 37th PW2 wagon in the fertilizer example runs 90T over 3,500T and is still accepted). Null/0 = no tolerance. */
@Column({
name: 'overage_tolerance_tons',
type: 'numeric',
precision: 10,
scale: 3,
nullable: true,
})
overageToleranceTons?: number | null;
/** Allowed deviation above maxTrainLengthMeters before a train is blocked. Null/0 = no tolerance. */
@Column({
name: 'overage_tolerance_meters',
type: 'numeric',
precision: 10,
scale: 3,
nullable: true,
})
overageToleranceMeters?: number | null;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
status!: LocomotiveStatus;

View File

@@ -64,6 +64,8 @@ export class LocomotivesService {
maxPullWeightTons:
dto.maxPullWeightTons ?? LocomotivesService.DEFAULT_MAX_PULL_WEIGHT_TONS,
maxTrainLengthMeters: dto.maxTrainLengthMeters,
overageToleranceTons: dto.overageToleranceTons ?? null,
overageToleranceMeters: dto.overageToleranceMeters ?? null,
powerKw: dto.powerKw ?? null,
tractionForceKn: dto.tractionForceKn ?? null,
maxSpeedKmh: dto.maxSpeedKmh ?? null,

View File

@@ -13,8 +13,10 @@ import {
Patch,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
import {
AuthUserPayload,
@@ -24,6 +26,8 @@ import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto";
import { NotificationInboxService } from "./notification-inbox.service";
@ApiTags("notifications")
@ApiBearerAuth()
@UseGuards(JwtGuard)
@Controller("notifications")
export class NotificationInboxController {
constructor(private readonly service: NotificationInboxService) {}

View File

@@ -1,4 +1,4 @@
import { Module } from "@nestjs/common";
import { Module, forwardRef } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
@@ -17,8 +17,9 @@ import { WsAuthService } from "./ws-auth.service";
@Module({
imports: [
TypeOrmModule.forFeature([Notification, User, Session]),
// ExternalProfileRepository + CompanyProfileRepository (portal targeting)
CompaniesModule,
// ExternalProfileRepository + CompanyProfileRepository (portal targeting).
// CompaniesModule imports this module back for CompanyNotifierService.
forwardRef(() => CompaniesModule),
// BackofficeService.getOrganizationEmployees (staff targeting)
BackofficeModule,
// EmailClientService + SmsClientService (HIGH-priority fan-out)

View File

@@ -15,9 +15,10 @@ import { WsAuthService } from "./ws-auth.service";
/**
* Server → client push for in-app notifications. Clients only *listen* (no
* `@SubscribeMessage` handlers), so the global HTTP JwtGuard never applies here;
* the handshake is authenticated in `handleConnection` and each socket joins a
* private `user:<id>` room the service targets.
* `@SubscribeMessage` handlers), and `@UseGuards(JwtGuard)` on the REST
* controller does not cover WebSockets; the handshake is authenticated in
* `handleConnection` and each socket joins a private `user:<id>` room the
* service targets.
*/
@WebSocketGateway({
namespace: NOTIFICATION_WS_NAMESPACE,

View File

@@ -22,6 +22,10 @@ export class SmsNotificationStrategy implements NotificationStrategy {
this.logger.debug(`Sending SMS to ${recipient} via ${url}`);
// axios defaults to no timeout — a hanging gateway would block the caller
// (and any transaction it sits in) indefinitely. Always bound the wait.
const timeout = Number(this.configService.get<string>("SMS_TIMEOUT_MS") ?? 8000);
try {
const response = await axios.post(
url,
@@ -34,6 +38,7 @@ export class SmsNotificationStrategy implements NotificationStrategy {
callbackUrl: "",
},
{
timeout,
headers: {
accept: "*/*",
"Content-Type": "application/json",

View File

@@ -50,6 +50,9 @@ export class OtpService {
await this.otpRepository.createOtp(target, otp);
}
// A freshly issued code gets a fresh guess budget.
this.actionAttempts.delete(this.targetKey(target));
if (target.email) {
// send email (queued to RabbitMQ via the shared Email service)
await this.emailClient.sendEmail({
@@ -121,24 +124,44 @@ export class OtpService {
// ---------------------------------------------------------------------------
// Fresh, single-use challenge gating a sensitive action (e.g. applying a
// contract signature). Unlike verifyOtp above — which marks a phone verified
// and leaves the code in place — this enforces a short TTL and consumes the
// code on success so it can never be replayed.
// contract signature, resetting a forgotten password). Unlike verifyOtp above
// — which marks a target verified and leaves the code in place — this enforces
// a TTL and consumes the code on success so it can never be replayed.
private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000;
async verifyOtpForAction(phone: string, otp: string) {
const otpData = await this.otpRepository.findByPhone(phone);
// Without a cap, a 6-digit code guarding a password reset is brute-forceable
// within its own TTL. `otp_verifications` has no attempt column, so the
// counter lives here and the code is burned once the budget is spent.
// Per-process: it resets on restart and is not shared across replicas — a
// persisted counter needs a migration on OtpVerification.
private readonly MAX_ACTION_ATTEMPTS = 5;
private readonly actionAttempts = new Map<string, number>();
private targetKey(target: OtpTarget): string {
return target.email ? `email:${target.email}` : `phone:${target.phone}`;
}
async verifyOtpForAction(
target: OtpTarget,
otp: string,
ttlMs: number = this.ACTION_OTP_TTL_MS,
) {
const otpData = await this.otpRepository.findByTarget(target);
const key = this.targetKey(target);
if (!otpData) {
throw new BadRequestException(
"No verification code was requested for this phone",
target.email
? "No verification code was requested for this email"
: "No verification code was requested for this phone",
);
}
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
if (ageMs > this.ACTION_OTP_TTL_MS) {
if (ageMs > ttlMs) {
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
throw new BadRequestException(
"Verification code has expired. Request a new one.",
@@ -146,11 +169,24 @@ export class OtpService {
}
if (otpData.otp !== otp) {
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
throw new BadRequestException(
"Too many incorrect attempts. Request a new code.",
);
}
this.actionAttempts.set(key, attempts);
throw new BadRequestException("Invalid verification code");
}
// single-use: consume on success
await this.otpRepository.deleteOtp(otpData);
this.actionAttempts.delete(key);
return { success: true };
}

View File

@@ -39,12 +39,32 @@ export class Route extends BaseEntity {
milestones?: RouteMilestone[];
}
/**
* Human-readable route label: yard names, not yard codes — "Addis Ababa → Dire Dawa",
* not "ADDIS_ABABA → DIRE_DAWA". A yard's display name is its `label`; `code` is the
* machine identifier and is only a fallback for a yard missing one.
*
* When the route's milestones are loaded (with their yards), the label is the FULL
* ordered corridor — "Addis Ababa → Adama → Dire Dawa" — since milestones already
* include the origin (first) and destination (last). Without milestones it falls
* back to origin → destination.
*/
export function formatRouteLabel(route: {
originYard?: { code?: string; name?: string } | null;
destinationYard?: { code?: string; name?: string } | null;
originYard?: { code?: string; label?: string } | null;
destinationYard?: { code?: string; label?: string } | null;
milestones?: Array<{
sequenceNo: number;
yard?: { code?: string; label?: string } | null;
}> | null;
}): string {
const origin = route.originYard?.code ?? route.originYard?.name ?? 'Origin';
const dest = route.destinationYard?.code ?? route.destinationYard?.name ?? 'Destination';
const stops = [...(route.milestones ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((m) => m.yard?.label ?? m.yard?.code)
.filter((name): name is string => Boolean(name));
if (stops.length >= 2) return stops.join(' → ');
const origin = route.originYard?.label ?? route.originYard?.code ?? 'Origin';
const dest = route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination';
return `${origin}${dest}`;
}

View File

@@ -22,7 +22,10 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
return this.repo(manager).findOne({
where: { id },
relations: {
route: true,
// Yards carry the route's display name; without them formatRouteLabel
// degrades to the literal "Origin → Destination". Milestones (with
// their yards) give it the full corridor path.
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
trainSet: {
locomotive: true,
locomotives: { locomotive: true },

View File

@@ -24,3 +24,23 @@ export const DEFAULT_CONTAINER_WAGON_LENGTH_METERS = 14;
/** Default CW3 covered wagon length for bulk bookings (m). */
export const DEFAULT_BULK_WAGON_LENGTH_METERS = 14;
/**
* Fallback tare weights (T) matching the length fallbacks above. The locomotive
* pull limit is a GROSS limit, so a booking's weight budget must include the
* empty weight of every wagon it occupies — not just its cargo.
*/
export const DEFAULT_CONTAINER_WAGON_TARE_TONS = 22.4;
/** Default CW3 gondola tare for bulk bookings (T). */
export const DEFAULT_BULK_WAGON_TARE_TONS = 23.4;
/**
* Fallback rated payloads (T) matching the tare fallbacks above. A bulk booking's
* wagon count is its cargo divided by this, so a zero here would make the count
* infinite — callers must floor it at a positive number.
*/
export const DEFAULT_CONTAINER_WAGON_CAPACITY_TONS = 70;
/** Default CW3 gondola rated payload for bulk bookings (T). */
export const DEFAULT_BULK_WAGON_CAPACITY_TONS = 60;

View File

@@ -31,6 +31,7 @@ describe('BookingBatchService — PAID reconcile', () => {
createMany: jest.Mock;
};
let trainSchedulesRepository: {
findById: jest.Mock;
findByIdWithFullGraph: jest.Mock;
findAll: jest.Mock;
};
@@ -65,6 +66,11 @@ describe('BookingBatchService — PAID reconcile', () => {
createMany: jest.fn().mockResolvedValue(undefined),
};
trainSchedulesRepository = {
findById: jest.fn().mockResolvedValue({
id: scheduleId,
bookingWindowStatus: 'OPEN',
windowPhase: null,
}),
findByIdWithFullGraph: jest.fn().mockResolvedValue({
id: scheduleId,
maxWagons: 10,
@@ -130,6 +136,7 @@ describe('BookingBatchService — PAID reconcile', () => {
expirePayable: jest.fn().mockResolvedValue(undefined),
} as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
);
});
@@ -163,7 +170,7 @@ describe('BookingBatchService — PAID reconcile', () => {
});
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(undefined);
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0);
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);
const reconcileSpy = jest.spyOn(service, 'reconcilePaidUnlinked').mockResolvedValue(undefined);
@@ -181,6 +188,77 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(reconcileOrder).toBeLessThan(wagonOrder);
});
describe('extendPaymentPhaseForTopUp', () => {
const schedRepo = () => dataSource.getRepository();
it('pushes paymentPhaseEndsAt out when a fresh window exceeds it', async () => {
const soon = new Date(Date.now() + 5_000); // phase almost over
const departure = new Date(Date.now() + 24 * 3_600_000);
schedRepo().findOne.mockResolvedValueOnce({
id: scheduleId,
windowPhase: 'PAYMENT',
paymentPhaseEndsAt: soon,
scheduledDepartureDate: departure,
});
await service.extendPaymentPhaseForTopUp(scheduleId);
// paymentWindowMinutes = 60 (mock) → new end ≈ now + 1h, which is > soon.
expect(schedRepo().update).toHaveBeenCalledWith(
scheduleId,
expect.objectContaining({ paymentPhaseEndsAt: expect.any(Date) }),
);
const [, patch] = schedRepo().update.mock.calls.at(-1)!;
expect((patch.paymentPhaseEndsAt as Date).getTime()).toBeGreaterThan(
soon.getTime(),
);
});
it('does not pull the deadline in when the current end is already later', async () => {
const far = new Date(Date.now() + 10 * 3_600_000); // 10h out, beyond a 1h window
schedRepo().findOne.mockResolvedValueOnce({
id: scheduleId,
windowPhase: 'PAYMENT',
paymentPhaseEndsAt: far,
scheduledDepartureDate: new Date(Date.now() + 24 * 3_600_000),
});
await service.extendPaymentPhaseForTopUp(scheduleId);
expect(schedRepo().update).not.toHaveBeenCalled();
});
it('is a no-op outside the PAYMENT phase', async () => {
schedRepo().findOne.mockResolvedValueOnce({
id: scheduleId,
windowPhase: 'OPEN',
paymentPhaseEndsAt: null,
scheduledDepartureDate: new Date(Date.now() + 24 * 3_600_000),
});
await service.extendPaymentPhaseForTopUp(scheduleId);
expect(schedRepo().update).not.toHaveBeenCalled();
});
it('never extends past departure', async () => {
const departure = new Date(Date.now() + 60_000); // 1 min away
schedRepo().findOne.mockResolvedValueOnce({
id: scheduleId,
windowPhase: 'PAYMENT',
paymentPhaseEndsAt: new Date(Date.now() + 1_000),
scheduledDepartureDate: departure,
});
await service.extendPaymentPhaseForTopUp(scheduleId);
const [, patch] = schedRepo().update.mock.calls.at(-1)!;
expect((patch.paymentPhaseEndsAt as Date).getTime()).toBeLessThanOrEqual(
departure.getTime(),
);
});
});
describe('fillRouteDay — day-level distribution', () => {
const originYardId = 'yard-origin';
const destinationYardId = 'yard-dest';
@@ -336,6 +414,49 @@ describe('BookingBatchService — PAID reconcile', () => {
// Never reserved — waits for its partner in a later cycle.
expect(notifier.payNow).not.toHaveBeenCalled();
});
it('clears a stale FULL flag and fills a train whose bookings all expired', async () => {
// The deadlock: train A filled once, every booking then expired, but
// bookingWindowStatus stayed FULL. isFillable() rejects FULL before it ever
// reads the budget, so the batch skipped the train forever — it just cycled
// PRE_WINDOW→DOC_REVIEW→PAYMENT with an empty consist, and only the odd
// already-pinned booking got settled, one per cycle.
const staleFull = {
id: trainA,
maxWagons: 1,
bookingWindowStatus: 'FULL',
// The batch runs while the customer window is closed.
windowPhase: 'PAYMENT',
direction: 'IMPORT',
trainSetId: `set-${trainA}`,
trainSet: { locomotive: smallLoco },
scheduleBookings: [],
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
originStationId: originYardId,
destinationStationId: destinationYardId,
};
trainSchedulesRepository.findAll.mockResolvedValue([{ ...staleFull }]);
// Live capacity says the train is empty: 1 free wagon, nothing allocated.
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(staleFull);
// refreshWindowStatus writes CLOSED (mid-PAYMENT, not a customer-open phase);
// the re-read reports it, and isFillable() admits CLOSED during PAYMENT.
trainSchedulesRepository.findById.mockResolvedValue({
id: trainA,
bookingWindowStatus: 'CLOSED',
windowPhase: 'PAYMENT',
});
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([
commercial('waiting', 30),
]);
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
// The train was reopened to the batch and actually filled, not skipped.
expect(touched).toEqual([trainA]);
expect(notifier.payNow).toHaveBeenCalledTimes(1);
expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting');
expect(notifier.unplaced).not.toHaveBeenCalled();
});
});
describe('expireUnacceptedForRouteDay — doc-review sweep', () => {
@@ -443,6 +564,7 @@ describe('BookingBatchService — PAID reconcile', () => {
trainSchedulingService as never,
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,
{ findOpenOffer: jest.fn() } as never,
);
@@ -465,6 +587,7 @@ describe('BookingBatchService — PAID reconcile', () => {
trainSchedulingService as never,
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,
{ findOpenOffer: jest.fn() } as never,
);
@@ -495,6 +618,7 @@ describe('BookingBatchService — PAID reconcile', () => {
trainSchedulingService as never,
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,
{ findOpenOffer: jest.fn() } as never,
);
@@ -510,4 +634,236 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(check({ ...importGeneral, contractKind: null } as Booking, false)).toBe(false);
});
});
describe('settleDueReservations — expire then promote the waiting list', () => {
const originYardId = 'yard-origin';
const destinationYardId = 'yard-dest';
const trainId = 'train-a';
// 14m / 70t default wagon → two wagon slots on this locomotive.
const smallLoco = { maxPullWeightTons: 200, maxTrainLengthMeters: 28 };
const booking = (id: string, priority: number, overrides = {}): Booking =>
({
id,
reference: id,
isGovernment: false,
priorityScore: priority,
status: 'FULLY_EXECUTED',
wagonsRequired: 1,
cargoTotalWeightVgm: 10,
freightType: 'CONTAINER',
bookingContainers: [],
originYardId,
destinationYardId,
trainScheduleId: trainId,
...overrides,
}) as unknown as Booking;
beforeEach(() => {
const scheduleRow = {
id: trainId,
maxWagons: 2,
bookingWindowStatus: 'CLOSED',
windowPhase: 'PAYMENT',
direction: 'IMPORT',
trainSetId: `set-${trainId}`,
trainSet: { locomotive: smallLoco },
scheduleBookings: [],
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
originStationId: originYardId,
destinationStationId: destinationYardId,
};
trainSchedulesRepository.findAll.mockResolvedValue([{ ...scheduleRow }]);
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleRow);
trainSchedulesRepository.findById.mockResolvedValue({
id: trainId,
bookingWindowStatus: 'CLOSED',
windowPhase: 'PAYMENT',
scheduledDepartureDate: scheduleRow.scheduledDepartureDate,
originStationId: originYardId,
destinationStationId: destinationYardId,
});
});
it('promotes a waiting booking into the wagons an expired reservation frees', async () => {
// One reservation whose pay window lapsed, and one booking on the waiting list.
const lapsed = booking('lapsed', 50, {
status: 'SELECTED_FOR_BATCH',
paymentDeadline: new Date(Date.now() - 60_000),
});
const waiting = booking('waiting', 10, { trainScheduleId: null });
bookingsRepository.findReservedForSchedule
.mockResolvedValueOnce([lapsed]) // settleReserved sees the lapsed one
.mockResolvedValue([]); // afterwards nothing is reserved
// The day pool the top-up draws from: only the waiting booking is eligible.
bookingsRepository.findBatchPoolByCorridorDay
.mockResolvedValueOnce([waiting])
.mockResolvedValue([]);
await service.settleDueReservations(trainId);
// The lapsed reservation expired...
expect(notifier.expired).toHaveBeenCalledTimes(1);
expect((notifier.expired.mock.calls[0][0] as Booking).id).toBe('lapsed');
// ...and the waiting booking was promoted in the SAME settle, not next cycle.
expect(notifier.payNow).toHaveBeenCalledTimes(1);
expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting');
});
it('serialises concurrent settles so the same reservation is not settled twice', async () => {
const lapsed = booking('lapsed', 50, {
status: 'SELECTED_FOR_BATCH',
paymentDeadline: new Date(Date.now() - 60_000),
});
// Both callers read the reservation; the lock must stop the second from
// acting on rows the first already expired. (The PAYMENT transition and the
// tick's overdue backstop do exactly this, in the same second.)
let reads = 0;
bookingsRepository.findReservedForSchedule.mockImplementation(() => {
reads += 1;
return Promise.resolve(reads === 1 ? [lapsed] : []);
});
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
await Promise.all([
service.settleDueReservations(trainId),
service.settleDueReservations(trainId),
]);
expect(notifier.expired).toHaveBeenCalledTimes(1);
});
});
});
describe('BookingBatchService — wagonsFor', () => {
// wagonsFor is pure arithmetic over its two arguments and touches no injected
// dependency, so the service can be built with none.
const service = new BookingBatchService(
null as never,
null as never,
null as never,
null as never,
null as never,
null as never,
null as never,
null as never,
null as never,
null as never,
) as unknown as {
wagonsFor(booking: unknown, dims: unknown): number;
needFor(booking: unknown, dims: unknown): {
wagons: number;
weightTons: number;
lengthMeters: number;
};
};
// PW2 box wagon: 70T rated payload, 25.2T tare, 17.066m.
const dims = {
container: { lengthMeters: 13.966, tareWeightTons: 22.4, capacityTons: 70 },
bulk: { lengthMeters: 17.066, tareWeightTons: 25.2, capacityTons: 70 },
byWagonTypeId: new Map(),
};
const bulk = (cargoTons: number, over: Record<string, unknown> = {}) => ({
freightType: 'BULK',
cargoTotalWeightVgm: cargoTons,
bookingContainers: [],
...over,
});
it('sizes a bulk booking by cargo ÷ rated payload, not a flat 1 wagon', () => {
// 37 × 1400 fertilizer packages × 50kg = 2590T of cargo.
expect(service.wagonsFor(bulk(2590), dims)).toBe(37);
});
it('rounds a partial wagon up', () => {
expect(service.wagonsFor(bulk(70.1), dims)).toBe(2);
expect(service.wagonsFor(bulk(70), dims)).toBe(1);
});
it('still floors at one wagon when a bulk booking has no recorded cargo', () => {
expect(service.wagonsFor(bulk(0), dims)).toBe(1);
});
it('honours an explicit wagonsRequired override', () => {
expect(service.wagonsFor(bulk(2590, { wagonsRequired: 40 }), dims)).toBe(40);
});
it('ignores a stale undersized wagonsRequired: 700T of sugar rides 10 wagons, not 1', () => {
// Rows written while sumWagonsRequired hardcoded BULK to 1 are still in the
// DB; trusting them charged one tare for the whole consist (700 + 25.2
// instead of 700 + 10 × 25.2 gross).
expect(service.wagonsFor(bulk(700, { wagonsRequired: 1 }), dims)).toBe(10);
});
it('takes the binding axis for containers: weight can exceed TEU geometry', () => {
// Two 40ft units => 2 wagons by TEU geometry, but 210T needs 3 at 70T each.
const booking = {
freightType: 'CONTAINER',
cargoTotalWeightVgm: 210,
bookingContainers: [
{ quantity: 2, wagonsRequired: 2, containerType: { wagonsPerUnit: 1, sizeFt: 40 } },
],
};
expect(service.wagonsFor(booking, dims)).toBe(3);
});
it('keeps TEU geometry when it binds before weight', () => {
// Four 20ft units => 2 wagons by geometry; 40T of cargo needs only 1 by weight.
const booking = {
freightType: 'CONTAINER',
cargoTotalWeightVgm: 40,
bookingContainers: [
{ quantity: 4, wagonsRequired: 2, containerType: { wagonsPerUnit: 0.5, sizeFt: 20 } },
],
};
expect(service.wagonsFor(booking, dims)).toBe(2);
});
describe('per-booking wagon type (cargo/container type FK)', () => {
// The booking's cargo type rides PW2 (25.2T tare / 70T), but the
// representative bulk fallback is a CW3-ish 23.4T tare. Measuring the
// booking on the fallback under-charged its gross (2100 + 30 × 23.4 =
// 2802 instead of 2856), so the fill loop admitted sets that allocation's
// real-consist check later rejected — after the customer had paid.
const dimsWithTypes = {
container: { lengthMeters: 13.966, tareWeightTons: 22.4, capacityTons: 70 },
bulk: { lengthMeters: 17.066, tareWeightTons: 23.4, capacityTons: 70 },
byWagonTypeId: new Map([
['pw2-id', { lengthMeters: 17.066, tareWeightTons: 25.2, capacityTons: 70 }],
]),
};
it('charges a bulk booking the tare of ITS wagon type, not the representative', () => {
const booking = bulk(2100, { cargoType: { wagonTypeId: 'pw2-id' } });
const need = service.needFor(booking, dimsWithTypes);
expect(need.wagons).toBe(30);
expect(need.weightTons).toBe(2856); // 2100 + 30 × 25.2 — matches allocation
});
it('falls back to the representative dims when no wagon type is configured', () => {
const need = service.needFor(bulk(2100), dimsWithTypes);
expect(need.weightTons).toBe(2802); // 2100 + 30 × 23.4 (legacy behavior)
});
it('resolves a container booking through its container type', () => {
const booking = {
freightType: 'CONTAINER',
cargoTotalWeightVgm: 140,
bookingContainers: [
{
quantity: 2,
wagonsRequired: 2,
containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypeId: 'pw2-id' },
},
],
};
const need = service.needFor(booking, dimsWithTypes);
expect(need.wagons).toBe(2);
expect(need.weightTons).toBe(190.4); // 140 + 2 × 25.2
expect(need.lengthMeters).toBeCloseTo(34.132, 3); // 2 × 17.066, not NW5's 13.966
});
});
});

View File

@@ -28,6 +28,9 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
* unload at its destination yard (IN_TRANSIT → ARRIVED for import/export,
* → COMPLETED for intercity), possibly long before the train's final arrival.
* Both are gated on the train's latest recorded checkpoint being at that yard.
* Unload also fires automatically: recording a checkpoint at a yard auto-
* unloads every booking destined there (autoUnloadAtYard), so the manual
* unload endpoint remains only a fallback.
*
* Unloading also settles the physical wagons: each wagon that alights with the
* booking is released at that yard and the move is written to the
@@ -198,6 +201,47 @@ export class BookingJourneyService {
};
}
/**
* Auto-unload on checkpoint: every IN_TRANSIT booking on this schedule whose
* destination is the yard the train just reached alights automatically, so
* the customer's booking flips to ARRIVED (COMPLETED for intercity) the
* moment the train is recorded at their yard — no separate operator unload.
* Runs through the same per-booking unload path (wagon settle + ledger +
* milestones); one booking's failure is logged and never blocks the
* checkpoint or the other bookings. Returns the unloaded booking ids.
*/
async autoUnloadAtYard(
scheduleId: string,
yardId: string,
userId?: string | null,
): Promise<string[]> {
const bookings = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('booking')
.innerJoin(
'freight.train_schedule_bookings',
'tsb',
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
{ scheduleId },
)
.where('booking.destination_yard_id = :yardId', { yardId })
.andWhere(`booking.status = 'IN_TRANSIT'`)
.getMany();
const unloaded: string[] = [];
for (const booking of bookings) {
try {
await this.unloadBooking(scheduleId, booking.id, userId);
unloaded.push(booking.id);
} catch (err) {
this.logger.warn(
`Auto-unload failed for booking ${booking.id} at yard ${yardId}: ${(err as Error).message}`,
);
}
}
return unloaded;
}
/**
* Bulk fallback at the train's FINAL arrival: any booking destined for the
* final yard that operators didn't unload individually gets its per-booking

View File

@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import {
NotificationAudience,
NotificationPriority,
NotificationType,
NotifyInput,
} from '@edr/types';
@@ -114,11 +115,15 @@ export class BookingNotifierService {
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
const msg =
`Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` +
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` +
`(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now. ` +
`The remaining ${totalWagons - offeredWagons} return${totalWagons - offeredWagons === 1 ? 's' : ''} to your contract — book them yourself in a later window. ` +
`If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
// HIGH: a split is a change to what the customer ordered AND a live payment
// deadline — it must reach email/SMS, not just the portal inbox.
this.inApp(b, 'Partial allocation offer', msg, {
type: NotificationType.INVOICE_ISSUED,
priority: NotificationPriority.HIGH,
});
}

View File

@@ -36,6 +36,9 @@ export interface SizedOffer {
* rows, so reducing the lines releases it automatically) and can be rebooked in
* any later window within contract validity. A ONE_TIME contract is promoted to
* GENERAL on split (see applySplit) so its remainder is actually rebookable.
* Once the remainder is rebooked and the cap hits zero, ContractBookingService
* completes the contract (CONTRACT_CLOSED): no further bookings or shipment
* requests, even while validity and a booking window are still open.
*/
@Injectable()
export class BookingSplitService {
@@ -55,12 +58,17 @@ export class BookingSplitService {
* Size the largest part of the booking that fits `freeWagons`, priced via an
* in-memory clone. Returns null when nothing meaningful fits (no whole
* container unit / no bulk tonnage, or pricing failed).
*
* `maxOfferedWeightTons` caps the offered CARGO tonnage (bulk only) — on a
* weight-limited train the wagons' own tare eats into the locomotive's
* remaining pull weight, so the caller passes the room left after tare.
*/
async sizeOffer(
booking: Booking,
freeWagons: number,
totalWagons: number,
bulkWagonCapacityTons: number,
maxOfferedWeightTons?: number,
): Promise<SizedOffer | null> {
if (freeWagons < 1 || freeWagons >= totalWagons) return null;
@@ -110,10 +118,15 @@ export class BookingSplitService {
if (!offeredLines.length || offeredWagons <= 0) return null;
clone.bookingContainers = clonedContainers;
} else {
// Bulk: split by weight — the offered part is what freeWagons can carry.
// Bulk: split by weight — the offered part is what freeWagons can carry,
// further capped by the caller's weight room when the pull limit binds.
const totalWeight = Number(booking.cargoTotalWeightVgm ?? 0);
if (totalWeight <= 0 || bulkWagonCapacityTons <= 0) return null;
offeredWeightTons = Math.min(totalWeight, freeWagons * bulkWagonCapacityTons);
offeredWeightTons = Math.min(
totalWeight,
freeWagons * bulkWagonCapacityTons,
maxOfferedWeightTons ?? Number.POSITIVE_INFINITY,
);
if (offeredWeightTons <= 0) return null;
offeredWagons = Math.min(
freeWagons,

View File

@@ -17,6 +17,8 @@ describe('BookingWindowService — window state machine', () => {
expireUnacceptedForRouteDay: jest.Mock;
settleDueReservations: jest.Mock;
isScheduleFull: jest.Mock;
hasLiveReservations: jest.Mock;
refreshWindowStatus: jest.Mock;
};
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
@@ -68,6 +70,9 @@ describe('BookingWindowService — window state machine', () => {
expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined),
settleDueReservations: jest.fn().mockResolvedValue(undefined),
isScheduleFull: jest.fn().mockResolvedValue(false),
// No reservation is mid-pay-window by default, so the cycle concludes.
hasLiveReservations: jest.fn().mockResolvedValue(false),
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
};
trainSchedulesRepository = {
findById: jest.fn().mockResolvedValue(null),
@@ -154,6 +159,26 @@ describe('BookingWindowService — window state machine', () => {
expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId);
});
it('PAYMENT holds the cycle open while a reservation is still inside its pay window', async () => {
// `paymentPhaseEndsAt` is stamped when the phase starts; reserve() then sets each
// booking's own deadline milliseconds later. So the phase deadline always passes
// first, and concluding here would kill customers who still had time to pay — and
// leave no cycle for the waiting-list top-up to run in.
batch.hasLiveReservations.mockResolvedValue(true);
const s = baseSchedule({
windowPhase: 'PAYMENT',
paymentPhaseEndsAt: new Date('2026-07-01T02:30:00.000Z'),
});
const advanced = await advanceImport(s, new Date('2026-07-01T02:30:01.000Z'));
expect(advanced).toBe(true);
expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId);
// Still PAYMENT — the cycle was NOT concluded and the window did not reopen.
expect(s.windowPhase).toBe('PAYMENT');
expect(batch.isScheduleFull).not.toHaveBeenCalled();
});
it('conclude: train FULL → window FULL + phase DONE + auto-finalize', async () => {
batch.isScheduleFull.mockResolvedValue(true);
const s = baseSchedule({ windowPhase: 'PAYMENT' });

View File

@@ -304,6 +304,38 @@ export class BookingWindowService implements OnModuleInit {
`(allocate paid / expire unpaid) then concluding the cycle`,
);
await this.bookingBatchService.settleDueReservations(schedule.id);
// The settle expires unpaid reservations and promotes the waiting list into
// the wagons they free. Those promoted customers get a fresh pay window, and
// `extendPaymentPhaseForTopUp` pushes `paymentPhaseEndsAt` past `now` to
// cover it. Concluding here on the STALE in-memory timestamp would end the
// cycle the top-up just extended and expire them before they could pay — so
// re-read, and stay in PAYMENT if the deadline moved.
const settled = await this.trainSchedulesRepository.findById(schedule.id);
if (settled?.paymentPhaseEndsAt && now < settled.paymentPhaseEndsAt) {
schedule.paymentPhaseEndsAt = settled.paymentPhaseEndsAt;
this.logger.log(
`[WINDOW] ${schedule.id} PAYMENT extended to ` +
`${settled.paymentPhaseEndsAt.toISOString()} — waiting-list bookings were ` +
`promoted into the freed wagons; not concluding this cycle yet`,
);
return true;
}
// `paymentPhaseEndsAt` is stamped when the phase starts; each reservation's own
// deadline is set milliseconds later, per booking, so the phase always expires
// a fraction before the reservations it opened. Concluding here would end the
// cycle while customers still had time to pay, and the settle that finally
// expires them (next tick) would have no cycle left to promote the waiting
// list into. Hold in PAYMENT until every reservation has actually resolved.
if (await this.bookingBatchService.hasLiveReservations(schedule.id)) {
this.logger.log(
`[WINDOW] ${schedule.id} PAYMENT phase past its deadline but reservations ` +
`are still within their pay windows — holding the cycle open`,
);
return true;
}
await this.concludeCycle(schedule, cfg, now);
return true;
}
@@ -328,6 +360,18 @@ export class BookingWindowService implements OnModuleInit {
return;
}
// Not full, so any FULL flag left over from a batch whose bookings later
// expired is stale. Clear it here too: the PRE_WINDOW→OPEN transition below
// refuses to reopen a FULL schedule, which is how a train with an empty
// consist used to cycle forever without ever being fillable again. Re-read
// the flag onto the in-memory row — advanceSchedule keeps looping on this
// same object, and PRE_WINDOW→OPEN reads it.
if (schedule.bookingWindowStatus === 'FULL') {
await this.bookingBatchService.refreshWindowStatus(schedule.id);
const fresh = await this.trainSchedulesRepository.findById(schedule.id);
if (fresh) schedule.bookingWindowStatus = fresh.bookingWindowStatus;
}
// Doc review + payment have already run, so the desk is ready to reopen NOW —
// office hours decide whether that is this afternoon or tomorrow morning. Past
// the last cycle before departure, nextCycleOpensAt returns null and we finish.

View File

@@ -0,0 +1,120 @@
import { Capacity, CorridorBudget } from './corridor-capacity.util';
import { sizePartialOfferWagons } from './train-capacity.util';
describe('corridor-capacity.util — overage tolerance', () => {
const pw2 = { lengthMeters: 17.066, capacityTons: 70, tareWeightTons: 25.2 };
const stops = ['yard-a', 'yard-b'];
const base: Capacity = { wagons: 44, weightTons: 3500, lengthMeters: 760 };
const tolerance = { weightTons: 90, lengthMeters: 0 };
const need = (weightTons: number, wagons = 1, lengthMeters = 17): Capacity => ({
wagons,
weightTons,
lengthMeters,
});
const budgetAt = (usedWeightTons: number): CorridorBudget => {
const budget = new CorridorBudget(stops, base, tolerance);
budget.subtract(need(usedWeightTons, 10, 170), budget.fullLeg());
return budget;
};
it('admits a whole booking that overflows the base cap by less than the tolerance', () => {
// 3500T train, 90T tolerance, 3560T committed: a 25T booking still boards
// entire (3585 ≤ 3590).
const budget = budgetAt(3560);
expect(budget.fits(need(25), budget.fullLeg())).toBe(true);
});
it('rejects a whole booking that overflows past the tolerance — no partial admission', () => {
// Same train at 3560T: a 210T booking would need 3770 > 3590 — skipped.
const budget = budgetAt(3560);
expect(budget.fits(need(210), budget.fullLeg())).toBe(false);
});
it('caps stacked overage admissions at base + tolerance', () => {
// Small units may keep boarding inside the overage zone, but never past it.
const budget = budgetAt(3560);
budget.subtract(need(25), budget.fullLeg()); // now 3585 committed
expect(budget.fits(need(5), budget.fullLeg())).toBe(true); // 3590 exactly
expect(budget.fits(need(6), budget.fullLeg())).toBe(false); // 3591 > 3590
});
it('excludes the tolerance from remainingFor, so split room never reaches into it', () => {
const budget = budgetAt(3400);
expect(budget.remainingFor(budget.fullLeg()).weightTons).toBe(100);
// Once a whole-unit admission spends the tolerance, base room goes negative.
const over = budgetAt(3560);
expect(over.remainingFor(over.fullLeg()).weightTons).toBe(-60);
});
it('yields no split offer once the base cap is spent — tolerance is whole-bookings-only', () => {
// The batch engine sizes splits from remainingFor; at/over base capacity
// that room cannot carry even one part-loaded wagon, so no offer opens.
const over = budgetAt(3560);
const room = over.remainingFor(over.fullLeg());
expect(sizePartialOfferWagons(room, 15, pw2)).toBeNull();
});
it('still offers a split while committed weight is under the base cap', () => {
// 744T of base room left: the boundary booking is offered the part that
// fits up to 3500, not up to 3590.
const budget = budgetAt(2756);
const room = budget.remainingFor(budget.fullLeg());
expect(sizePartialOfferWagons(room, 15, pw2)).toEqual({
wagons: 8,
maxCargoTons: 542.4,
});
});
it('leaves fits() strict when no tolerance is configured', () => {
const strict = new CorridorBudget(stops, base);
strict.subtract(need(3500, 10, 170), strict.fullLeg());
expect(strict.fits(need(1), strict.fullLeg())).toBe(false);
});
describe('isExhausted — train-wide FULL across all axes', () => {
// Lightest wagon at rated payload: PW2 25.2T tare + 70T = 95.2T gross.
const perWagon = {
grossWeightTons: pw2.tareWeightTons + pw2.capacityTons,
lengthMeters: pw2.lengthMeters,
};
it('reports FULL when weight binds first, with wagon slots still free', () => {
// 37 loaded PW2 wagons = 3522.4T of 3500+90T. 7 length-derived slots
// remain, but wagon 38 would need 95.2T against 67.6T of room — the
// schedule must finalize and its window must disappear.
const budget = new CorridorBudget(stops, base, tolerance);
budget.subtract(need(3522.4, 37, 631.442), budget.fullLeg());
expect(budget.maxRemaining().wagons).toBeGreaterThan(0); // slot check alone says "not full"
expect(budget.isExhausted(perWagon)).toBe(true);
});
it('is not FULL while one more loaded wagon still fits within base + tolerance', () => {
const budget = budgetAt(3300); // 200T base room + 90T tolerance ≥ 95.2T
expect(budget.isExhausted(perWagon)).toBe(false);
});
it('reports FULL when wagon slots run out regardless of weight room', () => {
const budget = new CorridorBudget(stops, base, tolerance);
budget.subtract(need(1000, 44, 700), budget.fullLeg());
expect(budget.isExhausted(perWagon)).toBe(true);
});
it('reports FULL when length room cannot take one more wagon', () => {
const budget = new CorridorBudget(stops, base, tolerance);
budget.subtract(need(1000, 30, 750), budget.fullLeg()); // 10m left < 17.066m
expect(budget.isExhausted(perWagon)).toBe(true);
});
it('only counts an edge as open when EVERY axis has room on that same edge', () => {
// Three stops → two edges. Edge 0 has weight but no slots; edge 1 has
// slots but no weight. Neither can board a wagon, so the train is FULL
// even though the per-axis maxima both look open.
const budget = new CorridorBudget(['a', 'b', 'c'], base, tolerance);
budget.subtract(need(0, 44, 0), { fromEdge: 0, toEdge: 1 });
budget.subtract(need(3522.4, 0, 0), { fromEdge: 1, toEdge: 2 });
expect(budget.isExhausted(perWagon)).toBe(true);
});
});
});

View File

@@ -63,18 +63,40 @@ export function stopYardsFor(
return [originStationId, destinationStationId];
}
/** Per-edge capacity budget along a schedule's stop list. */
/** Overage a locomotive may absorb beyond its base caps. */
export interface OverageTolerance {
weightTons: number;
lengthMeters: number;
}
/**
* Per-edge capacity budget along a schedule's stop list.
*
* `initial` must be the BASE caps (locomotive floored by rule caps, WITHOUT the
* overage tolerance). The tolerance is passed separately and is spendable only
* by admitting a unit WHOLE via {@link fits} — e.g. base 3500T + 90T tolerance,
* 3560T already committed: a 25T booking still boards entire (3585 ≤ 3590), a
* 210T booking does not. {@link remainingFor} deliberately excludes the
* tolerance (and goes negative once it is consumed), so split/partial offers
* sized from it can only fill up to the base cap and never spend the tolerance.
*/
export class CorridorBudget {
private readonly edges: Capacity[];
private readonly stopIndex: Map<string, number>;
private readonly tolerance: OverageTolerance;
constructor(
readonly stops: string[],
initial: Capacity,
tolerance?: Partial<OverageTolerance> | null,
) {
const edgeCount = Math.max(1, stops.length - 1);
this.edges = Array.from({ length: edgeCount }, () => ({ ...initial }));
this.stopIndex = new Map(stops.map((yardId, i) => [yardId, i]));
this.tolerance = {
weightTons: tolerance?.weightTons ?? 0,
lengthMeters: tolerance?.lengthMeters ?? 0,
};
}
/** The leg between two stops, or null when they aren't on this corridor in order. */
@@ -99,7 +121,12 @@ export class CorridorBudget {
return this.legOf(originYardId, destinationYardId) ?? this.fullLeg();
}
/** Remaining capacity usable by this leg = min across its edges. */
/**
* Remaining BASE capacity usable by this leg = min across its edges. Excludes
* the overage tolerance and goes negative once a whole-unit admission has
* spent it — sizing a split from this can therefore never reach into the
* tolerance, and yields nothing at all once the base cap is exhausted.
*/
remainingFor(leg: CorridorLeg): Capacity {
let min = { ...this.edges[leg.fromEdge] };
for (let i = leg.fromEdge + 1; i < leg.toEdge; i++) {
@@ -113,8 +140,20 @@ export class CorridorBudget {
return min;
}
/**
* Whether a unit fits WHOLE on this leg. This is the only place the overage
* tolerance may be spent: the unit boards entirely or not at all, so weight
* and length may dip into the tolerance. Admission keeps the invariant
* `remaining ≥ -tolerance` on every edge, i.e. the train never exceeds
* base + tolerance no matter how many small units board in the overage zone.
*/
fits(need: Capacity, leg: CorridorLeg): boolean {
return capacityFits(need, this.remainingFor(leg));
const remaining = this.remainingFor(leg);
return (
need.wagons <= remaining.wagons &&
need.weightTons <= remaining.weightTons + this.tolerance.weightTons &&
need.lengthMeters <= remaining.lengthMeters + this.tolerance.lengthMeters
);
}
subtract(need: Capacity, leg: CorridorLeg): void {
@@ -129,6 +168,25 @@ export class CorridorBudget {
}
}
/**
* Train-wide FULL across ALL capacity axes: true when no edge can board even
* one more loaded wagon. `perWagon` is the smallest gross weight and length
* a future wagon could add (lightest wagon type at rated payload); weight and
* length may dip into the overage tolerance, mirroring {@link fits}. Checked
* per edge — an edge with slots free but no pull weight is just as closed as
* one with no slots. A slot-only check misses weight-bound trains: PW2 at
* 37 × 95.2T = 3522.4T of 3500+90T has 7 length-derived slots free but no
* weight room for wagon 38, and its window must read FULL.
*/
isExhausted(perWagon: { grossWeightTons: number; lengthMeters: number }): boolean {
return this.edges.every(
(e) =>
e.wagons <= 0 ||
e.weightTons + this.tolerance.weightTons < perWagon.grossWeightTons ||
e.lengthMeters + this.tolerance.lengthMeters < perWagon.lengthMeters,
);
}
/**
* The most open edge — when even this has no wagon slots left, nothing can
* board anywhere and the schedule's window is genuinely FULL. (A train can be

View File

@@ -51,7 +51,7 @@ export class AssignBookingsDto {
@IsUUID('4', { each: true })
bookingIds!: string[];
@ApiPropertyOptional({ description: 'Bypass soft hold and overweight warnings' })
@ApiPropertyOptional({ description: 'Suppress soft hold and overweight warnings' })
@IsOptional()
@IsBoolean()
forceAssign?: boolean;

View File

@@ -0,0 +1,99 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsIn,
IsInt,
IsISO8601,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
export const BATCH_BOARD_STATUSES = [
'DRAFT',
'SCHEDULED',
'DISPATCHED',
'ARRIVED',
'CANCELLED',
] as const;
export const BATCH_BOARD_SORT_FIELDS = [
'createdAt',
'scheduledDepartureDate',
'trainNumber',
'status',
] as const;
export type BatchBoardSortField = (typeof BATCH_BOARD_SORT_FIELDS)[number];
/** Filters for the batch monitoring board list (import schedules, all statuses). */
export class BatchBoardQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ default: 12, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number;
@ApiPropertyOptional({
description:
'Comma-separated schedule statuses (DRAFT,SCHEDULED,DISPATCHED,ARRIVED,CANCELLED). Omit for all.',
example: 'DISPATCHED,ARRIVED',
})
@IsOptional()
@IsString()
statuses?: string;
@ApiPropertyOptional({ enum: ['OPEN', 'FULL', 'CLOSED'] })
@IsOptional()
@IsIn(['OPEN', 'FULL', 'CLOSED'])
bookingWindowStatus?: 'OPEN' | 'FULL' | 'CLOSED';
@ApiPropertyOptional({
description:
'Case-insensitive match on train number, route yards, stations, or locomotive code.',
})
@IsOptional()
@IsString()
@MaxLength(120)
search?: string;
@ApiPropertyOptional({ description: 'Departure date lower bound (ISO 8601).' })
@IsOptional()
@IsISO8601()
departureFrom?: string;
@ApiPropertyOptional({ description: 'Departure date upper bound (ISO 8601).' })
@IsOptional()
@IsISO8601()
departureTo?: string;
@ApiPropertyOptional({ description: 'Created-at lower bound (ISO 8601).' })
@IsOptional()
@IsISO8601()
createdFrom?: string;
@ApiPropertyOptional({ description: 'Created-at upper bound (ISO 8601).' })
@IsOptional()
@IsISO8601()
createdTo?: string;
@ApiPropertyOptional({ enum: BATCH_BOARD_SORT_FIELDS, default: 'createdAt' })
@IsOptional()
@IsIn(BATCH_BOARD_SORT_FIELDS as unknown as string[])
sortBy?: BatchBoardSortField;
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
@IsOptional()
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC';
}

View File

@@ -15,7 +15,6 @@ const nw5: WagonType = {
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
supportsContainer: true,

View File

@@ -4,6 +4,7 @@ import {
buildBulkWagonPlan,
buildContainerWagonPlan,
buildMixedWagonPlan,
containerWagonsForLines,
roundTons,
type WagonPlanSlot,
} from './wagon-plan.util';
@@ -43,11 +44,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n
return Math.max(1, Math.ceil(weight / capacity));
}
const lineSlots = (booking.bookingContainers ?? []).reduce(
(sum, line) => sum + Number(line.wagonsRequired ?? 0),
0,
);
return Math.max(1, lineSlots);
// TEU-aware, ceiled once at the booking level (40ft = 1 wagon, two 20ft = 1
// wagon). Honors containerType.wagonsPerUnit; falls back to the line's stored
// fraction. Ceiling per line would over-count split 20ft lines.
return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
}
export function countSlotsByType(wagonPlan: WagonPlanSlot[]): Map<string, { code: string; count: number }> {

Some files were not shown because too many files have changed in this diff Show More