mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 07:51:02 +00:00
42
.claude/skills/edr-db/SKILL.md
Normal file
42
.claude/skills/edr-db/SKILL.md
Normal 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.
|
||||
88
.claude/skills/edr-db/query.cjs
Normal file
88
.claude/skills/edr-db/query.cjs
Normal 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);
|
||||
});
|
||||
40
.claude/skills/standup/SKILL.md
Normal file
40
.claude/skills/standup/SKILL.md
Normal 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.
|
||||
59
.claude/skills/verify/SKILL.md
Normal file
59
.claude/skills/verify/SKILL.md
Normal 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.
|
||||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -28,3 +28,4 @@ coverage/
|
||||
*~
|
||||
\#*\#
|
||||
.\#*
|
||||
docker-compose.override.yml
|
||||
|
||||
294
CLAUDE_NEW.md
Normal file
294
CLAUDE_NEW.md
Normal 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.
|
||||
74
apps/edr-freight-api/docs/priority-batch-window-flow.md
Normal file
74
apps/edr-freight-api/docs/priority-batch-window-flow.md
Normal 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`).
|
||||
@@ -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,
|
||||
|
||||
63
apps/edr-freight-api/src/contracts/contract-article.util.ts
Normal file
63
apps/edr-freight-api/src/contracts/contract-article.util.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}}
|
||||
@@ -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 {
|
||||
|
||||
184
apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs
Normal file
184
apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs
Normal 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>
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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)],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Wagon spec belongs to the wagon TYPE, not to each physical wagon.
|
||||
*
|
||||
* `wagons.tare_weight` and `wagons.max_payload_weight` duplicated
|
||||
* `wagon_types.tare_weight_tons` / `wagon_types.capacity_tons` on all 1100 rows,
|
||||
* with nothing keeping them in step. They had drifted completely: every wagon
|
||||
* disagreed with its type's tare (seeded ~20T against a real 22.4T NW5), and a
|
||||
* third disagreed on payload (NW5 wagons claiming 22T–70T against a flat 70T).
|
||||
* None of those numbers came from the railway.
|
||||
*
|
||||
* Nothing reads them for capacity — that math resolves tare and capacity through
|
||||
* `wagon_type_id` — so dropping them removes a source of fiction rather than a
|
||||
* source of truth. `wagon_type_id` is NOT NULL with no orphans, so the type is
|
||||
* always reachable.
|
||||
*
|
||||
* A wagon re-tared after repair would need a nullable override column on
|
||||
* `wagons` falling back to the type; deliberately not added, since no such
|
||||
* per-wagon value exists today.
|
||||
*/
|
||||
export class DropWagonSpecColumns2080000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
169
apps/edr-freight-api/src/modules/auth/forgot-password.service.ts
Normal file
169
apps/edr-freight-api/src/modules/auth/forgot-password.service.ts
Normal 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)}`;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()}`);
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
invoiceService as never,
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingTransitionService
|
||||
);
|
||||
return {
|
||||
service,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
150
apps/edr-freight-api/src/modules/gps-tracking/README.md
Normal file
150
apps/edr-freight-api/src/modules/gps-tracking/README.md
Normal 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 ~1–2 min |
|
||||
|
||||
---
|
||||
|
||||
## 5. Security
|
||||
|
||||
- GT06 authenticates with **IMEI only**, which is **spoofable**. Anyone who can
|
||||
reach the port can inject fake positions.
|
||||
- **Do not** expose the port to `0.0.0.0/0`. Restrict at the firewall / security
|
||||
group to the SIM provider's **APN / IP range**.
|
||||
- Trackers must use the same host+port as the server:
|
||||
`SERVER,1,gps.example.com,<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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -15,7 +15,6 @@ const nw5: WagonType = {
|
||||
name: 'Flat Wagon',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
maxWagonsPerTrain: 53,
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
isActive: true,
|
||||
supportsContainer: true,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user