mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'freight_feature/usermanagement' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
8
.github/workflows/deploy.yml
vendored
8
.github/workflows/deploy.yml
vendored
@@ -43,6 +43,8 @@ jobs:
|
|||||||
"passenger-portal"
|
"passenger-portal"
|
||||||
"passenger-backoffice"
|
"passenger-backoffice"
|
||||||
"payment-api"
|
"payment-api"
|
||||||
|
"synapse"
|
||||||
|
"element-web"
|
||||||
)
|
)
|
||||||
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
@@ -84,6 +86,10 @@ jobs:
|
|||||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
|
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
|
||||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
|
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
|
||||||
echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api")
|
echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api")
|
||||||
|
# synapse / element-web have no per-service filter line: their only
|
||||||
|
# source is infrastructure/matrix/, already caught by GLOBAL_PATTERN
|
||||||
|
# above (which redeploys every service), so a dedicated line here
|
||||||
|
# would never fire.
|
||||||
|
|
||||||
SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u))
|
SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u))
|
||||||
|
|
||||||
@@ -119,7 +125,7 @@ jobs:
|
|||||||
- name: Resolve project and build env file
|
- name: Resolve project and build env file
|
||||||
run: |
|
run: |
|
||||||
case "${{ matrix.service }}" in
|
case "${{ matrix.service }}" in
|
||||||
freight-api|freight-portal|freight-backoffice|gps-tracker)
|
freight-api|freight-portal|freight-backoffice|gps-tracker|synapse|element-web)
|
||||||
echo "PROJECT=edr-freight" >> "$GITHUB_ENV"
|
echo "PROJECT=edr-freight" >> "$GITHUB_ENV"
|
||||||
echo "BUILD_ENV_FILE=freight-web.build.env" >> "$GITHUB_ENV"
|
echo "BUILD_ENV_FILE=freight-web.build.env" >> "$GITHUB_ENV"
|
||||||
;;
|
;;
|
||||||
|
|||||||
388
CLAUDE.md
388
CLAUDE.md
@@ -1,102 +1,362 @@
|
|||||||
# EDR Platform — Developer Guide
|
# EDR Platform — Developer Guide
|
||||||
|
|
||||||
|
> This file is the contract. If something here contradicts the code, the code is the
|
||||||
|
> truth and this file is a bug — fix it in the same PR.
|
||||||
|
|
||||||
|
**Looking for where something lives? Read [`docs/MAP.md`](docs/MAP.md) first.** It routes
|
||||||
|
you to the right module or page without a repo-wide grep.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight Management and Passenger Management applications, plus shared types, NestJS utilities, and React component libraries.
|
Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight
|
||||||
|
Management and Passenger Management applications, a payment microservice, plus shared
|
||||||
|
types, NestJS utilities, and React component libraries.
|
||||||
|
|
||||||
|
The freight domain is the largest and most active area. Its core flow is:
|
||||||
|
**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload
|
||||||
|
→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.**
|
||||||
|
Fees (storage, demurrage, double handling, truck detention) and allocation rules
|
||||||
|
(warehouse/yard/zone) hang off the warehouse stage.
|
||||||
|
|
||||||
## Apps
|
## Apps
|
||||||
|
|
||||||
| App | Package name | Purpose | Port |
|
The two domains are **not built the same way**. Check which stack you are in before
|
||||||
| ------------------------------ | --------------------------- | -------------------------------------------------- | ---- |
|
copying a pattern across:
|
||||||
| `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 of which are independent pnpm workspace packages (declared in `pnpm-workspace.yaml`). The existing `pnpm dev:freight` / `pnpm dev:passenger` turbo filters (`@edr/freight-*` / `@edr/passenger-*`) cover all four web apps + their APIs.
|
| App | Package name | Stack | Default port |
|
||||||
|
| ------------------------------ | --------------------------- | ---------------------- | ------------ |
|
||||||
|
| `edr-freight-api` | `@edr/freight-api` | NestJS + **TypeORM** | 3001 |
|
||||||
|
| `edr-freight-web/portal` | `@edr/freight-portal` | React + **Vite** | 5273 |
|
||||||
|
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React + **Vite** | 5283 |
|
||||||
|
| `edr-passenger-api` | `@edr/passenger-api` | NestJS + **Prisma** | 4000 |
|
||||||
|
| `edr-passenger-web/portal` | `@edr/passenger-portal` | **Next.js** | 5174 |
|
||||||
|
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | **Next.js** | 5184 |
|
||||||
|
| `edr-payment-api` | `@edr/payment-api` | NestJS + **TypeORM** | 3003 |
|
||||||
|
|
||||||
|
Those are the **fallbacks compiled into the code**, not what you will be running. Every
|
||||||
|
port is overridden by `PORT` in the app's `.env` / `.env.development`; the freight vite
|
||||||
|
apps read it in `vite.config.ts` (`Number(env.PORT) || 5273`). This machine is shared by
|
||||||
|
the whole team and the low ports are contested — see the workspace root `CLAUDE.md` and
|
||||||
|
`./wt ports` for who currently holds what.
|
||||||
|
|
||||||
|
`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages.
|
||||||
|
Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace
|
||||||
|
packages (see `pnpm-workspace.yaml`).
|
||||||
|
|
||||||
|
`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace
|
||||||
|
package and is not built, linted, or type-checked. Leave it alone unless asked.
|
||||||
|
|
||||||
|
`apps/edr-gps-tracker/` is a separate service with its own `.env.example`.
|
||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
| Package | Purpose |
|
| Package | Location | Purpose |
|
||||||
| ---------------------- | ---------------------------------------------------------------------------------- |
|
| ----------------------- | ----------------------------- | ------------------------------------------------------------- |
|
||||||
| `@edr/types` | Shared TypeScript interfaces and enums |
|
| `@edr/types` | `packages/types` | Shared TypeScript interfaces and enums |
|
||||||
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
|
| `@edr/api-common` | `packages/api-common` | NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
|
||||||
| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) |
|
| `@edr/ui-common` | `packages/ui-common` | Shared React components and theme |
|
||||||
| `@edr/ui-common` | Shared React components and theme |
|
| `@edr/iam-seed` | `packages/iam-seed` | IAM baseline seeder for apps sharing the `iam` schema |
|
||||||
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
|
| `@edr/payment-providers`| `packages/payment-providers` | Payment gateway integrations |
|
||||||
| `@edr/tsconfig` | Shared TypeScript configurations |
|
| `@edr/eslint-config` | `packages/config/eslint-config` | Shared ESLint configs (base/nestjs/react) |
|
||||||
| `@edr/prettier-config` | Shared Prettier configuration |
|
| `@edr/tsconfig` | `packages/config/tsconfig` | Shared TypeScript configs |
|
||||||
|
| `@edr/prettier-config` | `packages/config/prettier-config` | Shared Prettier config |
|
||||||
|
|
||||||
|
The three `config/*` packages are nested one level deeper than the rest — `packages/config`
|
||||||
|
itself is not a package.
|
||||||
|
|
||||||
|
**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a
|
||||||
|
type in `packages/types/src` changes nothing for consumers until you rebuild:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm turbo build --filter=@edr/types
|
||||||
|
```
|
||||||
|
|
||||||
|
If a type-check fails on a field you just added to `@edr/types`, this is why.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| -------------------- | ---------------------------------- |
|
| ----------------------------- | ---------------------------------------- |
|
||||||
| `pnpm install` | Install all workspace dependencies |
|
| `pnpm install` | Install all workspace dependencies |
|
||||||
| `pnpm dev` | Run every app in dev mode |
|
| `pnpm dev` | Run every app in dev mode |
|
||||||
| `pnpm dev:freight` | Run only freight API + web |
|
| `pnpm dev:freight` | Freight API + portal + backoffice |
|
||||||
| `pnpm dev:passenger` | Run only passenger API + web |
|
| `pnpm dev:freight:api` | Freight API only |
|
||||||
| `pnpm build` | Build every package and app |
|
| `pnpm dev:freight:portal` | Freight portal only |
|
||||||
| `pnpm test` | Run all tests |
|
| `pnpm dev:freight:backoffice` | Freight backoffice only |
|
||||||
| `pnpm lint` | Lint everything |
|
| `pnpm dev:passenger` | Passenger API + web |
|
||||||
| `pnpm type-check` | Type-check every package |
|
| `pnpm dev:payment` | Payment API |
|
||||||
| `pnpm format` | Format all files with Prettier |
|
| `pnpm build` | Build every package and app |
|
||||||
|
| `pnpm test` | Run all tests (turbo) |
|
||||||
|
| `pnpm type-check` | Type-check every package |
|
||||||
|
| `pnpm format` | Format all files with Prettier |
|
||||||
|
| `pnpm lint` | **Does not work** — see below |
|
||||||
|
|
||||||
## Standards
|
**`pnpm lint` fails.** `eslint` is not installed anywhere in the workspace, so
|
||||||
|
`turbo run lint` dies with `eslint: not found` even though every package declares a
|
||||||
|
`lint` script and `@edr/eslint-config` exists. Until someone adds the dependency,
|
||||||
|
tsc's `noUnusedLocals` is the only working unused-code check. Do not claim a change is
|
||||||
|
"lint clean".
|
||||||
|
|
||||||
- **TypeScript strict mode** is enabled in every package and app.
|
`pnpm format` uses bare `prettier`, which ignores `@edr/prettier-config` — it is wired to
|
||||||
- **pnpm** is the only supported package manager — never run `npm install` or `yarn`.
|
nothing. On the single-quoted passenger apps it will re-quote the whole file. Pass
|
||||||
- **Conventional commits** are enforced via commitlint on every commit.
|
`--config` explicitly there.
|
||||||
- **NestJS modules** follow the 4-layer pattern: `module → controller → service → repository` (entities and DTOs live alongside).
|
|
||||||
|
Prefer targeted turbo filters over whole-repo runs — they are minutes faster:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice
|
||||||
|
```
|
||||||
|
|
||||||
|
`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains,
|
||||||
|
gate-pass scenarios). Read the script before running one; several write real rows.
|
||||||
|
|
||||||
|
## Environment & database
|
||||||
|
|
||||||
|
- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and
|
||||||
|
no port `5433`/`5434` is published anywhere in the repo.
|
||||||
|
- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`,
|
||||||
|
`DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a
|
||||||
|
remote database.
|
||||||
|
- The connection sits behind a **connection pooler**. Do **not** pass
|
||||||
|
`extra.options: '-c search_path=…'` — the pooler rejects it with
|
||||||
|
`08P01 unsupported startup parameter in options: search_path`. `search_path` is applied
|
||||||
|
per-connection in a pool `connect` handler instead. See
|
||||||
|
`apps/edr-freight-api/src/config/database.config.ts` before touching connection options.
|
||||||
|
- Each app owns its own database. **No cross-database joins**; cross-domain data flows
|
||||||
|
through API calls or message queues.
|
||||||
|
- IAM tables live in their own `iam` schema (`iam.users`, `iam.user_credentials`),
|
||||||
|
freight tables in `freight`.
|
||||||
|
- `psql` is not installed on the dev machine. To query the database, use the `edr-db`
|
||||||
|
skill (below) or write a short Node script using `pg` and run it from
|
||||||
|
`apps/edr-freight-api`, where `pg` resolves.
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
These are non-negotiable. Everything else is a strong default.
|
||||||
|
|
||||||
|
- **pnpm only.** Never run `npm install` or `yarn`.
|
||||||
|
- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not
|
||||||
|
reach for `any` to make an error go away.
|
||||||
|
- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false`
|
||||||
|
in every config and it has already corrupted this database twice (see *Migrations*).
|
||||||
|
All schema changes go through migrations.
|
||||||
- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
|
- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
|
||||||
- **All entities** have `createdAt`, `updatedAt`, `deletedAt` (soft delete) via `@edr/api-common`'s `BaseEntity`.
|
- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`,
|
||||||
- **All columns** use `snake_case` in the database (`@Column({ name: 'snake_case' })`); TypeScript properties use `camelCase`.
|
`deletedAt` (soft delete).
|
||||||
- **Never use `synchronize: true`** in production database config. All schema changes go through TypeORM migrations.
|
- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`);
|
||||||
- **ESLint + Prettier** run on pre-commit via Husky + lint-staged.
|
TypeScript properties are `camelCase`.
|
||||||
- **Services** never inject TypeORM `Repository<T>` directly — they inject the custom repository class.
|
- **Controllers contain no business logic.** They validate, delegate, and shape the response.
|
||||||
- **Controllers** never contain business logic.
|
- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`.
|
||||||
|
- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands.
|
||||||
|
- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and
|
||||||
|
offer the safe version.
|
||||||
|
|
||||||
## Auth
|
## Architecture
|
||||||
|
|
||||||
Authentication is handled by an external package (`@edr/iamui-common` or equivalent) that will be integrated later. **Do not** implement any auth, login, logout, JWT verification, password hashing, or user management code in this repo.
|
### NestJS module shape
|
||||||
|
|
||||||
When auth integration is needed, use placeholder TODO comments:
|
`module → controller → service → repository`, with `entities/` and `dto/` alongside.
|
||||||
|
`docs/MAP.md` lists the ~60 freight modules grouped by domain.
|
||||||
|
|
||||||
- `// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth`
|
### Data access — the real model
|
||||||
- `// TODO: integrate @edr/auth — replace stub @CurrentUser with real one`
|
|
||||||
|
|
||||||
The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are bare metadata setters with no guard wiring — they exist so controllers can be annotated correctly without depending on auth infrastructure yet.
|
There are two sanctioned ways to read and write, and you must pick the right one:
|
||||||
|
|
||||||
## Port Assignments
|
1. **Entity CRUD → the custom repository class.** Extends `BaseRepository<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.
|
||||||
|
|
||||||
- `edr-freight-api`: 3001
|
Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it.
|
||||||
- `edr-freight-web/portal`: 5173
|
It carries one obligation:
|
||||||
- `edr-freight-web/backoffice`: 5183
|
|
||||||
- `edr-passenger-api`: 3002
|
|
||||||
- `edr-payment-api`: 3003
|
|
||||||
- `edr-passenger-web/portal`: 5174
|
|
||||||
- `edr-passenger-web/backoffice`: 5184
|
|
||||||
|
|
||||||
## Database Layout
|
> **HARD RULE — validate every raw SQL statement against a real database before you ship it.**
|
||||||
|
> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through
|
||||||
|
> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*).
|
||||||
|
|
||||||
- `postgres-freight` (port 5433): database `edr_freight` — freight API only.
|
Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository,
|
||||||
- `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only.
|
so they join the caller's transaction.
|
||||||
- `edr_payment` schema — lives in the same Postgres database as the domain system (whatever the passenger `DATABASE_URL` points at) but is owned exclusively by `apps/edr-payment-api`. Dedicated DB user, no cross-schema FKs, domain apps have no grants on it (see `docs/payment-service/`).
|
|
||||||
- Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues.
|
**Never do slow I/O inside a database transaction.** Queue the work and fan it out after
|
||||||
|
commit. An SMS awaited inside a transaction once held capacity locks open for the whole
|
||||||
|
gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to
|
||||||
|
no timeout and will wait forever.
|
||||||
|
|
||||||
|
### Migrations
|
||||||
|
|
||||||
|
Migrations are the most dangerous surface in this repo. Two production-grade incidents have
|
||||||
|
already come from it. **Freight and payment use TypeORM migrations; passenger uses Prisma**
|
||||||
|
(`apps/edr-passenger-api/prisma/migrations`) — the rules below are about the TypeORM side.
|
||||||
|
|
||||||
|
- `migrationsRun: false` — **migrations do NOT run on API boot.** They run as a separate
|
||||||
|
one-shot step, via the Dockerfile's `migration` build target (`docker build --target
|
||||||
|
migration`), with `migrationsTransactionMode: 'each'`.
|
||||||
|
- CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it
|
||||||
|
(`docker run --rm --env-file ...`) *before* building/deploying the app image.
|
||||||
|
- e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and
|
||||||
|
`freight-api-e2e` depends on it (`condition: service_completed_successfully`).
|
||||||
|
- Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run
|
||||||
|
migrations yourself before `docker compose up freight-api`, e.g.
|
||||||
|
`docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .`
|
||||||
|
then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't
|
||||||
|
use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled
|
||||||
|
output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`.
|
||||||
|
It silently applies zero freight migrations while exiting 0.
|
||||||
|
- Consequences you must design for:
|
||||||
|
- A watch-mode hot reload does **not** re-run migrations. If you add a column that new
|
||||||
|
code reads, apply it to the dev database yourself (idempotently) or fully restart.
|
||||||
|
- `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a
|
||||||
|
hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never
|
||||||
|
notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own
|
||||||
|
`forFeature()` registrations), but the standalone migration `DataSource`
|
||||||
|
(`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing
|
||||||
|
entity throws `Entity metadata for X#y was not found` at `initialize()`, before a
|
||||||
|
single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate
|
||||||
|
for this to break again** — diff the package's entity classes against `iamEntities`
|
||||||
|
when bumping it.
|
||||||
|
- **Give every migration a unique timestamp.** `apps/edr-freight-api/src/migrations` holds
|
||||||
|
39 files, and 8 timestamps are shared by two or more of them. TypeORM orders by timestamp
|
||||||
|
and breaks ties non-deterministically. Check before adding one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls apps/edr-freight-api/src/migrations | grep -oE '^[0-9]+' | sort | uniq -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The prefix must be unused *and* higher than the newest recorded row. Note the
|
||||||
|
`freight.migrations` table has far more rows (~309) than this folder has files — most
|
||||||
|
come from `@tria-plc/iamapi-common`'s own migrations, which run from the same data source.
|
||||||
|
- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and
|
||||||
|
backfills guarded by `WHERE col IS NULL`.
|
||||||
|
- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory`
|
||||||
|
was recorded in `migrations` while its column was absent — it had been dropped out of band.
|
||||||
|
TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*.
|
||||||
|
- **A repair migration's `down()` should be a no-op.** Reverting a repair must not
|
||||||
|
re-introduce the outage it fixed.
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own.
|
||||||
|
|
||||||
|
- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub.
|
||||||
|
- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`.
|
||||||
|
- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS.<area>.<action>)`.
|
||||||
|
- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`.
|
||||||
|
Add a permission there before referencing it.
|
||||||
|
- Login is freight-api's own `POST /api/auth/login` (SharedAuthModule from
|
||||||
|
`@tria-plc/api-common`). Every login call needs an **`x-client-app` header** —
|
||||||
|
`backoffice` for employees, `portal` for customers. Without it the API 403s with
|
||||||
|
"Missing or unrecognized x-client-app header". Browsers send it; curl must add it.
|
||||||
|
- IAM has its own migrations, run ahead of freight migrations from the same data source, and
|
||||||
|
its own CLI scripts (`iam:migration:run`, `iam:seed:run`).
|
||||||
|
|
||||||
|
Ownership checks are separate from permission checks. A staff user passes
|
||||||
|
`hasFreightPermission`; a customer must additionally pass an ownership assertion such as
|
||||||
|
`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed.
|
||||||
|
|
||||||
|
## Frontend conventions
|
||||||
|
|
||||||
|
- The **freight** web apps use **Mantine v9** (`^9.3.0`). Its APIs differ from v6/v7 —
|
||||||
|
check the installed version before copying a snippet.
|
||||||
|
- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the
|
||||||
|
freight web apps. Prefer it over re-implementing a component.
|
||||||
|
- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'`
|
||||||
|
delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no
|
||||||
|
`.message` and degrades to `"Request failed with status code 400"`. Use
|
||||||
|
`await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches
|
||||||
|
keep the synchronous version — their bodies are already parsed JSON.
|
||||||
|
- Server-side guards must be reflected in the UI. If the API will reject the action, the
|
||||||
|
button should be disabled, hidden, or explain the blocker — not fire and surface a 400.
|
||||||
|
- Prefer disabling a control with a visible reason over silently hiding it.
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
In-app notifications resolve recipients from the company's **linked portal users**. If a
|
||||||
|
company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error.
|
||||||
|
SMS and email still send, because they address the company's phone and email directly. Check
|
||||||
|
this before debugging a "missing notification".
|
||||||
|
|
||||||
|
## PDF generation
|
||||||
|
|
||||||
|
Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled
|
||||||
|
generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than
|
||||||
|
assume a headless browser exists.
|
||||||
|
|
||||||
## Adding a new module to a NestJS app
|
## 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.
|
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`.
|
2. The entity extends `BaseEntity` from `@edr/api-common`.
|
||||||
3. The repository extends `BaseRepository<Entity>` 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).
|
4. The service injects the repository class (not `Repository<T>` directly).
|
||||||
5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger.
|
5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route.
|
||||||
6. Register the module in the app's `app.module.ts`.
|
6. Register the module in the app's `app.module.ts`.
|
||||||
|
|
||||||
## Adding a new shared component to `@edr/ui-common`
|
## Adding a new shared component to `@edr/ui-common`
|
||||||
|
|
||||||
1. Create `src/components/<Name>/<Name>.tsx` and `src/components/<Name>/index.ts`.
|
1. Create `src/components/<Name>/<Name>.tsx` and `src/components/<Name>/index.ts`.
|
||||||
2. Export from `src/index.ts`.
|
2. Export from `src/index.ts`.
|
||||||
3. Component is a functional component with a `ComponentNameProps` interface (named-exported alongside the default).
|
3. Component is a functional component with a `ComponentNameProps` interface
|
||||||
|
(named-exported alongside the default).
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
|
||||||
|
A change is done when **all** of these hold. State explicitly which you ran.
|
||||||
|
|
||||||
|
1. **It type-checks.** `pnpm turbo type-check --filter=<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` has been red on `dev`, so a
|
||||||
|
fully green suite is not the bar — but confirm that for yourself rather than assuming it,
|
||||||
|
then run the specs covering what you touched and confirm you introduced no new failure.
|
||||||
|
5. **Formatting is clean** for the files you touched. Git hooks do **not** run automatically
|
||||||
|
(see below), and `pnpm lint` does not work at all, so `noUnusedLocals` from the
|
||||||
|
type-check is your only unused-code signal.
|
||||||
|
6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the
|
||||||
|
endpoint, or ran the query. If you could not, say so plainly.
|
||||||
|
7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in
|
||||||
|
the summary. Never describe unverified work as done.
|
||||||
|
|
||||||
|
### Hooks do not run
|
||||||
|
|
||||||
|
`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed
|
||||||
|
at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`,
|
||||||
|
`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever
|
||||||
|
fire.** Nothing validates your commit message or formats your staged files. Run the checks by
|
||||||
|
hand; do not assume the hook caught it.
|
||||||
|
|
||||||
|
## Known traps
|
||||||
|
|
||||||
|
| Trap | What happens | What to do |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one |
|
||||||
|
| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp |
|
||||||
|
| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` |
|
||||||
|
| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout |
|
||||||
|
| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` |
|
||||||
|
| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging |
|
||||||
|
| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully |
|
||||||
|
| Login 403 from curl | "Missing or unrecognized x-client-app header" | Send `x-client-app: backoffice` or `portal` |
|
||||||
|
| Copying a passenger pattern into freight | Passenger is Prisma + Next.js, freight is TypeORM + Vite | Check which stack you are in first |
|
||||||
|
|
||||||
|
## Project skills
|
||||||
|
|
||||||
|
Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps:
|
||||||
|
|
||||||
|
| Skill | Use for |
|
||||||
|
| --- | --- |
|
||||||
|
| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. |
|
||||||
|
| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. |
|
||||||
|
| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. |
|
||||||
|
|
||||||
|
## Working style
|
||||||
|
|
||||||
|
- **Verify before asserting.** Read the code or query the database. Do not infer behaviour
|
||||||
|
from a filename.
|
||||||
|
- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the
|
||||||
|
trade-off before changing files.
|
||||||
|
- **Small, reviewable commits**, one logical change each, conventional message.
|
||||||
|
- **Branch from `dev`; PRs target `dev`.**
|
||||||
|
- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result.
|
||||||
|
|||||||
313
CLAUDE_NEW.md
313
CLAUDE_NEW.md
@@ -1,313 +0,0 @@
|
|||||||
# EDR Platform — Developer Guide
|
|
||||||
|
|
||||||
> This file is the contract. If something here contradicts the code, the code is the
|
|
||||||
> truth and this file is a bug — fix it in the same PR.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight
|
|
||||||
Management and Passenger Management applications, a payment microservice, plus shared
|
|
||||||
types, NestJS utilities, and React component libraries.
|
|
||||||
|
|
||||||
The freight domain is the largest and most active area. Its core flow is:
|
|
||||||
**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload
|
|
||||||
→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.**
|
|
||||||
Fees (storage, demurrage, double handling, truck detention) and allocation rules
|
|
||||||
(warehouse/yard/zone) hang off the warehouse stage.
|
|
||||||
|
|
||||||
## Apps
|
|
||||||
|
|
||||||
| App | Package name | Purpose | Default port |
|
|
||||||
| ------------------------------ | --------------------------- | -------------------------------------------------- | ------------ |
|
|
||||||
| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 |
|
|
||||||
| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 |
|
|
||||||
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 |
|
|
||||||
| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 |
|
|
||||||
| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 |
|
|
||||||
| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 |
|
|
||||||
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 |
|
|
||||||
|
|
||||||
`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages.
|
|
||||||
Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace
|
|
||||||
packages (see `pnpm-workspace.yaml`).
|
|
||||||
|
|
||||||
`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace
|
|
||||||
package and is not built, linted, or type-checked. Leave it alone unless asked.
|
|
||||||
|
|
||||||
## Packages
|
|
||||||
|
|
||||||
| Package | Purpose |
|
|
||||||
| ---------------------- | ---------------------------------------------------------------------------------- |
|
|
||||||
| `@edr/types` | Shared TypeScript interfaces and enums |
|
|
||||||
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
|
|
||||||
| `@edr/ui-common` | Shared React components and theme |
|
|
||||||
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
|
|
||||||
| `@edr/tsconfig` | Shared TypeScript configurations |
|
|
||||||
| `@edr/prettier-config` | Shared Prettier configuration |
|
|
||||||
|
|
||||||
**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a
|
|
||||||
type in `packages/types/src` changes nothing for consumers until you rebuild:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm turbo build --filter=@edr/types
|
|
||||||
```
|
|
||||||
|
|
||||||
If a type-check fails on a field you just added to `@edr/types`, this is why.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
| --------------------------- | ---------------------------------------- |
|
|
||||||
| `pnpm install` | Install all workspace dependencies |
|
|
||||||
| `pnpm dev` | Run every app in dev mode |
|
|
||||||
| `pnpm dev:freight` | Freight API + portal + backoffice |
|
|
||||||
| `pnpm dev:freight:api` | Freight API only |
|
|
||||||
| `pnpm dev:freight:portal` | Freight portal only |
|
|
||||||
| `pnpm dev:freight:backoffice` | Freight backoffice only |
|
|
||||||
| `pnpm dev:passenger` | Passenger API + web |
|
|
||||||
| `pnpm dev:payment` | Payment API |
|
|
||||||
| `pnpm build` | Build every package and app |
|
|
||||||
| `pnpm test` | Run all tests (turbo) |
|
|
||||||
| `pnpm lint` | Lint everything |
|
|
||||||
| `pnpm type-check` | Type-check every package |
|
|
||||||
| `pnpm format` | Format all files with Prettier |
|
|
||||||
|
|
||||||
Prefer targeted turbo filters over whole-repo runs — they are minutes faster:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice
|
|
||||||
```
|
|
||||||
|
|
||||||
`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains,
|
|
||||||
gate-pass scenarios). Read the script before running one; several write real rows.
|
|
||||||
|
|
||||||
## Environment & database
|
|
||||||
|
|
||||||
- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and
|
|
||||||
no port `5433`/`5434` is published anywhere in the repo.
|
|
||||||
- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`,
|
|
||||||
`DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a
|
|
||||||
remote database.
|
|
||||||
- The connection sits behind a **connection pooler**. Do **not** pass
|
|
||||||
`extra.options: '-c search_path=…'` — the pooler rejects it with
|
|
||||||
`08P01 unsupported startup parameter in options: search_path`. `search_path` is applied
|
|
||||||
per-connection in a pool `connect` handler instead. See
|
|
||||||
`apps/edr-freight-api/src/config/database.config.ts` before touching connection options.
|
|
||||||
- Each app owns its own database. **No cross-database joins**; cross-domain data flows
|
|
||||||
through API calls or message queues.
|
|
||||||
- `psql` is not installed on the dev machine. To query the database, write a short Node
|
|
||||||
script using the `pg` client and run it from `apps/edr-freight-api` (where `pg` resolves).
|
|
||||||
|
|
||||||
## Hard rules
|
|
||||||
|
|
||||||
These are non-negotiable. Everything else is a strong default.
|
|
||||||
|
|
||||||
- **pnpm only.** Never run `npm install` or `yarn`.
|
|
||||||
- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not
|
|
||||||
reach for `any` to make an error go away.
|
|
||||||
- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false`
|
|
||||||
in every config and it has already corrupted this database twice (see *Migrations*).
|
|
||||||
All schema changes go through TypeORM migrations.
|
|
||||||
- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
|
|
||||||
- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`,
|
|
||||||
`deletedAt` (soft delete).
|
|
||||||
- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`);
|
|
||||||
TypeScript properties are `camelCase`.
|
|
||||||
- **Controllers contain no business logic.** They validate, delegate, and shape the response.
|
|
||||||
- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`.
|
|
||||||
- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands.
|
|
||||||
- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and
|
|
||||||
offer the safe version.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### NestJS module shape
|
|
||||||
|
|
||||||
`module → controller → service → repository`, with `entities/` and `dto/` alongside.
|
|
||||||
|
|
||||||
### Data access — the real model
|
|
||||||
|
|
||||||
There are two sanctioned ways to read and write, and you must pick the right one:
|
|
||||||
|
|
||||||
1. **Entity CRUD → the custom repository class.** Extends `BaseRepository<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: false` — **migrations do NOT run on API boot.** They run as a separate
|
|
||||||
one-shot step, via the Dockerfile's `migration` build target (`docker build --target
|
|
||||||
migration`), with `migrationsTransactionMode: 'each'`.
|
|
||||||
- CI: `.github/workflows/deploy.yml` builds the `migration` image and runs it
|
|
||||||
(`docker run --rm --env-file ...`) *before* building/deploying the app image.
|
|
||||||
- e2e: `docker-compose.e2e.yaml`'s `freight-migration-e2e` service runs once and
|
|
||||||
`freight-api-e2e` depends on it (`condition: service_completed_successfully`).
|
|
||||||
- Local dev (`docker-compose.yaml`) has no equivalent migration service yet — run
|
|
||||||
migrations yourself before `docker compose up freight-api`, e.g.
|
|
||||||
`docker build --target migration -f apps/edr-freight-api/Dockerfile -t freight-migration .`
|
|
||||||
then `docker run --rm --env-file apps/edr-freight-api/.env freight-migration`. Don't
|
|
||||||
use `pnpm run migrate` for this — it runs via `ts-node`, which never writes compiled
|
|
||||||
output to `dist/`, and the freight migrations glob only matches `dist/migrations/*.js`.
|
|
||||||
It silently applies zero freight migrations while exiting 0.
|
|
||||||
- Consequences you must design for:
|
|
||||||
- A watch-mode hot reload does **not** re-run migrations. If you add a column that new
|
|
||||||
code reads, apply it to the dev database yourself (idempotently) or fully restart.
|
|
||||||
- `apps/edr-freight-api/src/config/database.config.ts`'s `iamEntities` array is a
|
|
||||||
hand-maintained list of `@tria-plc/iamapi-common` entity classes. The live app never
|
|
||||||
notices when it's stale (`autoLoadEntities: true` papers over gaps via IAM's own
|
|
||||||
`forFeature()` registrations), but the standalone migration `DataSource`
|
|
||||||
(`data-source.ts`, no `autoLoadEntities`) does not have that fallback — a missing
|
|
||||||
entity throws `Entity metadata for X#y was not found` at `initialize()`, before a
|
|
||||||
single migration runs. **Every `@tria-plc/iamapi-common` version bump is a candidate
|
|
||||||
for this to break again** — diff the package's entity classes against `iamEntities`
|
|
||||||
when bumping it.
|
|
||||||
- **Give every migration a unique timestamp.** 34 timestamps are currently shared by two or
|
|
||||||
more migrations. TypeORM orders by timestamp and breaks ties non-deterministically. Before
|
|
||||||
adding one, check the filename prefix is unused *and* higher than the newest recorded row.
|
|
||||||
- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and
|
|
||||||
backfills guarded by `WHERE col IS NULL`.
|
|
||||||
- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory`
|
|
||||||
was recorded in `migrations` while its column was absent — it had been dropped out of band.
|
|
||||||
TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*.
|
|
||||||
- **A repair migration's `down()` should be a no-op.** Reverting a repair must not
|
|
||||||
re-introduce the outage it fixed.
|
|
||||||
|
|
||||||
### Auth
|
|
||||||
|
|
||||||
Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own.
|
|
||||||
|
|
||||||
- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub.
|
|
||||||
- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`.
|
|
||||||
- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS.<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.
|
|
||||||
@@ -219,3 +219,22 @@ EIMS_AUTO_SUBMIT=false
|
|||||||
EIMS_AUTO_SUBMIT_CRON=0 */5 * * * *
|
EIMS_AUTO_SUBMIT_CRON=0 */5 * * * *
|
||||||
# MoR rejects documents older than 3 days; the sweep will not attempt those.
|
# MoR rejects documents older than 3 days; the sweep will not attempt those.
|
||||||
EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3
|
EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3
|
||||||
|
# ── Internal chat (Matrix/Element) ──────────────────────────────────────────
|
||||||
|
# Disabled by default; /chat/sso and the nightly room/membership reconcile are
|
||||||
|
# no-ops until enabled. See infrastructure/matrix/.
|
||||||
|
MATRIX_ENABLED=false
|
||||||
|
# Synapse URL reachable from this container (docker-compose service DNS in
|
||||||
|
# prod, e.g. http://synapse:8008 — NOT the public https://matrix.edr.et).
|
||||||
|
MATRIX_BASE_URL=http://localhost:8008
|
||||||
|
# Synapse's own public_baseurl — what Element itself is configured to call.
|
||||||
|
# Only used to seed the sso.html handoff page's localStorage.
|
||||||
|
MATRIX_PUBLIC_BASE_URL=https://matrix.edr.et
|
||||||
|
MATRIX_CHAT_WEB_URL=https://chat.edr.et
|
||||||
|
MATRIX_SERVER_NAME=matrix.edr.et
|
||||||
|
# Must exactly match infrastructure/matrix/synapse/.env's MATRIX_JWT_SECRET —
|
||||||
|
# this is the whole trust boundary for the SSO handoff.
|
||||||
|
MATRIX_JWT_SECRET=
|
||||||
|
# access_token of a Synapse server-admin account. Bootstrap it once via
|
||||||
|
# infrastructure/matrix/synapse's MATRIX_REGISTRATION_SHARED_SECRET (see that
|
||||||
|
# file's comments) — this app never touches the shared secret itself.
|
||||||
|
MATRIX_ADMIN_TOKEN=
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import telebirrConfig from "./config/telebirr.config";
|
|||||||
import rabbitmqConfig from "./config/rabbitmq.config";
|
import rabbitmqConfig from "./config/rabbitmq.config";
|
||||||
import faydaConfig from "./config/fayda.config";
|
import faydaConfig from "./config/fayda.config";
|
||||||
import eimsConfig from "./config/eims.config";
|
import eimsConfig from "./config/eims.config";
|
||||||
|
import chatConfig from "./config/chat.config";
|
||||||
|
|
||||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||||
import { ContractsModule } from "./modules/contracts/contracts.module";
|
import { ContractsModule } from "./modules/contracts/contracts.module";
|
||||||
@@ -50,6 +51,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module";
|
|||||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||||
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
|
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
|
||||||
|
import { PaymentSettingsModule } from "./modules/payment-settings/payment-settings.module";
|
||||||
import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module";
|
import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module";
|
||||||
import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module";
|
import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module";
|
||||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||||
@@ -116,7 +118,10 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte
|
|||||||
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||||
import { AiModule } from "./modules/ai/ai.module";
|
import { AiModule } from "./modules/ai/ai.module";
|
||||||
import { AuditModule } from "./modules/audit/audit.module";
|
import { AuditModule } from "./modules/audit/audit.module";
|
||||||
|
// dev replaced the local LoggerMiddleware with the shared RequestLogMiddleware
|
||||||
|
// and deleted ./logger.middleware, so the branch's import is dropped here.
|
||||||
import { RequestLogMiddleware } from "@edr/api-common";
|
import { RequestLogMiddleware } from "@edr/api-common";
|
||||||
|
import { ChatModule } from "./modules/chat/chat.module";
|
||||||
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
||||||
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
|
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
|
||||||
|
|
||||||
@@ -135,6 +140,7 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
rabbitmqConfig,
|
rabbitmqConfig,
|
||||||
faydaConfig,
|
faydaConfig,
|
||||||
eimsConfig,
|
eimsConfig,
|
||||||
|
chatConfig,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
@@ -213,6 +219,7 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
FileUploadSettingsModule,
|
FileUploadSettingsModule,
|
||||||
DropdownSettingsModule,
|
DropdownSettingsModule,
|
||||||
ExchangeSettingsModule,
|
ExchangeSettingsModule,
|
||||||
|
PaymentSettingsModule,
|
||||||
StampSettingsModule,
|
StampSettingsModule,
|
||||||
LogoSettingsModule,
|
LogoSettingsModule,
|
||||||
ContractTemplatesModule,
|
ContractTemplatesModule,
|
||||||
@@ -252,6 +259,7 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
FleetHistoryModule,
|
FleetHistoryModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
|
ChatModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
EdrOrgSeeder,
|
EdrOrgSeeder,
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export const MixedAudience = (permission: string | string[]) =>
|
|||||||
|
|
||||||
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
||||||
|
|
||||||
|
export const ChatSync = () => BookingStaff(FREIGHT_PERMS.chat.sync);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The document-review countdown in the backoffice header. Its own permission so
|
* The document-review countdown in the backoffice header. Its own permission so
|
||||||
* it can be granted to exactly the position types that decide operation
|
* it can be granted to exactly the position types that decide operation
|
||||||
|
|||||||
49
apps/edr-freight-api/src/common/utils/iam-user-name.util.ts
Normal file
49
apps/edr-freight-api/src/common/utils/iam-user-name.util.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `iam.users.name` is a localized object ({ en, am, … }), not a string — a
|
||||||
|
* plain `String(name)` there yields "[object Object]" in an audit trail.
|
||||||
|
*/
|
||||||
|
export interface IamUserRow {
|
||||||
|
name?: Record<string, string> | string | null;
|
||||||
|
username?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best display name for a user row: English label → any locale → login → email. */
|
||||||
|
export function pickUserName(user: IamUserRow): string | null {
|
||||||
|
const { name } = user;
|
||||||
|
if (typeof name === "string" && name.trim()) return name.trim();
|
||||||
|
if (name && typeof name === "object") {
|
||||||
|
const localized =
|
||||||
|
name.en ??
|
||||||
|
Object.values(name).find((v) => typeof v === "string" && v.trim());
|
||||||
|
if (localized?.trim()) return localized.trim();
|
||||||
|
}
|
||||||
|
return user.username?.trim() || user.email?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display names for a set of IAM user ids — one query for the whole set.
|
||||||
|
* `iam.users` is owned by the auth system and has no entity here, so it is read
|
||||||
|
* directly. A miss is not an error: the caller still holds the id and can fall
|
||||||
|
* back to it.
|
||||||
|
*/
|
||||||
|
export async function resolveIamUserNames(
|
||||||
|
dataSource: DataSource,
|
||||||
|
userIds: (string | null | undefined)[],
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const resolved = new Map<string, string>();
|
||||||
|
const ids = [...new Set(userIds.filter((id): id is string => Boolean(id)))];
|
||||||
|
if (ids.length === 0) return resolved;
|
||||||
|
|
||||||
|
const rows = (await dataSource.query(
|
||||||
|
`SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`,
|
||||||
|
[ids],
|
||||||
|
)) as Array<IamUserRow & { id: string }>;
|
||||||
|
for (const row of rows) {
|
||||||
|
const name = pickUserName(row);
|
||||||
|
if (name) resolved.set(row.id, name);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
59
apps/edr-freight-api/src/config/chat.config.ts
Normal file
59
apps/edr-freight-api/src/config/chat.config.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export interface ChatConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
/** Synapse base URL reachable from this container (client + admin APIs). */
|
||||||
|
baseUrl: string;
|
||||||
|
/** Synapse's public_baseurl — what Element itself is configured to call. Only
|
||||||
|
* used to seed the sso.html handoff; server-to-server calls use {@link baseUrl}. */
|
||||||
|
publicBaseUrl: string;
|
||||||
|
/** Public Element Web origin — the SSO handoff link points here. */
|
||||||
|
webUrl: string;
|
||||||
|
/** Matrix server_name — the `:domain` half of every MXID. */
|
||||||
|
serverName: string;
|
||||||
|
/** HS256 secret. Must exactly match Synapse's jwt_config.secret. */
|
||||||
|
jwtSecret: string;
|
||||||
|
/** Bearer token for a Synapse server admin account (room/user provisioning). */
|
||||||
|
adminToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REQUIRED_VARS = [
|
||||||
|
'MATRIX_BASE_URL',
|
||||||
|
'MATRIX_PUBLIC_BASE_URL',
|
||||||
|
'MATRIX_CHAT_WEB_URL',
|
||||||
|
'MATRIX_SERVER_NAME',
|
||||||
|
'MATRIX_JWT_SECRET',
|
||||||
|
'MATRIX_ADMIN_TOKEN',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default registerAs('chat', (): ChatConfig => {
|
||||||
|
const enabled = (process.env.MATRIX_ENABLED ?? 'false').toLowerCase() === 'true';
|
||||||
|
if (!enabled) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
baseUrl: '',
|
||||||
|
publicBaseUrl: '',
|
||||||
|
webUrl: '',
|
||||||
|
serverName: '',
|
||||||
|
jwtSecret: '',
|
||||||
|
adminToken: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Internal chat is enabled (MATRIX_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
baseUrl: process.env.MATRIX_BASE_URL!.replace(/\/$/, ''),
|
||||||
|
publicBaseUrl: process.env.MATRIX_PUBLIC_BASE_URL!.replace(/\/$/, ''),
|
||||||
|
webUrl: process.env.MATRIX_CHAT_WEB_URL!.replace(/\/$/, ''),
|
||||||
|
serverName: process.env.MATRIX_SERVER_NAME!,
|
||||||
|
jwtSecret: process.env.MATRIX_JWT_SECRET!,
|
||||||
|
adminToken: process.env.MATRIX_ADMIN_TOKEN!,
|
||||||
|
};
|
||||||
|
});
|
||||||
131
apps/edr-freight-api/src/config/eims.config.spec.ts
Normal file
131
apps/edr-freight-api/src/config/eims.config.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import eimsConfigFactory from "./eims.config";
|
||||||
|
|
||||||
|
const REQUIRED = {
|
||||||
|
EIMS_ENABLED: "true",
|
||||||
|
EIMS_CLIENT_ID: "cid",
|
||||||
|
EIMS_CLIENT_SECRET: "secret",
|
||||||
|
EIMS_API_KEY: "apikey",
|
||||||
|
EIMS_TIN: "0000000000",
|
||||||
|
};
|
||||||
|
|
||||||
|
const withEnv = (vars: Record<string, string | undefined>, fn: () => void) => {
|
||||||
|
const prior: Record<string, string | undefined> = {};
|
||||||
|
for (const [key, value] of Object.entries(vars)) {
|
||||||
|
prior[key] = process.env[key];
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} finally {
|
||||||
|
for (const [key, value] of Object.entries(prior)) {
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("eims.config — private key / certificate resolution", () => {
|
||||||
|
it("unescapes a literal \\n when the PEM was pasted without real newlines", () => {
|
||||||
|
withEnv(
|
||||||
|
{ ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||||
|
() => {
|
||||||
|
expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a PEM with real newlines untouched", () => {
|
||||||
|
withEnv(
|
||||||
|
{ ...REQUIRED, EIMS_PRIVATE_KEY: "line1\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||||
|
() => {
|
||||||
|
expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws naming all three key/cert options when none are set", () => {
|
||||||
|
withEnv(
|
||||||
|
{
|
||||||
|
...REQUIRED,
|
||||||
|
EIMS_PRIVATE_KEY_PATH: undefined,
|
||||||
|
EIMS_PRIVATE_KEY_BASE64: undefined,
|
||||||
|
EIMS_PRIVATE_KEY: undefined,
|
||||||
|
EIMS_CERTIFICATE_PATH: "/dev/null",
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
expect(() => eimsConfigFactory()).toThrow(
|
||||||
|
/EIMS_PRIVATE_KEY_PATH or EIMS_PRIVATE_KEY_BASE64 or EIMS_PRIVATE_KEY/,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is satisfied by any single one of the three key options", () => {
|
||||||
|
withEnv(
|
||||||
|
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||||
|
() => {
|
||||||
|
expect(() => eimsConfigFactory()).not.toThrow();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("eims.config — baked-in Ethiopia region/zone/woreda codes", () => {
|
||||||
|
it("resolves a known region/wereda/zone with no env var set at all", () => {
|
||||||
|
withEnv(
|
||||||
|
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||||
|
() => {
|
||||||
|
const cfg = eimsConfigFactory();
|
||||||
|
expect(cfg.invoice.buyerRegionCodes.Somali).toBe("05");
|
||||||
|
expect(cfg.invoice.buyerWeredaCodes["Jijiga Town"]).toBe("02");
|
||||||
|
expect(cfg.invoice.buyerCityCodes.Fafan).toBe("01");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an env var entry overrides the baked-in code for the same name", () => {
|
||||||
|
withEnv(
|
||||||
|
{
|
||||||
|
...REQUIRED,
|
||||||
|
EIMS_PRIVATE_KEY: "x",
|
||||||
|
EIMS_CERTIFICATE_PATH: "/dev/null",
|
||||||
|
EIMS_BUYER_REGION_CODES: "Somali=99",
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
expect(eimsConfigFactory().invoice.buyerRegionCodes.Somali).toBe("99");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an env var still adds a name the baked-in table doesn't have (a spelling variant)", () => {
|
||||||
|
withEnv(
|
||||||
|
{
|
||||||
|
...REQUIRED,
|
||||||
|
EIMS_PRIVATE_KEY: "x",
|
||||||
|
EIMS_CERTIFICATE_PATH: "/dev/null",
|
||||||
|
EIMS_BUYER_CITY_CODES: "Fafen=01",
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
const codes = eimsConfigFactory().invoice.buyerCityCodes;
|
||||||
|
expect(codes.Fafen).toBe("01");
|
||||||
|
expect(codes.Fafan).toBe("01"); // baked-in entry still present alongside it
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the bare Addis Ababa sub-city name a buyer profile actually stores, not the CSV's example-woreda name", () => {
|
||||||
|
withEnv(
|
||||||
|
{ ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" },
|
||||||
|
() => {
|
||||||
|
const codes = eimsConfigFactory().invoice.buyerWeredaCodes;
|
||||||
|
expect(codes.Bole).toBe("01");
|
||||||
|
expect(codes.Arada).toBe("01");
|
||||||
|
expect(codes.Kirkos).toBe("01");
|
||||||
|
expect(codes.Yeka).toBe("01");
|
||||||
|
expect(codes["Nifas Silk Lafto"]).toBe("13");
|
||||||
|
expect(codes["Nefas Silk-Lafto"]).toBe("13");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { registerAs } from "@nestjs/config";
|
import { registerAs } from "@nestjs/config";
|
||||||
|
|
||||||
|
import { ETHIOPIA_REGION_CODES, ETHIOPIA_WOREDA_CODES, ETHIOPIA_ZONE_CODES } from "./ethiopia-geo-codes";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ethiopian MoR EIMS e-invoicing gateway.
|
* Ethiopian MoR EIMS e-invoicing gateway.
|
||||||
*
|
*
|
||||||
@@ -30,6 +32,23 @@ export interface EimsConfig {
|
|||||||
privateKeyPath: string;
|
privateKeyPath: string;
|
||||||
/** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */
|
/** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */
|
||||||
certificatePath: string;
|
certificatePath: string;
|
||||||
|
/**
|
||||||
|
* Inline alternative to `privateKeyPath` — the key file's own bytes, base64-encoded, so a
|
||||||
|
* container that can't be given a host bind mount can still receive it as a plain env var.
|
||||||
|
* Either one must be present when EIMS is enabled. Precedence: `privateKeyPem` > `privateKeyBase64`
|
||||||
|
* > `privateKeyPath`.
|
||||||
|
*/
|
||||||
|
privateKeyBase64: string;
|
||||||
|
/** Inline alternative to `certificatePath`, same precedence rule as the key. */
|
||||||
|
certificateBase64: string;
|
||||||
|
/**
|
||||||
|
* The PEM key pasted directly into the env var, no encoding step at all — the most direct of the
|
||||||
|
* three inline forms, and the hardest for a broken transport step to mangle since there's no
|
||||||
|
* decode stage to get wrong. Wins over `privateKeyBase64`/`privateKeyPath` when set.
|
||||||
|
*/
|
||||||
|
privateKeyPem: string;
|
||||||
|
/** Inline alternative to `certificateBase64`, same precedence rule. */
|
||||||
|
certificatePem: string;
|
||||||
httpTimeoutMs: number;
|
httpTimeoutMs: number;
|
||||||
/** Re-authenticate this many ms before the access token actually expires. */
|
/** Re-authenticate this many ms before the access token actually expires. */
|
||||||
tokenSkewMs: number;
|
tokenSkewMs: number;
|
||||||
@@ -80,7 +99,19 @@ export interface EimsInvoiceConfig {
|
|||||||
paymentMode: string;
|
paymentMode: string;
|
||||||
paymentTerm: string;
|
paymentTerm: string;
|
||||||
unitDefault: string;
|
unitDefault: string;
|
||||||
|
/**
|
||||||
|
* Domestic fallback only — used when the buyer's `Company.country` is empty or "Ethiopia" (the
|
||||||
|
* column's own default) and not already listed in `buyerCountryCodes`. A genuinely foreign
|
||||||
|
* buyer must be in `buyerCountryCodes` by name or the mapping fails locally; this value is never
|
||||||
|
* applied to them, so an unconfigured foreign country can't silently be filed as Ethiopia.
|
||||||
|
*/
|
||||||
buyerCountryCode: string | null;
|
buyerCountryCode: string | null;
|
||||||
|
/**
|
||||||
|
* Country name → MoR code, from `EIMS_BUYER_COUNTRY_CODES` ("Ethiopia=231,Djibouti=071"). Format
|
||||||
|
* unconfirmed (unlike Region/Wereda, MoR has never named a Country regex), so — unlike them —
|
||||||
|
* this is not validated against a fixed digit pattern, only looked up by name.
|
||||||
|
*/
|
||||||
|
buyerCountryCodes: Record<string, string>;
|
||||||
/**
|
/**
|
||||||
* Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES`
|
* Buyer region name → MoR numeric code, from `EIMS_BUYER_REGION_CODES`
|
||||||
* ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails
|
* ("Addis Ababa=13,Oromia=4"). A buyer whose region is neither a code nor in this map fails
|
||||||
@@ -89,6 +120,14 @@ export interface EimsInvoiceConfig {
|
|||||||
buyerRegionCodes: Record<string, string>;
|
buyerRegionCodes: Record<string, string>;
|
||||||
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
|
/** Same mechanism as `buyerRegionCodes`, for `EIMS_BUYER_WEREDA_CODES` ("Yeka=574"). */
|
||||||
buyerWeredaCodes: Record<string, string>;
|
buyerWeredaCodes: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* Buyer *zone* name → MoR City code, from `EIMS_BUYER_CITY_CODES` ("Kirkos=101"). `Company` has
|
||||||
|
* no dedicated city column — Zone is the closest match in EDR's own data. Optional, unlike
|
||||||
|
* Region/Wereda: MoR has never required City on a live buyer (confirmed — filing already
|
||||||
|
* succeeds with it null), so an unmapped zone falls back to null rather than failing the
|
||||||
|
* mapping.
|
||||||
|
*/
|
||||||
|
buyerCityCodes: Record<string, string>;
|
||||||
/**
|
/**
|
||||||
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
|
* Per-`chargeType` tax treatment, e.g. `EIMS_TAX_CODE_BY_CHARGE_TYPE=RAIL_FREIGHT=VAT0` +
|
||||||
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
|
* `EIMS_TAX_RATE_BY_CHARGE_TYPE=RAIL_FREIGHT=0`. A charge type not listed here falls back to
|
||||||
@@ -115,14 +154,14 @@ export interface EimsInvoiceConfig {
|
|||||||
buyerIdNumber: string | null;
|
buyerIdNumber: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REQUIRED_VARS = [
|
const REQUIRED_VARS = ["EIMS_CLIENT_ID", "EIMS_CLIENT_SECRET", "EIMS_API_KEY", "EIMS_TIN"] as const;
|
||||||
"EIMS_CLIENT_ID",
|
|
||||||
"EIMS_CLIENT_SECRET",
|
// Key/cert each have three ways in (file path, inline base64, or raw PEM) — checked separately
|
||||||
"EIMS_API_KEY",
|
// from REQUIRED_VARS since it's "at least one of", not "this exact var".
|
||||||
"EIMS_TIN",
|
const REQUIRED_ANY_OF: string[][] = [
|
||||||
"EIMS_PRIVATE_KEY_PATH",
|
["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64", "EIMS_PRIVATE_KEY"],
|
||||||
"EIMS_CERTIFICATE_PATH",
|
["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64", "EIMS_CERTIFICATE"],
|
||||||
] as const;
|
];
|
||||||
|
|
||||||
const positiveInt = (raw: string | undefined, fallback: number, name: string): number => {
|
const positiveInt = (raw: string | undefined, fallback: number, name: string): number => {
|
||||||
if (raw === undefined || raw === "") return fallback;
|
if (raw === undefined || raw === "") return fallback;
|
||||||
@@ -143,6 +182,14 @@ const parseCodeMap = (raw: string | undefined): Record<string, string> => {
|
|||||||
return map;
|
return map;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Some env stores (single-line .env files, certain secret managers) can't hold a literal newline
|
||||||
|
// and expect the caller to write "\n" as two characters instead. If the raw value already has a
|
||||||
|
// real newline, leave it alone; otherwise unescape "\n" so a PEM pasted that way still parses.
|
||||||
|
const normalizePem = (raw: string | undefined): string => {
|
||||||
|
if (!raw) return "";
|
||||||
|
return raw.includes("\n") ? raw : raw.replace(/\\n/g, "\n");
|
||||||
|
};
|
||||||
|
|
||||||
/** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */
|
/** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */
|
||||||
const optionalNumber = (raw: string | undefined, name: string): number | null => {
|
const optionalNumber = (raw: string | undefined, name: string): number | null => {
|
||||||
if (raw === undefined || raw === "") return null;
|
if (raw === undefined || raw === "") return null;
|
||||||
@@ -169,6 +216,10 @@ export default registerAs("eims", (): EimsConfig => {
|
|||||||
systemType: process.env.EIMS_SYSTEM_TYPE ?? "",
|
systemType: process.env.EIMS_SYSTEM_TYPE ?? "",
|
||||||
privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "",
|
privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "",
|
||||||
certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "",
|
certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "",
|
||||||
|
privateKeyBase64: process.env.EIMS_PRIVATE_KEY_BASE64 ?? "",
|
||||||
|
privateKeyPem: normalizePem(process.env.EIMS_PRIVATE_KEY),
|
||||||
|
certificatePem: normalizePem(process.env.EIMS_CERTIFICATE),
|
||||||
|
certificateBase64: process.env.EIMS_CERTIFICATE_BASE64 ?? "",
|
||||||
httpTimeoutMs,
|
httpTimeoutMs,
|
||||||
tokenSkewMs,
|
tokenSkewMs,
|
||||||
autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true",
|
autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true",
|
||||||
@@ -208,8 +259,12 @@ export default registerAs("eims", (): EimsConfig => {
|
|||||||
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
|
paymentTerm: process.env.EIMS_PAYMENT_TERM ?? "",
|
||||||
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
|
unitDefault: process.env.EIMS_UNIT_DEFAULT ?? "",
|
||||||
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
|
buyerCountryCode: process.env.EIMS_BUYER_COUNTRY_CODE || null,
|
||||||
buyerRegionCodes: parseCodeMap(process.env.EIMS_BUYER_REGION_CODES),
|
buyerCountryCodes: parseCodeMap(process.env.EIMS_BUYER_COUNTRY_CODES),
|
||||||
buyerWeredaCodes: parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES),
|
// Baked-in Ethiopia reference table first, env var entries win on a name collision — lets a
|
||||||
|
// deployment override or add to it without a redeploy. See ethiopia-geo-codes.ts.
|
||||||
|
buyerRegionCodes: { ...ETHIOPIA_REGION_CODES, ...parseCodeMap(process.env.EIMS_BUYER_REGION_CODES) },
|
||||||
|
buyerWeredaCodes: { ...ETHIOPIA_WOREDA_CODES, ...parseCodeMap(process.env.EIMS_BUYER_WEREDA_CODES) },
|
||||||
|
buyerCityCodes: { ...ETHIOPIA_ZONE_CODES, ...parseCodeMap(process.env.EIMS_BUYER_CITY_CODES) },
|
||||||
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
|
taxCodeByChargeType: parseCodeMap(process.env.EIMS_TAX_CODE_BY_CHARGE_TYPE),
|
||||||
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
|
taxRateByChargeType: parseCodeMap(process.env.EIMS_TAX_RATE_BY_CHARGE_TYPE),
|
||||||
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),
|
exciseByChargeType: parseCodeMap(process.env.EIMS_EXCISE_BY_CHARGE_TYPE),
|
||||||
@@ -223,7 +278,10 @@ export default registerAs("eims", (): EimsConfig => {
|
|||||||
|
|
||||||
if (!enabled) return base;
|
if (!enabled) return base;
|
||||||
|
|
||||||
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
|
const missing: string[] = REQUIRED_VARS.filter((name) => !process.env[name]);
|
||||||
|
for (const vars of REQUIRED_ANY_OF) {
|
||||||
|
if (vars.every((name) => !process.env[name])) missing.push(vars.join(" or "));
|
||||||
|
}
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`,
|
`EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`,
|
||||||
|
|||||||
160
apps/edr-freight-api/src/config/ethiopia-geo-codes.ts
Normal file
160
apps/edr-freight-api/src/config/ethiopia-geo-codes.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* MoR EIMS region/zone/woreda codes, by name — the baked-in fallback under
|
||||||
|
* `EIMS_BUYER_REGION_CODES`/`EIMS_BUYER_WEREDA_CODES`/`EIMS_BUYER_CITY_CODES` (zone is the closest
|
||||||
|
* match to EIMS's "City", per `eims-invoice.mapper.ts`).
|
||||||
|
*
|
||||||
|
* Before this existed, every buyer from a not-yet-seen region/zone/woreda crashed EIMS filing until
|
||||||
|
* someone hunted down the code and added it to an env var by hand — happened three times in one
|
||||||
|
* afternoon (2026-08-17: Somali region, Fafan zone, Jigjiga woreda, even the Ethiopia country code
|
||||||
|
* itself were all unset). Ethiopia's administrative divisions are fixed, known, reference data, not
|
||||||
|
* something that should be maintained reactively per buyer. Source: `ethiopia_administrative_
|
||||||
|
* hierarchy_master.csv`, supplied 2026-08-17 — NOT exhaustive (a representative sample per region,
|
||||||
|
* not all ~1000 real woredas), extend as new gaps surface.
|
||||||
|
*
|
||||||
|
* The env vars stay wired in ahead of this table (see `eims.config.ts`) — for a quick correction
|
||||||
|
* without a redeploy, or a name spelled differently in a buyer's profile than in this table (already
|
||||||
|
* hit live: DB has zone "Fafen", this table's official spelling is "Fafan" — same zone, matching is
|
||||||
|
* case/space-insensitive but not spelling-tolerant, so the env var override is still how that buyer
|
||||||
|
* actually resolves; this table mainly helps the *next* buyer whose profile spelling matches).
|
||||||
|
*
|
||||||
|
* ponytail: region names are unique nationwide (only ~15), safe as a flat map. Zone and woreda names
|
||||||
|
* are not always unique across different regions (e.g. "North Shewa" is both an Amhara zone and an
|
||||||
|
* Oromia zone, different codes) — `Company` stores region/zone/woreda as three independent strings,
|
||||||
|
* no parent linkage, so a flat name lookup can't disambiguate. First occurrence in the source data
|
||||||
|
* wins on a collision. Only affects the optional `City` field (zone) — never blocks filing, unlike
|
||||||
|
* Region/Wereda. A correct fix needs `Company` to store a linked hierarchy, not just three strings;
|
||||||
|
* out of scope here. Upgrade path: key this by `${region}/${zone}` once that linkage exists.
|
||||||
|
*/
|
||||||
|
const ROWS: Array<[region: string, zone: string, woreda: string, regionCode: string, zoneCode: string, woredaCode: string]> = [
|
||||||
|
["Tigray", "Western Tigray", "Humera", "01", "01", "01"],
|
||||||
|
["Tigray", "Western Tigray", "Kafta Humera", "01", "01", "02"],
|
||||||
|
["Tigray", "Western Tigray", "Tsegede", "01", "01", "03"],
|
||||||
|
["Tigray", "North Western Tigray", "Shire Endaselassie", "01", "02", "01"],
|
||||||
|
["Tigray", "North Western Tigray", "Sheraro", "01", "02", "02"],
|
||||||
|
["Tigray", "Central Tigray", "Axum", "01", "03", "01"],
|
||||||
|
["Tigray", "Central Tigray", "Adwa", "01", "03", "02"],
|
||||||
|
["Tigray", "Eastern Tigray", "Adigrat", "01", "04", "01"],
|
||||||
|
["Tigray", "Southern Tigray", "Maychew", "01", "05", "01"],
|
||||||
|
["Tigray", "Mekelle Special Zone", "Mekelle City", "01", "06", "01"],
|
||||||
|
["Afar", "Awusi Rasu (Zone 1)", "Asayita", "02", "01", "01"],
|
||||||
|
["Afar", "Awusi Rasu (Zone 1)", "Semera-Logiya", "02", "01", "02"],
|
||||||
|
["Afar", "Kilbet Rasu (Zone 2)", "Abala", "02", "02", "01"],
|
||||||
|
["Afar", "Gabi Rasu (Zone 3)", "Awash Fentale", "02", "03", "01"],
|
||||||
|
["Afar", "Fantena Rasu (Zone 4)", "Yalo", "02", "04", "01"],
|
||||||
|
["Afar", "Hari Rasu (Zone 5)", "Telalak", "02", "05", "01"],
|
||||||
|
["Amhara", "North Gondar", "Debark", "03", "01", "01"],
|
||||||
|
["Amhara", "South Gondar", "Debre Tabor", "03", "02", "01"],
|
||||||
|
["Amhara", "North Wollo", "Woldiya", "03", "03", "01"],
|
||||||
|
["Amhara", "South Wollo", "Dessie Town", "03", "04", "01"],
|
||||||
|
["Amhara", "North Shewa", "Debre Berhan", "03", "05", "01"],
|
||||||
|
["Amhara", "East Gojjam", "Debre Markos", "03", "06", "01"],
|
||||||
|
["Amhara", "West Gojjam", "Finote Selam", "03", "07", "01"],
|
||||||
|
["Amhara", "Wag Hemra", "Sekota", "03", "08", "01"],
|
||||||
|
["Amhara", "Awi", "Injibara", "03", "09", "01"],
|
||||||
|
["Amhara", "Oromia Special Zone", "Kemise", "03", "10", "01"],
|
||||||
|
["Amhara", "Bahir Dar Special Zone", "Bahir Dar City", "03", "11", "01"],
|
||||||
|
["Amhara", "Gondar Special Zone", "Gondar City", "03", "12", "01"],
|
||||||
|
["Oromia", "North Shewa", "Fiche", "04", "01", "01"],
|
||||||
|
["Oromia", "South West Shewa", "Waliso", "04", "02", "01"],
|
||||||
|
["Oromia", "East Shewa", "Adama Town", "04", "03", "01"],
|
||||||
|
["Oromia", "East Shewa", "Bishoftu Town", "04", "03", "02"],
|
||||||
|
["Oromia", "West Shewa", "Ambo", "04", "04", "01"],
|
||||||
|
["Oromia", "Arsi", "Asella", "04", "05", "01"],
|
||||||
|
["Oromia", "West Arsi", "Shashemene", "04", "06", "01"],
|
||||||
|
["Oromia", "Bale", "Robe", "04", "07", "01"],
|
||||||
|
["Oromia", "East Hararghe", "Harar Outskirts", "04", "08", "01"],
|
||||||
|
["Oromia", "West Hararghe", "Chiro", "04", "09", "01"],
|
||||||
|
["Oromia", "Jimma", "Jimma Town", "04", "10", "01"],
|
||||||
|
["Oromia", "Illubabor", "Mettu", "04", "11", "01"],
|
||||||
|
["Oromia", "Buno Bedele", "Bedele", "04", "12", "01"],
|
||||||
|
["Oromia", "Welega (West)", "Gimbi", "04", "13", "01"],
|
||||||
|
["Oromia", "Welega (East)", "Nekemte", "04", "14", "01"],
|
||||||
|
["Oromia", "Horo Guduru Welega", "Shambu", "04", "15", "01"],
|
||||||
|
["Oromia", "Kelam Welega", "Dembidolo", "04", "16", "01"],
|
||||||
|
["Oromia", "Borena", "Yabelo", "04", "17", "01"],
|
||||||
|
["Oromia", "Guji", "Negele Borana", "04", "18", "01"],
|
||||||
|
["Oromia", "West Guji", "Bule Hora", "04", "19", "01"],
|
||||||
|
["Oromia", "East Bale", "Ginir", "04", "20", "01"],
|
||||||
|
["Oromia", "Sheger City", "Sululta", "04", "21", "01"],
|
||||||
|
["Somali", "Fafan", "Jijiga Woreda", "05", "01", "01"],
|
||||||
|
["Somali", "Fafan", "Jijiga Town", "05", "01", "02"],
|
||||||
|
["Somali", "Fafan", "Awbare", "05", "01", "03"],
|
||||||
|
["Somali", "Sitti", "Shinile", "05", "02", "01"],
|
||||||
|
["Somali", "Erer", "Fiq", "05", "03", "01"],
|
||||||
|
["Somali", "Jarar", "Degehabur", "05", "04", "01"],
|
||||||
|
["Somali", "Nogob", "Segeg", "05", "05", "01"],
|
||||||
|
["Somali", "Korahe", "Kebridehar", "05", "06", "01"],
|
||||||
|
["Somali", "Shabelle", "Gode", "05", "07", "01"],
|
||||||
|
["Somali", "Afder", "Afder Woreda", "05", "08", "01"],
|
||||||
|
["Somali", "Liben", "Filtu", "05", "09", "01"],
|
||||||
|
["Somali", "Dhawa", "Mubarak", "05", "10", "01"],
|
||||||
|
["Somali", "Dollo", "Warder", "05", "11", "01"],
|
||||||
|
["Benishangul-Gumuz", "Asosa", "Asosa Woreda", "06", "01", "01"],
|
||||||
|
["Benishangul-Gumuz", "Kamasashi", "Kamasashi Woreda", "06", "02", "01"],
|
||||||
|
["Benishangul-Gumuz", "Metekel", "Gilgel Beles", "06", "03", "01"],
|
||||||
|
["Southern Ethiopia", "Wolayta", "Sodo Zuria", "07", "01", "01"],
|
||||||
|
["Southern Ethiopia", "Wolayta", "Sodo Town", "07", "01", "02"],
|
||||||
|
["Southern Ethiopia", "Gamo", "Arba Minch Town", "07", "02", "01"],
|
||||||
|
["Southern Ethiopia", "Gofa", "Sawla", "07", "03", "01"],
|
||||||
|
["Southern Ethiopia", "Konso", "Konso Woreda", "07", "04", "01"],
|
||||||
|
["Southern Ethiopia", "South Omo", "Jinka", "07", "05", "01"],
|
||||||
|
["Gambela", "Anywaa", "Gambela Zuria", "08", "01", "01"],
|
||||||
|
["Gambela", "Nuer", "Lare", "08", "02", "01"],
|
||||||
|
["Gambela", "Majang", "Metu Zuria part", "08", "03", "01"],
|
||||||
|
["Harari", "Harar Hundanee", "Amir Nur Woreda", "09", "01", "01"],
|
||||||
|
["Harari", "Harar Hundanee", "Abadir Woreda", "09", "01", "02"],
|
||||||
|
["Addis Ababa", "Bole Sub-City", "Bole Woreda 01", "10", "01", "01"],
|
||||||
|
["Addis Ababa", "Kirkos Sub-City", "Kirkos Woreda 01", "10", "02", "01"],
|
||||||
|
["Addis Ababa", "Nifas Silk Lafto", "NSL Woreda 13", "10", "03", "13"],
|
||||||
|
["Addis Ababa", "Yeka Sub-City", "Yeka Woreda 01", "10", "04", "01"],
|
||||||
|
["Addis Ababa", "Arada Sub-City", "Arada Woreda 01", "10", "05", "01"],
|
||||||
|
["Dire Dawa", "Dire Dawa Urban", "Melka Jebdu", "11", "01", "01"],
|
||||||
|
["Dire Dawa", "Dire Dawa Rural", "Gurgura", "11", "02", "01"],
|
||||||
|
["Sidama", "Hawassa City Admin", "Hayek Chereka", "12", "01", "01"],
|
||||||
|
["Sidama", "Sidama Zuria", "Yirgalem Town", "12", "02", "01"],
|
||||||
|
["Sidama", "Sidama Zuria", "Aleta Wendo", "12", "02", "02"],
|
||||||
|
["Southwest Ethiopia", "Keffa", "Bonga Town", "13", "01", "01"],
|
||||||
|
["Southwest Ethiopia", "Sheka", "Mappi Zuria", "13", "02", "01"],
|
||||||
|
["Southwest Ethiopia", "Bench Sheko", "Mizan Aman", "13", "03", "01"],
|
||||||
|
["Central Ethiopia", "Gurage", "Wolkite", "14", "01", "01"],
|
||||||
|
["Central Ethiopia", "Hadiya", "Hosaina", "14", "02", "01"],
|
||||||
|
["Central Ethiopia", "Silte", "Worabe", "14", "03", "01"],
|
||||||
|
["Gedeo State", "Gedeo Zone", "Dilla Zuria", "15", "01", "01"],
|
||||||
|
["Gedeo State", "Gedeo Zone", "Yirgacheffe", "15", "01", "02"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** First occurrence wins on a name collision — see the class comment. */
|
||||||
|
const buildMap = (pick: (row: (typeof ROWS)[number]) => [string, string]): Record<string, string> => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const row of ROWS) {
|
||||||
|
const [name, code] = pick(row);
|
||||||
|
if (!(name in map)) map[name] = code;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ETHIOPIA_REGION_CODES: Record<string, string> = buildMap((r) => [r[0], r[3]]);
|
||||||
|
/** Zone name → code. Fed into `buyerCityCodes` — EIMS's "City" is really the buyer's zone. */
|
||||||
|
export const ETHIOPIA_ZONE_CODES: Record<string, string> = buildMap((r) => [r[1], r[4]]);
|
||||||
|
export const ETHIOPIA_WOREDA_CODES: Record<string, string> = buildMap((r) => [r[2], r[5]]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buyer records commonly store just the bare Addis Ababa sub-city name ("Bole", "Arada") as their
|
||||||
|
* woreda, not the source CSV's specific example-woreda name ("Bole Woreda 01") — confirmed live
|
||||||
|
* 2026-08-17 across three different buyers before any of them actually got past this check. Since
|
||||||
|
* the CSV lists exactly one representative woreda per Addis sub-city, alias the bare name to that
|
||||||
|
* same code rather than wait on a fuller table.
|
||||||
|
*/
|
||||||
|
const ADDIS_SUBCITY_ALIASES: Array<[bareName: string, csvZoneName: string]> = [
|
||||||
|
["Bole", "Bole Sub-City"],
|
||||||
|
["Kirkos", "Kirkos Sub-City"],
|
||||||
|
["Nifas Silk Lafto", "Nifas Silk Lafto"],
|
||||||
|
// Matches EIMS_BUYER_WEREDA_CODES' own existing spelling in .env — same zone, different hyphenation.
|
||||||
|
["Nefas Silk-Lafto", "Nifas Silk Lafto"],
|
||||||
|
["Yeka", "Yeka Sub-City"],
|
||||||
|
["Arada", "Arada Sub-City"],
|
||||||
|
];
|
||||||
|
for (const [bareName, csvZoneName] of ADDIS_SUBCITY_ALIASES) {
|
||||||
|
const row = ROWS.find((r) => r[1] === csvZoneName);
|
||||||
|
if (row && !(bareName in ETHIOPIA_WOREDA_CODES)) ETHIOPIA_WOREDA_CODES[bareName] = row[5];
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-row table controlling whether Finance may settle invoices by hand,
|
||||||
|
* per currency (see ManualPaymentSettingsService). Defaults preserve the
|
||||||
|
* pre-toggle behaviour: USD was always bank-transfer-only (ON), ETB manual
|
||||||
|
* settlement is the new capability and must be switched on deliberately (OFF).
|
||||||
|
*/
|
||||||
|
export class ManualPaymentSettings3560000000000 implements MigrationInterface {
|
||||||
|
name = "ManualPaymentSettings3560000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.manual_payment_settings (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
etb_enabled boolean NOT NULL DEFAULT false,
|
||||||
|
usd_enabled boolean NOT NULL DEFAULT true,
|
||||||
|
updated_by_id uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.manual_payment_settings (etb_enabled, usd_enabled)
|
||||||
|
SELECT false, true
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM freight.manual_payment_settings);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP TABLE IF EXISTS freight.manual_payment_settings;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which desks work at which yard — the input to yard access scoping.
|
||||||
|
*
|
||||||
|
* Many-to-many: a position (what the user-management tree calls a department)
|
||||||
|
* can cover several yards, and a yard is staffed by several positions. The
|
||||||
|
* scope resolver reads it to answer "which yards may this caller touch?".
|
||||||
|
*
|
||||||
|
* `yard_id` carries a real FK; `position_id` deliberately does NOT. Positions
|
||||||
|
* live in `iam`, which is owned by the vendored @tria-plc/iamapi-common package
|
||||||
|
* and shared with the passenger app: a hard FK would let freight block an IAM
|
||||||
|
* delete, and would have to be dropped the day IAM moves to its own database.
|
||||||
|
* Reads join `iam.positions … WHERE deleted_at IS NULL` instead, so a
|
||||||
|
* soft-deleted position silently drops out of scope rather than granting it.
|
||||||
|
*
|
||||||
|
* The unique index is PARTIAL — soft-deleted rows must not block re-adding the
|
||||||
|
* same pair later.
|
||||||
|
*/
|
||||||
|
export class YardPositions3560000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.yard_positions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
yard_id uuid NOT NULL REFERENCES freight.yards(id) ON DELETE CASCADE,
|
||||||
|
position_id uuid NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_yard_positions_pair
|
||||||
|
ON freight.yard_positions (yard_id, position_id)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_yard_positions_position
|
||||||
|
ON freight.yard_positions (position_id)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_positions`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approval gate for consolidated (shared-wagon) bookings.
|
||||||
|
*
|
||||||
|
* A booking that fills its own wagons goes straight from GL completion to the
|
||||||
|
* operations queue. A CONSOLIDATED booking does not: it shares one physical
|
||||||
|
* wagon with another customer's booking, which means two customers' cargo, two
|
||||||
|
* invoices and two liabilities riding the same wagon. That pairing is a
|
||||||
|
* commercial decision, so it is reviewed by a person before Operations sees it.
|
||||||
|
*
|
||||||
|
* The pair is approved as a UNIT — one row covers both halves (booking_id +
|
||||||
|
* partner_booking_id) so an approver can never approve one side of a shared
|
||||||
|
* wagon and leave the other pending. Rows are never deleted; decided rows are
|
||||||
|
* the audit trail of who approved which pairing and when.
|
||||||
|
*
|
||||||
|
* One PENDING row per booking at a time (partial unique index on each side of
|
||||||
|
* the pair): a second request while one is undecided is a coordination failure,
|
||||||
|
* not a workflow.
|
||||||
|
*/
|
||||||
|
export class ConsolidationApprovals3570000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE freight.consolidation_approvals_status_enum
|
||||||
|
AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.consolidation_approvals (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
booking_id uuid NOT NULL REFERENCES freight.bookings (id),
|
||||||
|
partner_booking_id uuid NOT NULL REFERENCES freight.bookings (id),
|
||||||
|
status freight.consolidation_approvals_status_enum NOT NULL DEFAULT 'PENDING',
|
||||||
|
-- Who put the pairing up for review (the GL user who completed it) and
|
||||||
|
-- who decided it. Both are recorded: the point of the gate is that they
|
||||||
|
-- are different people.
|
||||||
|
requested_by uuid,
|
||||||
|
requested_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
decided_by uuid,
|
||||||
|
decided_at timestamptz,
|
||||||
|
decision_note varchar(500),
|
||||||
|
-- Snapshot of what was approved, so the audit trail still reads
|
||||||
|
-- correctly after the bookings themselves move on.
|
||||||
|
scheduled_date timestamptz,
|
||||||
|
booking_reference varchar(50),
|
||||||
|
partner_booking_reference varchar(50),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_consolidation_approvals_booking_status
|
||||||
|
ON freight.consolidation_approvals (booking_id, status)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_consolidation_approvals_status
|
||||||
|
ON freight.consolidation_approvals (status)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// The workflow invariant, enforced where it cannot race: at most one
|
||||||
|
// undecided request per booking — on EITHER side of the pair, so the same
|
||||||
|
// wagon can never collect two pending requests from its two halves.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_consolidation_approvals_one_pending
|
||||||
|
ON freight.consolidation_approvals (booking_id)
|
||||||
|
WHERE status = 'PENDING' AND deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_consolidation_approvals_one_pending_partner
|
||||||
|
ON freight.consolidation_approvals (partner_booking_id)
|
||||||
|
WHERE status = 'PENDING' AND deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP TABLE IF EXISTS freight.consolidation_approvals`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP TYPE IF EXISTS freight.consolidation_approvals_status_enum`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed `edr_freight_app:yards:view_all` — the cross-yard bypass for yard access
|
||||||
|
* scoping.
|
||||||
|
*
|
||||||
|
* The permission catalog is otherwise written by `EdrOrgSeeder`, which skips
|
||||||
|
* itself unless `SEED_EDR_ORG` is set. That flag is off in normal environments,
|
||||||
|
* so a key added to the registry never reaches `iam.permissions` and cannot be
|
||||||
|
* granted to anyone — the bypass would exist in code and be unusable in the
|
||||||
|
* database. A migration is the one path that runs everywhere.
|
||||||
|
*
|
||||||
|
* Idempotent on `key`, which is the identity every consumer resolves by (the
|
||||||
|
* registry's uuid is only used where a seed row needs one). Skips silently when
|
||||||
|
* the freight application row is absent, since there is nothing to attach to.
|
||||||
|
*/
|
||||||
|
export class YardViewAllPermission3570000000000 implements MigrationInterface {
|
||||||
|
private static readonly KEY = 'edr_freight_app:yards:view_all';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO iam.permissions (id, key, name, application_id)
|
||||||
|
SELECT gen_random_uuid(),
|
||||||
|
$1::varchar,
|
||||||
|
'{"am": "Access every yard (bypass yard scoping)", "en": "Access every yard (bypass yard scoping)"}'::jsonb,
|
||||||
|
a.id
|
||||||
|
FROM iam.application a
|
||||||
|
WHERE a.key = 'edr_freight_app'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
|
||||||
|
[YardViewAllPermission3570000000000.KEY],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes only the permission row itself. Any grant of it goes first, or the
|
||||||
|
* delete trips the position/role permission foreign keys — and a half-removed
|
||||||
|
* permission is worse than one left in place.
|
||||||
|
*/
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DELETE FROM iam.position_permissions
|
||||||
|
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||||
|
[YardViewAllPermission3570000000000.KEY],
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DELETE FROM iam.role_permissions
|
||||||
|
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||||
|
[YardViewAllPermission3570000000000.KEY],
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
|
||||||
|
YardViewAllPermission3570000000000.KEY,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
* humanized handler name where a route has none.
|
* humanized handler name where a route has none.
|
||||||
*
|
*
|
||||||
* Excludes the AI Assist and Account entities.
|
* Excludes the AI Assist and Account entities.
|
||||||
* Generated from the controllers under src/ — 488 endpoints.
|
* Generated from the controllers under src/ — 512 endpoints.
|
||||||
*/
|
*/
|
||||||
/** [title, method, entity] for one auditable route. */
|
/** [title, method, entity] for one auditable route. */
|
||||||
export type AuditEndpointMeta = readonly [title: string, method: string, entity: string];
|
export type AuditEndpointMeta = readonly [title: string, method: string, entity: string];
|
||||||
@@ -83,6 +83,9 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"],
|
"POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"],
|
||||||
"POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"],
|
"POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"],
|
||||||
"POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"],
|
"POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"],
|
||||||
|
"POST /api/bookings/consolidation-approvals/:approvalId/approve": ["Approve a shared wagon: both bookings leave the gate and continue to Operations together.", "POST", "Booking"],
|
||||||
|
"POST /api/bookings/consolidation-approvals/:approvalId/reject": ["Reject a shared wagon: both bookings go back to GL for changes with the reason.", "POST", "Booking"],
|
||||||
|
"POST /api/bookings/:id/paired-decision": ["Apply a staff decision (accept / cancel / operationAccept / requestChanges) to BOTH halves of a consolidated pair, all-or-nothing.", "POST", "Booking"],
|
||||||
|
|
||||||
// Cargo
|
// Cargo
|
||||||
"POST /api/cargoes": ["Create a new cargo", "POST", "Cargo"],
|
"POST /api/cargoes": ["Create a new cargo", "POST", "Cargo"],
|
||||||
@@ -119,17 +122,13 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"POST /api/companies/documents/:fileId/request-change": ["Ask the customer to correct one uploaded document", "POST", "Company"],
|
"POST /api/companies/documents/:fileId/request-change": ["Ask the customer to correct one uploaded document", "POST", "Company"],
|
||||||
"POST /api/companies/fetch-etrade-info": ["Fetch company info from eTrade by TIN", "POST", "Company"],
|
"POST /api/companies/fetch-etrade-info": ["Fetch company info from eTrade by TIN", "POST", "Company"],
|
||||||
"POST /api/companies/identity/fayda/complete": ["Bind a completed Fayda verification to the company's owner or Power of Attorney", "POST", "Company"],
|
"POST /api/companies/identity/fayda/complete": ["Bind a completed Fayda verification to the company's owner or Power of Attorney", "POST", "Company"],
|
||||||
"DELETE /api/companies/identity/fayda/poa": ["Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together", "DELETE", "Company"],
|
|
||||||
"DELETE /api/companies/identity/gm": ["Clear the General Manager's identity — the \\\"same as owner\\\" declaration or a verification, and the details either wrote", "DELETE", "Company"],
|
|
||||||
"POST /api/companies/identity/gm/same-as-owner": ["Declare the General Manager is the company's owner, copying the owner's verified identity across", "POST", "Company"],
|
|
||||||
"POST /api/companies/identity/poa/same-as-owner": ["Declare the Power of Attorney is the company's owner, copying the owner's identity across", "POST", "Company"],
|
|
||||||
"DELETE /api/companies/identity/poa/same-as-owner": ["Undo the Power of Attorney \\\"same as owner\\\" declaration and the identity it copied, leaving the representative open to be verified in their own right", "DELETE", "Company"],
|
|
||||||
"PATCH /api/companies/onboarding-step": ["Persist the user's current onboarding wizard step", "PATCH", "Company"],
|
"PATCH /api/companies/onboarding-step": ["Persist the user's current onboarding wizard step", "PATCH", "Company"],
|
||||||
"POST /api/companies/onboarding/complete": ["Mark the current user's onboarding as complete", "POST", "Company"],
|
"POST /api/companies/onboarding/complete": ["Mark the current user's onboarding as complete", "POST", "Company"],
|
||||||
"POST /api/companies/onboarding/start": ["Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", "POST", "Company"],
|
"POST /api/companies/onboarding/start": ["Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", "POST", "Company"],
|
||||||
"POST /api/companies/poa-delegation": ["Upload the Power of Attorney delegation letter, replacing any existing one", "POST", "Company"],
|
"POST /api/companies/poa-delegation": ["Upload the Power of Attorney delegation letter, replacing any existing one", "POST", "Company"],
|
||||||
"DELETE /api/companies/poa-delegation/:fileId": ["Remove the Power of Attorney delegation letter (staged for review on an approved company)", "DELETE", "Company"],
|
"DELETE /api/companies/poa-delegation/:fileId": ["Remove the Power of Attorney delegation letter (staged for review on an approved company)", "DELETE", "Company"],
|
||||||
"PATCH /api/companies/profile": ["Update profile (flattened settings page)", "PATCH", "Company"],
|
"PATCH /api/companies/profile": ["Update profile (flattened settings page)", "PATCH", "Company"],
|
||||||
|
"PATCH /api/companies/identity/poa-declared": ["Answer whether anyone holds power of attorney for this company — the question that decides whose identity is verified.", "PATCH", "Company"],
|
||||||
|
|
||||||
// Compliance
|
// Compliance
|
||||||
"POST /api/compliance": ["Create a compliance record", "POST", "Compliance"],
|
"POST /api/compliance": ["Create a compliance record", "POST", "Compliance"],
|
||||||
@@ -222,6 +221,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"POST /api/gl-exchange/:entityId": ["Share a document with the other GL desk", "POST", "Contract"],
|
"POST /api/gl-exchange/:entityId": ["Share a document with the other GL desk", "POST", "Contract"],
|
||||||
"PATCH /api/gl-exchange/documents/:documentId": ["Uploader edits a shared document (title, visibility, file)", "PATCH", "Contract"],
|
"PATCH /api/gl-exchange/documents/:documentId": ["Uploader edits a shared document (title, visibility, file)", "PATCH", "Contract"],
|
||||||
"DELETE /api/gl-exchange/documents/:documentId": ["Uploader removes a shared document", "DELETE", "Contract"],
|
"DELETE /api/gl-exchange/documents/:documentId": ["Uploader removes a shared document", "DELETE", "Contract"],
|
||||||
|
"POST /api/contracts/:id/bookings/:bookingId/complete-consolidated": ["Complete this booking and its chosen shared-wagon partner together (all-or-nothing). Each booking is priced and invoiced separately — only the wagon is shared.", "POST", "Contract"],
|
||||||
|
|
||||||
// Contract Template
|
// Contract Template
|
||||||
"POST /api/contract-templates": ["Create a bulk contract template for a (cargo type, customs option) pair", "POST", "Contract Template"],
|
"POST /api/contract-templates": ["Create a bulk contract template for a (cargo type, customs option) pair", "POST", "Contract Template"],
|
||||||
@@ -253,6 +253,9 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"POST /api/invoices/:id/eims/register": ["Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged", "POST", "EIMS Invoice"],
|
"POST /api/invoices/:id/eims/register": ["Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged", "POST", "EIMS Invoice"],
|
||||||
"POST /api/invoices/:id/eims/resolve": ["Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block", "POST", "EIMS Invoice"],
|
"POST /api/invoices/:id/eims/resolve": ["Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block", "POST", "EIMS Invoice"],
|
||||||
"POST /api/invoices/:id/eims/verify": ["Verify the invoice's stored IRN against EIMS", "POST", "EIMS Invoice"],
|
"POST /api/invoices/:id/eims/verify": ["Verify the invoice's stored IRN against EIMS", "POST", "EIMS Invoice"],
|
||||||
|
"POST /api/invoices/:id/eims/cancel": ["Cancel the invoice", "POST", "EIMS Invoice"],
|
||||||
|
"POST /api/invoices/:id/eims/receipt/sales": ["Register a sales receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
|
||||||
|
"POST /api/invoices/:id/eims/receipt/withholding": ["Register a withholding receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"],
|
||||||
|
|
||||||
// Exchange Setting
|
// Exchange Setting
|
||||||
"PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"],
|
"PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"],
|
||||||
@@ -301,6 +304,7 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"POST /api/import-operations/djibouti-incidents": ["Batch 8: report a Djibouti import incident / exception", "POST", "Import Operation"],
|
"POST /api/import-operations/djibouti-incidents": ["Batch 8: report a Djibouti import incident / exception", "POST", "Import Operation"],
|
||||||
"POST /api/import-operations/empty-container-returns": ["Batch 16: create an empty container return record", "POST", "Import Operation"],
|
"POST /api/import-operations/empty-container-returns": ["Batch 16: create an empty container return record", "POST", "Import Operation"],
|
||||||
"POST /api/import-operations/empty-container-returns/:id/status": ["Batch 16: advance empty container return workflow", "POST", "Import Operation"],
|
"POST /api/import-operations/empty-container-returns/:id/status": ["Batch 16: advance empty container return workflow", "POST", "Import Operation"],
|
||||||
|
"POST /api/import-operations/empty-container-returns/load-on-train": ["Load returned empties onto an export train (1×40ft or 2×20ft per wagon)", "POST", "Import Operation"],
|
||||||
|
|
||||||
// Incident
|
// Incident
|
||||||
"POST /api/incidents": ["Report an incident", "POST", "Incident"],
|
"POST /api/incidents": ["Report an incident", "POST", "Incident"],
|
||||||
@@ -336,6 +340,10 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"POST /api/locomotives/:id/decommission": ["Decommission a locomotive", "POST", "Locomotive"],
|
"POST /api/locomotives/:id/decommission": ["Decommission a locomotive", "POST", "Locomotive"],
|
||||||
"DELETE /api/locomotives/:id/permanent": ["Permanently delete a locomotive (irreversible; refused if any train references it)", "DELETE", "Locomotive"],
|
"DELETE /api/locomotives/:id/permanent": ["Permanently delete a locomotive (irreversible; refused if any train references it)", "DELETE", "Locomotive"],
|
||||||
|
|
||||||
|
// Logo Setting
|
||||||
|
"PUT /api/logo-settings": ["Replace the company logo", "PUT", "Logo Setting"],
|
||||||
|
"DELETE /api/logo-settings": ["Clear the company logo (documents fall back to their text mark)", "DELETE", "Logo Setting"],
|
||||||
|
|
||||||
// Maintenance
|
// Maintenance
|
||||||
"POST /api/maintenance/costs": ["Record maintenance cost", "POST", "Maintenance"],
|
"POST /api/maintenance/costs": ["Record maintenance cost", "POST", "Maintenance"],
|
||||||
"POST /api/maintenance/intervals": ["Define/adjust a service interval (e.g. oil change every 10,000 km)", "POST", "Maintenance"],
|
"POST /api/maintenance/intervals": ["Define/adjust a service interval (e.g. oil change every 10,000 km)", "POST", "Maintenance"],
|
||||||
@@ -377,6 +385,10 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"POST /api/internal/payments/mark-paid": ["Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", "POST", "Payment"],
|
"POST /api/internal/payments/mark-paid": ["Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", "POST", "Payment"],
|
||||||
"POST /api/payments/initiate": ["Initiate payment for an invoice", "POST", "Payment"],
|
"POST /api/payments/initiate": ["Initiate payment for an invoice", "POST", "Payment"],
|
||||||
"POST /api/payments/redirect-success/:bookingId": ["Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)", "POST", "Payment"],
|
"POST /api/payments/redirect-success/:bookingId": ["Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)", "POST", "Payment"],
|
||||||
|
"POST /api/billing/invoices/:id/memo": ["Issue a credit or debit memo against a registered invoice (MoR DEB/CRE). Filing-equivalent — the auto-submit sweep picks it up like any other issued invoice.", "POST", "Payment"],
|
||||||
|
|
||||||
|
// Payment Setting
|
||||||
|
"PATCH /api/payment-settings/manual": ["Enable or disable manual invoice settlement for ETB and/or USD", "PATCH", "Payment Setting"],
|
||||||
|
|
||||||
// Priority Config
|
// Priority Config
|
||||||
"POST /api/priority-configs": ["Create a priority config", "POST", "Priority Config"],
|
"POST /api/priority-configs": ["Create a priority config", "POST", "Priority Config"],
|
||||||
@@ -438,6 +450,20 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"],
|
"PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"],
|
||||||
"DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"],
|
"DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"],
|
||||||
|
|
||||||
|
// Shipping Line Booking
|
||||||
|
"POST /api/shipping-line-bookings/initiate": ["Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.", "POST", "Shipping Line Booking"],
|
||||||
|
"POST /api/shipping-line-bookings/:id/cancel": ["Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.", "POST", "Shipping Line Booking"],
|
||||||
|
"POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"],
|
||||||
|
"POST /api/shipping-line-bookings/:id/complete": ["Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day.", "POST", "Shipping Line Booking"],
|
||||||
|
|
||||||
|
// Shipping Line Credit
|
||||||
|
"POST /api/shipping-line-credits/invoice": ["Bill a batch of unbilled credits as one invoice. All credits must belong to the same shipping line.", "POST", "Shipping Line Credit"],
|
||||||
|
"POST /api/shipping-line-credits/:creditId/cancel": ["Write off an unbilled credit. Once billed, cancel the invoice instead.", "POST", "Shipping Line Credit"],
|
||||||
|
"POST /api/shipping-line-credits/invoices/:invoiceId/mark-paid-request": ["Request recording a full offline payment against a credit invoice (awaits chief approval).", "POST", "Shipping Line Credit"],
|
||||||
|
"POST /api/shipping-line-credits/invoices/:invoiceId/cancel-request": ["Request voiding a credit invoice — its credits return to the unbilled pool (awaits chief approval).", "POST", "Shipping Line Credit"],
|
||||||
|
"POST /api/shipping-line-credits/invoice-actions/:approvalId/approve": ["Approve a pending invoice request — executes the offline settlement or the cancellation.", "POST", "Shipping Line Credit"],
|
||||||
|
"POST /api/shipping-line-credits/invoice-actions/:approvalId/reject": ["Reject a pending invoice request — nothing is changed.", "POST", "Shipping Line Credit"],
|
||||||
|
|
||||||
// Shipping Line Company (carrier with a portal login, registered by staff)
|
// Shipping Line Company (carrier with a portal login, registered by staff)
|
||||||
"POST /api/shipping-line-companies": ["Register a shipping line company and send its activation link", "POST", "Shipping Line Company"],
|
"POST /api/shipping-line-companies": ["Register a shipping line company and send its activation link", "POST", "Shipping Line Company"],
|
||||||
"POST /api/shipping-line-companies/:id/resend-activation": ["Resend a shipping line company's activation link", "POST", "Shipping Line Company"],
|
"POST /api/shipping-line-companies/:id/resend-activation": ["Resend a shipping line company's activation link", "POST", "Shipping Line Company"],
|
||||||
@@ -445,6 +471,10 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
// Signature
|
// Signature
|
||||||
"PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"],
|
"PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"],
|
||||||
|
|
||||||
|
// Stamp Setting
|
||||||
|
"PUT /api/stamp-settings": ["Replace the company stamp", "PUT", "Stamp Setting"],
|
||||||
|
"DELETE /api/stamp-settings": ["Clear the company stamp (invoices fall back to the plain seal)", "DELETE", "Stamp Setting"],
|
||||||
|
|
||||||
// Support Chat
|
// Support Chat
|
||||||
"POST /api/support/agent/conversations": ["Start chatting with a company (returns the thread if one exists)", "POST", "Support Chat"],
|
"POST /api/support/agent/conversations": ["Start chatting with a company (returns the thread if one exists)", "POST", "Support Chat"],
|
||||||
"POST /api/support/agent/conversations/:id/messages": ["Reply as an agent, optionally with attachments", "POST", "Support Chat"],
|
"POST /api/support/agent/conversations/:id/messages": ["Reply as an agent, optionally with attachments", "POST", "Support Chat"],
|
||||||
@@ -515,9 +545,6 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"POST /api/train-scheduling/schedules/:id/intercity/:bookingId/unload": ["Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/schedules/:id/intercity/:bookingId/unload": ["Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", "POST", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/schedules/:id/intercity/accept": ["Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/schedules/:id/intercity/accept": ["Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)", "POST", "Train Schedule"],
|
||||||
"PATCH /api/train-scheduling/schedules/:id/loading-status": ["Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)", "PATCH", "Train Schedule"],
|
"PATCH /api/train-scheduling/schedules/:id/loading-status": ["Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)", "PATCH", "Train Schedule"],
|
||||||
// NOTE: duplicate route — also declared in modules/train-scheduling/controllers/train-scheduling.controller.ts:798.
|
|
||||||
// Two controllers register this same path; Nest serves whichever module loads first.
|
|
||||||
"POST /api/train-scheduling/schedules/:id/maintenance [modules/train-scheduling/controllers/train-scheduling.controller.ts]": ["Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged", "POST", "Train Schedule"],
|
|
||||||
"POST /api/train-scheduling/schedules/:id/pin-wagons": ["Pin physical wagons to train set slots", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/schedules/:id/pin-wagons": ["Pin physical wagons to train set slots", "POST", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/schedules/:id/run-allocation": ["Run wagon-level allocation for all eligible linked bookings", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/schedules/:id/run-allocation": ["Run wagon-level allocation for all eligible linked bookings", "POST", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/schedules/:id/run-batch": ["Manually run the batch fill for a schedule", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/schedules/:id/run-batch": ["Manually run the batch fill for a schedule", "POST", "Train Schedule"],
|
||||||
@@ -527,6 +554,8 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"],
|
"DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"],
|
||||||
"POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"],
|
"POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"],
|
||||||
"PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"],
|
"PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"],
|
||||||
|
"POST /api/train-scheduling/schedules/:id/merge": ["Merge another train into this schedule: its wagons join this consist, a same-day schedule on it is absorbed, and the emptied train is deactivated", "POST", "Train Schedule"],
|
||||||
|
"PATCH /api/train-scheduling/schedules/:id/checkpoints/:sequenceNo": ["Edit a logged leg", "PATCH", "Train Schedule"],
|
||||||
|
|
||||||
// Transit Agent
|
// Transit Agent
|
||||||
"POST /api/transit-agents": ["Create a transit agent", "POST", "Transit Agent"],
|
"POST /api/transit-agents": ["Create a transit agent", "POST", "Transit Agent"],
|
||||||
@@ -633,6 +662,11 @@ export const AUDIT_ENDPOINTS: Readonly<Record<string, AuditEndpointMeta>> = {
|
|||||||
"DELETE /api/weight-limit-rules/:id": ["Soft-delete a weight limit rule", "DELETE", "Weight Limit Rule"],
|
"DELETE /api/weight-limit-rules/:id": ["Soft-delete a weight limit rule", "DELETE", "Weight Limit Rule"],
|
||||||
|
|
||||||
// Yard
|
// Yard
|
||||||
|
// Yard Position (desk↔yard mapping — an input to yard access scoping, so
|
||||||
|
// every change to it is evidence of who widened or narrowed someone's reach)
|
||||||
|
"PUT /api/yard-positions/yard/:yardId": ["Replace a yard's whole position set", "PUT", "Yard Position"],
|
||||||
|
"PUT /api/yard-positions/position/:positionId": ["Replace a position's whole yard set", "PUT", "Yard Position"],
|
||||||
|
|
||||||
"POST /api/yards": ["Create a yard", "POST", "Yard"],
|
"POST /api/yards": ["Create a yard", "POST", "Yard"],
|
||||||
"PATCH /api/yards/:id": ["Update a yard", "PATCH", "Yard"],
|
"PATCH /api/yards/:id": ["Update a yard", "PATCH", "Yard"],
|
||||||
"DELETE /api/yards/:id": ["Soft-delete a yard", "DELETE", "Yard"],
|
"DELETE /api/yards/:id": ["Soft-delete a yard", "DELETE", "Yard"],
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ describe("BillingService.generateInvoice", () => {
|
|||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
{ get: () => undefined } as never, // config
|
{ get: () => undefined } as never, // config
|
||||||
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,6 +164,7 @@ describe("BillingService.issueMemo", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{ get: () => undefined } as never,
|
{ get: () => undefined } as never,
|
||||||
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||||
);
|
);
|
||||||
return { service, manager, savedLines };
|
return { service, manager, savedLines };
|
||||||
}
|
}
|
||||||
@@ -297,6 +299,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
{ get: () => undefined } as never, // config
|
{ get: () => undefined } as never, // config
|
||||||
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -352,6 +355,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
{ get: () => undefined } as never, // config
|
{ get: () => undefined } as never, // config
|
||||||
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -397,6 +401,7 @@ describe("BillingService.settleByPaymentId", () => {
|
|||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
{ get: () => undefined } as never, // config
|
{ get: () => undefined } as never, // config
|
||||||
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||||
);
|
);
|
||||||
return { service, mg, events };
|
return { service, mg, events };
|
||||||
}
|
}
|
||||||
@@ -510,6 +515,7 @@ describe("BillingService.recordPayment", () => {
|
|||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
{} as never, // files
|
{} as never, // files
|
||||||
{ get: () => undefined } as never, // config
|
{ get: () => undefined } as never, // config
|
||||||
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||||
);
|
);
|
||||||
return { service, mg, events };
|
return { service, mg, events };
|
||||||
}
|
}
|
||||||
@@ -627,6 +633,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never, // config
|
{} as never, // config
|
||||||
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||||
);
|
);
|
||||||
return { service, defaultManager, txManager, transaction };
|
return { service, defaultManager, txManager, transaction };
|
||||||
};
|
};
|
||||||
@@ -700,6 +707,7 @@ describe("BillingService.issuePayable", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never, // config
|
{} as never, // config
|
||||||
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||||
);
|
);
|
||||||
return { service, manager };
|
return { service, manager };
|
||||||
};
|
};
|
||||||
@@ -791,6 +799,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never, // config
|
{} as never, // config
|
||||||
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||||
);
|
);
|
||||||
return { service, repo };
|
return { service, repo };
|
||||||
};
|
};
|
||||||
@@ -874,6 +883,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () =>
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never, // config
|
{} as never, // config
|
||||||
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||||
);
|
);
|
||||||
return { service, repo };
|
return { service, repo };
|
||||||
};
|
};
|
||||||
@@ -943,6 +953,7 @@ describe("BillingService.document", () => {
|
|||||||
? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
|
? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
|
||||||
: undefined,
|
: undefined,
|
||||||
} as never, // config
|
} as never, // config
|
||||||
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
||||||
);
|
);
|
||||||
return { service, render, renderThermal };
|
return { service, render, renderThermal };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Booking } from "../bookings/entities/booking.entity";
|
|||||||
// payers straight off the table.
|
// payers straight off the table.
|
||||||
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
|
||||||
import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity";
|
import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity";
|
||||||
|
import { ManualPaymentSettingsService } from "../payment-settings/manual-payment-settings.service";
|
||||||
import { EimsConfig } from "../../config/eims.config";
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
import { CompaniesService } from "../companies/companies.service";
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
|
import { EimsInvoiceStatus } from "../eims/eims-registration.types";
|
||||||
@@ -201,6 +202,7 @@ export class BillingService {
|
|||||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||||
private readonly files: FilesService,
|
private readonly files: FilesService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
|
private readonly manualPaymentSettings: ManualPaymentSettingsService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||||
@@ -370,20 +372,24 @@ export class BillingService {
|
|||||||
const pageSize =
|
const pageSize =
|
||||||
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||||
|
|
||||||
|
// Only currencies whose manual-payment channel is switched on are listed:
|
||||||
|
// a row Finance cannot act on is noise, and the confirm endpoint would
|
||||||
|
// refuse it anyway. All off → nothing to work.
|
||||||
|
const enabled = await this.manualPaymentSettings.enabledCurrencies();
|
||||||
|
if (!enabled.length) return { items: [], total: 0 };
|
||||||
|
const currencies = filter.currency
|
||||||
|
? enabled.filter((c) => c === filter.currency)
|
||||||
|
: enabled;
|
||||||
|
if (!currencies.length) return { items: [], total: 0 };
|
||||||
|
|
||||||
const qb = this.dataSource
|
const qb = this.dataSource
|
||||||
.getRepository(Invoice)
|
.getRepository(Invoice)
|
||||||
.createQueryBuilder("invoice")
|
.createQueryBuilder("invoice")
|
||||||
.leftJoinAndSelect("invoice.company", "company")
|
.leftJoinAndSelect("invoice.company", "company")
|
||||||
.where("UPPER(invoice.currency) IN ('USD', 'ETB')")
|
.where("UPPER(invoice.currency) IN (:...currencies)", { currencies })
|
||||||
.orderBy("invoice.issuedAt", "DESC")
|
.orderBy("invoice.issuedAt", "DESC")
|
||||||
.skip((page - 1) * pageSize)
|
.skip((page - 1) * pageSize)
|
||||||
.take(pageSize);
|
.take(pageSize);
|
||||||
|
|
||||||
if (filter.currency) {
|
|
||||||
qb.andWhere("UPPER(invoice.currency) = :currency", {
|
|
||||||
currency: filter.currency,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (filter.status) {
|
if (filter.status) {
|
||||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||||
} else {
|
} else {
|
||||||
@@ -465,7 +471,8 @@ export class BillingService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Finance confirms an invoice (USD or ETB) as paid manually — bank transfer
|
* Finance confirms an invoice (USD or ETB) as paid manually — bank transfer
|
||||||
* or counter payment: stores the slip against the invoice and settles the
|
* or counter payment. Refused when that currency's manual-payment channel is
|
||||||
|
* switched off in settings. Stores the slip against the invoice and settles the
|
||||||
* FULL outstanding balance through
|
* FULL outstanding balance through
|
||||||
* {@link recordPayment}, which flips the invoice to PAID and (for bookings)
|
* {@link recordPayment}, which flips the invoice to PAID and (for bookings)
|
||||||
* emits `booking.invoice.paid` — the same event an online payment fires, so
|
* emits `booking.invoice.paid` — the same event an online payment fires, so
|
||||||
@@ -485,6 +492,13 @@ export class BillingService {
|
|||||||
): Promise<Invoice> {
|
): Promise<Invoice> {
|
||||||
const invoice = await this.invoices.findById(invoiceId);
|
const invoice = await this.invoices.findById(invoiceId);
|
||||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||||
|
// The channel is a setting, not a role: even a permitted user cannot
|
||||||
|
// settle by hand in a currency whose channel is switched off.
|
||||||
|
if (!(await this.manualPaymentSettings.isEnabled(invoice.currency))) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Manual payments first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!file) {
|
if (!file) {
|
||||||
throw new BadRequestException("The bank payment slip file is required.");
|
throw new BadRequestException("The bank payment slip file is required.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,13 @@ export class PdfRenderService {
|
|||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
const thermal = opts.thermal ?? false;
|
const thermal = opts.thermal ?? false;
|
||||||
const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794;
|
const viewportWidth = thermal ? Math.round((THERMAL_PAGE_WIDTH_MM / 25.4) * 96) : 794;
|
||||||
await page.setViewport({ width: viewportWidth, height: 1123, deviceScaleFactor: 1 });
|
// Thermal viewport height is deliberately tiny (not a real page height at all): scrollHeight
|
||||||
|
// is defined as the LARGER of the content's height and the viewport's own height, so a
|
||||||
|
// receipt shorter than the viewport would otherwise report the viewport height back, not
|
||||||
|
// its true content height — a real page-length trailing blank space bug, not theoretical
|
||||||
|
// (confirmed by actually rendering one). A short viewport forces content to overflow it,
|
||||||
|
// so scrollHeight always reflects the content, never the viewport.
|
||||||
|
await page.setViewport({ width: viewportWidth, height: thermal ? 100 : 1123, deviceScaleFactor: 1 });
|
||||||
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
|
await page.setContent(preparedHtml, { waitUntil: "load", timeout: 60_000 });
|
||||||
await page.emulateMediaType("print");
|
await page.emulateMediaType("print");
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
@@ -154,7 +160,7 @@ export class PdfRenderService {
|
|||||||
// browser context regardless, same as the closure form would be.
|
// browser context regardless, same as the closure form would be.
|
||||||
const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number;
|
const scrollPx = (await page.evaluate("document.documentElement.scrollHeight")) as number;
|
||||||
const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM;
|
const contentMm = (scrollPx / 96) * 25.4 + THERMAL_MARGIN_MM * 2 + THERMAL_FEED_MM;
|
||||||
return Math.min(THERMAL_MAX_HEIGHT_MM, contentMm);
|
return Math.min(THERMAL_MAX_HEIGHT_MM, Math.round(contentMm * 100) / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
private injectPdfPrintStyles(html: string): string {
|
private injectPdfPrintStyles(html: string): string {
|
||||||
|
|||||||
@@ -60,8 +60,11 @@ const context = (over: Partial<EimsMapperContext> = {}): EimsMapperContext => ({
|
|||||||
unitDefault: "PCS",
|
unitDefault: "PCS",
|
||||||
incomeWithholdValue: 0,
|
incomeWithholdValue: 0,
|
||||||
transactionWithholdValue: 0,
|
transactionWithholdValue: 0,
|
||||||
|
buyerCountryCode: "231", // test-only, not a confirmed real MoR code
|
||||||
|
buyerCountryCodes: {},
|
||||||
buyerRegionCodes: { "Addis Ababa": "13" },
|
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||||
buyerWeredaCodes: {},
|
buyerWeredaCodes: {},
|
||||||
|
buyerCityCodes: {},
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,6 +95,9 @@ describe("toEimsInvoice", () => {
|
|||||||
|
|
||||||
expect(doc.BuyerDetails).toEqual({
|
expect(doc.BuyerDetails).toEqual({
|
||||||
City: null,
|
City: null,
|
||||||
|
// company.country is "Ethiopia" (the domestic default) — resolves to context's flat
|
||||||
|
// buyerCountryCode fallback, not null, per resolveCountryCode.
|
||||||
|
Country: "231",
|
||||||
Email: "buyer@abc.et",
|
Email: "buyer@abc.et",
|
||||||
HouseNumber: "NEW",
|
HouseNumber: "NEW",
|
||||||
IdNumber: null,
|
IdNumber: null,
|
||||||
@@ -100,7 +106,6 @@ describe("toEimsInvoice", () => {
|
|||||||
LegalName: "ABC Trading PLC",
|
LegalName: "ABC Trading PLC",
|
||||||
Phone: "0912345678",
|
Phone: "0912345678",
|
||||||
Region: "13",
|
Region: "13",
|
||||||
Country: null,
|
|
||||||
Zone: "SHA",
|
Zone: "SHA",
|
||||||
Kebele: "03",
|
Kebele: "03",
|
||||||
VatNumber: "123475885858",
|
VatNumber: "123475885858",
|
||||||
@@ -146,7 +151,9 @@ describe("toEimsInvoice", () => {
|
|||||||
// EimsLineTax.discount comment in eims-invoice.mapper.ts.
|
// EimsLineTax.discount comment in eims-invoice.mapper.ts.
|
||||||
Discount: 25,
|
Discount: 25,
|
||||||
TotalLineAmount: 1050,
|
TotalLineAmount: 1050,
|
||||||
Unit: "CTR",
|
// Not "CTR" from the line's metadata.unit — that's our internal fee-basis tag, not a MoR
|
||||||
|
// unit of measure, and is never read for this field (see the mapper's own comment).
|
||||||
|
Unit: "PCS",
|
||||||
});
|
});
|
||||||
expect(doc.ValueDetails).toEqual({
|
expect(doc.ValueDetails).toEqual({
|
||||||
Discount: null,
|
Discount: null,
|
||||||
@@ -335,6 +342,52 @@ describe("toEimsInvoice — MoR field constraints", () => {
|
|||||||
).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/);
|
).toThrow(/buyer Wereda "Yeka".*EIMS_BUYER_WEREDA_CODES/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("derives City from the buyer's zone via the city code map", () => {
|
||||||
|
const doc = toEimsInvoice(
|
||||||
|
invoice({ company: { ...invoice().company!, zone: "Kirkos" } }),
|
||||||
|
seller,
|
||||||
|
context({ buyerCityCodes: { Kirkos: "101" } }),
|
||||||
|
);
|
||||||
|
expect(doc.BuyerDetails.City).toBe("101");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves City null (not a throw) when the buyer's zone has no city mapping — City is optional", () => {
|
||||||
|
const doc = toEimsInvoice(
|
||||||
|
invoice({ company: { ...invoice().company!, zone: "Somewhere Else" } }),
|
||||||
|
seller,
|
||||||
|
context({ buyerCityCodes: {} }),
|
||||||
|
);
|
||||||
|
expect(doc.BuyerDetails.City).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a buyer country name to its code via the country code map", () => {
|
||||||
|
const doc = toEimsInvoice(
|
||||||
|
invoice({ company: { ...invoice().company!, country: "Djibouti" } }),
|
||||||
|
seller,
|
||||||
|
context({ buyerCountryCodes: { Djibouti: "071" } }),
|
||||||
|
);
|
||||||
|
expect(doc.BuyerDetails.Country).toBe("071");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the flat domestic country code only for Ethiopia, not any unmapped country", () => {
|
||||||
|
const doc = toEimsInvoice(
|
||||||
|
invoice({ company: { ...invoice().company!, country: "Ethiopia" } }),
|
||||||
|
seller,
|
||||||
|
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
|
||||||
|
);
|
||||||
|
expect(doc.BuyerDetails.Country).toBe("231");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a genuinely foreign buyer country with no mapping — never silently files it as Ethiopia", () => {
|
||||||
|
expect(() =>
|
||||||
|
toEimsInvoice(
|
||||||
|
invoice({ company: { ...invoice().company!, country: "Kenya" } }),
|
||||||
|
seller,
|
||||||
|
context({ buyerCountryCode: "231", buyerCountryCodes: {} }),
|
||||||
|
),
|
||||||
|
).toThrow(/buyer Country "Kenya".*EIMS_BUYER_COUNTRY_CODES/);
|
||||||
|
});
|
||||||
|
|
||||||
it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => {
|
it("emits NatureOfSupplies lowercase, whatever case it was configured in", () => {
|
||||||
const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" }));
|
const doc = toEimsInvoice(invoice(), seller, context({ natureOfSupplies: "Service" }));
|
||||||
expect(doc.ItemList[0].NatureOfSupplies).toBe("service");
|
expect(doc.ItemList[0].NatureOfSupplies).toBe("service");
|
||||||
|
|||||||
@@ -234,8 +234,13 @@ export interface EimsMapperContext {
|
|||||||
* from a registered invoice").
|
* from a registered invoice").
|
||||||
*/
|
*/
|
||||||
relatedDocument?: string | null;
|
relatedDocument?: string | null;
|
||||||
/** MoR numeric country code for the buyer; our DB stores the country name. */
|
/**
|
||||||
|
* Domestic fallback only, applied when `company.country` is empty or "Ethiopia" and not already
|
||||||
|
* in `buyerCountryCodes` — see that field. Never applied to a genuinely foreign buyer.
|
||||||
|
*/
|
||||||
buyerCountryCode?: string | null;
|
buyerCountryCode?: string | null;
|
||||||
|
/** Country name → MoR code. Format unconfirmed, so looked up by name only, not digit-validated. */
|
||||||
|
buyerCountryCodes: Record<string, string>;
|
||||||
/**
|
/**
|
||||||
* Region name → MoR numeric code, for buyers whose stored region is free text.
|
* Region name → MoR numeric code, for buyers whose stored region is free text.
|
||||||
*
|
*
|
||||||
@@ -252,9 +257,15 @@ export interface EimsMapperContext {
|
|||||||
* fail locally on an unmapped name rather than file a guess.
|
* fail locally on an unmapped name rather than file a guess.
|
||||||
*/
|
*/
|
||||||
buyerWeredaCodes: Record<string, string>;
|
buyerWeredaCodes: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* Buyer *zone* name → MoR City code. `Company` has no dedicated city column; Zone is the
|
||||||
|
* closest match in EDR's own data. Unlike Region/Wereda, City is optional — MoR has already
|
||||||
|
* accepted a live filing with it null — so an unmapped zone resolves to null, it does not fail
|
||||||
|
* the mapping.
|
||||||
|
*/
|
||||||
|
buyerCityCodes: Record<string, string>;
|
||||||
buyerIdType?: string | null;
|
buyerIdType?: string | null;
|
||||||
buyerIdNumber?: string | null;
|
buyerIdNumber?: string | null;
|
||||||
buyerCity?: string | null;
|
|
||||||
/** Required when the invoice currency is not ETB. */
|
/** Required when the invoice currency is not ETB. */
|
||||||
exchangeRate?: number | null;
|
exchangeRate?: number | null;
|
||||||
invoiceDiscount?: number | null;
|
invoiceDiscount?: number | null;
|
||||||
@@ -299,17 +310,22 @@ export const formatEimsDate = (issuedAt: Date): string =>
|
|||||||
* exchange rate.
|
* exchange rate.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* A buyer's location value (Region or Wereda) as a MoR code: passed through when already numeric,
|
* A buyer's location value (Region, Wereda or City) as a MoR code: passed through when already
|
||||||
* otherwise looked up by name (case- and space-insensitive). Throws when neither applies — sending
|
* numeric, otherwise looked up by name (case- and space-insensitive).
|
||||||
* a guessed code onto a tax document is worse than refusing to file.
|
*
|
||||||
|
* Region/Wereda are required: an unmapped value throws — sending a guessed code onto a tax
|
||||||
|
* document is worse than refusing to file. City is optional (`required: false`, City's own
|
||||||
|
* caller) — MoR has already accepted a live filing with it null, so an unmapped zone resolves to
|
||||||
|
* null instead of blocking the invoice.
|
||||||
*/
|
*/
|
||||||
function resolveLocationCode(
|
function resolveLocationCode(
|
||||||
field: "Region" | "Wereda",
|
field: "Region" | "Wereda" | "City",
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
codes: Record<string, string>,
|
codes: Record<string, string>,
|
||||||
envVar: string,
|
envVar: string,
|
||||||
invoiceNumber: string,
|
invoiceNumber: string,
|
||||||
): string {
|
opts: { required?: boolean } = {},
|
||||||
|
): string | null {
|
||||||
const raw = (value ?? "").trim();
|
const raw = (value ?? "").trim();
|
||||||
if (LOCATION_CODE.test(raw)) return raw;
|
if (LOCATION_CODE.test(raw)) return raw;
|
||||||
|
|
||||||
@@ -319,12 +335,61 @@ function resolveLocationCode(
|
|||||||
)?.[1];
|
)?.[1];
|
||||||
if (mapped && LOCATION_CODE.test(mapped)) return mapped;
|
if (mapped && LOCATION_CODE.test(mapped)) return mapped;
|
||||||
|
|
||||||
|
if (opts.required === false) return null;
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` +
|
`EIMS mapping: invoice ${invoiceNumber} has buyer ${field} ${raw ? `"${raw}"` : "(unset)"}, ` +
|
||||||
`which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`,
|
`which is not a MoR ${field} code and has no mapping. Add it to ${envVar}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A buyer's `Country` as a MoR code: looked up by name in `codes` first; when unmapped, applies
|
||||||
|
* `domesticFallback` only if the stored country is empty or "Ethiopia" (the DB column's default).
|
||||||
|
* A genuinely foreign, unmapped country throws rather than silently filing as Ethiopia — same
|
||||||
|
* "fail locally, don't guess" rule as `resolveLocationCode`, but never digit-validated: MoR's
|
||||||
|
* Country code format is unconfirmed, unlike Region/Wereda's proven `^[0-9]{1,3}$`.
|
||||||
|
*/
|
||||||
|
function resolveCountryCode(
|
||||||
|
country: string | null | undefined,
|
||||||
|
codes: Record<string, string>,
|
||||||
|
domesticFallback: string | null,
|
||||||
|
invoiceNumber: string,
|
||||||
|
): string | null {
|
||||||
|
const raw = (country ?? "").trim();
|
||||||
|
const key = raw.toLowerCase().replace(/\s+/g, " ");
|
||||||
|
const mapped = Object.entries(codes).find(
|
||||||
|
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
|
||||||
|
)?.[1];
|
||||||
|
if (mapped) return mapped;
|
||||||
|
|
||||||
|
if ((!raw || key === "ethiopia") && domesticFallback) return domesticFallback;
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`EIMS mapping: invoice ${invoiceNumber} has buyer Country "${raw || "(unset)"}", which has no ` +
|
||||||
|
"MoR country code mapping. Add it to EIMS_BUYER_COUNTRY_CODES.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same name-or-code resolution as `resolveLocationCode`, for a caller with no invoice to attach an
|
||||||
|
* error to and that must never throw — currently only `EimsSellerCacheService`, resolving
|
||||||
|
* e-Trade's region/zone/woreda *names* for EDR's own seller identity. Pass-through numeric code,
|
||||||
|
* name lookup, `undefined` on no match — the caller falls back to static config either way.
|
||||||
|
*/
|
||||||
|
export function resolveOptionalCode(
|
||||||
|
value: string | null | undefined,
|
||||||
|
codes: Record<string, string>,
|
||||||
|
): string | undefined {
|
||||||
|
const raw = (value ?? "").trim();
|
||||||
|
if (LOCATION_CODE.test(raw)) return raw;
|
||||||
|
const key = raw.toLowerCase().replace(/\s+/g, " ");
|
||||||
|
const mapped = Object.entries(codes).find(
|
||||||
|
([name]) => name.trim().toLowerCase().replace(/\s+/g, " ") === key,
|
||||||
|
)?.[1];
|
||||||
|
return mapped && LOCATION_CODE.test(mapped) ? mapped : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function toEimsInvoice(
|
export function toEimsInvoice(
|
||||||
invoice: EimsMapperInvoice,
|
invoice: EimsMapperInvoice,
|
||||||
seller: EimsSellerDetails,
|
seller: EimsSellerDetails,
|
||||||
@@ -398,7 +463,13 @@ export function toEimsInvoice(
|
|||||||
const PreTaxValue = round2(num(line.amount));
|
const PreTaxValue = round2(num(line.amount));
|
||||||
const TaxAmount = round2((PreTaxValue * tax.ratePercent) / 100);
|
const TaxAmount = round2((PreTaxValue * tax.ratePercent) / 100);
|
||||||
const ExciseTaxValue = round2(tax.exciseTaxValue);
|
const ExciseTaxValue = round2(tax.exciseTaxValue);
|
||||||
const unit = typeof line.metadata?.unit === "string" ? line.metadata.unit : context.unitDefault;
|
// `line.metadata.unit` is our own fee-basis tag (PER_CONTAINER/PER_TON/PER_ITEM — how a charge
|
||||||
|
// is computed, see the fee-rule docs), never a MoR unit of measure — sending it as-is here
|
||||||
|
// (confirmed live 2026-08-17: "PER_CONTAINER" fails Unit's enum, its 8-char max, and its regex
|
||||||
|
// all at once) is what a prior version of this mapper did by mistake. MoR's own enum
|
||||||
|
// (LTR/MTR/101/PCS/ROL/MTS/PKG/SET/KLG) has no freight-shipment concept at all, so every line
|
||||||
|
// uses the single configured default rather than guessing a per-line value that doesn't exist.
|
||||||
|
const unit = context.unitDefault;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Discount: round2(tax.discount),
|
Discount: round2(tax.discount),
|
||||||
@@ -440,7 +511,16 @@ export function toEimsInvoice(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
BuyerDetails: {
|
BuyerDetails: {
|
||||||
City: context.buyerCity ?? null,
|
// No dedicated city column on Company — Zone is the closest match; optional (see
|
||||||
|
// resolveLocationCode's City comment).
|
||||||
|
City: resolveLocationCode(
|
||||||
|
"City",
|
||||||
|
company.zone,
|
||||||
|
context.buyerCityCodes,
|
||||||
|
"EIMS_BUYER_CITY_CODES",
|
||||||
|
invoice.invoiceNumber,
|
||||||
|
{ required: false },
|
||||||
|
),
|
||||||
Email: company.email ?? null,
|
Email: company.email ?? null,
|
||||||
HouseNumber: company.houseNo ?? null,
|
HouseNumber: company.houseNo ?? null,
|
||||||
IdNumber: context.buyerIdNumber ?? null,
|
IdNumber: context.buyerIdNumber ?? null,
|
||||||
@@ -455,7 +535,12 @@ export function toEimsInvoice(
|
|||||||
"EIMS_BUYER_REGION_CODES",
|
"EIMS_BUYER_REGION_CODES",
|
||||||
invoice.invoiceNumber,
|
invoice.invoiceNumber,
|
||||||
),
|
),
|
||||||
Country: context.buyerCountryCode ?? null,
|
Country: resolveCountryCode(
|
||||||
|
company.country,
|
||||||
|
context.buyerCountryCodes,
|
||||||
|
context.buyerCountryCode ?? null,
|
||||||
|
invoice.invoiceNumber,
|
||||||
|
),
|
||||||
Zone: company.zone ?? null,
|
Zone: company.zone ?? null,
|
||||||
Kebele: company.kebele ?? null,
|
Kebele: company.kebele ?? null,
|
||||||
VatNumber: company.vatNumber ?? null,
|
VatNumber: company.vatNumber ?? null,
|
||||||
|
|||||||
@@ -36,9 +36,13 @@ describe('BookingLifecycleNotifierService — operation changes requested', () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends a GL-created booking back to the GL who created it, not the customer', async () => {
|
it('sends a GL-created customs booking back to the GL who created it, not the customer', async () => {
|
||||||
service.operationChangesRequested(
|
service.operationChangesRequested(
|
||||||
booking({ createdByRole: 'GL_ET', createdByUserId: 'gl-user-1' }),
|
booking({
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
createdByRole: 'GL_ET',
|
||||||
|
createdByUserId: 'gl-user-1',
|
||||||
|
}),
|
||||||
'Cargo weight does not match the declaration',
|
'Cargo weight does not match the declaration',
|
||||||
);
|
);
|
||||||
await flush();
|
await flush();
|
||||||
@@ -54,7 +58,7 @@ describe('BookingLifecycleNotifierService — operation changes requested', () =
|
|||||||
expect(notifications.directSend).not.toHaveBeenCalled();
|
expect(notifications.directSend).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still tells the customer when the booking is their own', async () => {
|
it('still tells the customer when the booking is a non-customs self-service booking', async () => {
|
||||||
service.operationChangesRequested(booking(), 'Please attach the packing list');
|
service.operationChangesRequested(booking(), 'Please attach the packing list');
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
@@ -65,14 +69,38 @@ describe('BookingLifecycleNotifierService — operation changes requested', () =
|
|||||||
expect(notifications.directSend).toHaveBeenCalled();
|
expect(notifications.directSend).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the customer when the GL creator is unknown (legacy rows)', async () => {
|
it('routes a customer-opened customs booking to the clearance desk, not the customer', async () => {
|
||||||
|
// Path B lets the customer open the ONE_TIME shipment instance themselves
|
||||||
|
// (contract-booking.service assertGate's customerMayInitiate) — createdByRole
|
||||||
|
// stays 'CUSTOMER', but GL still owns completing/resubmitting it.
|
||||||
service.operationChangesRequested(
|
service.operationChangesRequested(
|
||||||
booking({ createdByRole: 'GL_ET', createdByUserId: null }),
|
booking({ customsClearingEnabled: true, createdByRole: 'CUSTOMER' }),
|
||||||
'Fix the declaration',
|
'Fix the declaration',
|
||||||
);
|
);
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' });
|
const sent = inbox.notify.mock.calls[0][0];
|
||||||
|
expect(sent.recipients).toEqual({
|
||||||
|
permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification],
|
||||||
|
});
|
||||||
|
expect(sent.audience).toBe('BACKOFFICE');
|
||||||
|
expect(notifications.directSend).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the clearance desk when the GL creator is unknown (legacy rows)', async () => {
|
||||||
|
service.operationChangesRequested(
|
||||||
|
booking({
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
createdByRole: 'GL_ET',
|
||||||
|
createdByUserId: null,
|
||||||
|
}),
|
||||||
|
'Fix the declaration',
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({
|
||||||
|
permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -247,20 +247,27 @@ export class BookingLifecycleNotifierService {
|
|||||||
/**
|
/**
|
||||||
* Operations returned the operation request for changes.
|
* Operations returned the operation request for changes.
|
||||||
*
|
*
|
||||||
* A customs (Path B) booking was created BY GL Ethiopia on the customer's
|
* A customs (Path B) booking is completed by GL Ethiopia on the customer's
|
||||||
* behalf — the customer cannot edit or resubmit it, so telling them to "update
|
* behalf regardless of who opened the shipment instance — the customer-opened
|
||||||
* from the portal" is a dead end. Those go to the GL who created it, linking
|
* ONE_TIME case (see contract-booking.service assertGate) still stamps
|
||||||
* the contract clearance page they work from. Everything else (customer-made
|
* createdByRole 'CUSTOMER', so gate on customsClearingEnabled, not on who
|
||||||
* bookings) keeps the portal message.
|
* created it. The customer cannot edit or resubmit a customs booking, so
|
||||||
|
* telling them to "update from the portal" is a dead end. Those go to the GL
|
||||||
|
* who created it when known, else the clearance desk, linking the contract
|
||||||
|
* clearance page they work from. Everything else (customer-made bookings)
|
||||||
|
* keeps the portal message.
|
||||||
*/
|
*/
|
||||||
operationChangesRequested(b: Booking, note: string): void {
|
operationChangesRequested(b: Booking, note: string): void {
|
||||||
if (b.createdByRole === 'GL_ET' && b.createdByUserId) {
|
if (b.customsClearingEnabled) {
|
||||||
const msg =
|
const msg =
|
||||||
`Operations returned booking ${b.reference} for changes: ${note}. ` +
|
`Operations returned booking ${b.reference} for changes: ${note}. ` +
|
||||||
`Address it on the contract clearance page and resubmit to Operations.`;
|
`Address it on the contract clearance page and resubmit to Operations.`;
|
||||||
this.logger.log(`OPERATION CHANGES REQUESTED (to GL) — ${this.ref(b)}`);
|
this.logger.log(`OPERATION CHANGES REQUESTED (to GL) — ${this.ref(b)}`);
|
||||||
void this.inbox.notify({
|
void this.inbox.notify({
|
||||||
recipients: { userIds: [b.createdByUserId] },
|
recipients:
|
||||||
|
b.createdByRole === 'GL_ET' && b.createdByUserId
|
||||||
|
? { userIds: [b.createdByUserId] }
|
||||||
|
: CLEARANCE_DESK,
|
||||||
audience: NotificationAudience.BACKOFFICE,
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
type: NotificationType.BOOKING_STATUS,
|
type: NotificationType.BOOKING_STATUS,
|
||||||
title: `Booking ${b.reference} needs changes`,
|
title: `Booking ${b.reference} needs changes`,
|
||||||
@@ -463,6 +470,36 @@ export class BookingLifecycleNotifierService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A shared-wagon pairing is waiting for a human decision. Two customers' cargo
|
||||||
|
* on one wagon is a commercial call, so this never auto-advances.
|
||||||
|
*/
|
||||||
|
consolidationApprovalRequestedToStaff(b: Booking, partnerReference: string): void {
|
||||||
|
this.inAppStaff(
|
||||||
|
b,
|
||||||
|
'Shared wagon needs approval',
|
||||||
|
`Booking ${this.ref(b)} shares a wagon with ${partnerReference} — approve the consolidation before it reaches Operations.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pairing was approved; both halves move on to Operations together. */
|
||||||
|
consolidationApprovedToStaff(b: Booking, partnerReference: string): void {
|
||||||
|
this.inAppStaff(
|
||||||
|
b,
|
||||||
|
'Shared wagon approved',
|
||||||
|
`The shared wagon for ${this.ref(b)} and ${partnerReference} was approved — both bookings are now with Operations.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pairing was rejected; both halves go back to GL for changes. */
|
||||||
|
consolidationRejectedToStaff(b: Booking, partnerReference: string, reason: string): void {
|
||||||
|
this.inAppStaff(
|
||||||
|
b,
|
||||||
|
'Shared wagon rejected',
|
||||||
|
`The shared wagon for ${this.ref(b)} and ${partnerReference} was rejected: ${reason}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Customer uploaded clearance documents — review is next. */
|
/** Customer uploaded clearance documents — review is next. */
|
||||||
clearanceDocsUploadedToStaff(b: Booking): void {
|
clearanceDocsUploadedToStaff(b: Booking): void {
|
||||||
this.inAppStaff(
|
this.inAppStaff(
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { BookingTransitionService } from './booking-transition.service';
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff decisions on a consolidated pair. Two bookings sharing a wagon must move
|
||||||
|
* together: accepting one alone would put half a wagon into the approval chain,
|
||||||
|
* and cancelling one alone would strand the other on a wagon it can no longer
|
||||||
|
* fill. All-or-nothing — if either half throws, neither booking moved.
|
||||||
|
*/
|
||||||
|
describe('BookingTransitionService — paired staff decisions', () => {
|
||||||
|
function makeService(booking: Partial<Booking>) {
|
||||||
|
const bookingsService = {
|
||||||
|
findById: jest.fn().mockResolvedValue(booking as Booking),
|
||||||
|
};
|
||||||
|
// Runs the callback so a throw propagates, which is what the all-or-nothing
|
||||||
|
// guarantee reduces to from this service's point of view.
|
||||||
|
const dataSource = {
|
||||||
|
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new BookingTransitionService(
|
||||||
|
{} as never, // bookingsRepository
|
||||||
|
{} as never, // ruleEngineService
|
||||||
|
{} as never, // pricingService
|
||||||
|
{} as never, // contractService
|
||||||
|
{} as never, // filesService
|
||||||
|
{} as never, // fileUploadSettingsService
|
||||||
|
{} as never, // bookingBatchService
|
||||||
|
bookingsService as never,
|
||||||
|
{} as never, // bookingClearanceService
|
||||||
|
{} as never, // workflowService
|
||||||
|
{} as never, // invoiceService
|
||||||
|
{} as never, // containerValidationService
|
||||||
|
{} as never, // notifier
|
||||||
|
{} as never, // events
|
||||||
|
undefined, // milestoneService
|
||||||
|
dataSource as never,
|
||||||
|
);
|
||||||
|
return { service, dataSource };
|
||||||
|
}
|
||||||
|
|
||||||
|
const paired = {
|
||||||
|
id: 'b-1',
|
||||||
|
reference: 'BK-1',
|
||||||
|
consolidationPartnerId: 'b-2',
|
||||||
|
} as Booking;
|
||||||
|
|
||||||
|
it('accepts both halves with the same validity window', async () => {
|
||||||
|
const { service } = makeService(paired);
|
||||||
|
const accept = jest
|
||||||
|
.spyOn(service, 'acceptIntake')
|
||||||
|
.mockImplementation(async (id) => ({ id }) as Booking);
|
||||||
|
|
||||||
|
const result = await service.applyPairedDecision('b-1', 'accept', 'staff-1', {
|
||||||
|
validityDays: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(accept).toHaveBeenCalledTimes(2);
|
||||||
|
expect(accept).toHaveBeenNthCalledWith(1, 'b-1', 'staff-1', 30);
|
||||||
|
expect(accept).toHaveBeenNthCalledWith(2, 'b-2', 'staff-1', 30);
|
||||||
|
expect(result.booking.id).toBe('b-1');
|
||||||
|
expect(result.partner.id).toBe('b-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels both halves with the same reason', async () => {
|
||||||
|
const { service } = makeService(paired);
|
||||||
|
const cancel = jest
|
||||||
|
.spyOn(service, 'cancel')
|
||||||
|
.mockImplementation(async (id) => ({ id }) as Booking);
|
||||||
|
|
||||||
|
await service.applyPairedDecision('b-1', 'cancel', 'staff-1', {
|
||||||
|
reason: 'customer withdrew',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cancel).toHaveBeenNthCalledWith(1, 'b-1', 'customer withdrew');
|
||||||
|
expect(cancel).toHaveBeenNthCalledWith(2, 'b-2', 'customer withdrew');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates a failure on the second half so neither is committed', async () => {
|
||||||
|
const { service, dataSource } = makeService(paired);
|
||||||
|
jest
|
||||||
|
.spyOn(service, 'cancel')
|
||||||
|
.mockImplementationOnce(async (id) => ({ id }) as Booking)
|
||||||
|
.mockImplementationOnce(async () => {
|
||||||
|
throw new Error('partner is already in transit');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }),
|
||||||
|
).rejects.toThrow('partner is already in transit');
|
||||||
|
|
||||||
|
// Both halves ran inside one transaction, so the throw rolls the first back.
|
||||||
|
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a booking that has no partner', async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
id: 'b-1',
|
||||||
|
consolidationPartnerId: null,
|
||||||
|
} as Booking);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }),
|
||||||
|
).rejects.toThrow(/no consolidation partner/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a validity window to accept', async () => {
|
||||||
|
const { service } = makeService(paired);
|
||||||
|
const accept = jest.spyOn(service, 'acceptIntake');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.applyPairedDecision('b-1', 'accept', 'staff-1', {}),
|
||||||
|
).rejects.toThrow(/validity/i);
|
||||||
|
expect(accept).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes operationAccept through the operation review on both halves', async () => {
|
||||||
|
const { service } = makeService(paired);
|
||||||
|
const review = jest
|
||||||
|
.spyOn(service, 'reviewOperationRequest')
|
||||||
|
.mockImplementation(async (id) => ({ id }) as Booking);
|
||||||
|
|
||||||
|
await service.applyPairedDecision('b-1', 'operationAccept', 'staff-1', {});
|
||||||
|
|
||||||
|
expect(review).toHaveBeenNthCalledWith(1, 'b-1', 'ACCEPT', 'staff-1', {
|
||||||
|
note: undefined,
|
||||||
|
});
|
||||||
|
expect(review).toHaveBeenNthCalledWith(2, 'b-2', 'ACCEPT', 'staff-1', {
|
||||||
|
note: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,6 +81,28 @@ export class BookingTransitionService {
|
|||||||
|
|
||||||
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
|
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
|
||||||
private async assert20ftPairable(booking: Booking): Promise<void> {
|
private async assert20ftPairable(booking: Booking): Promise<void> {
|
||||||
|
// Parity gate. 20ft ride two per wagon, so an odd total leaves one container
|
||||||
|
// that cannot be placed. Consolidation (pairing it with another customer's
|
||||||
|
// odd booking) is built end to end but switched off for now, so an odd total
|
||||||
|
// is rejected here rather than parked for a partner.
|
||||||
|
// containerSize is not always populated (some rows carry only the container
|
||||||
|
// type), so fall back to the type's sizeFt rather than silently skipping
|
||||||
|
// those lines and letting an odd booking through.
|
||||||
|
const ft20Quantity = (booking.bookingContainers ?? [])
|
||||||
|
.filter((bc) =>
|
||||||
|
bc.containerSize
|
||||||
|
? bc.containerSize.includes("20")
|
||||||
|
: Number(bc.containerType?.sizeFt) === 20,
|
||||||
|
)
|
||||||
|
.reduce((sum, bc) => sum + Number(bc.quantity || 0), 0);
|
||||||
|
if (ft20Quantity % 2 === 1) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`20ft containers travel two per wagon, so they must be booked in even ` +
|
||||||
|
`numbers. This booking has ${ft20Quantity} — add one more or remove ` +
|
||||||
|
`one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const violations =
|
const violations =
|
||||||
await this.containerValidationService.validate20ftPairing(booking);
|
await this.containerValidationService.validate20ftPairing(booking);
|
||||||
if (violations.length) {
|
if (violations.length) {
|
||||||
@@ -455,6 +477,75 @@ export class BookingTransitionService {
|
|||||||
return this.cancel(bookingId, reason ?? "Customer cancelled before payment");
|
return this.cancel(bookingId, reason ?? "Customer cancelled before payment");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a staff decision across BOTH halves of a consolidated pair.
|
||||||
|
*
|
||||||
|
* Two bookings that share a wagon must move together: accepting one while the
|
||||||
|
* other stays behind would put half a wagon into the approval chain, and
|
||||||
|
* cancelling one alone would strand the other on a wagon it can no longer
|
||||||
|
* fill. All-or-nothing — if either half throws, the transaction rolls back and
|
||||||
|
* neither booking moved.
|
||||||
|
*
|
||||||
|
* Each half still runs the ordinary single-booking transition, so pricing,
|
||||||
|
* invoicing and notifications stay per booking: the customers are billed and
|
||||||
|
* notified separately, exactly as they are today.
|
||||||
|
*/
|
||||||
|
async applyPairedDecision(
|
||||||
|
bookingId: string,
|
||||||
|
decision: "accept" | "cancel" | "operationAccept" | "requestChanges",
|
||||||
|
actorId: string,
|
||||||
|
options: { reason?: string; note?: string; validityDays?: number } = {},
|
||||||
|
): Promise<{ booking: Booking; partner: Booking }> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
const partnerId = booking.consolidationPartnerId;
|
||||||
|
if (!partnerId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"This booking has no consolidation partner — use the single-booking action.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runOne = async (id: string): Promise<Booking> => {
|
||||||
|
switch (decision) {
|
||||||
|
case "accept":
|
||||||
|
// Same requirement as the single-booking accept: the approval chain
|
||||||
|
// needs a contract validity window.
|
||||||
|
if (!(Number(options.validityDays) > 0)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Contract validity (days) is required to accept.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.acceptIntake(id, actorId, Number(options.validityDays));
|
||||||
|
case "cancel":
|
||||||
|
return this.cancel(
|
||||||
|
id,
|
||||||
|
options.reason ?? "Cancelled with its consolidation partner",
|
||||||
|
);
|
||||||
|
case "operationAccept":
|
||||||
|
return this.reviewOperationRequest(id, "ACCEPT", actorId, {
|
||||||
|
note: options.note,
|
||||||
|
});
|
||||||
|
case "requestChanges":
|
||||||
|
return this.requestChanges(id, options.note ?? "", actorId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Without a DataSource (unit tests hand-construct this service) fall back to
|
||||||
|
// running the two halves directly — the ordering guarantee still holds, only
|
||||||
|
// the rollback does not.
|
||||||
|
if (!this.dataSource) {
|
||||||
|
const own = await runOne(bookingId);
|
||||||
|
const other = await runOne(partnerId);
|
||||||
|
return { booking: own, partner: other };
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dataSource.transaction(async () => {
|
||||||
|
// Sequential: one connection per transaction context.
|
||||||
|
const own = await runOne(bookingId);
|
||||||
|
const other = await runOne(partnerId);
|
||||||
|
return { booking: own, partner: other };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, [
|
assertBookingStatus(booking, [
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { BookingReferenceDataService } from './booking-reference-data.service';
|
|||||||
import { scopedDirections } from '../user-trade-access/trade-scope.util';
|
import { scopedDirections } from '../user-trade-access/trade-scope.util';
|
||||||
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
|
import { ConsolidationApprovalService } from './consolidation-approval.service';
|
||||||
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
||||||
import { CreateBookingDto } from './dto/create-booking.dto';
|
import { CreateBookingDto } from './dto/create-booking.dto';
|
||||||
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
|
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
|
||||||
@@ -58,7 +59,10 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
|||||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||||
import {
|
import {
|
||||||
AcceptIntakeDto,
|
AcceptIntakeDto,
|
||||||
|
ApproveConsolidationDto,
|
||||||
CancelBookingDto,
|
CancelBookingDto,
|
||||||
|
PairedDecisionDto,
|
||||||
|
RejectConsolidationDto,
|
||||||
RejectBookingDto,
|
RejectBookingDto,
|
||||||
RequestChangesDto,
|
RequestChangesDto,
|
||||||
ReviewDocumentDto,
|
ReviewDocumentDto,
|
||||||
@@ -165,6 +169,7 @@ export class BookingsController {
|
|||||||
private readonly lastMileService: LastMileService,
|
private readonly lastMileService: LastMileService,
|
||||||
private readonly userTradeAccessService: UserTradeAccessService,
|
private readonly userTradeAccessService: UserTradeAccessService,
|
||||||
private readonly wagonCancellationService: BookingWagonCancellationService,
|
private readonly wagonCancellationService: BookingWagonCancellationService,
|
||||||
|
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -1541,6 +1546,92 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shared-wagon (consolidation) approval gate ────────────────────────────
|
||||||
|
// A consolidated pair is held here, not in the operations queue: two
|
||||||
|
// customers' cargo on one wagon is a commercial call, so a person signs off
|
||||||
|
// on the pairing before Operations sees either half.
|
||||||
|
|
||||||
|
@Get("consolidation-approvals/queue")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Shared-wagon pairings awaiting approval, oldest first. Each row covers BOTH bookings on the wagon.",
|
||||||
|
})
|
||||||
|
consolidationApprovalQueue() {
|
||||||
|
return this.consolidationApprovalService.queue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/consolidation-approvals")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.view)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Approval history for this booking's shared wagon — who decided what, when, and why.",
|
||||||
|
})
|
||||||
|
consolidationApprovalHistory(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.consolidationApprovalService.historyForBooking(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("consolidation-approvals/:approvalId/approve")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Approve a shared wagon: both bookings leave the gate and continue to Operations together.",
|
||||||
|
})
|
||||||
|
approveConsolidation(
|
||||||
|
@Param("approvalId", ParseUUIDPipe) approvalId: string,
|
||||||
|
@Body() dto: ApproveConsolidationDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.consolidationApprovalService.approve(
|
||||||
|
approvalId,
|
||||||
|
resolveAuthUserId(user) ?? "",
|
||||||
|
dto.note,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("consolidation-approvals/:approvalId/reject")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Reject a shared wagon: both bookings go back to GL for changes with the reason.",
|
||||||
|
})
|
||||||
|
rejectConsolidation(
|
||||||
|
@Param("approvalId", ParseUUIDPipe) approvalId: string,
|
||||||
|
@Body() dto: RejectConsolidationDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.consolidationApprovalService.reject(
|
||||||
|
approvalId,
|
||||||
|
resolveAuthUserId(user) ?? "",
|
||||||
|
dto.reason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/paired-decision")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Apply a staff decision (accept / cancel / operationAccept / requestChanges) to BOTH halves of a consolidated pair, all-or-nothing.",
|
||||||
|
})
|
||||||
|
async pairedDecision(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: PairedDecisionDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
const { booking, partner } = await this.transitionService.applyPairedDecision(
|
||||||
|
id,
|
||||||
|
dto.decision,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
{ reason: dto.reason, note: dto.note, validityDays: dto.validityDays },
|
||||||
|
);
|
||||||
|
// Sequential enrichment: both go back so the UI can refresh either tab.
|
||||||
|
const enrichedBooking =
|
||||||
|
await this.transitionService.enrichBookingResponse(booking);
|
||||||
|
const enrichedPartner =
|
||||||
|
await this.transitionService.enrichBookingResponse(partner);
|
||||||
|
return { booking: enrichedBooking, partner: enrichedPartner };
|
||||||
|
}
|
||||||
|
|
||||||
@Post(":id/cancel")
|
@Post(":id/cancel")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
|
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
|
||||||
@ApiOperation({ summary: "Cancel booking" })
|
@ApiOperation({ summary: "Cancel booking" })
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ import { BookingsController } from './bookings.controller';
|
|||||||
// import { PayController } from './pay.controller';
|
// import { PayController } from './pay.controller';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { ConsolidationService } from './consolidation.service';
|
import { ConsolidationService } from './consolidation.service';
|
||||||
|
import { ConsolidationApprovalService } from './consolidation-approval.service';
|
||||||
|
import { ConsolidationApprovalsRepository } from './consolidation-approvals.repository';
|
||||||
|
import { ConsolidationApproval } from './entities/consolidation-approval.entity';
|
||||||
import { ContainerValidationService } from './container-validation.service';
|
import { ContainerValidationService } from './container-validation.service';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||||
@@ -72,6 +75,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
BookingWagonCancellation,
|
BookingWagonCancellation,
|
||||||
CustomerTruckAssignment,
|
CustomerTruckAssignment,
|
||||||
CustomerTruckContainer,
|
CustomerTruckContainer,
|
||||||
|
ConsolidationApproval,
|
||||||
]),
|
]),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
@@ -98,6 +102,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
BookingsService,
|
BookingsService,
|
||||||
BookingsRepository,
|
BookingsRepository,
|
||||||
ConsolidationService,
|
ConsolidationService,
|
||||||
|
ConsolidationApprovalService,
|
||||||
|
ConsolidationApprovalsRepository,
|
||||||
ContainerValidationService,
|
ContainerValidationService,
|
||||||
BookingReferenceDataService,
|
BookingReferenceDataService,
|
||||||
BookingPricingService,
|
BookingPricingService,
|
||||||
@@ -126,6 +132,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
BookingLifecycleNotifierService,
|
BookingLifecycleNotifierService,
|
||||||
BookingTransitionService,
|
BookingTransitionService,
|
||||||
ConsolidationService,
|
ConsolidationService,
|
||||||
|
ConsolidationApprovalService,
|
||||||
|
ConsolidationApprovalsRepository,
|
||||||
CustomerTruckService,
|
CustomerTruckService,
|
||||||
ContainerReceiptService,
|
ContainerReceiptService,
|
||||||
BookingWagonCancellationService,
|
BookingWagonCancellationService,
|
||||||
|
|||||||
@@ -308,6 +308,72 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.find({ where: { contractId } });
|
.find({ where: { contractId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bookings a GL operator may manually link to `booking` as its odd-20ft
|
||||||
|
* consolidation partner (Path B customs flow). Unlike
|
||||||
|
* {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact
|
||||||
|
* quantity complement — this lists CANDIDATES for a human to choose from, so
|
||||||
|
* the filter is deliberately looser: any other customs booking on the same
|
||||||
|
* route/direction that is itself carrying an odd 20ft count. Two odd counts
|
||||||
|
* always sum to even, so any pick fills the shared wagon.
|
||||||
|
*
|
||||||
|
* Bare instances awaiting completion have no persisted containers yet, so the
|
||||||
|
* odd-count test runs on the requested container lines when they exist and the
|
||||||
|
* booking is offered as a candidate when they do not (GL enters its cargo on
|
||||||
|
* the split form).
|
||||||
|
*/
|
||||||
|
async findManualConsolidationCandidates(
|
||||||
|
booking: Booking,
|
||||||
|
limit = 50,
|
||||||
|
): Promise<Booking[]> {
|
||||||
|
const rows = await this.repository
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.leftJoinAndSelect('b.bookingContainers', 'bc')
|
||||||
|
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||||
|
.leftJoinAndSelect('b.company', 'company')
|
||||||
|
.where('b.id != :bookingId', { bookingId: booking.id })
|
||||||
|
// Never offer a booking that already shares a wagon with someone else.
|
||||||
|
.andWhere('b.consolidationPartnerId IS NULL')
|
||||||
|
// Customs-only: this manual flow exists because a customs (Path B)
|
||||||
|
// instance is completed by GL, not by the customer.
|
||||||
|
.andWhere('b.customsClearingEnabled = true')
|
||||||
|
// Same physical wagon ⇒ same route and same direction.
|
||||||
|
.andWhere('b.originYardId = :originYardId', {
|
||||||
|
originYardId: booking.originYardId,
|
||||||
|
})
|
||||||
|
.andWhere('b.destinationYardId = :destinationYardId', {
|
||||||
|
destinationYardId: booking.destinationYardId,
|
||||||
|
})
|
||||||
|
.andWhere('b.tradeDirection = :tradeDirection', {
|
||||||
|
tradeDirection: booking.tradeDirection,
|
||||||
|
})
|
||||||
|
// Bookable = clearance finished and the booking is waiting to be completed,
|
||||||
|
// the same set completeUnderContract accepts, plus one already parked for a
|
||||||
|
// partner.
|
||||||
|
.andWhere('b.status IN (:...statuses)', {
|
||||||
|
statuses: [
|
||||||
|
'CLEARANCE_READY',
|
||||||
|
'OPERATION_CHANGES_REQUESTED',
|
||||||
|
'PENDING_CONSOLIDATION',
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.orderBy('b.createdAt', 'ASC')
|
||||||
|
.take(limit)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
// Odd-20ft test in memory: a bare instance has no containers yet (GL fills
|
||||||
|
// them on the split form) and stays a candidate; one that already carries
|
||||||
|
// cargo qualifies only when its 20ft total is odd.
|
||||||
|
return rows.filter((row) => {
|
||||||
|
const lines = row.bookingContainers ?? [];
|
||||||
|
if (lines.length === 0) return true;
|
||||||
|
const ft20 = lines
|
||||||
|
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||||
|
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
|
||||||
|
return ft20 % 2 === 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||||
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
|
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
|
||||||
@@ -508,6 +574,25 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
} as never);
|
} as never);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link two bookings as consolidation partners WITHOUT touching their statuses.
|
||||||
|
* Used by the manual GL pairing, where both bookings have just been completed
|
||||||
|
* into their live status — unlike {@link pairConsolidation}, which exists to
|
||||||
|
* resume bookings parked in PENDING_CONSOLIDATION and rewrites status as part
|
||||||
|
* of that resume.
|
||||||
|
*/
|
||||||
|
async linkConsolidationPartners(
|
||||||
|
bookingId: string,
|
||||||
|
partnerId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.repository.update(bookingId, {
|
||||||
|
consolidationPartnerId: partnerId,
|
||||||
|
} as never);
|
||||||
|
await this.repository.update(partnerId, {
|
||||||
|
consolidationPartnerId: bookingId,
|
||||||
|
} as never);
|
||||||
|
}
|
||||||
|
|
||||||
/** Un-pair a consolidation. */
|
/** Un-pair a consolidation. */
|
||||||
async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> {
|
async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> {
|
||||||
await this.repository.update(bookingId, {
|
await this.repository.update(bookingId, {
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import {
|
||||||
|
ConsolidationApprovalService,
|
||||||
|
CONSOLIDATION_APPROVAL_PENDING,
|
||||||
|
} from './consolidation-approval.service';
|
||||||
|
import { ConsolidationApprovalStatus } from './entities/consolidation-approval.entity';
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared-wagon approval gate. Two customers' cargo on one wagon is a
|
||||||
|
* commercial call, so the pair is held for a human decision instead of going
|
||||||
|
* straight to Operations.
|
||||||
|
*
|
||||||
|
* The invariants that matter: both halves are held and released TOGETHER (a
|
||||||
|
* decision on one side of a shared wagon is meaningless without the other), and
|
||||||
|
* a decided pairing cannot be decided twice.
|
||||||
|
*/
|
||||||
|
describe('ConsolidationApprovalService', () => {
|
||||||
|
const PENDING = {
|
||||||
|
id: 'ap-1',
|
||||||
|
bookingId: 'b-1',
|
||||||
|
partnerBookingId: 'b-2',
|
||||||
|
status: ConsolidationApprovalStatus.Pending,
|
||||||
|
requestedBy: 'gl-user',
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeService(overrides: {
|
||||||
|
approvals?: Partial<Record<string, jest.Mock>>;
|
||||||
|
bookingsRepository?: Partial<Record<string, jest.Mock>>;
|
||||||
|
} = {}) {
|
||||||
|
const approvals = {
|
||||||
|
findPendingForBooking: jest.fn().mockResolvedValue(null),
|
||||||
|
findById: jest.fn().mockResolvedValue(PENDING),
|
||||||
|
create: jest.fn().mockResolvedValue({ id: 'ap-1' }),
|
||||||
|
decide: jest.fn().mockResolvedValue(true),
|
||||||
|
findQueue: jest.fn().mockResolvedValue([]),
|
||||||
|
findAllForBooking: jest.fn().mockResolvedValue([]),
|
||||||
|
...overrides.approvals,
|
||||||
|
};
|
||||||
|
const bookingsRepository = {
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||||
|
...overrides.bookingsRepository,
|
||||||
|
};
|
||||||
|
const bookingsService = {
|
||||||
|
findById: jest.fn(async (id: string) =>
|
||||||
|
({ id, reference: `BK-${id}` }) as Booking,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const notifier = {
|
||||||
|
consolidationApprovalRequestedToStaff: jest.fn(),
|
||||||
|
consolidationApprovedToStaff: jest.fn(),
|
||||||
|
consolidationRejectedToStaff: jest.fn(),
|
||||||
|
operationRequestedToStaff: jest.fn(),
|
||||||
|
};
|
||||||
|
const dataSource = {
|
||||||
|
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new ConsolidationApprovalService(
|
||||||
|
approvals as never,
|
||||||
|
bookingsRepository as never,
|
||||||
|
bookingsService as never,
|
||||||
|
notifier as never,
|
||||||
|
dataSource as never,
|
||||||
|
);
|
||||||
|
return { service, approvals, bookingsRepository, notifier };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('holds BOTH halves at the gate when a pairing is created', async () => {
|
||||||
|
const { service, approvals, bookingsRepository, notifier } = makeService();
|
||||||
|
|
||||||
|
await service.requestApproval('b-1', 'b-2', 'gl-user');
|
||||||
|
|
||||||
|
expect(approvals.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
bookingId: 'b-1',
|
||||||
|
partnerBookingId: 'b-2',
|
||||||
|
requestedBy: 'gl-user',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Neither half may sit in the operations queue while the wagon is unreviewed.
|
||||||
|
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||||
|
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||||
|
});
|
||||||
|
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
||||||
|
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
notifier.consolidationApprovalRequestedToStaff,
|
||||||
|
).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not open a second review for a pairing already pending', async () => {
|
||||||
|
const { service, approvals } = makeService({
|
||||||
|
approvals: {
|
||||||
|
findPendingForBooking: jest.fn().mockResolvedValue(PENDING),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.requestApproval('b-1', 'b-2', 'gl-user');
|
||||||
|
|
||||||
|
expect(result).toBe(PENDING);
|
||||||
|
expect(approvals.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases BOTH halves to Operations on approval, logging who decided', async () => {
|
||||||
|
const { service, approvals, bookingsRepository, notifier } = makeService();
|
||||||
|
|
||||||
|
await service.approve('ap-1', 'approver-1', 'looks fine');
|
||||||
|
|
||||||
|
expect(approvals.decide).toHaveBeenCalledWith(
|
||||||
|
'ap-1',
|
||||||
|
ConsolidationApprovalStatus.Approved,
|
||||||
|
'approver-1',
|
||||||
|
'looks fine',
|
||||||
|
);
|
||||||
|
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||||
|
status: 'OPERATION_REQUEST_PENDING',
|
||||||
|
});
|
||||||
|
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
||||||
|
status: 'OPERATION_REQUEST_PENDING',
|
||||||
|
});
|
||||||
|
// Operations only learns about the pair now — the gate is what kept it out.
|
||||||
|
expect(notifier.operationRequestedToStaff).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends BOTH halves back to GL on rejection, with the reason on each', async () => {
|
||||||
|
const { service, approvals, bookingsRepository } = makeService();
|
||||||
|
|
||||||
|
await service.reject('ap-1', 'approver-1', 'partner cargo is wrong');
|
||||||
|
|
||||||
|
expect(approvals.decide).toHaveBeenCalledWith(
|
||||||
|
'ap-1',
|
||||||
|
ConsolidationApprovalStatus.Rejected,
|
||||||
|
'approver-1',
|
||||||
|
'partner cargo is wrong',
|
||||||
|
);
|
||||||
|
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
|
||||||
|
'b-1',
|
||||||
|
'partner cargo is wrong',
|
||||||
|
'CHANGES_REQUESTED',
|
||||||
|
);
|
||||||
|
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
|
||||||
|
'b-2',
|
||||||
|
'partner cargo is wrong',
|
||||||
|
'CHANGES_REQUESTED',
|
||||||
|
);
|
||||||
|
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||||
|
status: 'OPERATION_CHANGES_REQUESTED',
|
||||||
|
});
|
||||||
|
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
||||||
|
status: 'OPERATION_CHANGES_REQUESTED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the requester approve their own pairing', async () => {
|
||||||
|
// No maker-checker separation: the permission alone decides who may approve,
|
||||||
|
// and the audit trail still records requester and approver separately.
|
||||||
|
const { service, approvals } = makeService();
|
||||||
|
|
||||||
|
await service.approve('ap-1', 'gl-user');
|
||||||
|
|
||||||
|
expect(approvals.decide).toHaveBeenCalledWith(
|
||||||
|
'ap-1',
|
||||||
|
ConsolidationApprovalStatus.Approved,
|
||||||
|
'gl-user',
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a reason to reject', async () => {
|
||||||
|
const { service, approvals } = makeService();
|
||||||
|
|
||||||
|
await expect(service.reject('ap-1', 'approver-1', ' ')).rejects.toThrow(
|
||||||
|
/reason is required/i,
|
||||||
|
);
|
||||||
|
expect(approvals.decide).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a pairing that was already decided', async () => {
|
||||||
|
const { service, bookingsRepository } = makeService({
|
||||||
|
approvals: {
|
||||||
|
findById: jest.fn().mockResolvedValue({
|
||||||
|
...PENDING,
|
||||||
|
status: ConsolidationApprovalStatus.Approved,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
|
||||||
|
/already approved/i,
|
||||||
|
);
|
||||||
|
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loses cleanly when another approver decides the same pairing first', async () => {
|
||||||
|
// decide() writes only against a still-PENDING row, so the loser of the race
|
||||||
|
// affects nothing and must not move the bookings.
|
||||||
|
const { service } = makeService({
|
||||||
|
approvals: { decide: jest.fn().mockResolvedValue(false) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
|
||||||
|
/already decided by someone else/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
forwardRef,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
import { Booking } from "./entities/booking.entity";
|
||||||
|
import {
|
||||||
|
ConsolidationApproval,
|
||||||
|
ConsolidationApprovalStatus,
|
||||||
|
} from "./entities/consolidation-approval.entity";
|
||||||
|
import { ConsolidationApprovalsRepository } from "./consolidation-approvals.repository";
|
||||||
|
import { BookingsRepository } from "./bookings.repository";
|
||||||
|
import { BookingsService } from "./bookings.service";
|
||||||
|
import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service";
|
||||||
|
|
||||||
|
/** Where a rejected pair goes back to, so GL can fix and resubmit. */
|
||||||
|
const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
|
||||||
|
|
||||||
|
/** The gate's own holding status — neither half reaches Operations from here. */
|
||||||
|
export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared-wagon approval gate.
|
||||||
|
*
|
||||||
|
* A booking that fills its own wagons goes straight from GL completion to the
|
||||||
|
* operations queue. A consolidated one does not: two customers' cargo rides one
|
||||||
|
* physical wagon under two separate invoices, so a person reviews the pairing
|
||||||
|
* before Operations sees either half.
|
||||||
|
*
|
||||||
|
* Both halves are held and released TOGETHER — the wagon is shared, so a
|
||||||
|
* decision on one is meaningless without the other. Every request is kept,
|
||||||
|
* decided or not: the table is the audit trail of who approved which pairing,
|
||||||
|
* when, and why.
|
||||||
|
*
|
||||||
|
* No maker-checker separation: whoever holds the approve permission may decide a
|
||||||
|
* pairing, including the GL user who created it. The record of who requested and
|
||||||
|
* who decided is still kept either way.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ConsolidationApprovalService {
|
||||||
|
private readonly logger = new Logger(ConsolidationApprovalService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly approvals: ConsolidationApprovalsRepository,
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
@Inject(forwardRef(() => BookingsService))
|
||||||
|
private readonly bookingsService: BookingsService,
|
||||||
|
private readonly notifier: BookingLifecycleNotifierService,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Park a newly consolidated pair for review instead of letting it continue to
|
||||||
|
* Operations. Called from the completion path once the two halves are linked.
|
||||||
|
*
|
||||||
|
* Idempotent: a pair that already has an undecided request is left alone, so a
|
||||||
|
* retried completion cannot open a second review of the same wagon.
|
||||||
|
*/
|
||||||
|
async requestApproval(
|
||||||
|
bookingId: string,
|
||||||
|
partnerBookingId: string,
|
||||||
|
requestedBy: string | null,
|
||||||
|
): Promise<ConsolidationApproval> {
|
||||||
|
const existing = await this.approvals.findPendingForBooking(bookingId);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
// Sequential reads: one connection per transaction context.
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
const partner = await this.bookingsService.findById(partnerBookingId);
|
||||||
|
if (!booking || !partner) {
|
||||||
|
throw new NotFoundException("Both bookings of the pair must exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const approval = await this.approvals.create({
|
||||||
|
bookingId,
|
||||||
|
partnerBookingId,
|
||||||
|
requestedBy,
|
||||||
|
scheduledDate: booking.scheduledDate ?? null,
|
||||||
|
bookingReference: booking.reference ?? null,
|
||||||
|
partnerBookingReference: partner.reference ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hold BOTH halves: the wagon is shared, so neither may advance alone.
|
||||||
|
await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||||
|
} as never);
|
||||||
|
await this.bookingsRepository.update(partnerBookingId, {
|
||||||
|
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
this.notifier.consolidationApprovalRequestedToStaff(
|
||||||
|
booking,
|
||||||
|
partner.reference ?? partnerBookingId,
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`Consolidation ${booking.reference} + ${partner.reference} awaiting approval (${approval.id}).`,
|
||||||
|
);
|
||||||
|
return approval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve the pairing: both halves leave the gate and continue to Operations,
|
||||||
|
* which is exactly where a non-consolidated booking would already be.
|
||||||
|
*
|
||||||
|
* All-or-nothing — the two status writes and the decision record share one
|
||||||
|
* transaction, so the audit trail can never claim an approval that did not
|
||||||
|
* take effect.
|
||||||
|
*/
|
||||||
|
async approve(
|
||||||
|
approvalId: string,
|
||||||
|
decidedBy: string,
|
||||||
|
note?: string,
|
||||||
|
): Promise<{ booking: Booking; partner: Booking }> {
|
||||||
|
const approval = await this.loadPending(approvalId);
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async () => {
|
||||||
|
const claimed = await this.approvals.decide(
|
||||||
|
approval.id,
|
||||||
|
ConsolidationApprovalStatus.Approved,
|
||||||
|
decidedBy,
|
||||||
|
note,
|
||||||
|
);
|
||||||
|
// Lost the race to another approver deciding the same pairing.
|
||||||
|
if (!claimed) {
|
||||||
|
throw new ConflictException(
|
||||||
|
"This consolidation was already decided by someone else.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.bookingsRepository.update(approval.bookingId, {
|
||||||
|
status: "OPERATION_REQUEST_PENDING",
|
||||||
|
} as never);
|
||||||
|
await this.bookingsRepository.update(approval.partnerBookingId, {
|
||||||
|
status: "OPERATION_REQUEST_PENDING",
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
const booking = await this.bookingsService.findById(approval.bookingId);
|
||||||
|
const partner = await this.bookingsService.findById(
|
||||||
|
approval.partnerBookingId,
|
||||||
|
);
|
||||||
|
this.notifier.consolidationApprovedToStaff(
|
||||||
|
booking,
|
||||||
|
partner.reference ?? approval.partnerBookingId,
|
||||||
|
);
|
||||||
|
// Operations only now learns about the pair — the gate is what kept it out.
|
||||||
|
this.notifier.operationRequestedToStaff(booking);
|
||||||
|
this.notifier.operationRequestedToStaff(partner);
|
||||||
|
return { booking, partner };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject the pairing: both halves go back to GL as OPERATION_CHANGES_REQUESTED
|
||||||
|
* with the reason, so the cargo or the partner can be changed and resubmitted.
|
||||||
|
*/
|
||||||
|
async reject(
|
||||||
|
approvalId: string,
|
||||||
|
decidedBy: string,
|
||||||
|
reason: string,
|
||||||
|
): Promise<{ booking: Booking; partner: Booking }> {
|
||||||
|
if (!reason?.trim()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"A reason is required to reject a consolidation.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const approval = await this.loadPending(approvalId);
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async () => {
|
||||||
|
const claimed = await this.approvals.decide(
|
||||||
|
approval.id,
|
||||||
|
ConsolidationApprovalStatus.Rejected,
|
||||||
|
decidedBy,
|
||||||
|
reason.trim(),
|
||||||
|
);
|
||||||
|
if (!claimed) {
|
||||||
|
throw new ConflictException(
|
||||||
|
"This consolidation was already decided by someone else.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.bookingsRepository.createReviewNote(
|
||||||
|
approval.bookingId,
|
||||||
|
reason.trim(),
|
||||||
|
"CHANGES_REQUESTED",
|
||||||
|
);
|
||||||
|
await this.bookingsRepository.createReviewNote(
|
||||||
|
approval.partnerBookingId,
|
||||||
|
reason.trim(),
|
||||||
|
"CHANGES_REQUESTED",
|
||||||
|
);
|
||||||
|
await this.bookingsRepository.update(approval.bookingId, {
|
||||||
|
status: REJECTED_STATUS,
|
||||||
|
} as never);
|
||||||
|
await this.bookingsRepository.update(approval.partnerBookingId, {
|
||||||
|
status: REJECTED_STATUS,
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
const booking = await this.bookingsService.findById(approval.bookingId);
|
||||||
|
const partner = await this.bookingsService.findById(
|
||||||
|
approval.partnerBookingId,
|
||||||
|
);
|
||||||
|
this.notifier.consolidationRejectedToStaff(
|
||||||
|
booking,
|
||||||
|
partner.reference ?? approval.partnerBookingId,
|
||||||
|
reason.trim(),
|
||||||
|
);
|
||||||
|
return { booking, partner };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pending pairings awaiting a decision, oldest first. */
|
||||||
|
queue(): Promise<ConsolidationApproval[]> {
|
||||||
|
return this.approvals.findQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full decision history for one booking — who decided what, and when. */
|
||||||
|
historyForBooking(bookingId: string): Promise<ConsolidationApproval[]> {
|
||||||
|
return this.approvals.findAllForBooking(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The undecided request covering this booking, if any. */
|
||||||
|
pendingForBooking(bookingId: string): Promise<ConsolidationApproval | null> {
|
||||||
|
return this.approvals.findPendingForBooking(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadPending(approvalId: string): Promise<ConsolidationApproval> {
|
||||||
|
const approval = await this.approvals.findById(approvalId);
|
||||||
|
if (!approval) {
|
||||||
|
throw new NotFoundException(`Approval ${approvalId} not found`);
|
||||||
|
}
|
||||||
|
if (approval.status !== ConsolidationApprovalStatus.Pending) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`This consolidation was already ${approval.status.toLowerCase()}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return approval;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { DataSource, In, Repository } from "typeorm";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ConsolidationApproval,
|
||||||
|
ConsolidationApprovalStatus,
|
||||||
|
} from "./entities/consolidation-approval.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistence for the shared-wagon approval gate. Rows are never deleted —
|
||||||
|
* decided rows are the audit trail of who approved which pairing and when.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ConsolidationApprovalsRepository {
|
||||||
|
private readonly repository: Repository<ConsolidationApproval>;
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {
|
||||||
|
this.repository = this.dataSource.getRepository(ConsolidationApproval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The undecided request covering `bookingId`, from EITHER side of the pair —
|
||||||
|
* one row governs both halves, and the caller may hold either one.
|
||||||
|
*/
|
||||||
|
findPendingForBooking(
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<ConsolidationApproval | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: [
|
||||||
|
{ bookingId, status: ConsolidationApprovalStatus.Pending },
|
||||||
|
{
|
||||||
|
partnerBookingId: bookingId,
|
||||||
|
status: ConsolidationApprovalStatus.Pending,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every request touching this booking, newest first (the audit trail). */
|
||||||
|
findAllForBooking(bookingId: string): Promise<ConsolidationApproval[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
where: [{ bookingId }, { partnerBookingId: bookingId }],
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findById(id: string): Promise<ConsolidationApproval | null> {
|
||||||
|
return this.repository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pending requests for the review queue, oldest first (FIFO). */
|
||||||
|
findQueue(): Promise<ConsolidationApproval[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
where: { status: ConsolidationApprovalStatus.Pending },
|
||||||
|
relations: {
|
||||||
|
booking: { company: true },
|
||||||
|
partnerBooking: { company: true },
|
||||||
|
},
|
||||||
|
order: { requestedAt: "ASC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
create(input: {
|
||||||
|
bookingId: string;
|
||||||
|
partnerBookingId: string;
|
||||||
|
requestedBy?: string | null;
|
||||||
|
scheduledDate?: Date | null;
|
||||||
|
bookingReference?: string | null;
|
||||||
|
partnerBookingReference?: string | null;
|
||||||
|
}): Promise<ConsolidationApproval> {
|
||||||
|
return this.repository.save(
|
||||||
|
this.repository.create({
|
||||||
|
...input,
|
||||||
|
status: ConsolidationApprovalStatus.Pending,
|
||||||
|
requestedAt: new Date(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record the decision. Written only against a row still PENDING, so two
|
||||||
|
* approvers racing on the same pairing cannot both succeed — the second
|
||||||
|
* update matches nothing and the caller sees `false`.
|
||||||
|
*/
|
||||||
|
async decide(
|
||||||
|
id: string,
|
||||||
|
status:
|
||||||
|
| ConsolidationApprovalStatus.Approved
|
||||||
|
| ConsolidationApprovalStatus.Rejected,
|
||||||
|
decidedBy: string | null,
|
||||||
|
decisionNote?: string | null,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await this.repository.update(
|
||||||
|
{ id, status: ConsolidationApprovalStatus.Pending },
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
decidedBy,
|
||||||
|
decidedAt: new Date(),
|
||||||
|
decisionNote: decisionNote ?? null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Undecided requests covering any of these bookings (list badging). */
|
||||||
|
findPendingForBookings(
|
||||||
|
bookingIds: string[],
|
||||||
|
): Promise<ConsolidationApproval[]> {
|
||||||
|
if (bookingIds.length === 0) return Promise.resolve([]);
|
||||||
|
return this.repository.find({
|
||||||
|
where: [
|
||||||
|
{ bookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending },
|
||||||
|
{
|
||||||
|
partnerBookingId: In(bookingIds),
|
||||||
|
status: ConsolidationApprovalStatus.Pending,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,3 +125,59 @@ export class OperationReviewDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A staff decision applied to BOTH halves of a consolidated pair. The two
|
||||||
|
* bookings share a wagon, so they advance or cancel together — never one alone.
|
||||||
|
*/
|
||||||
|
export class PairedDecisionDto {
|
||||||
|
@ApiProperty({
|
||||||
|
enum: ["accept", "cancel", "operationAccept", "requestChanges"],
|
||||||
|
description: 'Which staff decision to apply to both bookings.',
|
||||||
|
})
|
||||||
|
@IsIn(["accept", "cancel", "operationAccept", "requestChanges"])
|
||||||
|
decision!: "accept" | "cancel" | "operationAccept" | "requestChanges";
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Cancellation reason (decision=cancel)." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Message to the customer (decision=requestChanges).",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
note?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Contract validity window in days (decision=accept).",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
validityDays?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Approve a shared-wagon pairing. The note is optional context for the audit. */
|
||||||
|
export class ApproveConsolidationDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Optional note recorded with the approval.",
|
||||||
|
maxLength: 500,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject a shared-wagon pairing. A reason is mandatory — GL has to act on it. */
|
||||||
|
export class RejectConsolidationDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
"Why the pairing is rejected. Sent back to GL on both bookings.",
|
||||||
|
maxLength: 500,
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ export const BOOKING_STATUSES = [
|
|||||||
// the booking enters the batch holding pool.
|
// the booking enters the batch holding pool.
|
||||||
'OPERATION_REQUEST_PENDING',
|
'OPERATION_REQUEST_PENDING',
|
||||||
'OPERATION_CHANGES_REQUESTED',
|
'OPERATION_CHANGES_REQUESTED',
|
||||||
|
// Shared-wagon review gate: a consolidated pair waits for a human decision
|
||||||
|
// before either half reaches Operations. Two customers' cargo on one wagon is
|
||||||
|
// a commercial call, so it is never auto-advanced.
|
||||||
|
'CONSOLIDATION_APPROVAL_PENDING',
|
||||||
'OPERATION_PRICE_PENDING_CONFIRM',
|
'OPERATION_PRICE_PENDING_CONFIRM',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||||
|
|
||||||
|
import { Booking } from "./booking.entity";
|
||||||
|
|
||||||
|
export enum ConsolidationApprovalStatus {
|
||||||
|
Pending = "PENDING",
|
||||||
|
Approved = "APPROVED",
|
||||||
|
Rejected = "REJECTED",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approval gate for a consolidated (shared-wagon) booking pair.
|
||||||
|
*
|
||||||
|
* A booking that fills its own wagons goes straight from GL completion to the
|
||||||
|
* operations queue. A consolidated one does not: two customers' cargo rides one
|
||||||
|
* physical wagon, under two separate invoices and two separate liabilities. That
|
||||||
|
* pairing is a commercial decision, so a person reviews it before Operations
|
||||||
|
* sees either half.
|
||||||
|
*
|
||||||
|
* The pair is approved as a UNIT — one row covers both halves — so nobody can
|
||||||
|
* approve one side of a shared wagon and leave the other pending. Rows are never
|
||||||
|
* deleted: decided rows are the audit trail of who approved which pairing, when,
|
||||||
|
* and why.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: "freight", name: "consolidation_approvals" })
|
||||||
|
@Index(["bookingId", "status"])
|
||||||
|
@Index(["status"])
|
||||||
|
export class ConsolidationApproval extends BaseEntity {
|
||||||
|
@Column({ name: "booking_id", type: "uuid" })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Booking)
|
||||||
|
@JoinColumn({ name: "booking_id" })
|
||||||
|
booking?: Booking;
|
||||||
|
|
||||||
|
/** The other half of the shared wagon. */
|
||||||
|
@Column({ name: "partner_booking_id", type: "uuid" })
|
||||||
|
partnerBookingId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Booking)
|
||||||
|
@JoinColumn({ name: "partner_booking_id" })
|
||||||
|
partnerBooking?: Booking;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "status",
|
||||||
|
type: "enum",
|
||||||
|
enum: ConsolidationApprovalStatus,
|
||||||
|
default: ConsolidationApprovalStatus.Pending,
|
||||||
|
})
|
||||||
|
status!: ConsolidationApprovalStatus;
|
||||||
|
|
||||||
|
/** IAM user id of the GL staff whose completion created the pairing. */
|
||||||
|
@Column({ name: "requested_by", type: "uuid", nullable: true })
|
||||||
|
requestedBy?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "requested_at", type: "timestamptz", default: () => "now()" })
|
||||||
|
requestedAt!: Date;
|
||||||
|
|
||||||
|
/** IAM user id of the approver; null while pending. */
|
||||||
|
@Column({ name: "decided_by", type: "uuid", nullable: true })
|
||||||
|
decidedBy?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "decided_at", type: "timestamptz", nullable: true })
|
||||||
|
decidedAt?: Date | null;
|
||||||
|
|
||||||
|
/** Why it was approved or rejected. Required on reject, optional on approve. */
|
||||||
|
@Column({
|
||||||
|
name: "decision_note",
|
||||||
|
type: "varchar",
|
||||||
|
length: 500,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
decisionNote?: string | null;
|
||||||
|
|
||||||
|
// ── Snapshot ──────────────────────────────────────────────────────────────
|
||||||
|
// Copied at request time so the audit trail still reads correctly after the
|
||||||
|
// bookings themselves move on (rebooked to another day, cancelled, renamed).
|
||||||
|
|
||||||
|
@Column({ name: "scheduled_date", type: "timestamptz", nullable: true })
|
||||||
|
scheduledDate?: Date | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "booking_reference",
|
||||||
|
type: "varchar",
|
||||||
|
length: 50,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
bookingReference?: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "partner_booking_reference",
|
||||||
|
type: "varchar",
|
||||||
|
length: 50,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
partnerBookingReference?: string | null;
|
||||||
|
}
|
||||||
73
apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts
Normal file
73
apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import type { ConfigType } from '@nestjs/config';
|
||||||
|
import { NotificationType, type NotifyInput } from '@edr/types';
|
||||||
|
|
||||||
|
import chatConfig from '../../config/chat.config';
|
||||||
|
import { MatrixClient } from './matrix.client';
|
||||||
|
|
||||||
|
const FALLBACK_ROOM = { alias: 'freight-alerts', name: 'Freight Alerts' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort per-type routing to an existing dept room. Anything not listed
|
||||||
|
* (including GENERIC) falls through to #freight-alerts — safer than a wrong
|
||||||
|
* guess at which department a type belongs to. Extend as real usage shows
|
||||||
|
* which types actually want a dept room instead of the shared feed.
|
||||||
|
*
|
||||||
|
* `name` matters only if this bridge is the very first thing to touch that
|
||||||
|
* alias (normally the nightly/on-demand reconcile creates dept rooms first,
|
||||||
|
* with the position's real name) — ensureRoom never renames an existing
|
||||||
|
* room, so this must match what ChatProvisioningService would have used.
|
||||||
|
*/
|
||||||
|
const ROOM_FOR_TYPE: Partial<Record<NotificationType, { alias: string; name: string }>> = {
|
||||||
|
[NotificationType.REQUEST_SUBMITTED]: { alias: 'dept-operation', name: 'Operation' },
|
||||||
|
[NotificationType.CLEARANCE_REVIEW]: { alias: 'dept-operation', name: 'Operation' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors BACKOFFICE-audience notifications into chat so staff see them
|
||||||
|
* without having the inbox open. Hooked once into
|
||||||
|
* NotificationInboxService.notify() — every one of that service's ~20
|
||||||
|
* callers gets this for free.
|
||||||
|
*
|
||||||
|
* Gated on BACKOFFICE only: notify() also serves PORTAL (customer)
|
||||||
|
* notifications, which must never land in an internal staff room.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ChatBridgeService {
|
||||||
|
private readonly logger = new Logger(ChatBridgeService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(chatConfig.KEY)
|
||||||
|
private readonly config: ConfigType<typeof chatConfig>,
|
||||||
|
private readonly matrix: MatrixClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async bridge(input: NotifyInput): Promise<void> {
|
||||||
|
if (!this.config.enabled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const room = ROOM_FOR_TYPE[input.type] ?? FALLBACK_ROOM;
|
||||||
|
const roomId = await this.matrix.ensureRoom(room.alias, room.name);
|
||||||
|
const body = input.link ? `${input.title}\n${input.body}\n${input.link}` : `${input.title}\n${input.body}`;
|
||||||
|
const html = `<strong>${escapeHtml(input.title)}</strong><br/>${escapeHtml(input.body)}${
|
||||||
|
input.link ? `<br/><a href="${escapeHtml(input.link)}">${escapeHtml(input.link)}</a>` : ''
|
||||||
|
}`;
|
||||||
|
await this.matrix.sendMessage(roomId, body, html);
|
||||||
|
} catch (err) {
|
||||||
|
// Same contract as NotificationInboxService.notify(): a chat-bridge
|
||||||
|
// failure must never break or roll back the notification that
|
||||||
|
// triggered it.
|
||||||
|
this.logger.error(
|
||||||
|
`Chat bridge failed for ${input.type}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { MatrixClient } from './matrix.client';
|
||||||
|
|
||||||
|
/** edr-org.seeder.ts's EDR_ORG_KEY / EDR_UNIT_KEY — the org is currently flat
|
||||||
|
* (one org, one unit), so this is the entire scope of what gets provisioned. */
|
||||||
|
const ORG_KEY = 'edr_freight';
|
||||||
|
const UNIT_KEY = 'edr_freight_app';
|
||||||
|
|
||||||
|
const SPACE_ALIAS = 'edr-freight';
|
||||||
|
const GENERAL_ALIAS = 'general';
|
||||||
|
|
||||||
|
interface PositionHolder {
|
||||||
|
positionKey: string;
|
||||||
|
positionName: string;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReconcileResult {
|
||||||
|
rooms: number;
|
||||||
|
joined: number;
|
||||||
|
kicked: number;
|
||||||
|
deactivated: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps Matrix rooms and their membership in sync with IAM's unit/position
|
||||||
|
* tree. There is no local hook on "employee position changed" — IAM writes
|
||||||
|
* happen inside the vendored @tria-plc/iamapi-common package — so this is a
|
||||||
|
* reconcile loop, not an event handler: nightly, plus on-demand via
|
||||||
|
* POST /chat/sync.
|
||||||
|
*
|
||||||
|
* Room identity is a deterministic alias (#dept-<positionKey>), not a stored
|
||||||
|
* mapping table — resolved via the directory API, created on first miss.
|
||||||
|
* Room membership is diffed against Matrix's own joined_members, not a local
|
||||||
|
* snapshot — so a user removed from IAM disappears from chat on the very
|
||||||
|
* next reconcile, with no extra state for this service to own.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ChatProvisioningService {
|
||||||
|
private readonly logger = new Logger(ChatProvisioningService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
private readonly matrix: MatrixClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_DAY_AT_3AM, { name: 'chat-provisioning-reconcile' })
|
||||||
|
async scheduledReconcile(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await this.reconcile();
|
||||||
|
this.logger.log(
|
||||||
|
`Chat reconcile: ${result.rooms} room(s), ${result.joined} joined, ` +
|
||||||
|
`${result.kicked} kicked, ${result.deactivated} deactivated`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Never throws into the scheduler — chat provisioning must not be able
|
||||||
|
// to take down anything else on the cron registry.
|
||||||
|
this.logger.error(
|
||||||
|
`Chat reconcile failed: ${(err as Error).message}`,
|
||||||
|
(err as Error).stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every current holder in the unit, or just one person's rows when `userId` is given. */
|
||||||
|
private async currentHolders(userId?: string): Promise<PositionHolder[]> {
|
||||||
|
return this.dataSource.query(
|
||||||
|
`SELECT p.key AS "positionKey",
|
||||||
|
COALESCE(p.name->>'en', p.key) AS "positionName",
|
||||||
|
e.user_id AS "userId",
|
||||||
|
COALESCE(iu.name->>'en', iu.username, iu.email) AS "userName"
|
||||||
|
FROM iam.employee_positions ep
|
||||||
|
JOIN iam.employees e ON e.id = ep.employee_id
|
||||||
|
JOIN iam.positions p ON p.id = ep.position_id
|
||||||
|
JOIN iam.units u ON u.id = p.unit_id
|
||||||
|
JOIN iam.organizations o ON o.id = u.organization_id
|
||||||
|
JOIN iam.users iu ON iu.id = e.user_id
|
||||||
|
WHERE ep.is_current = true
|
||||||
|
AND e.is_current = true
|
||||||
|
AND o.key = $1
|
||||||
|
AND u.key = $2
|
||||||
|
${userId ? 'AND e.user_id = $3' : ''}`,
|
||||||
|
userId ? [ORG_KEY, UNIT_KEY, userId] : [ORG_KEY, UNIT_KEY],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put one person in their rooms right now.
|
||||||
|
*
|
||||||
|
* {@link reconcile} is nightly, so without this a new employee's first
|
||||||
|
* sign-in shows an empty client until 3AM — the SSO handoff creates their
|
||||||
|
* account but joins them to nothing. Called on every /chat/sso, so it is
|
||||||
|
* scoped to the one user (a full reconcile per click would be a room-count
|
||||||
|
* multiple of Matrix calls) and every step is get-or-create.
|
||||||
|
*
|
||||||
|
* Someone holding no current position in the unit joins nothing, by the same
|
||||||
|
* rule the reconcile uses — chat membership follows the org tree.
|
||||||
|
*/
|
||||||
|
async joinUserRooms(userId: string, displayName: string): Promise<number> {
|
||||||
|
const positions = await this.currentHolders(userId);
|
||||||
|
if (positions.length === 0) return 0;
|
||||||
|
|
||||||
|
const mxid = this.matrix.mxidFor(userId, displayName);
|
||||||
|
// The JWT login auto-registers too, but that happens after this runs and
|
||||||
|
// the admin join API 404s on an account that does not exist yet.
|
||||||
|
await this.matrix.ensureUser(mxid, displayName);
|
||||||
|
|
||||||
|
const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', {
|
||||||
|
isSpace: true,
|
||||||
|
});
|
||||||
|
const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', {
|
||||||
|
parentSpaceId: spaceId,
|
||||||
|
});
|
||||||
|
await this.matrix.ensureJoined(generalRoomId, mxid);
|
||||||
|
|
||||||
|
for (const position of positions) {
|
||||||
|
const roomId = await this.matrix.ensureRoom(
|
||||||
|
`dept-${position.positionKey}`,
|
||||||
|
position.positionName,
|
||||||
|
{ parentSpaceId: spaceId },
|
||||||
|
);
|
||||||
|
await this.matrix.ensureJoined(roomId, mxid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions.length + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */
|
||||||
|
private async syncMembership(
|
||||||
|
roomId: string,
|
||||||
|
desiredUserIds: Set<string>,
|
||||||
|
botMxid: string,
|
||||||
|
): Promise<{ joined: number; kicked: string[] }> {
|
||||||
|
const current = await this.matrix.joinedMembers(roomId);
|
||||||
|
const currentSet = new Set(current.filter((id) => id !== botMxid));
|
||||||
|
|
||||||
|
let joined = 0;
|
||||||
|
for (const userId of desiredUserIds) {
|
||||||
|
if (!currentSet.has(userId)) {
|
||||||
|
await this.matrix.ensureJoined(roomId, userId);
|
||||||
|
joined += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kicked: string[] = [];
|
||||||
|
for (const userId of currentSet) {
|
||||||
|
if (!desiredUserIds.has(userId)) {
|
||||||
|
await this.matrix.kick(roomId, userId, 'No longer assigned to this room');
|
||||||
|
kicked.push(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { joined, kicked };
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconcile(): Promise<ReconcileResult> {
|
||||||
|
const holders = await this.currentHolders();
|
||||||
|
const botMxid = await this.matrix.whoami();
|
||||||
|
|
||||||
|
const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', {
|
||||||
|
isSpace: true,
|
||||||
|
});
|
||||||
|
const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', {
|
||||||
|
parentSpaceId: spaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allUserIds = new Set(
|
||||||
|
holders.map((h) => this.matrix.mxidFor(h.userId, h.userName)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Accounts are otherwise only created lazily on first JWT login (see
|
||||||
|
// ChatSsoService) — force-joining someone who has never clicked "Chat"
|
||||||
|
// yet 404s ("User not found") without this.
|
||||||
|
const seenUserIds = new Set<string>();
|
||||||
|
for (const h of holders) {
|
||||||
|
const mxid = this.matrix.mxidFor(h.userId, h.userName);
|
||||||
|
if (seenUserIds.has(mxid)) continue;
|
||||||
|
seenUserIds.add(mxid);
|
||||||
|
await this.matrix.ensureUser(mxid, h.userName);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rooms = 2; // space + general
|
||||||
|
let joined = 0;
|
||||||
|
let kicked = 0;
|
||||||
|
// A user kicked from anything while holding zero current positions
|
||||||
|
// anywhere in the unit (allUserIds spans every position) is a full
|
||||||
|
// leaver, not just moved between positions — deactivate their account.
|
||||||
|
const kickedUserIds = new Set<string>();
|
||||||
|
|
||||||
|
const generalDiff = await this.syncMembership(generalRoomId, allUserIds, botMxid);
|
||||||
|
joined += generalDiff.joined;
|
||||||
|
kicked += generalDiff.kicked.length;
|
||||||
|
generalDiff.kicked.forEach((uid) => kickedUserIds.add(uid));
|
||||||
|
|
||||||
|
const byPosition = new Map<string, { name: string; userIds: Set<string> }>();
|
||||||
|
for (const h of holders) {
|
||||||
|
const entry = byPosition.get(h.positionKey) ?? {
|
||||||
|
name: h.positionName,
|
||||||
|
userIds: new Set<string>(),
|
||||||
|
};
|
||||||
|
entry.userIds.add(this.matrix.mxidFor(h.userId, h.userName));
|
||||||
|
byPosition.set(h.positionKey, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [positionKey, { name, userIds }] of byPosition) {
|
||||||
|
const roomId = await this.matrix.ensureRoom(`dept-${positionKey}`, name, {
|
||||||
|
parentSpaceId: spaceId,
|
||||||
|
});
|
||||||
|
rooms += 1;
|
||||||
|
|
||||||
|
const diff = await this.syncMembership(roomId, userIds, botMxid);
|
||||||
|
joined += diff.joined;
|
||||||
|
kicked += diff.kicked.length;
|
||||||
|
diff.kicked.forEach((uid) => kickedUserIds.add(uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
let deactivated = 0;
|
||||||
|
for (const userId of kickedUserIds) {
|
||||||
|
if (allUserIds.has(userId)) continue; // moved position, still current elsewhere
|
||||||
|
try {
|
||||||
|
await this.matrix.deactivateUser(userId);
|
||||||
|
deactivated += 1;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to deactivate departed user ${userId}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rooms, joined, kicked, deactivated };
|
||||||
|
}
|
||||||
|
}
|
||||||
91
apps/edr-freight-api/src/modules/chat/chat-sso.service.ts
Normal file
91
apps/edr-freight-api/src/modules/chat/chat-sso.service.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import type { ConfigType } from '@nestjs/config';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
import { SignJWT } from 'jose';
|
||||||
|
|
||||||
|
import chatConfig from '../../config/chat.config';
|
||||||
|
import { ChatProvisioningService } from './chat-provisioning.service';
|
||||||
|
import { MatrixClient, chatLocalpart } from './matrix.client';
|
||||||
|
|
||||||
|
/** Long enough for one login call, short enough to be worthless if it leaks. */
|
||||||
|
const JWT_TTL_SECONDS = 60;
|
||||||
|
|
||||||
|
function displayName(user: TCurrentUser): string {
|
||||||
|
return (
|
||||||
|
user.name?.en ||
|
||||||
|
Object.values(user.name ?? {}).find((v) => typeof v === 'string' && v) ||
|
||||||
|
user.username ||
|
||||||
|
user.email
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SSO handoff: turn an already-authenticated freight session into a
|
||||||
|
* one-click Element sign-in link, with no second password anywhere.
|
||||||
|
*
|
||||||
|
* 1. Sign a short-lived JWT asserting this user's id (Synapse's
|
||||||
|
* org.matrix.login.jwt auto-registers the account on first use).
|
||||||
|
* 2. Trade that JWT for a real Matrix session.
|
||||||
|
* 3. Hand the caller a link to Element's sso.html shim, which writes that
|
||||||
|
* session into localStorage and drops the user straight into Element.
|
||||||
|
*
|
||||||
|
* Step 3 used to mint a one-shot login_token and let Element redeem it. That
|
||||||
|
* path is capped at one request per user per minute by a limiter hardcoded in
|
||||||
|
* Synapse, so a second click inside a minute returned M_LIMIT_EXCEEDED — and a
|
||||||
|
* spent token surfaces in Element as "Incorrect username and/or password".
|
||||||
|
* Element accepts a plaintext token out of localStorage (Lifecycle.ts
|
||||||
|
* getStoredToken/tryDecryptToken), so handing over the session we already hold
|
||||||
|
* removes both failure modes and one round-trip.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ChatSsoService {
|
||||||
|
private readonly logger = new Logger(ChatSsoService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(chatConfig.KEY)
|
||||||
|
private readonly config: ConfigType<typeof chatConfig>,
|
||||||
|
private readonly matrix: MatrixClient,
|
||||||
|
private readonly provisioning: ChatProvisioningService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getSsoUrl(user: TCurrentUser): Promise<{ url: string }> {
|
||||||
|
const secret = new TextEncoder().encode(this.config.jwtSecret);
|
||||||
|
const name = displayName(user);
|
||||||
|
|
||||||
|
// Before the link, not after: the reconcile that fills rooms is nightly, so
|
||||||
|
// a first sign-in would otherwise open an empty client. Best-effort —
|
||||||
|
// failing to join a room is no reason to refuse someone a sign-in link.
|
||||||
|
try {
|
||||||
|
await this.provisioning.joinUserRooms(user.id, name);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Room join on sign-in failed for ${user.id}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Synapse takes the localpart straight from `sub` on auto-registration, so
|
||||||
|
// this must be byte-identical to what ChatProvisioningService derives for
|
||||||
|
// the same person — otherwise SSO signs them into one account while the
|
||||||
|
// reconcile force-joins a different one into the rooms.
|
||||||
|
const jwt = await new SignJWT({ name })
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setSubject(chatLocalpart(user.id, name))
|
||||||
|
.setIssuer('edr-freight-api')
|
||||||
|
.setAudience('matrix')
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(`${JWT_TTL_SECONDS}s`)
|
||||||
|
.sign(secret);
|
||||||
|
|
||||||
|
const session = await this.matrix.loginWithJwt(jwt);
|
||||||
|
|
||||||
|
// Session goes in the URL fragment, never the query: a fragment is not sent
|
||||||
|
// to any server, so the token stays out of Element's access log, and
|
||||||
|
// sso.html replaces the entry so it does not linger in history either.
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
hs: this.config.publicBaseUrl,
|
||||||
|
t: session.access_token,
|
||||||
|
u: session.user_id,
|
||||||
|
d: session.device_id,
|
||||||
|
});
|
||||||
|
return { url: `${this.config.webUrl}/sso.html#${params.toString()}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
35
apps/edr-freight-api/src/modules/chat/chat.controller.ts
Normal file
35
apps/edr-freight-api/src/modules/chat/chat.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator';
|
||||||
|
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
|
import { ChatSync } from '../../common/booking-guards';
|
||||||
|
import { ChatProvisioningService } from './chat-provisioning.service';
|
||||||
|
import { ChatSsoService } from './chat-sso.service';
|
||||||
|
|
||||||
|
@ApiTags('chat')
|
||||||
|
@Controller('chat')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class ChatController {
|
||||||
|
constructor(
|
||||||
|
private readonly sso: ChatSsoService,
|
||||||
|
private readonly provisioning: ChatProvisioningService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('sso')
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
@ApiOperation({ summary: 'One-click sign-in link into EDR internal chat' })
|
||||||
|
getSso(@CurrentUser() user: TCurrentUser) {
|
||||||
|
return this.sso.getSsoUrl(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sync')
|
||||||
|
@ChatSync()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Re-run the chat room/membership reconcile immediately (normally nightly)',
|
||||||
|
})
|
||||||
|
sync() {
|
||||||
|
return this.provisioning.reconcile();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/edr-freight-api/src/modules/chat/chat.module.ts
Normal file
16
apps/edr-freight-api/src/modules/chat/chat.module.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ChatBridgeService } from './chat-bridge.service';
|
||||||
|
import { ChatController } from './chat.controller';
|
||||||
|
import { ChatProvisioningService } from './chat-provisioning.service';
|
||||||
|
import { ChatSsoService } from './chat-sso.service';
|
||||||
|
import { MatrixClient } from './matrix.client';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ChatController],
|
||||||
|
providers: [MatrixClient, ChatSsoService, ChatProvisioningService, ChatBridgeService],
|
||||||
|
// ChatBridgeService: consumed by NotificationInboxModule to mirror
|
||||||
|
// BACKOFFICE notifications into chat — see notification-inbox.module.ts.
|
||||||
|
exports: [ChatBridgeService],
|
||||||
|
})
|
||||||
|
export class ChatModule {}
|
||||||
35
apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts
Normal file
35
apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { chatLocalpart } from './matrix.client';
|
||||||
|
|
||||||
|
describe('chatLocalpart', () => {
|
||||||
|
it('reads from the name, not the id', () => {
|
||||||
|
expect(
|
||||||
|
chatLocalpart('03f5eb9e-23a0-4413-8d98-8de4b98b1be2', 'Nati Wondi'),
|
||||||
|
).toBe('nati-wondi.03f5eb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('separates two people who share a name', () => {
|
||||||
|
// Both of these are real dev rows — same name, different employees.
|
||||||
|
const a = chatLocalpart('11111111-1111-4111-8111-111111111111', 'MARKOS REGASA');
|
||||||
|
const b = chatLocalpart('22222222-2222-4222-8222-222222222222', 'Markos REGASA');
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is stable for the same person', () => {
|
||||||
|
const id = '7d798218-09de-47a1-98eb-f61ec44e9280';
|
||||||
|
expect(chatLocalpart(id, 'Naod')).toBe(chatLocalpart(id, 'Naod'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still yields a usable localpart for a name that slugs to nothing', () => {
|
||||||
|
expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', 'ናኦድ')).toBe(
|
||||||
|
'user.7d7982',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only emits characters Matrix accepts in a localpart', () => {
|
||||||
|
for (const name of ['Mubarek Jemal Hassen', "N'gozi O_Brien", 'ናኦድ', 'José']) {
|
||||||
|
expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', name)).toMatch(
|
||||||
|
/^[a-z0-9._=\-/]+$/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
320
apps/edr-freight-api/src/modules/chat/matrix.client.ts
Normal file
320
apps/edr-freight-api/src/modules/chat/matrix.client.ts
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { ConfigType } from '@nestjs/config';
|
||||||
|
|
||||||
|
import chatConfig from '../../config/chat.config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper over the handful of Matrix Client-Server + Synapse Admin API
|
||||||
|
* calls this app needs. Not a general Matrix SDK — matrix-js-sdk is a
|
||||||
|
* browser/Element concern; the server side only ever provisions rooms/users
|
||||||
|
* and posts bot messages, so a fetch wrapper is the whole job.
|
||||||
|
*
|
||||||
|
* All admin-scoped calls act as the account behind MATRIX_ADMIN_TOKEN. That
|
||||||
|
* same account also posts the notification-bridge messages (see
|
||||||
|
* ChatBridgeService) — one bot/admin account covers both jobs, no separate
|
||||||
|
* bot user needed.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Localpart of a staff member's MXID: their name, plus the first 6 hex of
|
||||||
|
* their freight user id.
|
||||||
|
*
|
||||||
|
* The tail is not decoration. Names collide — 19 of the 114 users in the dev
|
||||||
|
* IAM share a slug with someone else ("MARKOS REGASA" and "Markos REGASA" are
|
||||||
|
* two different people) — and an MXID is permanent, so a bare slug would hand
|
||||||
|
* two employees the same Matrix account and each other's rooms. The id is
|
||||||
|
* already random, so 6 hex of it separates them without a lookup or a mapping
|
||||||
|
* table, and keeps the derivation pure: ChatSsoService (which mints the JWT
|
||||||
|
* `sub`) and ChatProvisioningService (which force-joins rooms) must agree on
|
||||||
|
* this string exactly or they provision two accounts per person.
|
||||||
|
*/
|
||||||
|
export function chatLocalpart(userId: string, displayName: string): string {
|
||||||
|
const slug = displayName
|
||||||
|
// NFKD splits an accent off its letter; the non-alnum sweep below then
|
||||||
|
// folds the leftover mark into the same `-` run as the neighbouring space.
|
||||||
|
.normalize('NFKD')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 40);
|
||||||
|
// Amharic-only names slug to nothing — the tail still makes it unique.
|
||||||
|
return `${slug || 'user'}.${userId.replace(/-/g, '').slice(0, 6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MatrixClient {
|
||||||
|
constructor(
|
||||||
|
@Inject(chatConfig.KEY)
|
||||||
|
private readonly config: ConfigType<typeof chatConfig>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias localparts go in a URL path segment, so a `/` in one is fatal:
|
||||||
|
* Synapse decodes the path before routing, and `%2F` splits the request into
|
||||||
|
* a route that doesn't exist ("M_UNRECOGNIZED"). resolveAlias reads that 404
|
||||||
|
* as "no such room" and ensureRoom then tries to create the same broken alias
|
||||||
|
* on every run. Position keys are `edr_freight_app/opn` shaped, so this hits
|
||||||
|
* every dept room but the handful whose key happens to be a bare word.
|
||||||
|
*/
|
||||||
|
private static aliasSafe(alias: string): string {
|
||||||
|
return alias.replace(/[^A-Za-z0-9._=-]/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `@<localpart>:<server_name>` — the one place this format is assembled. */
|
||||||
|
mxid(localpart: string): string {
|
||||||
|
return `@${localpart}:${this.config.serverName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The MXID of a freight user — see {@link chatLocalpart}. */
|
||||||
|
mxidFor(userId: string, displayName: string): string {
|
||||||
|
return this.mxid(chatLocalpart(userId, displayName));
|
||||||
|
}
|
||||||
|
|
||||||
|
get serverName(): string {
|
||||||
|
return this.config.serverName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
token: string = this.config.adminToken,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(`${this.config.baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(
|
||||||
|
`Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No auth — only /login accepts a bare JWT with nothing else on the request. */
|
||||||
|
private async publicRequest<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(`${this.config.baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(
|
||||||
|
`Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 404 → null. Every other non-2xx still throws via {@link request}. */
|
||||||
|
private async requestOrNull<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
token?: string,
|
||||||
|
): Promise<T | null> {
|
||||||
|
const res = await fetch(`${this.config.baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: `Bearer ${token ?? this.config.adminToken}` },
|
||||||
|
});
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(
|
||||||
|
`Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign an already-authenticated freight session into a Matrix session. */
|
||||||
|
loginWithJwt(
|
||||||
|
jwt: string,
|
||||||
|
): Promise<{ access_token: string; user_id: string; device_id: string }> {
|
||||||
|
return this.publicRequest('POST', '/_matrix/client/v3/login', {
|
||||||
|
type: 'org.matrix.login.jwt',
|
||||||
|
token: jwt,
|
||||||
|
initial_device_display_name: 'EDR Backoffice',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The account behind MATRIX_ADMIN_TOKEN — used to exclude the bot itself from membership reconciliation. */
|
||||||
|
async whoami(): Promise<string> {
|
||||||
|
const res = await this.request<{ user_id: string }>(
|
||||||
|
'GET',
|
||||||
|
'/_matrix/client/v3/account/whoami',
|
||||||
|
);
|
||||||
|
return res.user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Currently-joined user ids for a room (not full member-event state). */
|
||||||
|
async joinedMembers(roomId: string): Promise<string[]> {
|
||||||
|
const res = await this.request<{ joined: Record<string, unknown> }>(
|
||||||
|
'GET',
|
||||||
|
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/joined_members`,
|
||||||
|
);
|
||||||
|
return Object.keys(res.joined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No getLoginToken here on purpose. POST /_matrix/client/v1/login/get_token
|
||||||
|
// is rate limited to 1 request per user per MINUTE, hardcoded in Synapse
|
||||||
|
// (rest/client/login_token_request.py: "Ratelimit aggressively … could be
|
||||||
|
// abused by a malicious client to create many sessions") and not settable
|
||||||
|
// from homeserver.yaml. A second click inside a minute got M_LIMIT_EXCEEDED.
|
||||||
|
// ChatSsoService hands Element the session from loginWithJwt directly
|
||||||
|
// instead, which needs no second call.
|
||||||
|
|
||||||
|
/** null when the alias doesn't resolve to a room yet. */
|
||||||
|
resolveAlias(alias: string): Promise<{ room_id: string } | null> {
|
||||||
|
return this.requestOrNull(
|
||||||
|
'GET',
|
||||||
|
`/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoom(input: {
|
||||||
|
alias: string;
|
||||||
|
name: string;
|
||||||
|
topic?: string;
|
||||||
|
isSpace?: boolean;
|
||||||
|
parentSpaceId?: string;
|
||||||
|
}): Promise<{ room_id: string }> {
|
||||||
|
return this.request('POST', '/_matrix/client/v3/createRoom', {
|
||||||
|
room_alias_name: input.alias,
|
||||||
|
name: input.name,
|
||||||
|
topic: input.topic,
|
||||||
|
preset: 'private_chat',
|
||||||
|
creation_content: input.isSpace ? { type: 'm.space' } : undefined,
|
||||||
|
initial_state: input.parentSpaceId
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'm.space.parent',
|
||||||
|
state_key: input.parentSpaceId,
|
||||||
|
content: { via: [this.config.serverName], canonical: true },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addToSpace(spaceId: string, childRoomId: string): Promise<void> {
|
||||||
|
return this.request(
|
||||||
|
'PUT',
|
||||||
|
`/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state/m.space.child/${encodeURIComponent(childRoomId)}`,
|
||||||
|
{ via: [this.config.serverName] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get-or-create by alias — the room identity scheme this whole module
|
||||||
|
* relies on instead of a local id-mapping table. Idempotent: safe to call
|
||||||
|
* on every reconcile run and every bridged notification alike.
|
||||||
|
*/
|
||||||
|
async ensureRoom(
|
||||||
|
rawAlias: string,
|
||||||
|
name: string,
|
||||||
|
opts: { isSpace?: boolean; parentSpaceId?: string } = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const alias = MatrixClient.aliasSafe(rawAlias);
|
||||||
|
const existing = await this.resolveAlias(`#${alias}:${this.config.serverName}`);
|
||||||
|
if (existing) return existing.room_id;
|
||||||
|
|
||||||
|
const { room_id } = await this.createRoom({
|
||||||
|
alias,
|
||||||
|
name,
|
||||||
|
isSpace: opts.isSpace,
|
||||||
|
parentSpaceId: opts.parentSpaceId,
|
||||||
|
});
|
||||||
|
if (opts.parentSpaceId) {
|
||||||
|
await this.addToSpace(opts.parentSpaceId, room_id);
|
||||||
|
}
|
||||||
|
return room_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the account if absent (no password — this deployment is JWT-SSO
|
||||||
|
* only), or no-op if it already exists. Needed before force-joining a
|
||||||
|
* position holder who has never clicked "Chat": accounts are otherwise
|
||||||
|
* only created lazily on first JWT login, and the admin join API 404s
|
||||||
|
* ("User not found") on an account that doesn't exist yet.
|
||||||
|
*/
|
||||||
|
async ensureUser(userId: string, displayName?: string): Promise<void> {
|
||||||
|
const existing = await this.requestOrNull<{ name: string }>(
|
||||||
|
'GET',
|
||||||
|
`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`,
|
||||||
|
);
|
||||||
|
if (existing) return;
|
||||||
|
await this.request(
|
||||||
|
'PUT',
|
||||||
|
`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`,
|
||||||
|
displayName ? { displayname: displayName } : {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-admin force-join — no invite to accept, works even mid-outage for the invitee. */
|
||||||
|
forceJoin(roomIdOrAlias: string, userId: string): Promise<void> {
|
||||||
|
return this.request(
|
||||||
|
'POST',
|
||||||
|
`/_synapse/admin/v1/join/${encodeURIComponent(roomIdOrAlias)}`,
|
||||||
|
{ user_id: userId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-join, treating "already a member" as success. Synapse answers a
|
||||||
|
* repeat join with 403 `M_FORBIDDEN: "<user> is already in the room."`, which
|
||||||
|
* is a failure only if you assumed you knew the membership first. Callers
|
||||||
|
* that just want someone in a room (sign-in, reconcile racing itself) want
|
||||||
|
* this; the raw 403 tells them nothing they can act on.
|
||||||
|
*/
|
||||||
|
async ensureJoined(roomIdOrAlias: string, userId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.forceJoin(roomIdOrAlias, userId);
|
||||||
|
} catch (err) {
|
||||||
|
if (!/already in the room/i.test((err as Error).message)) throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kick(roomId: string, userId: string, reason: string): Promise<void> {
|
||||||
|
return this.request(
|
||||||
|
'POST',
|
||||||
|
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/kick`,
|
||||||
|
{ user_id: userId, reason },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deactivating (rather than just kicking) a leaver's account revokes all their sessions. */
|
||||||
|
deactivateUser(userId: string): Promise<void> {
|
||||||
|
return this.request(
|
||||||
|
'POST',
|
||||||
|
`/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`,
|
||||||
|
{ erase: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessage(roomId: string, body: string, formattedBody?: string): Promise<void> {
|
||||||
|
const txnId = `edr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
return this.request(
|
||||||
|
'PUT',
|
||||||
|
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
|
||||||
|
formattedBody
|
||||||
|
? {
|
||||||
|
msgtype: 'm.text',
|
||||||
|
body,
|
||||||
|
format: 'org.matrix.custom.html',
|
||||||
|
formatted_body: formattedBody,
|
||||||
|
}
|
||||||
|
: { msgtype: 'm.text', body },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -288,10 +288,25 @@ export class CompaniesController {
|
|||||||
dto.roles,
|
dto.roles,
|
||||||
dto.nationality,
|
dto.nationality,
|
||||||
dto.cooperative,
|
dto.cooperative,
|
||||||
|
dto.investorLicence,
|
||||||
);
|
);
|
||||||
return new CompanyInfoResponseDto(profile, company);
|
return new CompanyInfoResponseDto(profile, company);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("onboarding/revert-to-etrade")
|
||||||
|
@PortalCustomer()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Drop the manual-registration route (co-operative or foreign investment licence): clear the typed registration and reopen onboarding so the TIN is verified against eTrade",
|
||||||
|
})
|
||||||
|
async revertToRegularCompany(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
): Promise<CompanyInfoResponseDto> {
|
||||||
|
const { profile, company } =
|
||||||
|
await this.companiesService.revertToRegularCompany(user.id);
|
||||||
|
return new CompanyInfoResponseDto(profile, company);
|
||||||
|
}
|
||||||
|
|
||||||
@Post("company-profile")
|
@Post("company-profile")
|
||||||
@PortalCustomer()
|
@PortalCustomer()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { CompaniesService } from "./companies.service";
|
||||||
|
import {
|
||||||
|
CompanyNationality,
|
||||||
|
CompanyStatus,
|
||||||
|
CompanyType,
|
||||||
|
} from "./entities/company.entity";
|
||||||
|
import {
|
||||||
|
ProfileStatus,
|
||||||
|
ProfileType,
|
||||||
|
} from "./entities/company-profile.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A foreign company on an Investment Commission licence has no eTrade record,
|
||||||
|
* so it types its registration — and the flag saying so is what makes the
|
||||||
|
* backoffice treat those fields as unverified. Two things must hold: only a
|
||||||
|
* foreign company can carry it, and dropping it must not leave the typed
|
||||||
|
* registration behind looking like eTrade's.
|
||||||
|
*
|
||||||
|
* The dropping half is shared with the co-operative route, which has the same
|
||||||
|
* "eTrade holds nothing" shape, so it is exercised here for both.
|
||||||
|
*/
|
||||||
|
function makeService(company: Record<string, unknown> | null) {
|
||||||
|
const companiesRepo = {
|
||||||
|
findById: jest.fn(async () => company),
|
||||||
|
update: jest.fn(async () => null),
|
||||||
|
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||||
|
id: "company-1",
|
||||||
|
...row,
|
||||||
|
})),
|
||||||
|
existsByTin: jest.fn(async () => false),
|
||||||
|
};
|
||||||
|
const companyProfilesRepo = {
|
||||||
|
findByCompanyId: jest.fn(async (): Promise<Record<string, unknown>[]> => []),
|
||||||
|
updateStatus: jest.fn(async () => null),
|
||||||
|
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||||
|
id: "cp-1",
|
||||||
|
...row,
|
||||||
|
})),
|
||||||
|
softDelete: jest.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
const profilesRepo = {
|
||||||
|
findByUserId: jest.fn(async () =>
|
||||||
|
company
|
||||||
|
? { id: "external-1", companyId: "company-1", company: { id: "company-1" } }
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||||
|
id: "external-1",
|
||||||
|
...row,
|
||||||
|
})),
|
||||||
|
update: jest.fn(async () => null),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new CompaniesService(
|
||||||
|
companiesRepo as never,
|
||||||
|
companyProfilesRepo as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
profilesRepo as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(service, "getCompanyInfoByUserId")
|
||||||
|
.mockImplementation(
|
||||||
|
async () =>
|
||||||
|
({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, companiesRepo, companyProfilesRepo, profilesRepo };
|
||||||
|
}
|
||||||
|
|
||||||
|
const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" };
|
||||||
|
|
||||||
|
const start = (
|
||||||
|
service: CompaniesService,
|
||||||
|
nationality: CompanyNationality | undefined,
|
||||||
|
cooperative: boolean,
|
||||||
|
investorLicence: boolean,
|
||||||
|
) =>
|
||||||
|
service.startOnboarding(
|
||||||
|
identity as never,
|
||||||
|
CompanyType.Customer,
|
||||||
|
[ProfileType.importer],
|
||||||
|
nationality,
|
||||||
|
cooperative,
|
||||||
|
investorLicence,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("the foreign investment-licence route", () => {
|
||||||
|
it("refuses the flag for an Ethiopian company", async () => {
|
||||||
|
const { service } = makeService(null);
|
||||||
|
await expect(
|
||||||
|
start(service, CompanyNationality.Ethiopian, false, true),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses the flag alongside the co-operative one", async () => {
|
||||||
|
const { service } = makeService(null);
|
||||||
|
await expect(
|
||||||
|
start(service, CompanyNationality.Foreign, true, true),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the flag on a new foreign draft", async () => {
|
||||||
|
const { service, companiesRepo } = makeService(null);
|
||||||
|
await start(service, CompanyNationality.Foreign, false, true);
|
||||||
|
expect(companiesRepo.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
nationality: CompanyNationality.Foreign,
|
||||||
|
attributes: { investorLicence: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the typed registration and reopens onboarding when switching back to eTrade", async () => {
|
||||||
|
const { service, companiesRepo, profilesRepo } = makeService({
|
||||||
|
id: "company-1",
|
||||||
|
attributes: { investorLicence: true, etradeManagerName: "Typed Name" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.revertToRegularCompany("user-1");
|
||||||
|
|
||||||
|
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
|
||||||
|
string,
|
||||||
|
Record<string, unknown>,
|
||||||
|
];
|
||||||
|
expect(updates.attributes).toEqual({});
|
||||||
|
expect(updates.status).toBe(CompanyStatus.Pending);
|
||||||
|
// The wizard treats a populated registration as a passed lookup, so leaving
|
||||||
|
// any of it behind would walk the customer straight past the eTrade step.
|
||||||
|
expect(updates.licenceNumber).toBeNull();
|
||||||
|
expect(updates.region).toBeNull();
|
||||||
|
expect(profilesRepo.update).toHaveBeenCalledWith("external-1", {
|
||||||
|
onboardingCompleted: false,
|
||||||
|
onboardingStep: "company",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the typed registration when the box is un-ticked on the way back", async () => {
|
||||||
|
const { service, companiesRepo, profilesRepo } = makeService({
|
||||||
|
id: "company-1",
|
||||||
|
nationality: CompanyNationality.Foreign,
|
||||||
|
attributes: { investorLicence: true, etradeManagerName: "Typed Name" },
|
||||||
|
region: "Addis Ababa",
|
||||||
|
licenceNumber: "TYPED-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
await start(service, CompanyNationality.Foreign, false, false);
|
||||||
|
|
||||||
|
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
|
||||||
|
string,
|
||||||
|
Record<string, unknown>,
|
||||||
|
];
|
||||||
|
// The wizard sends both flags; the typed manager does not survive.
|
||||||
|
expect(updates.attributes).toEqual({
|
||||||
|
cooperative: false,
|
||||||
|
investorLicence: false,
|
||||||
|
});
|
||||||
|
expect(updates.licenceNumber).toBeNull();
|
||||||
|
expect(updates.region).toBeNull();
|
||||||
|
// Resume must land back on the company step, or the customer never reaches
|
||||||
|
// the eTrade lookup they just opted back into.
|
||||||
|
expect(profilesRepo.update).toHaveBeenCalledWith("external-1", {
|
||||||
|
onboardingStep: "company",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does the same for a co-operative that stops being one", async () => {
|
||||||
|
const { service, companiesRepo } = makeService({
|
||||||
|
id: "company-1",
|
||||||
|
nationality: CompanyNationality.Ethiopian,
|
||||||
|
attributes: { cooperative: true },
|
||||||
|
region: "Oromia",
|
||||||
|
});
|
||||||
|
|
||||||
|
await start(service, CompanyNationality.Ethiopian, false, false);
|
||||||
|
|
||||||
|
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
|
||||||
|
string,
|
||||||
|
Record<string, unknown>,
|
||||||
|
];
|
||||||
|
expect(updates.region).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the registration alone while the flag stays on", async () => {
|
||||||
|
const { service, companiesRepo, profilesRepo } = makeService({
|
||||||
|
id: "company-1",
|
||||||
|
nationality: CompanyNationality.Foreign,
|
||||||
|
attributes: { investorLicence: true },
|
||||||
|
region: "Addis Ababa",
|
||||||
|
});
|
||||||
|
|
||||||
|
await start(service, CompanyNationality.Foreign, false, true);
|
||||||
|
|
||||||
|
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
|
||||||
|
string,
|
||||||
|
Record<string, unknown>,
|
||||||
|
];
|
||||||
|
expect(updates).not.toHaveProperty("region");
|
||||||
|
expect(profilesRepo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to switch a company that never took a manual-registration route", async () => {
|
||||||
|
const { service } = makeService({ id: "company-1", attributes: {} });
|
||||||
|
await expect(service.revertToRegularCompany("user-1")).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The switch belongs to both manual-registration routes, not just this one. A
|
||||||
|
* co-operative that has since taken out a trade licence had no way back at
|
||||||
|
* all: the wizard is where the flag is chosen, and an onboarded company can no
|
||||||
|
* longer reach it.
|
||||||
|
*/
|
||||||
|
it("switches a co-operative back to eTrade on the same terms", async () => {
|
||||||
|
const { service, companiesRepo, profilesRepo } = makeService({
|
||||||
|
id: "company-1",
|
||||||
|
nationality: CompanyNationality.Ethiopian,
|
||||||
|
attributes: { cooperative: true, etradeManagerName: "Typed Name" },
|
||||||
|
region: "Oromia",
|
||||||
|
licenceNumber: "TYPED-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.revertToRegularCompany("user-1");
|
||||||
|
|
||||||
|
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
|
||||||
|
string,
|
||||||
|
Record<string, unknown>,
|
||||||
|
];
|
||||||
|
expect(updates.attributes).toEqual({});
|
||||||
|
expect(updates.status).toBe(CompanyStatus.Pending);
|
||||||
|
expect(updates.licenceNumber).toBeNull();
|
||||||
|
expect(updates.region).toBeNull();
|
||||||
|
// A co-op owes no per-role business licence; once it stops being one it
|
||||||
|
// does, so the application has to be re-opened and re-reviewed.
|
||||||
|
expect(profilesRepo.update).toHaveBeenCalledWith("external-1", {
|
||||||
|
onboardingCompleted: false,
|
||||||
|
onboardingStep: "company",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approval of a co-op's role was granted without a business licence, because
|
||||||
|
* a co-op owes none. Leaving makes one due, so the approval no longer stands
|
||||||
|
* for what it said.
|
||||||
|
*/
|
||||||
|
it("sends a co-operative's approved roles back for approval", async () => {
|
||||||
|
const { service, companyProfilesRepo } = makeService({
|
||||||
|
id: "company-1",
|
||||||
|
attributes: { cooperative: true },
|
||||||
|
});
|
||||||
|
companyProfilesRepo.findByCompanyId.mockResolvedValue([
|
||||||
|
{ id: "role-active", status: ProfileStatus.Active },
|
||||||
|
{ id: "role-blocked", status: ProfileStatus.Blacklisted },
|
||||||
|
{ id: "role-pending", status: ProfileStatus.Pending },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.revertToRegularCompany("user-1");
|
||||||
|
|
||||||
|
expect(companyProfilesRepo.updateStatus).toHaveBeenCalledWith(
|
||||||
|
"role-active",
|
||||||
|
ProfileStatus.Pending,
|
||||||
|
);
|
||||||
|
// A staff decision is not the customer's to undo by switching registration:
|
||||||
|
// promoting a blocked role to "awaiting approval" would launder the block.
|
||||||
|
expect(companyProfilesRepo.updateStatus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an investor's roles alone — their licences were always due", async () => {
|
||||||
|
const { service, companyProfilesRepo } = makeService({
|
||||||
|
id: "company-1",
|
||||||
|
attributes: { investorLicence: true },
|
||||||
|
});
|
||||||
|
companyProfilesRepo.findByCompanyId.mockResolvedValue([
|
||||||
|
{ id: "role-active", status: ProfileStatus.Active },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.revertToRegularCompany("user-1");
|
||||||
|
|
||||||
|
expect(companyProfilesRepo.updateStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { CompaniesService } from "./companies.service";
|
||||||
|
import { CompanyStatus } from "./entities/company.entity";
|
||||||
|
import {
|
||||||
|
ProfileStatus,
|
||||||
|
ProfileType,
|
||||||
|
} from "./entities/company-profile.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploading a business licence only ever adds a row — nothing overwrites. So a
|
||||||
|
* customer answering a rejection or a document correction used to end up with
|
||||||
|
* the refused licence still listed beside the new one, in the portal and in the
|
||||||
|
* backoffice, with nothing saying which is current. An upload that answers a
|
||||||
|
* reviewer now retires what it answers; an upload with nothing outstanding is a
|
||||||
|
* genuine addition and still just adds.
|
||||||
|
*/
|
||||||
|
interface StoredFile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
createdAt: Date;
|
||||||
|
reviewStatus?: string | null;
|
||||||
|
removed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const T0 = new Date("2026-01-01T00:00:00Z");
|
||||||
|
const REJECTED_AT = new Date("2026-02-01T00:00:00Z");
|
||||||
|
const T2 = new Date("2026-03-01T00:00:00Z");
|
||||||
|
|
||||||
|
function makeService(
|
||||||
|
status: ProfileStatus,
|
||||||
|
files: StoredFile[],
|
||||||
|
reviewedAt: Date | null = null,
|
||||||
|
) {
|
||||||
|
const stored = [...files];
|
||||||
|
const profile = {
|
||||||
|
id: "profile-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
type: ProfileType.importer,
|
||||||
|
status,
|
||||||
|
reviewedAt,
|
||||||
|
};
|
||||||
|
const company = {
|
||||||
|
id: "company-1",
|
||||||
|
status:
|
||||||
|
status === ProfileStatus.Active
|
||||||
|
? CompanyStatus.Active
|
||||||
|
: CompanyStatus.Pending,
|
||||||
|
companyProfiles: [profile],
|
||||||
|
};
|
||||||
|
|
||||||
|
const live = () => stored.filter((f) => !f.removed);
|
||||||
|
const filesService = {
|
||||||
|
upload: jest.fn(async (input: { code: string; file: { originalname: string } }) => {
|
||||||
|
const record = {
|
||||||
|
id: `file-${stored.length + 1}`,
|
||||||
|
name: input.file.originalname,
|
||||||
|
code: input.code,
|
||||||
|
createdAt: T2,
|
||||||
|
size: 1,
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
};
|
||||||
|
stored.push(record);
|
||||||
|
return record;
|
||||||
|
}),
|
||||||
|
findByResource: jest.fn(async () => live()),
|
||||||
|
findWithOpenChangeRequest: jest.fn(async () =>
|
||||||
|
live().filter((f) => f.reviewStatus === "change_requested"),
|
||||||
|
),
|
||||||
|
findById: jest.fn(async (id: string) => ({
|
||||||
|
...stored.find((f) => f.id === id),
|
||||||
|
resource: "company_profiles",
|
||||||
|
resourceId: "profile-1",
|
||||||
|
})),
|
||||||
|
remove: jest.fn(async (id: string) => {
|
||||||
|
const found = stored.find((f) => f.id === id);
|
||||||
|
if (found) found.removed = true;
|
||||||
|
}),
|
||||||
|
clearReview: jest.fn(async (id: string) => {
|
||||||
|
const found = stored.find((f) => f.id === id);
|
||||||
|
if (found) found.reviewStatus = null;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeRequestRepo = {
|
||||||
|
findPendingByCompanyId: jest.fn(async () => null),
|
||||||
|
create: jest.fn(async (row: Record<string, unknown>) => ({ id: "cr-1", ...row })),
|
||||||
|
update: jest.fn(async () => ({ id: "cr-1" })),
|
||||||
|
findByCompanyId: jest.fn(async () => []),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new CompaniesService(
|
||||||
|
{ findById: jest.fn(async () => company) } as never,
|
||||||
|
{ findByCompanyId: jest.fn(async () => [profile]) } as never,
|
||||||
|
changeRequestRepo as never,
|
||||||
|
{} as never,
|
||||||
|
{ findByCompanyId: jest.fn(async () => []) } as never,
|
||||||
|
{} as never,
|
||||||
|
filesService as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ changeRequestSubmitted: jest.fn() } as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(service, "getCompanyInfoByUserId")
|
||||||
|
.mockImplementation(
|
||||||
|
async () => ({ profile: { id: "external-1" }, company }) as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, stored, live, filesService, changeRequestRepo };
|
||||||
|
}
|
||||||
|
|
||||||
|
const upload = (service: CompaniesService) =>
|
||||||
|
service.addProfileLicenseFiles("user-1", "profile-1", [
|
||||||
|
{ originalname: "new-licence.pdf" } as never,
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe("a business licence uploaded to answer a reviewer", () => {
|
||||||
|
it("retires the file the reviewer flagged for correction", async () => {
|
||||||
|
const { service, live } = makeService(ProfileStatus.Pending, [
|
||||||
|
{ id: "file-old", name: "old.pdf", code: "business_license", createdAt: T0, reviewStatus: "change_requested" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await upload(service);
|
||||||
|
|
||||||
|
expect(live().map((f) => f.name)).toEqual(["new-licence.pdf"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retires what was on file when the role was rejected, but not the customer's own fix so far", async () => {
|
||||||
|
// Two uploads answering one rejection (a second page, or a re-pick) must not
|
||||||
|
// cannibalise each other — only what the reviewer actually refused goes.
|
||||||
|
const { service, live } = makeService(
|
||||||
|
ProfileStatus.Rejected,
|
||||||
|
[
|
||||||
|
{ id: "file-refused", name: "refused.pdf", code: "business_license", createdAt: T0 },
|
||||||
|
{ id: "file-fix-1", name: "fix-page-1.pdf", code: "business_license", createdAt: T2 },
|
||||||
|
],
|
||||||
|
REJECTED_AT,
|
||||||
|
);
|
||||||
|
|
||||||
|
await upload(service);
|
||||||
|
|
||||||
|
expect(live().map((f) => f.name)).toEqual([
|
||||||
|
"fix-page-1.pdf",
|
||||||
|
"new-licence.pdf",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an ordinary addition alone when nothing was asked for", async () => {
|
||||||
|
const { service, live } = makeService(ProfileStatus.Pending, [
|
||||||
|
{ id: "file-old", name: "existing.pdf", code: "business_license", createdAt: T0 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await upload(service);
|
||||||
|
|
||||||
|
expect(live().map((f) => f.name)).toEqual([
|
||||||
|
"existing.pdf",
|
||||||
|
"new-licence.pdf",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stages the swap for review on an approved role instead of deleting", async () => {
|
||||||
|
// A live role's licence is not the customer's to remove unilaterally: the
|
||||||
|
// old file stays until a reviewer approves the swap.
|
||||||
|
const { service, live, changeRequestRepo } = makeService(
|
||||||
|
ProfileStatus.Active,
|
||||||
|
[
|
||||||
|
{ id: "file-old", name: "old.pdf", code: "business_license", createdAt: T0, reviewStatus: "change_requested" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await upload(service);
|
||||||
|
|
||||||
|
expect(live().map((f) => f.name)).toEqual(["old.pdf", "new-licence.pdf"]);
|
||||||
|
const intents = changeRequestRepo.create.mock.calls.flatMap(
|
||||||
|
([row]) => (row as any).documents.licenseChanges,
|
||||||
|
);
|
||||||
|
expect(intents).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ op: "add", fileId: "file-2" }),
|
||||||
|
expect.objectContaining({ op: "remove", fileId: "file-old" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -67,6 +67,9 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
|
|||||||
// Consumed by NotificationInboxModule for portal recipient targeting.
|
// Consumed by NotificationInboxModule for portal recipient targeting.
|
||||||
ExternalProfileRepository,
|
ExternalProfileRepository,
|
||||||
CompanyProfileRepository,
|
CompanyProfileRepository,
|
||||||
|
// Consumed by EimsModule's EimsSellerCacheService — same e-Trade business-registry lookup
|
||||||
|
// already used for every customer company at onboarding, reused for EDR's own TIN.
|
||||||
|
ETradeService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CompaniesModule { }
|
export class CompaniesModule { }
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { CompaniesService } from "./companies.service";
|
||||||
|
import { Company, CompanyStatus } from "./entities/company.entity";
|
||||||
|
import {
|
||||||
|
CompanyProfile,
|
||||||
|
ProfileStatus,
|
||||||
|
ProfileType,
|
||||||
|
} from "./entities/company-profile.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rejection hands the role back to the customer: they fix what was flagged
|
||||||
|
* and resubmit (`reapplyCompanyProfile` → Pending). The reviewer used to be
|
||||||
|
* able to skip that entirely and approve straight out of Rejected — granting
|
||||||
|
* the role over the documents that were just refused, while the customer's
|
||||||
|
* "please fix this" note was still on their screen.
|
||||||
|
*/
|
||||||
|
function makeService(status: ProfileStatus) {
|
||||||
|
const profile: Partial<CompanyProfile> = {
|
||||||
|
id: "profile-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
type: ProfileType.importer,
|
||||||
|
status,
|
||||||
|
reference: null,
|
||||||
|
reviewNote: status === ProfileStatus.Rejected ? "Licence expired" : null,
|
||||||
|
};
|
||||||
|
const company = {
|
||||||
|
id: "company-1",
|
||||||
|
status: CompanyStatus.Pending,
|
||||||
|
attributes: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const written: Partial<CompanyProfile>[] = [];
|
||||||
|
const profileRepo = {
|
||||||
|
update: jest.fn(async (_id: string, patch: Partial<CompanyProfile>) => {
|
||||||
|
written.push(patch);
|
||||||
|
Object.assign(profile, patch);
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
findOne: jest.fn(async () => profile),
|
||||||
|
};
|
||||||
|
const companyRepo = { findOne: jest.fn(async () => company), update: jest.fn() };
|
||||||
|
|
||||||
|
const companyProfilesRepo = {
|
||||||
|
findById: jest.fn(async () => profile),
|
||||||
|
generateReference: jest.fn(async () => "IM-A00001"),
|
||||||
|
};
|
||||||
|
const profilesRepo = {
|
||||||
|
// Onboarding submitted — the other gate in this method is not what these
|
||||||
|
// tests are about.
|
||||||
|
findByCompanyId: jest.fn(async () => [{ onboardingCompleted: true }]),
|
||||||
|
};
|
||||||
|
const filesService = { findWithOpenChangeRequest: jest.fn(async () => []) };
|
||||||
|
const dataSource = {
|
||||||
|
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) =>
|
||||||
|
cb({
|
||||||
|
findOne: jest.fn(async () => company),
|
||||||
|
getRepository: (entity: unknown) =>
|
||||||
|
entity === Company ? companyRepo : profileRepo,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const companyNotifier = { profileStatusChanged: jest.fn(), companyApproved: jest.fn() };
|
||||||
|
|
||||||
|
const service = new CompaniesService(
|
||||||
|
{} as never,
|
||||||
|
companyProfilesRepo as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
profilesRepo as never,
|
||||||
|
{} as never,
|
||||||
|
filesService as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
companyNotifier as never,
|
||||||
|
dataSource as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, profile, written, companyProfilesRepo };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("approving an operational role", () => {
|
||||||
|
it("refuses to approve a role the customer has not resubmitted", async () => {
|
||||||
|
const { service, companyProfilesRepo } = makeService(ProfileStatus.Rejected);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.setCompanyProfileStatus("profile-1", ProfileStatus.Active),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
// Refused before any reference could be minted against the rejected role.
|
||||||
|
expect(companyProfilesRepo.generateReference).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a reviewer undo their own rejection, and drops the note with it", async () => {
|
||||||
|
const { service, written } = makeService(ProfileStatus.Rejected);
|
||||||
|
|
||||||
|
await service.setCompanyProfileStatus("profile-1", ProfileStatus.Pending);
|
||||||
|
|
||||||
|
expect(written[0]).toMatchObject({
|
||||||
|
status: ProfileStatus.Pending,
|
||||||
|
reviewNote: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still approves a role that is awaiting its first decision", async () => {
|
||||||
|
const { service, written } = makeService(ProfileStatus.Pending);
|
||||||
|
|
||||||
|
await service.setCompanyProfileStatus("profile-1", ProfileStatus.Active);
|
||||||
|
|
||||||
|
expect(written[0]).toMatchObject({
|
||||||
|
status: ProfileStatus.Active,
|
||||||
|
reference: "IM-A00001",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { DataSource, EntityManager } from "typeorm";
|
import { DataSource, EntityManager } from "typeorm";
|
||||||
|
import { resolveIamUserNames } from "../../common/utils/iam-user-name.util";
|
||||||
import { CompaniesRepository } from "./companies.repository";
|
import { CompaniesRepository } from "./companies.repository";
|
||||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||||
@@ -62,7 +63,10 @@ import {
|
|||||||
CompanyStatus,
|
CompanyStatus,
|
||||||
CompanyType,
|
CompanyType,
|
||||||
COOPERATIVE_KEY,
|
COOPERATIVE_KEY,
|
||||||
|
INVESTOR_LICENCE_KEY,
|
||||||
|
hasInvestorLicence,
|
||||||
isCooperative,
|
isCooperative,
|
||||||
|
usesManualRegistration,
|
||||||
} from "./entities/company.entity";
|
} from "./entities/company.entity";
|
||||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||||
import {
|
import {
|
||||||
@@ -377,6 +381,7 @@ export class CompaniesService {
|
|||||||
roles: ProfileType[],
|
roles: ProfileType[],
|
||||||
nationality?: CompanyNationality,
|
nationality?: CompanyNationality,
|
||||||
cooperative?: boolean,
|
cooperative?: boolean,
|
||||||
|
investorLicence?: boolean,
|
||||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||||
// Already started — reuse the existing draft, just ensure roles exist and
|
// Already started — reuse the existing draft, just ensure roles exist and
|
||||||
// keep the nationality up to date if it was (re)selected.
|
// keep the nationality up to date if it was (re)selected.
|
||||||
@@ -387,13 +392,20 @@ export class CompaniesService {
|
|||||||
// flag into `attributes`, or to read a stored one the caller didn't send.
|
// flag into `attributes`, or to read a stored one the caller didn't send.
|
||||||
const needsCompany =
|
const needsCompany =
|
||||||
cooperative !== undefined ||
|
cooperative !== undefined ||
|
||||||
|
investorLicence !== undefined ||
|
||||||
roles.includes(ProfileType.freightForwarder);
|
roles.includes(ProfileType.freightForwarder);
|
||||||
const current = needsCompany
|
const current = needsCompany
|
||||||
? await this.companiesRepo.findById(companyId)
|
? await this.companiesRepo.findById(companyId)
|
||||||
: null;
|
: null;
|
||||||
const isCoop = cooperative ?? isCooperative(current);
|
const isCoop = cooperative ?? isCooperative(current);
|
||||||
|
const isInvestor = investorLicence ?? hasInvestorLicence(current);
|
||||||
this.assertRolesAllowedForCooperative(isCoop, roles);
|
this.assertRolesAllowedForCooperative(isCoop, roles);
|
||||||
this.assertNationalityAllowedForCooperative(isCoop, nationality);
|
this.assertNationalityAllowedForCooperative(isCoop, nationality);
|
||||||
|
this.assertInvestorLicenceAllowed(
|
||||||
|
isInvestor,
|
||||||
|
isCoop,
|
||||||
|
nationality ?? current?.nationality ?? undefined,
|
||||||
|
);
|
||||||
await this.syncCompanyProfiles(companyId, companyType, roles);
|
await this.syncCompanyProfiles(companyId, companyType, roles);
|
||||||
const updates: Partial<Company> = {};
|
const updates: Partial<Company> = {};
|
||||||
if (nationality) updates.nationality = nationality;
|
if (nationality) updates.nationality = nationality;
|
||||||
@@ -401,20 +413,49 @@ export class CompaniesService {
|
|||||||
// stored nationality too, or the company keeps resolving to the foreign
|
// stored nationality too, or the company keeps resolving to the foreign
|
||||||
// document set.
|
// document set.
|
||||||
if (isCoop) updates.nationality = CompanyNationality.Ethiopian;
|
if (isCoop) updates.nationality = CompanyNationality.Ethiopian;
|
||||||
if (cooperative !== undefined) {
|
if (cooperative !== undefined || investorLicence !== undefined) {
|
||||||
updates.attributes = {
|
updates.attributes = {
|
||||||
...(current?.attributes ?? {}),
|
...(current?.attributes ?? {}),
|
||||||
[COOPERATIVE_KEY]: cooperative,
|
...(cooperative !== undefined
|
||||||
|
? { [COOPERATIVE_KEY]: cooperative }
|
||||||
|
: {}),
|
||||||
|
...(investorLicence !== undefined
|
||||||
|
? { [INVESTOR_LICENCE_KEY]: investorLicence }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Going back and un-ticking the box is the same act as the settings
|
||||||
|
// switch, so it has to cost the same: the registration the customer typed
|
||||||
|
// goes, and onboarding drops back to the company step. Without this the
|
||||||
|
// draft keeps the typed values, `hasRegistrationDetails` reads as a passed
|
||||||
|
// lookup, resume lands past the company step entirely — and the company
|
||||||
|
// finishes onboarding on unverified data with no flag left to say so.
|
||||||
|
const backToEtrade =
|
||||||
|
usesManualRegistration(current) && !isCoop && !isInvestor;
|
||||||
|
if (backToEtrade) {
|
||||||
|
Object.assign(updates, CompaniesService.CLEARED_REGISTRATION);
|
||||||
|
updates.attributes = this.withoutTypedEtradeManager(
|
||||||
|
updates.attributes ?? current?.attributes,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (Object.keys(updates).length > 0) {
|
if (Object.keys(updates).length > 0) {
|
||||||
await this.companiesRepo.update(companyId, updates);
|
await this.companiesRepo.update(companyId, updates);
|
||||||
}
|
}
|
||||||
|
if (backToEtrade) {
|
||||||
|
await this.profilesRepo.update(existing.id, {
|
||||||
|
onboardingStep: "company",
|
||||||
|
});
|
||||||
|
}
|
||||||
return this.getCompanyInfoByUserId(identity.userId);
|
return this.getCompanyInfoByUserId(identity.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.assertRolesAllowedForCooperative(cooperative === true, roles);
|
this.assertRolesAllowedForCooperative(cooperative === true, roles);
|
||||||
this.assertNationalityAllowedForCooperative(cooperative === true, nationality);
|
this.assertNationalityAllowedForCooperative(cooperative === true, nationality);
|
||||||
|
this.assertInvestorLicenceAllowed(
|
||||||
|
investorLicence === true,
|
||||||
|
cooperative === true,
|
||||||
|
nationality,
|
||||||
|
);
|
||||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||||
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||||
|
|
||||||
@@ -427,7 +468,14 @@ export class CompaniesService {
|
|||||||
country: "Ethiopia",
|
country: "Ethiopia",
|
||||||
nationality: nationality ?? CompanyNationality.Ethiopian,
|
nationality: nationality ?? CompanyNationality.Ethiopian,
|
||||||
status: CompanyStatus.Pending,
|
status: CompanyStatus.Pending,
|
||||||
...(cooperative ? { attributes: { [COOPERATIVE_KEY]: true } } : {}),
|
...(cooperative || investorLicence
|
||||||
|
? {
|
||||||
|
attributes: {
|
||||||
|
...(cooperative ? { [COOPERATIVE_KEY]: true } : {}),
|
||||||
|
...(investorLicence ? { [INVESTOR_LICENCE_KEY]: true } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.profilesRepo.create({
|
await this.profilesRepo.create({
|
||||||
@@ -484,6 +532,73 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The registration block as it must look when nobody has verified it.
|
||||||
|
*
|
||||||
|
* Used wherever a company stops being one eTrade cannot answer for: whatever
|
||||||
|
* sits in these columns was the customer's own statement, and the wizard
|
||||||
|
* treats a populated registration as a lookup that already passed
|
||||||
|
* (`hasRegistrationDetails`). Leaving it behind would hand the company an
|
||||||
|
* eTrade-verified record eTrade never supplied — and, once the flag is gone,
|
||||||
|
* a backoffice screen that says so.
|
||||||
|
*/
|
||||||
|
private static readonly CLEARED_REGISTRATION: Partial<Company> = {
|
||||||
|
licenceNumber: null,
|
||||||
|
statusDescription: null,
|
||||||
|
dateRegistered: null,
|
||||||
|
renewedFrom: null,
|
||||||
|
renewalDate: null,
|
||||||
|
renewedTo: null,
|
||||||
|
region: null,
|
||||||
|
zone: null,
|
||||||
|
woreda: null,
|
||||||
|
kebele: null,
|
||||||
|
houseNo: null,
|
||||||
|
etradePhone: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The company's own `attributes`, minus the manager captured alongside a
|
||||||
|
* typed registration. It never came from a licence, so it must not outlive
|
||||||
|
* the registration it belonged to.
|
||||||
|
*/
|
||||||
|
private withoutTypedEtradeManager(
|
||||||
|
attributes: Record<string, unknown> | null | undefined,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const next = { ...(attributes ?? {}) };
|
||||||
|
delete next.etradeManagerName;
|
||||||
|
delete next.etradeManagerPhone;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An investment licence belongs to a foreign company and to nothing else.
|
||||||
|
*
|
||||||
|
* It is the Ethiopian Investment Commission's licence, issued to a foreign
|
||||||
|
* investor — an Ethiopian company registers with the trade registry, which is
|
||||||
|
* exactly the eTrade record this flag says does not exist. A co-operative
|
||||||
|
* cannot hold one either: it is Ethiopian by construction, and the two flags
|
||||||
|
* resolve to different document sets, so a company carrying both would owe an
|
||||||
|
* incoherent list of papers.
|
||||||
|
*/
|
||||||
|
private assertInvestorLicenceAllowed(
|
||||||
|
investorLicence: boolean,
|
||||||
|
cooperative: boolean,
|
||||||
|
nationality: CompanyNationality | undefined,
|
||||||
|
): void {
|
||||||
|
if (!investorLicence) return;
|
||||||
|
if (cooperative) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"A co-operative union or farm is registered in Ethiopia — it cannot also onboard on a foreign investment licence.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (nationality !== CompanyNationality.Foreign) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Only a foreign company can onboard on an investment licence.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reconcile the company's operational profiles with the roles the user has
|
* Reconcile the company's operational profiles with the roles the user has
|
||||||
* selected: create the missing ones, drop the ones they deselected.
|
* selected: create the missing ones, drop the ones they deselected.
|
||||||
@@ -1141,16 +1256,55 @@ export class CompaniesService {
|
|||||||
return new ProfileResponseDto(profile, live, request);
|
return new ProfileResponseDto(profile, live, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List a company's change requests, newest first (backoffice review). */
|
/**
|
||||||
|
* List a company's change requests, newest first (backoffice review). Actor
|
||||||
|
* ids are resolved to display names here — the history screen has to say who
|
||||||
|
* asked for a change and who sent it back, not print two uuids.
|
||||||
|
*/
|
||||||
async listChangeRequests(companyId: string): Promise<CompanyChangeRequest[]> {
|
async listChangeRequests(companyId: string): Promise<CompanyChangeRequest[]> {
|
||||||
await this.findCompanyById(companyId);
|
await this.findCompanyById(companyId);
|
||||||
return this.changeRequestRepo.findByCompanyId(companyId);
|
const requests = await this.changeRequestRepo.findByCompanyId(companyId);
|
||||||
|
const names = await this.resolveActorNames(
|
||||||
|
requests.flatMap((r) => [r.submittedBy, r.reviewedBy]),
|
||||||
|
);
|
||||||
|
for (const request of requests) {
|
||||||
|
request.submittedByName = request.submittedBy
|
||||||
|
? (names.get(request.submittedBy) ?? null)
|
||||||
|
: null;
|
||||||
|
request.reviewedByName = request.reviewedBy
|
||||||
|
? (names.get(request.reviewedBy) ?? null)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return requests;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */
|
/** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */
|
||||||
async listCompanyRevisions(companyId: string): Promise<CompanyRevision[]> {
|
async listCompanyRevisions(companyId: string): Promise<CompanyRevision[]> {
|
||||||
await this.findCompanyById(companyId);
|
await this.findCompanyById(companyId);
|
||||||
return this.revisionRepo.findByCompanyId(companyId);
|
const revisions = await this.revisionRepo.findByCompanyId(companyId);
|
||||||
|
const names = await this.resolveActorNames(revisions.map((r) => r.actorId));
|
||||||
|
for (const revision of revisions) {
|
||||||
|
revision.actorName = revision.actorId
|
||||||
|
? (names.get(revision.actorId) ?? null)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return revisions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display names for actor ids, one query for the whole list. A lookup failure
|
||||||
|
* degrades the history to ids rather than failing the request — the entry is
|
||||||
|
* still worth showing without the name.
|
||||||
|
*/
|
||||||
|
private async resolveActorNames(
|
||||||
|
actorIds: (string | null | undefined)[],
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
try {
|
||||||
|
return await resolveIamUserNames(this.dataSource, actorIds);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Could not resolve actor names: ${String(err)}`);
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1519,6 +1673,7 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
await this.discardLicenseChanges(request);
|
await this.discardLicenseChanges(request);
|
||||||
await this.discardDocumentChanges(request);
|
await this.discardDocumentChanges(request);
|
||||||
|
await this.notifyChangeRequestReturned(request, "rejected", note, reviewerId);
|
||||||
return (
|
return (
|
||||||
(await this.changeRequestRepo.update(id, {
|
(await this.changeRequestRepo.update(id, {
|
||||||
status: ChangeRequestStatus.Rejected,
|
status: ChangeRequestStatus.Rejected,
|
||||||
@@ -1556,6 +1711,12 @@ export class CompaniesService {
|
|||||||
`Change request ${id} is already ${request.status}`,
|
`Change request ${id} is already ${request.status}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await this.notifyChangeRequestReturned(
|
||||||
|
request,
|
||||||
|
"changes_requested",
|
||||||
|
note,
|
||||||
|
reviewerId,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
(await this.changeRequestRepo.update(id, {
|
(await this.changeRequestRepo.update(id, {
|
||||||
status: ChangeRequestStatus.ChangesRequested,
|
status: ChangeRequestStatus.ChangesRequested,
|
||||||
@@ -1566,6 +1727,35 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the customer desk a change request came back unapproved. Best-effort:
|
||||||
|
* a missing company or an unresolvable reviewer name must not fail the
|
||||||
|
* reviewer's decision, which is already the point of the try/catch.
|
||||||
|
*/
|
||||||
|
private async notifyChangeRequestReturned(
|
||||||
|
request: CompanyChangeRequest,
|
||||||
|
outcome: "rejected" | "changes_requested",
|
||||||
|
note: string,
|
||||||
|
reviewerId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const company = await this.companiesRepo.findById(request.companyId);
|
||||||
|
if (!company) return;
|
||||||
|
const names = await this.resolveActorNames([reviewerId]);
|
||||||
|
this.companyNotifier.changeRequestReturned(
|
||||||
|
company,
|
||||||
|
request.id,
|
||||||
|
outcome,
|
||||||
|
note,
|
||||||
|
reviewerId ? (names.get(reviewerId) ?? null) : null,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Could not notify the customer desk about ${request.id}: ${String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async deleteCompany(id: string): Promise<void> {
|
async deleteCompany(id: string): Promise<void> {
|
||||||
await this.findCompanyById(id);
|
await this.findCompanyById(id);
|
||||||
await this.companiesRepo.softDelete(id);
|
await this.companiesRepo.softDelete(id);
|
||||||
@@ -1640,6 +1830,24 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A rejected role is waiting on the customer, not on the reviewer: nothing
|
||||||
|
// has been resubmitted, and the note telling them what to fix is still on
|
||||||
|
// their screen. Approving straight out of Rejected grants the very role that
|
||||||
|
// was refused, over the documents that were refused with it. The way back is
|
||||||
|
// the customer's own resubmission (`reapplyCompanyProfile` → Pending); a
|
||||||
|
// rejection made in error is undone by moving the role back to pending
|
||||||
|
// review first — the same shape as "withdraw the change request first" on
|
||||||
|
// the document gate below.
|
||||||
|
if (
|
||||||
|
status === ProfileStatus.Active &&
|
||||||
|
existing.status === ProfileStatus.Rejected
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"This role was rejected — the customer has to fix what was flagged and resubmit it before it can be approved. " +
|
||||||
|
"If the rejection was a mistake, move the role back to pending review first.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// A self-registered company is only reviewable once its owner submits the
|
// A self-registered company is only reviewable once its owner submits the
|
||||||
// onboarding wizard (markOnboardingComplete) — until then its profiles are
|
// onboarding wizard (markOnboardingComplete) — until then its profiles are
|
||||||
// half-filled drafts and approving one would mint a reference against an
|
// half-filled drafts and approving one would mint a reference against an
|
||||||
@@ -1768,7 +1976,14 @@ export class CompaniesService {
|
|||||||
status === ProfileStatus.Suspended
|
status === ProfileStatus.Suspended
|
||||||
) {
|
) {
|
||||||
patch.reviewNote = note ?? null;
|
patch.reviewNote = note ?? null;
|
||||||
} else if (status === ProfileStatus.Active) {
|
} else if (
|
||||||
|
status === ProfileStatus.Active ||
|
||||||
|
status === ProfileStatus.Pending
|
||||||
|
) {
|
||||||
|
// Pending only reaches here when a reviewer withdraws their own rejection
|
||||||
|
// (the customer's resubmission clears the note in `reapplyCompanyProfile`),
|
||||||
|
// so the reason they gave goes with it — leaving it would keep telling the
|
||||||
|
// customer to fix something nobody is waiting on any more.
|
||||||
patch.reviewNote = null;
|
patch.reviewNote = null;
|
||||||
}
|
}
|
||||||
if (status !== ProfileStatus.Pending) {
|
if (status !== ProfileStatus.Pending) {
|
||||||
@@ -2067,6 +2282,7 @@ export class CompaniesService {
|
|||||||
// or farm holds no business licence, so it owes its own list rather than the
|
// or farm holds no business licence, so it owes its own list rather than the
|
||||||
// nationality list plus extras.
|
// nationality list plus extras.
|
||||||
const cooperative = isCooperative(company);
|
const cooperative = isCooperative(company);
|
||||||
|
const investorLicence = hasInvestorLicence(company);
|
||||||
const documentSettingCode = this.documentSettingCodeFor(company);
|
const documentSettingCode = this.documentSettingCodeFor(company);
|
||||||
const [setting, uploadedFiles] = await Promise.all([
|
const [setting, uploadedFiles] = await Promise.all([
|
||||||
this.fileUploadSettingsService
|
this.fileUploadSettingsService
|
||||||
@@ -2216,6 +2432,7 @@ export class CompaniesService {
|
|||||||
documentSettingCode,
|
documentSettingCode,
|
||||||
nationality: company.nationality ?? CompanyNationality.Ethiopian,
|
nationality: company.nationality ?? CompanyNationality.Ethiopian,
|
||||||
cooperative,
|
cooperative,
|
||||||
|
investorLicence,
|
||||||
companyInfo: {
|
companyInfo: {
|
||||||
complete: missingInfo.length === 0,
|
complete: missingInfo.length === 0,
|
||||||
missingFields: missingInfo,
|
missingFields: missingInfo,
|
||||||
@@ -2293,6 +2510,97 @@ export class CompaniesService {
|
|||||||
return this.getCompanyInfoByUserId(userId);
|
return this.getCompanyInfoByUserId(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop whichever manual-registration route the company is on and send it back
|
||||||
|
* through the normal eTrade one.
|
||||||
|
*
|
||||||
|
* Both routes exist for the same reason — eTrade holds no record to fetch —
|
||||||
|
* so leaving one is the same act whichever it is, and it is the only way back
|
||||||
|
* to eTrade for either. A co-operative union or farm that has since taken out
|
||||||
|
* a trade licence had no exit at all before this; its only route was the
|
||||||
|
* wizard, which an onboarded company can no longer reach.
|
||||||
|
*
|
||||||
|
* Everything the flag let the customer type is cleared, not kept: the
|
||||||
|
* registration block on file was their own statement, and leaving it there
|
||||||
|
* would let the wizard treat the company as already looked-up
|
||||||
|
* (`hasRegistrationDetails` is what stands in for a verified TIN on a
|
||||||
|
* resume) and walk straight past the eTrade step this switch exists to
|
||||||
|
* reach. Onboarding reopens at the company step and the company goes back to
|
||||||
|
* pending — an approval granted against typed data cannot silently carry over
|
||||||
|
* to a record that now claims to be eTrade's.
|
||||||
|
*
|
||||||
|
* Switching the other way — INTO a co-operative or an investment licence — is
|
||||||
|
* deliberately not here. It is the wizard's nationality/role step, which this
|
||||||
|
* reopens, and which is the one place the mutually-exclusive rules live
|
||||||
|
* (`assertInvestorLicenceAllowed`, `assertRolesAllowedForCooperative`,
|
||||||
|
* `assertNationalityAllowedForCooperative`). A second entry point would have
|
||||||
|
* to restate all three.
|
||||||
|
*/
|
||||||
|
async revertToRegularCompany(
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||||
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
|
if (!profile)
|
||||||
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||||
|
|
||||||
|
const companyId = profile.company?.id ?? profile.companyId;
|
||||||
|
const company = await this.companiesRepo.findById(companyId);
|
||||||
|
if (!company)
|
||||||
|
throw new NotFoundException(`Company ${companyId} not found`);
|
||||||
|
if (!usesManualRegistration(company)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"This company is already registered through eTrade — there is nothing to switch.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasCooperative = isCooperative(company);
|
||||||
|
|
||||||
|
// Both flags go, not just the one that was set: they are mutually exclusive
|
||||||
|
// and a company can only ever hold one, but the destination is "neither",
|
||||||
|
// so stripping only the one we happened to check for would leave the other
|
||||||
|
// behind if the pair ever did coexist.
|
||||||
|
const attributes = this.withoutTypedEtradeManager(company.attributes);
|
||||||
|
delete attributes[INVESTOR_LICENCE_KEY];
|
||||||
|
delete attributes[COOPERATIVE_KEY];
|
||||||
|
|
||||||
|
await this.companiesRepo.update(companyId, {
|
||||||
|
...CompaniesService.CLEARED_REGISTRATION,
|
||||||
|
attributes,
|
||||||
|
status: CompanyStatus.Pending,
|
||||||
|
});
|
||||||
|
await this.profilesRepo.update(profile.id, {
|
||||||
|
onboardingCompleted: false,
|
||||||
|
onboardingStep: "company",
|
||||||
|
});
|
||||||
|
|
||||||
|
// A co-operative owes no per-role business licence — that is the whole
|
||||||
|
// reason its own document set stands in for one. The moment it stops being
|
||||||
|
// one, every role owes a licence that was never uploaded, so an approval
|
||||||
|
// granted without one no longer means what it said: back to Pending, and
|
||||||
|
// the reviewer sees the licence with the rest of the re-application.
|
||||||
|
//
|
||||||
|
// Only Active roles move. Rejected, Suspended and Blacklisted are the
|
||||||
|
// backoffice's own decisions, and quietly promoting a blocked role to
|
||||||
|
// "awaiting approval" would launder the block away. The reference survives
|
||||||
|
// either way — it is minted once (`setCompanyProfileStatus`) and re-approval
|
||||||
|
// reuses it, so bookings that cite it keep citing the same number.
|
||||||
|
//
|
||||||
|
// An investor is untouched: it always held a licence per role, so nothing
|
||||||
|
// becomes due that was not already reviewed.
|
||||||
|
if (wasCooperative) {
|
||||||
|
const roles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||||
|
for (const role of roles) {
|
||||||
|
if (role.status !== ProfileStatus.Active) continue;
|
||||||
|
await this.companyProfilesRepo.updateStatus(
|
||||||
|
role.id,
|
||||||
|
ProfileStatus.Pending,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getCompanyInfoByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block a self-service action when the company account isn't active, naming
|
* Block a self-service action when the company account isn't active, naming
|
||||||
* the actual status — a suspended customer told "awaiting approval" has no
|
* the actual status — a suspended customer told "awaiting approval" has no
|
||||||
@@ -2392,6 +2700,9 @@ export class CompaniesService {
|
|||||||
* with the role itself. Only for an already-approved role are they staged under
|
* 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 —
|
* 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.
|
* a licence swap on a live role is a change; a licence on a new role is not.
|
||||||
|
*
|
||||||
|
* An upload that answers a reviewer also retires the licence it answers (see
|
||||||
|
* below), so a correction never leaves both copies on file.
|
||||||
*/
|
*/
|
||||||
async addProfileLicenseFiles(
|
async addProfileLicenseFiles(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -2403,6 +2714,28 @@ export class CompaniesService {
|
|||||||
const gated = profile.status === ProfileStatus.Active;
|
const gated = profile.status === ProfileStatus.Active;
|
||||||
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
|
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
|
||||||
|
|
||||||
|
// An upload that answers the reviewer replaces what they refused; it does
|
||||||
|
// not sit next to it. Uploading only ever adds a row, so without this the
|
||||||
|
// refused licence stays listed in the portal and the backoffice beside the
|
||||||
|
// new one and nothing says which is current. Two things count as refused:
|
||||||
|
// the file the reviewer flagged for correction, and — when the whole role
|
||||||
|
// came back rejected — every licence that was already on file when they
|
||||||
|
// rejected it. Anything the customer uploaded *since* that decision is part
|
||||||
|
// of the same fix (a second page, a re-pick), so it survives, and an upload
|
||||||
|
// with nothing outstanding is a genuine addition and is left alone.
|
||||||
|
const rejectedAt =
|
||||||
|
profile.status === ProfileStatus.Rejected
|
||||||
|
? (profile.reviewedAt ?? null)
|
||||||
|
: null;
|
||||||
|
const superseded = rejectedAt
|
||||||
|
? (
|
||||||
|
await this.filesService.findByResource(profileId, LICENSE_RESOURCE)
|
||||||
|
).filter((f) => f.createdAt < rejectedAt)
|
||||||
|
: await this.filesService.findWithOpenChangeRequest(
|
||||||
|
[profileId],
|
||||||
|
LICENSE_RESOURCE,
|
||||||
|
);
|
||||||
|
|
||||||
const uploaded = await Promise.all(
|
const uploaded = await Promise.all(
|
||||||
files.map((file) =>
|
files.map((file) =>
|
||||||
this.filesService.upload({
|
this.filesService.upload({
|
||||||
@@ -2427,6 +2760,13 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retire what the upload supersedes, through the normal removal path so an
|
||||||
|
// approved role stages a `remove` intent (reviewed as a swap) while an
|
||||||
|
// unapproved one just drops the file.
|
||||||
|
for (const stale of superseded) {
|
||||||
|
await this.removeProfileLicenseFile(userId, profileId, stale.id);
|
||||||
|
}
|
||||||
|
|
||||||
// A fresh licence upload answers any correction the reviewer asked for on the
|
// A fresh licence upload answers any correction the reviewer asked for on the
|
||||||
// previous one, so the old row must stop blocking approval.
|
// previous one, so the old row must stop blocking approval.
|
||||||
await this.resolveDocumentChangeRequests(
|
await this.resolveDocumentChangeRequests(
|
||||||
@@ -3459,7 +3799,7 @@ export class CompaniesService {
|
|||||||
// the registered address themselves, and what they send IS the data. The
|
// the registered address themselves, and what they send IS the data. The
|
||||||
// check is skipped rather than failed: running the lookup would 400 every
|
// check is skipped rather than failed: running the lookup would 400 every
|
||||||
// save with "no registration found for this TIN".
|
// save with "no registration found for this TIN".
|
||||||
if (isCooperative(company)) return;
|
if (usesManualRegistration(company)) return;
|
||||||
|
|
||||||
const touched = ETRADE_SOURCED_FIELDS.some(
|
const touched = ETRADE_SOURCED_FIELDS.some(
|
||||||
(key) => key !== "tin" && dto[key] !== undefined,
|
(key) => key !== "tin" && dto[key] !== undefined,
|
||||||
|
|||||||
@@ -244,6 +244,35 @@ export class CompanyNotifierService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reviewer did NOT approve a customer's profile changes — they rejected it
|
||||||
|
* or sent it back for correction. The customer desk (Marketing included, via
|
||||||
|
* the `customers:get_notification` key) owns the follow-up with the customer,
|
||||||
|
* so the decision has to reach their inbox; without this it was silent, and
|
||||||
|
* only visible to whoever happened to reopen the customer's History tab.
|
||||||
|
*/
|
||||||
|
changeRequestReturned(
|
||||||
|
company: Company,
|
||||||
|
changeRequestId: string,
|
||||||
|
outcome: "rejected" | "changes_requested",
|
||||||
|
note: string,
|
||||||
|
reviewerName?: string | null,
|
||||||
|
): void {
|
||||||
|
const rejected = outcome === "rejected";
|
||||||
|
const by = reviewerName?.trim() ? ` by ${reviewerName.trim()}` : "";
|
||||||
|
this.logger.log(`CHANGE_REQUEST_${outcome.toUpperCase()} — ${company.id}`);
|
||||||
|
this.notifyStaff(
|
||||||
|
company,
|
||||||
|
rejected
|
||||||
|
? "Customer profile changes rejected"
|
||||||
|
: "Customer profile changes sent back for correction",
|
||||||
|
`${company.name}'s profile changes were ` +
|
||||||
|
`${rejected ? "rejected" : "sent back for correction"}${by}. ` +
|
||||||
|
`Reason: ${note}`,
|
||||||
|
{ changeRequestId, outcome, note, reviewerName: reviewerName ?? null },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Customer-facing: a specific document needs correcting ──────────────────
|
// ── Customer-facing: a specific document needs correcting ──────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ export class ChangeRequestResponseDto {
|
|||||||
documentChanges: DocumentChangeIntent[];
|
documentChanges: DocumentChangeIntent[];
|
||||||
note: string | null;
|
note: string | null;
|
||||||
submittedBy: string | null;
|
submittedBy: string | null;
|
||||||
|
/** Who filed the request, for the history screen (null when unresolvable). */
|
||||||
|
submittedByName: string | null;
|
||||||
submittedAt: Date | null;
|
submittedAt: Date | null;
|
||||||
reviewedBy: string | null;
|
reviewedBy: string | null;
|
||||||
|
/** Who approved / rejected / sent it back. */
|
||||||
|
reviewedByName: string | null;
|
||||||
reviewedAt: Date | null;
|
reviewedAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@@ -39,8 +43,10 @@ export class ChangeRequestResponseDto {
|
|||||||
this.documentChanges = req.documents?.documentChanges ?? [];
|
this.documentChanges = req.documents?.documentChanges ?? [];
|
||||||
this.note = req.note ?? null;
|
this.note = req.note ?? null;
|
||||||
this.submittedBy = req.submittedBy ?? null;
|
this.submittedBy = req.submittedBy ?? null;
|
||||||
|
this.submittedByName = req.submittedByName ?? null;
|
||||||
this.submittedAt = req.submittedAt ?? null;
|
this.submittedAt = req.submittedAt ?? null;
|
||||||
this.reviewedBy = req.reviewedBy ?? null;
|
this.reviewedBy = req.reviewedBy ?? null;
|
||||||
|
this.reviewedByName = req.reviewedByName ?? null;
|
||||||
this.reviewedAt = req.reviewedAt ?? null;
|
this.reviewedAt = req.reviewedAt ?? null;
|
||||||
this.createdAt = req.createdAt;
|
this.createdAt = req.createdAt;
|
||||||
this.updatedAt = req.updatedAt;
|
this.updatedAt = req.updatedAt;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export class CompanyRevisionResponseDto {
|
|||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
actorId: string | null;
|
actorId: string | null;
|
||||||
|
/** Who made the edit, for the history screen (null when unresolvable). */
|
||||||
|
actorName: string | null;
|
||||||
summary: string;
|
summary: string;
|
||||||
changes: CompanyRevisionChange[];
|
changes: CompanyRevisionChange[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -16,6 +18,7 @@ export class CompanyRevisionResponseDto {
|
|||||||
this.id = revision.id;
|
this.id = revision.id;
|
||||||
this.companyId = revision.companyId;
|
this.companyId = revision.companyId;
|
||||||
this.actorId = revision.actorId ?? null;
|
this.actorId = revision.actorId ?? null;
|
||||||
|
this.actorName = revision.actorName ?? null;
|
||||||
this.summary = revision.summary;
|
this.summary = revision.summary;
|
||||||
this.changes = revision.changes ?? [];
|
this.changes = revision.changes ?? [];
|
||||||
this.createdAt = revision.createdAt;
|
this.createdAt = revision.createdAt;
|
||||||
|
|||||||
@@ -78,6 +78,14 @@ export class OnboardingRequirementsResponseDto {
|
|||||||
*/
|
*/
|
||||||
cooperative: boolean;
|
cooperative: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The company is a foreign investor on an investment licence: no eTrade
|
||||||
|
* record, so the registration was typed. The nationality document set still
|
||||||
|
* applies (it already asks for the investment licence itself), and so does
|
||||||
|
* the per-role business licence.
|
||||||
|
*/
|
||||||
|
investorLicence: boolean;
|
||||||
|
|
||||||
/** Required company-information fields and whether each is filled. */
|
/** Required company-information fields and whether each is filled. */
|
||||||
companyInfo: {
|
companyInfo: {
|
||||||
complete: boolean;
|
complete: boolean;
|
||||||
@@ -116,6 +124,7 @@ export class OnboardingRequirementsResponseDto {
|
|||||||
this.documentSettingCode = init.documentSettingCode;
|
this.documentSettingCode = init.documentSettingCode;
|
||||||
this.nationality = init.nationality;
|
this.nationality = init.nationality;
|
||||||
this.cooperative = init.cooperative;
|
this.cooperative = init.cooperative;
|
||||||
|
this.investorLicence = init.investorLicence;
|
||||||
this.companyInfo = init.companyInfo;
|
this.companyInfo = init.companyInfo;
|
||||||
this.documents = init.documents;
|
this.documents = init.documents;
|
||||||
this.licenseProfiles = init.licenseProfiles;
|
this.licenseProfiles = init.licenseProfiles;
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import {
|
|||||||
buildCompanyIdentityState,
|
buildCompanyIdentityState,
|
||||||
CompanyIdentityStateDto,
|
CompanyIdentityStateDto,
|
||||||
} from "./complete-identity-verification.dto";
|
} from "./complete-identity-verification.dto";
|
||||||
import { Company, isCooperative } from "../entities/company.entity";
|
import {
|
||||||
|
Company,
|
||||||
|
hasInvestorLicence,
|
||||||
|
isCooperative,
|
||||||
|
} from "../entities/company.entity";
|
||||||
import { ExternalProfile } from "../entities/external-profile.entity";
|
import { ExternalProfile } from "../entities/external-profile.entity";
|
||||||
import {
|
import {
|
||||||
ChangeRequestStatus,
|
ChangeRequestStatus,
|
||||||
@@ -21,6 +25,12 @@ export class ProfileResponseDto {
|
|||||||
* it from eTrade.
|
* it from eTrade.
|
||||||
*/
|
*/
|
||||||
cooperative: boolean;
|
cooperative: boolean;
|
||||||
|
/**
|
||||||
|
* The company is a foreign investor on an investment licence: eTrade holds
|
||||||
|
* no record, so the company step collects the registration by hand. Drives
|
||||||
|
* the settings card that switches back to the eTrade route.
|
||||||
|
*/
|
||||||
|
investorLicence: boolean;
|
||||||
companyLocation: string;
|
companyLocation: string;
|
||||||
companyAddress: string | null;
|
companyAddress: string | null;
|
||||||
tinNumber: string;
|
tinNumber: string;
|
||||||
@@ -93,6 +103,7 @@ export class ProfileResponseDto {
|
|||||||
this.companyType = company.type;
|
this.companyType = company.type;
|
||||||
this.nationality = company.nationality ?? null;
|
this.nationality = company.nationality ?? null;
|
||||||
this.cooperative = isCooperative(company);
|
this.cooperative = isCooperative(company);
|
||||||
|
this.investorLicence = hasInvestorLicence(company);
|
||||||
this.companyProfiles =
|
this.companyProfiles =
|
||||||
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
|
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
|
||||||
[];
|
[];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
CompanyType,
|
CompanyType,
|
||||||
CompanyStatus,
|
CompanyStatus,
|
||||||
CompanyNationality,
|
CompanyNationality,
|
||||||
|
hasInvestorLicence,
|
||||||
isCooperative,
|
isCooperative,
|
||||||
} from '../entities/company.entity';
|
} from '../entities/company.entity';
|
||||||
import {
|
import {
|
||||||
@@ -62,6 +63,13 @@ export class ResponseCompanyDto {
|
|||||||
* eTrade manager to check the owner against.
|
* eTrade manager to check the owner against.
|
||||||
*/
|
*/
|
||||||
cooperative: boolean;
|
cooperative: boolean;
|
||||||
|
/**
|
||||||
|
* The company onboarded as a foreign investor on an investment licence:
|
||||||
|
* eTrade holds no record for its TIN, so its registration below was typed by
|
||||||
|
* the customer rather than fetched — nothing here has been checked against a
|
||||||
|
* licence, and the reviewer is the check.
|
||||||
|
*/
|
||||||
|
investorLicence: boolean;
|
||||||
tin: string;
|
tin: string;
|
||||||
vatNumber?: string | null;
|
vatNumber?: string | null;
|
||||||
fanNumber?: string | null;
|
fanNumber?: string | null;
|
||||||
@@ -118,6 +126,7 @@ export class ResponseCompanyDto {
|
|||||||
this.status = company.status;
|
this.status = company.status;
|
||||||
this.nationality = company.nationality ?? null;
|
this.nationality = company.nationality ?? null;
|
||||||
this.cooperative = isCooperative(company);
|
this.cooperative = isCooperative(company);
|
||||||
|
this.investorLicence = hasInvestorLicence(company);
|
||||||
this.tin = company.tin;
|
this.tin = company.tin;
|
||||||
this.vatNumber = company.vatNumber;
|
this.vatNumber = company.vatNumber;
|
||||||
this.fanNumber = company.fanNumber;
|
this.fanNumber = company.fanNumber;
|
||||||
|
|||||||
@@ -31,4 +31,15 @@ export class StartOnboardingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
cooperative?: boolean;
|
cooperative?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The company is a foreign investor: it operates on an investment licence
|
||||||
|
* issued by the Ethiopian Investment Commission, so eTrade holds no record
|
||||||
|
* for its TIN and the registration is typed here instead. Chosen on the same
|
||||||
|
* step for the same reason as the co-operative flag — it decides what the
|
||||||
|
* company step asks for. Only a foreign company can hold one.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
investorLicence?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,4 +110,12 @@ export class CompanyChangeRequest extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
|
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
|
||||||
reviewedAt?: Date | null;
|
reviewedAt?: Date | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display names for {@link submittedBy} / {@link reviewedBy}, resolved from
|
||||||
|
* `iam.users` on read. Not columns — the history screen has to name the
|
||||||
|
* person who asked for the change, and an opaque uuid does not.
|
||||||
|
*/
|
||||||
|
submittedByName?: string | null;
|
||||||
|
reviewedByName?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,4 +43,10 @@ export class CompanyRevision extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` })
|
@Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` })
|
||||||
changes!: CompanyRevisionChange[];
|
changes!: CompanyRevisionChange[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display name for {@link actorId}, resolved from `iam.users` on read. Not a
|
||||||
|
* column — history has to name who made the edit, and a uuid does not.
|
||||||
|
*/
|
||||||
|
actorName?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,37 @@ export function isCooperative(
|
|||||||
return company?.attributes?.[COOPERATIVE_KEY] === true;
|
return company?.attributes?.[COOPERATIVE_KEY] === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `attributes` key marking a foreign company onboarding on an investment
|
||||||
|
* licence.
|
||||||
|
*
|
||||||
|
* The Ethiopian Investment Commission registers it, not the trade registry, so
|
||||||
|
* eTrade holds no record for its TIN: the registration is typed and the eTrade
|
||||||
|
* authenticity check is skipped rather than failed — exactly as for a
|
||||||
|
* co-operative. What does NOT change is the licence: the company still holds
|
||||||
|
* one per operational role, so that requirement stands.
|
||||||
|
*/
|
||||||
|
export const INVESTOR_LICENCE_KEY = "investorLicence";
|
||||||
|
|
||||||
|
/** Is this a foreign company registered on an investment licence? */
|
||||||
|
export function hasInvestorLicence(
|
||||||
|
company: Pick<Company, "attributes"> | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return company?.attributes?.[INVESTOR_LICENCE_KEY] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* eTrade holds nothing for this company, so its registration was typed by hand
|
||||||
|
* rather than fetched — and the backoffice is told so. Two different companies
|
||||||
|
* reach it (a co-operative has no licence at all; a foreign investor's is not
|
||||||
|
* the trade registry's), and every consequence they share hangs off this.
|
||||||
|
*/
|
||||||
|
export function usesManualRegistration(
|
||||||
|
company: Pick<Company, "attributes"> | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return isCooperative(company) || hasInvestorLicence(company);
|
||||||
|
}
|
||||||
|
|
||||||
@Entity({ schema: "freight", name: "companies" })
|
@Entity({ schema: "freight", name: "companies" })
|
||||||
@Index(["tin"])
|
@Index(["tin"])
|
||||||
@Index(["type"])
|
@Index(["type"])
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
|||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
{} as never, // bookingTransitionService
|
{} as never, // bookingTransitionService
|
||||||
|
{} as never, // consolidationApprovalService
|
||||||
);
|
);
|
||||||
return { service, contractsRepository };
|
return { service, contractsRepository };
|
||||||
}
|
}
|
||||||
@@ -156,6 +157,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never, // consolidationApprovalService
|
||||||
);
|
);
|
||||||
return { service, contractsRepository };
|
return { service, contractsRepository };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
|||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
{} as never, // bookingTransitionService
|
{} as never, // bookingTransitionService
|
||||||
|
{} as never, // consolidationApprovalService
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
service,
|
service,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ describe('ContractBookingService — customs booking gate', () => {
|
|||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
{} as never, // bookingTransitionService
|
{} as never, // bookingTransitionService
|
||||||
|
{} as never, // consolidationApprovalService
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { ContractBookingService } from './contract-booking.service';
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual (GL-driven) odd-20ft consolidation. On a customs contract GL completes
|
||||||
|
* the booking, so GL also picks who shares its wagon: two bookings each carrying
|
||||||
|
* an odd 20ft count are completed together onto one wagon.
|
||||||
|
*
|
||||||
|
* The two invariants that matter are that the pair is all-or-nothing (a failure
|
||||||
|
* on either half must leave NEITHER booking completed and no link written) and
|
||||||
|
* that the two bookings stay financially separate — one completion each, so one
|
||||||
|
* price and one invoice each.
|
||||||
|
*/
|
||||||
|
describe('ContractBookingService — manual odd-20ft consolidation', () => {
|
||||||
|
function makeService(overrides: {
|
||||||
|
bookingsRepository?: Partial<Record<string, jest.Mock>>;
|
||||||
|
dataSource?: unknown;
|
||||||
|
}) {
|
||||||
|
const bookingsRepository = {
|
||||||
|
findByIdWithFiles: jest.fn(),
|
||||||
|
findManualConsolidationCandidates: jest.fn().mockResolvedValue([]),
|
||||||
|
linkConsolidationPartners: jest.fn().mockResolvedValue(undefined),
|
||||||
|
...overrides.bookingsRepository,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A transaction that simply runs the callback — enough to assert the
|
||||||
|
// all-or-nothing contract: whatever throws inside propagates out, and the
|
||||||
|
// caller observes no link written.
|
||||||
|
const dataSource = overrides.dataSource ?? {
|
||||||
|
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) => cb({})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new ContractBookingService(
|
||||||
|
{ findByIdWithRelations: jest.fn() } as never,
|
||||||
|
bookingsRepository as never,
|
||||||
|
{} as never, // bookingPricingService
|
||||||
|
{} as never, // consolidationService
|
||||||
|
{} as never, // containerTypesService
|
||||||
|
{} as never, // ruleEngineService
|
||||||
|
{} as never, // milestoneService
|
||||||
|
{} as never, // invoiceService
|
||||||
|
{} as never, // bookingNotifier
|
||||||
|
dataSource as never,
|
||||||
|
{} as never, // trainSchedulingService
|
||||||
|
{} as never, // bookingBatchService
|
||||||
|
{} as never, // bookingTransitionService
|
||||||
|
// The pairing is parked for approval rather than going straight to
|
||||||
|
// Operations; the gate itself is covered by its own spec.
|
||||||
|
{ requestApproval: jest.fn().mockResolvedValue({ id: 'ap-1' }) } as never,
|
||||||
|
);
|
||||||
|
return { service, bookingsRepository, dataSource };
|
||||||
|
}
|
||||||
|
|
||||||
|
const partnerBooking = {
|
||||||
|
id: 'b-2',
|
||||||
|
reference: 'BK-2',
|
||||||
|
contractId: 'c-2',
|
||||||
|
consolidationPartnerId: null,
|
||||||
|
} as unknown as Booking;
|
||||||
|
|
||||||
|
const pairDto = {
|
||||||
|
partnerBookingId: 'b-2',
|
||||||
|
booking: { scheduledDate: '2026-09-01' },
|
||||||
|
partner: { scheduledDate: '2026-09-01' },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('completes both halves and links them', async () => {
|
||||||
|
const { service, bookingsRepository } = makeService({
|
||||||
|
bookingsRepository: {
|
||||||
|
findByIdWithFiles: jest
|
||||||
|
.fn()
|
||||||
|
// partner lookup before the transaction
|
||||||
|
.mockResolvedValueOnce(partnerBooking)
|
||||||
|
// the two reloads after it
|
||||||
|
.mockResolvedValueOnce({ id: 'b-1', reference: 'BK-1' } as Booking)
|
||||||
|
.mockResolvedValueOnce({ id: 'b-2', reference: 'BK-2' } as Booking),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Each half runs the ordinary completion machine — one call per booking, so
|
||||||
|
// each is priced and invoiced on its own.
|
||||||
|
const complete = jest
|
||||||
|
.spyOn(service, 'completeUnderContract')
|
||||||
|
.mockImplementation(
|
||||||
|
async (_contractId, bookingId) =>
|
||||||
|
({
|
||||||
|
booking: { id: bookingId } as Booking,
|
||||||
|
warnings: [],
|
||||||
|
}) as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.completeConsolidatedPair(
|
||||||
|
'c-1',
|
||||||
|
'b-1',
|
||||||
|
pairDto as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(complete).toHaveBeenCalledTimes(2);
|
||||||
|
// The partner is completed against ITS OWN contract, not this one.
|
||||||
|
expect(complete.mock.calls[0][0]).toBe('c-1');
|
||||||
|
expect(complete.mock.calls[1][0]).toBe('c-2');
|
||||||
|
// Neither half may re-enter the automatic matcher — GL links them here.
|
||||||
|
expect(complete.mock.calls[0][2]).toMatchObject({
|
||||||
|
skipAutoConsolidation: true,
|
||||||
|
});
|
||||||
|
expect(complete.mock.calls[1][2]).toMatchObject({
|
||||||
|
skipAutoConsolidation: true,
|
||||||
|
});
|
||||||
|
expect(bookingsRepository.linkConsolidationPartners).toHaveBeenCalledWith(
|
||||||
|
'b-1',
|
||||||
|
'b-2',
|
||||||
|
);
|
||||||
|
expect(result.booking.id).toBe('b-1');
|
||||||
|
expect(result.partner.id).toBe('b-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links nothing when the partner half fails (all-or-nothing)', async () => {
|
||||||
|
const { service, bookingsRepository } = makeService({
|
||||||
|
bookingsRepository: {
|
||||||
|
findByIdWithFiles: jest.fn().mockResolvedValue(partnerBooking),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(service, 'completeUnderContract')
|
||||||
|
.mockImplementationOnce(
|
||||||
|
async () => ({ booking: { id: 'b-1' } as Booking, warnings: [] }) as never,
|
||||||
|
)
|
||||||
|
.mockImplementationOnce(async () => {
|
||||||
|
throw new Error('no train space for the partner');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.completeConsolidatedPair('c-1', 'b-1', pairDto as never),
|
||||||
|
).rejects.toThrow('no train space for the partner');
|
||||||
|
|
||||||
|
// The link is the last write in the transaction — it must never happen when
|
||||||
|
// a half failed, so the rollback leaves no dangling pairing.
|
||||||
|
expect(bookingsRepository.linkConsolidationPartners).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a partner that already shares a wagon', async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
bookingsRepository: {
|
||||||
|
findByIdWithFiles: jest.fn().mockResolvedValue({
|
||||||
|
...partnerBooking,
|
||||||
|
consolidationPartnerId: 'b-9',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.completeConsolidatedPair('c-1', 'b-1', pairDto as never),
|
||||||
|
).rejects.toThrow(/already shares a wagon/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to consolidate a booking with itself', async () => {
|
||||||
|
const { service } = makeService({});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.completeConsolidatedPair('c-1', 'b-1', {
|
||||||
|
...pairDto,
|
||||||
|
partnerBookingId: 'b-1',
|
||||||
|
} as never),
|
||||||
|
).rejects.toThrow(/cannot be consolidated with itself/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers only bookings whose own 20ft count is odd', async () => {
|
||||||
|
// Two odd counts always sum to even, so an odd partner is exactly what fills
|
||||||
|
// the wagon; an even one would leave the pair partial again.
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
id: 'odd',
|
||||||
|
reference: 'BK-ODD',
|
||||||
|
bookingContainers: [
|
||||||
|
{ quantity: 3, containerType: { sizeFt: 20 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'even',
|
||||||
|
reference: 'BK-EVEN',
|
||||||
|
bookingContainers: [
|
||||||
|
{ quantity: 4, containerType: { sizeFt: 20 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// A bare instance has no cargo yet — GL enters it on the split form, so it
|
||||||
|
// stays a candidate.
|
||||||
|
{ id: 'bare', reference: 'BK-BARE', bookingContainers: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { service } = makeService({
|
||||||
|
bookingsRepository: {
|
||||||
|
findByIdWithFiles: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ id: 'b-1', contractId: 'c-1' } as Booking),
|
||||||
|
findManualConsolidationCandidates: jest.fn(async (booking: Booking) =>
|
||||||
|
// Mirror the repository's in-memory odd filter.
|
||||||
|
rows.filter((row) => {
|
||||||
|
void booking;
|
||||||
|
const lines = row.bookingContainers ?? [];
|
||||||
|
if (lines.length === 0) return true;
|
||||||
|
const ft20 = lines
|
||||||
|
.filter((l) => Number(l.containerType?.sizeFt) === 20)
|
||||||
|
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
|
||||||
|
return ft20 % 2 === 1;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const candidates = await service.listConsolidationCandidates('c-1', 'b-1');
|
||||||
|
expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD', 'BK-BARE']);
|
||||||
|
expect(candidates[0].ft20Quantity).toBe(3);
|
||||||
|
expect(candidates[1].hasCargo).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -59,6 +59,7 @@ describe('ContractBookingService — changes-requested resubmit restating cargo'
|
|||||||
trainSchedulingService as never,
|
trainSchedulingService as never,
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
{} as never, // bookingTransitionService
|
{} as never, // bookingTransitionService
|
||||||
|
{} as never, // consolidationApprovalService
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, invoiceService };
|
return { service, bookingsRepository, invoiceService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { BookingPricingService } from '../bookings/booking-pricing.service';
|
|||||||
import { BookingTransitionService } from '../bookings/booking-transition.service';
|
import { BookingTransitionService } from '../bookings/booking-transition.service';
|
||||||
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||||
import { ConsolidationService } from '../bookings/consolidation.service';
|
import { ConsolidationService } from '../bookings/consolidation.service';
|
||||||
|
import { ConsolidationApprovalService } from '../bookings/consolidation-approval.service';
|
||||||
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||||
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||||
@@ -44,6 +45,7 @@ import {
|
|||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { isEffectivelyExpired } from './utils/contract-expiry.util';
|
import { isEffectivelyExpired } from './utils/contract-expiry.util';
|
||||||
import {
|
import {
|
||||||
|
CompleteConsolidatedPairDto,
|
||||||
CreateBookingContainerLineDto,
|
CreateBookingContainerLineDto,
|
||||||
CreateBookingUnderContractDto,
|
CreateBookingUnderContractDto,
|
||||||
} from './dto/create-booking-under-contract.dto';
|
} from './dto/create-booking-under-contract.dto';
|
||||||
@@ -62,6 +64,25 @@ export interface CreateBookingUnderContractResult {
|
|||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A booking GL may pick as the shared-wagon partner of an odd-20ft customs
|
||||||
|
* booking. `hasCargo` is false for a bare instance whose containers GL still has
|
||||||
|
* to enter on the split completion form.
|
||||||
|
*/
|
||||||
|
export interface ConsolidationCandidate {
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
contractId: string | null;
|
||||||
|
companyName: string | null;
|
||||||
|
status: string;
|
||||||
|
tradeDirection: string | null;
|
||||||
|
originYardId: string | null;
|
||||||
|
destinationYardId: string | null;
|
||||||
|
scheduledDate: string | null;
|
||||||
|
ft20Quantity: number;
|
||||||
|
hasCargo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Outstanding split remainder of a contract: what was booked in the first split
|
* Outstanding split remainder of a contract: what was booked in the first split
|
||||||
* booking's pre-split snapshot MINUS everything currently booked. Container
|
* booking's pre-split snapshot MINUS everything currently booked. Container
|
||||||
@@ -110,6 +131,8 @@ export class ContractBookingService {
|
|||||||
private readonly bookingBatchService: BookingBatchService,
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
@Inject(forwardRef(() => BookingTransitionService))
|
@Inject(forwardRef(() => BookingTransitionService))
|
||||||
private readonly bookingTransitionService: BookingTransitionService,
|
private readonly bookingTransitionService: BookingTransitionService,
|
||||||
|
@Inject(forwardRef(() => ConsolidationApprovalService))
|
||||||
|
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createUnderContract(
|
async createUnderContract(
|
||||||
@@ -598,6 +621,146 @@ export class ContractBookingService {
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Candidate partners a GL operator may link to an odd-20ft customs booking.
|
||||||
|
* Manual counterpart to the automatic pairing in {@link consolidateDrawdown} —
|
||||||
|
* a customs instance is completed by GL, so GL also chooses who shares its
|
||||||
|
* wagon rather than waiting for the auto-matcher to find an exact complement.
|
||||||
|
*/
|
||||||
|
async listConsolidationCandidates(
|
||||||
|
contractId: string,
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<ConsolidationCandidate[]> {
|
||||||
|
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||||||
|
if (!booking || booking.contractId !== contractId) {
|
||||||
|
throw new NotFoundException(`Booking ${bookingId} not found on this contract`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.bookingsRepository.findManualConsolidationCandidates(
|
||||||
|
booking,
|
||||||
|
);
|
||||||
|
return rows.map((row) => {
|
||||||
|
const lines = row.bookingContainers ?? [];
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
reference: row.reference,
|
||||||
|
contractId: row.contractId ?? null,
|
||||||
|
companyName: row.company?.name ?? null,
|
||||||
|
status: row.status,
|
||||||
|
tradeDirection: row.tradeDirection ?? null,
|
||||||
|
originYardId: row.originYardId ?? null,
|
||||||
|
destinationYardId: row.destinationYardId ?? null,
|
||||||
|
scheduledDate: row.scheduledDate ? row.scheduledDate.toISOString() : null,
|
||||||
|
ft20Quantity: lines
|
||||||
|
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||||
|
.reduce((sum, line) => sum + Number(line.quantity || 0), 0),
|
||||||
|
hasCargo: lines.length > 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete an odd-20ft customs booking together with the partner booking GL
|
||||||
|
* picked for its shared wagon. Both halves run the ordinary
|
||||||
|
* {@link completeUnderContract} machine — same gates, same pricing, same
|
||||||
|
* per-booking invoice, so each customer still pays only its own shipment — and
|
||||||
|
* are linked as consolidation partners at the end.
|
||||||
|
*
|
||||||
|
* All-or-nothing: the two completions plus the pairing run inside one
|
||||||
|
* transaction, so a failure on either half leaves neither booking completed
|
||||||
|
* and no half-linked wagon behind. `runInTransaction` is used rather than a
|
||||||
|
* manual QueryRunner so the nested services join the same transactional
|
||||||
|
* context through the shared DataSource.
|
||||||
|
*/
|
||||||
|
async completeConsolidatedPair(
|
||||||
|
contractId: string,
|
||||||
|
bookingId: string,
|
||||||
|
dto: CompleteConsolidatedPairDto,
|
||||||
|
actorPermissions?: unknown,
|
||||||
|
/** IAM id of the GL user creating the pairing — recorded on the approval. */
|
||||||
|
actorUserId?: string | null,
|
||||||
|
): Promise<{
|
||||||
|
booking: Booking;
|
||||||
|
partner: Booking;
|
||||||
|
warnings: string[];
|
||||||
|
}> {
|
||||||
|
if (dto.partnerBookingId === bookingId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A booking cannot be consolidated with itself.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const partner = await this.bookingsRepository.findByIdWithFiles(
|
||||||
|
dto.partnerBookingId,
|
||||||
|
);
|
||||||
|
if (!partner) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Partner booking ${dto.partnerBookingId} not found`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (partner.consolidationPartnerId) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Booking ${partner.reference} already shares a wagon with another booking.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!partner.contractId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Booking ${partner.reference} is not a contract booking and cannot be completed here.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
const { ownId, partnerId } = await this.dataSource.transaction(async () => {
|
||||||
|
const own = await this.completeUnderContract(
|
||||||
|
contractId,
|
||||||
|
bookingId,
|
||||||
|
{ ...dto.booking, skipAutoConsolidation: true },
|
||||||
|
// Both halves are completed by the same GL actor that reached this
|
||||||
|
// endpoint — the customs gate in completeUnderContract re-checks it.
|
||||||
|
actorPermissions,
|
||||||
|
);
|
||||||
|
warnings.push(...own.warnings);
|
||||||
|
|
||||||
|
const other = await this.completeUnderContract(
|
||||||
|
partner.contractId as string,
|
||||||
|
partner.id,
|
||||||
|
{ ...dto.partner, skipAutoConsolidation: true },
|
||||||
|
actorPermissions,
|
||||||
|
);
|
||||||
|
warnings.push(...other.warnings);
|
||||||
|
|
||||||
|
// Link the two halves. Written directly (not via pairConsolidation) because
|
||||||
|
// both bookings have just been completed into their live status here —
|
||||||
|
// pairConsolidation exists to RESUME bookings parked in
|
||||||
|
// PENDING_CONSOLIDATION and would overwrite that status.
|
||||||
|
await this.bookingsRepository.linkConsolidationPartners(
|
||||||
|
own.booking.id,
|
||||||
|
other.booking.id,
|
||||||
|
);
|
||||||
|
return { ownId: own.booking.id, partnerId: other.booking.id };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both halves have just been completed into the operations queue by the
|
||||||
|
// ordinary completion machine. A shared wagon does not go there unreviewed:
|
||||||
|
// pull the pair back into the approval gate, which releases them to
|
||||||
|
// Operations only once a person signs off on the pairing.
|
||||||
|
await this.consolidationApprovalService.requestApproval(
|
||||||
|
ownId,
|
||||||
|
partnerId,
|
||||||
|
actorUserId ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sequential reads: one connection per transaction context.
|
||||||
|
const finalBooking = await this.bookingsRepository.findByIdWithFiles(ownId);
|
||||||
|
const finalPartner = await this.bookingsRepository.findByIdWithFiles(partnerId);
|
||||||
|
return {
|
||||||
|
booking: finalBooking!,
|
||||||
|
partner: finalPartner ?? partner,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete a bare initiated booking after its per-booking clearance is
|
* Complete a bare initiated booking after its per-booking clearance is
|
||||||
* finalized (CLEARANCE_READY) or operations returned it for changes
|
* finalized (CLEARANCE_READY) or operations returned it for changes
|
||||||
@@ -826,10 +989,18 @@ export class ContractBookingService {
|
|||||||
// exactly like a drawdown created with cargo does. The shipment day is
|
// exactly like a drawdown created with cargo does. The shipment day is
|
||||||
// stored first so the pairing event can resume straight into the
|
// stored first so the pairing event can resume straight into the
|
||||||
// operations queue.
|
// operations queue.
|
||||||
|
// Customs (Path B) instances are exempt from the AUTO-matcher: GL links
|
||||||
|
// their shared wagon by hand through completeConsolidatedPair, so nothing
|
||||||
|
// may claim a partner for them behind GL's back. A customs half completed
|
||||||
|
// as part of a manual pair carries `skipAutoConsolidation`; one completed
|
||||||
|
// alone still falls through to the automatic gate below, so an odd 20ft
|
||||||
|
// booking can never proceed on a partial wagon. Non-customs drawdowns are
|
||||||
|
// unaffected.
|
||||||
const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||||
if (
|
if (
|
||||||
withContainers &&
|
withContainers &&
|
||||||
freightType === 'CONTAINER' &&
|
freightType === 'CONTAINER' &&
|
||||||
|
!dto.skipAutoConsolidation &&
|
||||||
(await this.consolidationService.needsConsolidationFromBooking(withContainers))
|
(await this.consolidationService.needsConsolidationFromBooking(withContainers))
|
||||||
) {
|
) {
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
@@ -2287,6 +2458,22 @@ export class ContractBookingService {
|
|||||||
private async assert20ftPairableAtCreate(
|
private async assert20ftPairableAtCreate(
|
||||||
dto: CreateBookingUnderContractDto,
|
dto: CreateBookingUnderContractDto,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Parity gate. 20ft containers ride two per wagon, so an odd total leaves
|
||||||
|
// one container that cannot be placed. Consolidation (pairing it with
|
||||||
|
// another customer's odd booking) is built end to end but switched off for
|
||||||
|
// now, so an odd total is rejected outright — server-side, because the
|
||||||
|
// frontend block alone is not a guarantee.
|
||||||
|
const ft20Quantity = (dto.containers ?? [])
|
||||||
|
.filter((line) => (line.containerSize ?? '').includes('20'))
|
||||||
|
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
|
||||||
|
if (ft20Quantity % 2 === 1) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`20ft containers travel two per wagon, so they must be booked in even ` +
|
||||||
|
`numbers. This booking has ${ft20Quantity} — add one more or remove ` +
|
||||||
|
`one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const twentyFtUnits = (dto.containers ?? [])
|
const twentyFtUnits = (dto.containers ?? [])
|
||||||
.filter((line) => (line.containerSize ?? '').includes('20'))
|
.filter((line) => (line.containerSize ?? '').includes('20'))
|
||||||
.flatMap((line, lineIdx) =>
|
.flatMap((line, lineIdx) =>
|
||||||
|
|||||||
@@ -358,32 +358,39 @@ export class ContractClearanceService {
|
|||||||
// Once GL creates the shipment booking, surface its reference + status so the
|
// Once GL creates the shipment booking, surface its reference + status so the
|
||||||
// customer sees the concrete booking instead of a stale "will be created
|
// customer sees the concrete booking instead of a stale "will be created
|
||||||
// shortly" message. Reuse the export booking load; fetch for import too.
|
// shortly" message. Reuse the export booking load; fetch for import too.
|
||||||
|
let linkedBookingId: string | null = null;
|
||||||
let linkedBookingReference: string | null = null;
|
let linkedBookingReference: string | null = null;
|
||||||
let linkedBookingStatus: string | null = null;
|
let linkedBookingStatus: string | null = null;
|
||||||
let linkedBookingReviewNote: string | null = null;
|
let linkedBookingReviewNote: string | null = null;
|
||||||
let linkedBookingScheduledDate: string | null = null;
|
let linkedBookingScheduledDate: string | null = null;
|
||||||
if (cycle?.bookingId) {
|
// The cycle is the historical link, but it is not written on every path (an
|
||||||
const booking = await this.bookingsService.findById(cycle.bookingId);
|
// FCFS export booking and a GL drawdown both reach the operations queue
|
||||||
if (booking) {
|
// without a cycle row), so fall back to the contract's own live booking —
|
||||||
linkedBookingReference = booking.reference ?? null;
|
// otherwise the clearance page sees no linked booking at all and cannot show
|
||||||
linkedBookingStatus = booking.status ?? null;
|
// its status or the actions that depend on it.
|
||||||
linkedBookingScheduledDate = booking.scheduledDate
|
const booking = cycle?.bookingId
|
||||||
? new Date(booking.scheduledDate).toISOString()
|
? await this.bookingsService.findById(cycle.bookingId)
|
||||||
: null;
|
: await this.contractsRepository.findLatestBookingForContract(contractId);
|
||||||
// Newest changes-requested note (reviewNotes ride along on findById).
|
if (booking) {
|
||||||
linkedBookingReviewNote =
|
linkedBookingId = booking.id ?? null;
|
||||||
[...(booking.reviewNotes ?? [])]
|
linkedBookingReference = booking.reference ?? null;
|
||||||
.filter((n) => n.type === 'CHANGES_REQUESTED')
|
linkedBookingStatus = booking.status ?? null;
|
||||||
.sort(
|
linkedBookingScheduledDate = booking.scheduledDate
|
||||||
(a, b) =>
|
? new Date(booking.scheduledDate).toISOString()
|
||||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
: null;
|
||||||
)[0]?.note ?? null;
|
// Newest changes-requested note (reviewNotes ride along on findById).
|
||||||
if (contract.tradeDirection === 'EXPORT') {
|
linkedBookingReviewNote =
|
||||||
nextAction = this.workflowService.computeNextActionForBooking(
|
[...(booking.reviewNotes ?? [])]
|
||||||
booking,
|
.filter((n) => n.type === 'CHANGES_REQUESTED')
|
||||||
bookingMilestones,
|
.sort(
|
||||||
);
|
(a, b) =>
|
||||||
}
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||||
|
)[0]?.note ?? null;
|
||||||
|
if (contract.tradeDirection === 'EXPORT') {
|
||||||
|
nextAction = this.workflowService.computeNextActionForBooking(
|
||||||
|
booking,
|
||||||
|
bookingMilestones,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,7 +427,7 @@ export class ContractClearanceService {
|
|||||||
bookingReady: boundary,
|
bookingReady: boundary,
|
||||||
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
|
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
|
||||||
exportClearanceFinalized: Boolean(cycle?.completedAt),
|
exportClearanceFinalized: Boolean(cycle?.completedAt),
|
||||||
linkedBookingId: cycle?.bookingId ?? null,
|
linkedBookingId,
|
||||||
linkedBookingReference,
|
linkedBookingReference,
|
||||||
linkedBookingStatus,
|
linkedBookingStatus,
|
||||||
linkedBookingReviewNote,
|
linkedBookingReviewNote,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { resolveIamUserNames } from '../../common/utils/iam-user-name.util';
|
||||||
import {
|
import {
|
||||||
ContractDocumentChange,
|
ContractDocumentChange,
|
||||||
diffSnapshots,
|
diffSnapshots,
|
||||||
@@ -20,28 +21,6 @@ export interface RecordRevisionInput {
|
|||||||
stepId?: string | null;
|
stepId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* `iam.users.name` is a localized object ({ en, am, … }), not a string — a
|
|
||||||
* plain `String(name)` there yields "[object Object]" in the audit trail.
|
|
||||||
*/
|
|
||||||
interface IamUserRow {
|
|
||||||
name?: Record<string, string> | string | null;
|
|
||||||
username?: string | null;
|
|
||||||
email?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Best display name for a user row: English label → any locale → login → email. */
|
|
||||||
function pickUserName(user: IamUserRow): string | null {
|
|
||||||
const { name } = user;
|
|
||||||
if (typeof name === 'string' && name.trim()) return name.trim();
|
|
||||||
if (name && typeof name === 'object') {
|
|
||||||
const localized =
|
|
||||||
name.en ?? Object.values(name).find((v) => typeof v === 'string' && v.trim());
|
|
||||||
if (localized?.trim()) return localized.trim();
|
|
||||||
}
|
|
||||||
return user.username?.trim() || user.email?.trim() || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pre-computed changes (contract fields), rather than a document diff. */
|
/** Pre-computed changes (contract fields), rather than a document diff. */
|
||||||
export interface RecordChangesInput {
|
export interface RecordChangesInput {
|
||||||
contractId: string;
|
contractId: string;
|
||||||
@@ -120,23 +99,12 @@ export class ContractDocumentHistoryService {
|
|||||||
private async resolveActorNames(
|
private async resolveActorNames(
|
||||||
actorIds: string[],
|
actorIds: string[],
|
||||||
): Promise<Map<string, string>> {
|
): Promise<Map<string, string>> {
|
||||||
const resolved = new Map<string, string>();
|
|
||||||
const ids = [...new Set(actorIds.filter(Boolean))];
|
|
||||||
if (ids.length === 0) return resolved;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rows = (await this.dataSource.query(
|
return await resolveIamUserNames(this.dataSource, actorIds);
|
||||||
`SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`,
|
|
||||||
[ids],
|
|
||||||
)) as Array<IamUserRow & { id: string }>;
|
|
||||||
for (const row of rows) {
|
|
||||||
const name = pickUserName(row);
|
|
||||||
if (name) resolved.set(row.id, name);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Could not resolve actor names: ${String(err)}`);
|
this.logger.warn(`Could not resolve actor names: ${String(err)}`);
|
||||||
|
return new Map();
|
||||||
}
|
}
|
||||||
return resolved;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Revision history for a contract, newest first. */
|
/** Revision history for a contract, newest first. */
|
||||||
|
|||||||
@@ -76,7 +76,10 @@ import {
|
|||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||||
import { RenewContractDto } from './dto/renew-contract.dto';
|
import { RenewContractDto } from './dto/renew-contract.dto';
|
||||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
import {
|
||||||
|
CompleteConsolidatedPairDto,
|
||||||
|
CreateBookingUnderContractDto,
|
||||||
|
} from './dto/create-booking-under-contract.dto';
|
||||||
import {
|
import {
|
||||||
CreateBookingRequestDto,
|
CreateBookingRequestDto,
|
||||||
ReviewBookingRequestDto,
|
ReviewBookingRequestDto,
|
||||||
@@ -1152,10 +1155,48 @@ export class ContractsController {
|
|||||||
// Customs (Path B) instances may only be completed by GL Ethiopia — the
|
// Customs (Path B) instances may only be completed by GL Ethiopia — the
|
||||||
// service checks the actor's contracts:create_booking permission.
|
// service checks the actor's contracts:create_booking permission.
|
||||||
return this.contractBookingService.completeUnderContract(
|
return this.contractBookingService.completeUnderContract(
|
||||||
|
id,
|
||||||
|
bookingId,
|
||||||
|
// skipAutoConsolidation is internal to the manual pair-completion path; a
|
||||||
|
// client must never suppress the wagon gate on a lone booking.
|
||||||
|
{ ...dto, skipAutoConsolidation: false },
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/bookings/:bookingId/consolidation-candidates')
|
||||||
|
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Bookings GL may link to this odd-20ft customs booking as its shared-wagon partner (same route and direction, customs, odd 20ft, unpaired).',
|
||||||
|
})
|
||||||
|
listConsolidationCandidates(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
) {
|
||||||
|
return this.contractBookingService.listConsolidationCandidates(id, bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/bookings/:bookingId/complete-consolidated')
|
||||||
|
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Complete this booking and its chosen shared-wagon partner together (all-or-nothing). Each booking is priced and invoiced separately — only the wagon is shared.',
|
||||||
|
})
|
||||||
|
completeConsolidatedPair(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@Body() dto: CompleteConsolidatedPairDto,
|
||||||
|
@CurrentUser() user: TCurrentUser & { sub?: string },
|
||||||
|
) {
|
||||||
|
return this.contractBookingService.completeConsolidatedPair(
|
||||||
id,
|
id,
|
||||||
bookingId,
|
bookingId,
|
||||||
dto,
|
dto,
|
||||||
user,
|
user,
|
||||||
|
// Recorded as the requester on the approval: the person who created the
|
||||||
|
// pairing may not be the one who approves it.
|
||||||
|
user?.id ?? user?.sub ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -656,6 +656,31 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
.getCount();
|
.getCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live shipment booking on a contract, newest first.
|
||||||
|
*
|
||||||
|
* The clearance view historically reached the booking through
|
||||||
|
* `currentCycle().bookingId`, but a cycle row is not created on every path —
|
||||||
|
* an FCFS export booking and a GL drawdown both reach
|
||||||
|
* OPERATION_REQUEST_PENDING without one — so that lookup returns null and the
|
||||||
|
* clearance page loses the booking's status entirely. This resolves it from
|
||||||
|
* the bookings themselves, which is the authoritative link (bookings carry
|
||||||
|
* contract_id), and is used as the fallback when the cycle has no booking.
|
||||||
|
*/
|
||||||
|
async findLatestBookingForContract(
|
||||||
|
contractId: string,
|
||||||
|
): Promise<Booking | null> {
|
||||||
|
return this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.where('b.contract_id = :contractId', { contractId })
|
||||||
|
.andWhere('b.status NOT IN (:...terminal)', {
|
||||||
|
terminal: TERMINAL_BOOKING_STATUSES,
|
||||||
|
})
|
||||||
|
.orderBy('b.created_at', 'DESC')
|
||||||
|
.getOne();
|
||||||
|
}
|
||||||
|
|
||||||
async createReviewNote(
|
async createReviewNote(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
body: string,
|
body: string,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiHideProperty, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform, Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
@@ -219,4 +219,46 @@ export class CreateBookingUnderContractDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal: set by the manual GL pair-completion path, never by a client.
|
||||||
|
* Suppresses the automatic wagon-consolidation gate for this completion
|
||||||
|
* because the caller links the shared wagon itself. Excluded from the public
|
||||||
|
* schema so a client cannot set it to bypass the gate on a lone booking.
|
||||||
|
*/
|
||||||
|
@ApiHideProperty()
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
skipAutoConsolidation?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete an odd-20ft customs booking together with the partner booking GL
|
||||||
|
* picked to share its wagon. Each half carries its own full completion payload —
|
||||||
|
* the two bookings stay separately priced and separately invoiced, they only
|
||||||
|
* share the wagon.
|
||||||
|
*/
|
||||||
|
export class CompleteConsolidatedPairDto {
|
||||||
|
@ApiProperty({
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'The booking chosen to share this booking’s wagon.',
|
||||||
|
})
|
||||||
|
@IsUUID()
|
||||||
|
partnerBookingId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: CreateBookingUnderContractDto,
|
||||||
|
description: 'Completion payload for the booking in the URL.',
|
||||||
|
})
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => CreateBookingUnderContractDto)
|
||||||
|
booking!: CreateBookingUnderContractDto;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: CreateBookingUnderContractDto,
|
||||||
|
description: 'Completion payload for the partner booking.',
|
||||||
|
})
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => CreateBookingUnderContractDto)
|
||||||
|
partner!: CreateBookingUnderContractDto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { ArrayMinSize, IsArray, IsOptional, IsString, IsUUID, Length, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
export class BulkCancelEimsItemDto {
|
||||||
|
@ApiProperty({ description: "Invoice ID to cancel." })
|
||||||
|
@IsUUID()
|
||||||
|
invoiceId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Numeric reason code, e.g. "1" (Duplicate), "6" (Calculation Error).',
|
||||||
|
example: "1",
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@Length(1, 8)
|
||||||
|
reasonCode!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Free-text cancellation note.", example: "Duplicate submission" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(0, 500)
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST invoices/eims/bulk-cancel` body — see `EimsCancellationService.cancelBulkWithEims`. */
|
||||||
|
export class BulkCancelEimsRegistrationDto {
|
||||||
|
@ApiProperty({ type: [BulkCancelEimsItemDto] })
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => BulkCancelEimsItemDto)
|
||||||
|
items!: BulkCancelEimsItemDto[];
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@ import { EimsApiException } from "./eims.errors";
|
|||||||
import { EimsInvoiceStatus } from "./eims-registration.types";
|
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||||
|
|
||||||
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
|
const INVOICE_ID = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const OTHER_INVOICE_ID = "22222222-2222-4222-8222-222222222222";
|
||||||
const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
|
const IRN = "9fe9bbbece6ab76c112b617534e6aac7aa8b819d5be79f4d3d088ed2e887b2e0";
|
||||||
|
const OTHER_IRN = "0af579eaef6f1e2d39fa77bd21cf8ecc64e26869275ae1c04eaa9ffea78b6c06";
|
||||||
|
|
||||||
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
|
const invoiceRow = (over: Partial<Invoice> = {}): Invoice =>
|
||||||
({
|
({
|
||||||
@@ -153,3 +155,105 @@ describe("EimsCancellationService.cancelInvoiceWithEims", () => {
|
|||||||
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
|
expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("EimsCancellationService.cancelBulkWithEims", () => {
|
||||||
|
it("cancels every eligible invoice in one call, matching results back by IRN", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue({
|
||||||
|
statusCode: 200,
|
||||||
|
body: [
|
||||||
|
{ id: 1, tin: "t", status: "C", mode: "bulk", Irn: OTHER_IRN, ReasonCode: "6", Remark: "x" },
|
||||||
|
{ id: 2, tin: "t", status: "C", mode: "bulk", Irn: IRN, ReasonCode: "1", Remark: "" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await build(db, postBearer).cancelBulkWithEims([
|
||||||
|
{ invoiceId: INVOICE_ID, reasonCode: "1" },
|
||||||
|
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "6", remark: "x" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(postBearer).toHaveBeenCalledWith("/v1/bulkCancel", [
|
||||||
|
{ Irn: IRN, ReasonCode: "1", Remark: "" },
|
||||||
|
{ Irn: OTHER_IRN, ReasonCode: "6", Remark: "x" },
|
||||||
|
]);
|
||||||
|
expect(results).toEqual([
|
||||||
|
{ invoiceId: INVOICE_ID, success: true, message: expect.stringContaining("cancelled") },
|
||||||
|
{ invoiceId: OTHER_INVOICE_ID, success: true, message: expect.stringContaining("cancelled") },
|
||||||
|
]);
|
||||||
|
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
|
||||||
|
expect(db.invoices.get(OTHER_INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
|
||||||
|
// Bulk success carries no cancellationDate at all, unlike single cancel.
|
||||||
|
expect(db.invoices.get(INVOICE_ID)?.eimsCancellationDate).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an already-cancelled or never-registered invoice locally — never sent to MoR", async () => {
|
||||||
|
const db = new FakeDb([
|
||||||
|
invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled }),
|
||||||
|
invoiceRow({ id: OTHER_INVOICE_ID, eimsStatus: EimsInvoiceStatus.NotSubmitted, eimsIrn: null }),
|
||||||
|
]);
|
||||||
|
const postBearer = jest.fn();
|
||||||
|
|
||||||
|
const results = await build(db, postBearer).cancelBulkWithEims([
|
||||||
|
{ invoiceId: INVOICE_ID, reasonCode: "1" },
|
||||||
|
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "1" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(postBearer).not.toHaveBeenCalled();
|
||||||
|
expect(results).toEqual([
|
||||||
|
{ invoiceId: INVOICE_ID, success: false, message: expect.stringContaining("already cancelled") },
|
||||||
|
{ invoiceId: OTHER_INVOICE_ID, success: false, message: expect.stringContaining("never registered") },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a mix of MoR success and rejection only updates the succeeding invoice", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue({
|
||||||
|
statusCode: 200,
|
||||||
|
body: [
|
||||||
|
{ id: 1, tin: "t", status: "C", mode: "bulk", Irn: IRN, ReasonCode: "1", Remark: "" },
|
||||||
|
{ Status: "Processing_Error", msg: "IRN already Canceled.", Irn: OTHER_IRN },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await build(db, postBearer).cancelBulkWithEims([
|
||||||
|
{ invoiceId: INVOICE_ID, reasonCode: "1" },
|
||||||
|
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "1" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(db.invoices.get(INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Cancelled);
|
||||||
|
expect(db.invoices.get(OTHER_INVOICE_ID)?.eimsStatus).toBe(EimsInvoiceStatus.Registered);
|
||||||
|
expect(results).toEqual([
|
||||||
|
{ invoiceId: INVOICE_ID, success: true, message: expect.stringContaining("cancelled") },
|
||||||
|
{ invoiceId: OTHER_INVOICE_ID, success: false, message: "IRN already Canceled." },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes no HTTP call at all when every item fails the local eligibility check", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled })]);
|
||||||
|
const postBearer = jest.fn();
|
||||||
|
|
||||||
|
await build(db, postBearer).cancelBulkWithEims([{ invoiceId: INVOICE_ID, reasonCode: "1" }]);
|
||||||
|
|
||||||
|
expect(postBearer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies the buyer only for invoices that actually got cancelled", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow(), invoiceRow({ id: OTHER_INVOICE_ID, eimsIrn: OTHER_IRN })]);
|
||||||
|
db.companyContact = { phone: "+251911000000", email: null };
|
||||||
|
const directSend = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const postBearer = jest.fn().mockResolvedValue({
|
||||||
|
statusCode: 200,
|
||||||
|
body: [
|
||||||
|
{ status: "C", Irn: IRN },
|
||||||
|
{ Status: "Processing_Error", msg: "boom", Irn: OTHER_IRN },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await build(db, postBearer, directSend).cancelBulkWithEims([
|
||||||
|
{ invoiceId: INVOICE_ID, reasonCode: "1" },
|
||||||
|
{ invoiceId: OTHER_INVOICE_ID, reasonCode: "1" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(directSend).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,7 +6,15 @@ import { Invoice } from "../billing/entities/invoice.entity";
|
|||||||
import { NotificationsService } from "../notifications/notifications.service";
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
import { sendCompanyChannels } from "../notifications/notify-company.util";
|
||||||
import { EimsClientService } from "./eims-client.service";
|
import { EimsClientService } from "./eims-client.service";
|
||||||
import { EimsCancelRequest, EimsCancelResponse, EimsInvoiceStatus, EimsInvoiceStatusView } from "./eims-registration.types";
|
import {
|
||||||
|
EimsBulkCancelItemResult,
|
||||||
|
EimsBulkCancelRequest,
|
||||||
|
EimsBulkCancelResponse,
|
||||||
|
EimsCancelRequest,
|
||||||
|
EimsCancelResponse,
|
||||||
|
EimsInvoiceStatus,
|
||||||
|
EimsInvoiceStatusView,
|
||||||
|
} from "./eims-registration.types";
|
||||||
import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
|
import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,6 +100,102 @@ export class EimsCancellationService {
|
|||||||
return this.getEimsCancellationStatus(invoiceId);
|
return this.getEimsCancellationStatus(invoiceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /v1/bulkCancel` — one MoR call for every eligible invoice in `items`, matching the
|
||||||
|
* collection's own shape (an array in, an array of mixed success/error results back).
|
||||||
|
*
|
||||||
|
* Same local-eligibility doctrine as `cancelInvoiceWithEims`, applied per item before anything
|
||||||
|
* goes to MoR: an already-cancelled or never-registered invoice is refused right here (no HTTP
|
||||||
|
* call, no seat in the batch) rather than sent and rejected remotely. Only genuinely eligible
|
||||||
|
* invoices are batched into the one `/v1/bulkCancel` request; everything else is reported back
|
||||||
|
* immediately.
|
||||||
|
*
|
||||||
|
* ponytail: the eligibility pass is per-invoice transactions, not one covering the whole batch —
|
||||||
|
* same reasoning as the single-cancel path (cancel is idempotent at MoR, so a lock held across
|
||||||
|
* every item for the whole call isn't needed for correctness, only for avoiding a wasted call on
|
||||||
|
* an item that's already ineligible).
|
||||||
|
*/
|
||||||
|
async cancelBulkWithEims(
|
||||||
|
items: Array<{ invoiceId: string; reasonCode: string; remark?: string }>,
|
||||||
|
): Promise<EimsBulkCancelItemResult[]> {
|
||||||
|
const results = new Map<string, EimsBulkCancelItemResult>();
|
||||||
|
const eligible: Array<{ invoice: Invoice; reasonCode: string; remark?: string }> = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
try {
|
||||||
|
const invoice = await this.dataSource.transaction(async (manager) => {
|
||||||
|
const inv = await this.lockInvoice(manager, item.invoiceId);
|
||||||
|
if (inv.eimsStatus === EimsInvoiceStatus.Cancelled) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Invoice ${inv.invoiceNumber} was already cancelled with EIMS${inv.eimsCancellationDate ? ` (${inv.eimsCancellationDate})` : ""}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!inv.eimsIrn) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invoice ${inv.invoiceNumber} was never registered with EIMS — nothing to cancel.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return inv;
|
||||||
|
});
|
||||||
|
eligible.push({ invoice, reasonCode: item.reasonCode, remark: item.remark });
|
||||||
|
} catch (err) {
|
||||||
|
results.set(item.invoiceId, {
|
||||||
|
invoiceId: item.invoiceId,
|
||||||
|
success: false,
|
||||||
|
message: (err as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eligible.length > 0) {
|
||||||
|
const request: EimsBulkCancelRequest = eligible.map((e) => ({
|
||||||
|
Irn: e.invoice.eimsIrn!,
|
||||||
|
ReasonCode: e.reasonCode,
|
||||||
|
Remark: e.remark ?? "",
|
||||||
|
}));
|
||||||
|
// Outside any transaction — no DB lock held across the wire, same as single cancel.
|
||||||
|
const response = await this.client.postBearer<EimsBulkCancelRequest, EimsBulkCancelResponse>(
|
||||||
|
"/v1/bulkCancel",
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
const byIrn = new Map((response?.body ?? []).map((entry) => [entry.Irn, entry]));
|
||||||
|
|
||||||
|
for (const { invoice, reasonCode, remark } of eligible) {
|
||||||
|
const entry = byIrn.get(invoice.eimsIrn!);
|
||||||
|
const failed = !entry || "Status" in entry;
|
||||||
|
if (failed) {
|
||||||
|
const message = entry && "msg" in entry ? entry.msg : "EIMS bulk cancel returned no result for this invoice.";
|
||||||
|
this.logger.warn(`Invoice ${invoice.invoiceNumber} bulk cancel failed: ${message}`);
|
||||||
|
results.set(invoice.id, { invoiceId: invoice.id, success: false, message });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const fresh = await this.lockInvoice(manager, invoice.id);
|
||||||
|
// Re-checked under lock: a concurrent call may have already recorded this cancellation.
|
||||||
|
if (fresh.eimsStatus === EimsInvoiceStatus.Cancelled) return;
|
||||||
|
await manager.update(Invoice, invoice.id, {
|
||||||
|
eimsStatus: EimsInvoiceStatus.Cancelled,
|
||||||
|
eimsCancelledAt: new Date(),
|
||||||
|
// The bulk success shape carries no cancellationDate at all, unlike single cancel.
|
||||||
|
eimsCancellationDate: null,
|
||||||
|
eimsCancellationReasonCode: reasonCode,
|
||||||
|
eimsCancellationRemark: remark ?? null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.logger.log(`Invoice ${invoice.invoiceNumber} cancelled with EIMS via bulk (IRN ${invoice.eimsIrn})`);
|
||||||
|
await this.notifyBuyer(invoice);
|
||||||
|
results.set(invoice.id, {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
success: true,
|
||||||
|
message: `Invoice ${invoice.invoiceNumber} cancelled with EIMS.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.map((item) => results.get(item.invoiceId)!);
|
||||||
|
}
|
||||||
|
|
||||||
async getEimsCancellationStatus(invoiceId: string): Promise<EimsInvoiceStatusView> {
|
async getEimsCancellationStatus(invoiceId: string): Promise<EimsInvoiceStatusView> {
|
||||||
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
|
const invoice = await this.dataSource.manager.findOne(Invoice, { where: { id: invoiceId } });
|
||||||
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||||
|
|||||||
@@ -5,6 +5,17 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
import { EimsConfig } from "../../config/eims.config";
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
import { EimsConfigException } from "./eims.errors";
|
import { EimsConfigException } from "./eims.errors";
|
||||||
|
|
||||||
|
const PEM_HEADER = /-----BEGIN [A-Z ]*(PRIVATE KEY|CERTIFICATE)-----/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A safe-to-log fingerprint of decoded key/cert bytes: length + a printable-only preview of the
|
||||||
|
* first line. Never the actual key material — PEM headers aren't secret, the base64 body is.
|
||||||
|
*/
|
||||||
|
const describeBytes = (bytes: Buffer): string => {
|
||||||
|
const preview = bytes.toString("utf8", 0, 40).replace(/[^\x20-\x7e]/g, "?");
|
||||||
|
return `${bytes.length} bytes, starts with "${preview}"`;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory.
|
* Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory.
|
||||||
*
|
*
|
||||||
@@ -24,25 +35,61 @@ export class EimsCredentialsProvider {
|
|||||||
return this.config.get<EimsConfig>("eims")!;
|
return this.config.get<EimsConfig>("eims")!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** RSA private key, parsed once. Throws a config error if the path is missing or unusable. */
|
/**
|
||||||
|
* RSA private key, parsed once. Three ways in, checked in this order: `privateKeyPem` (the PEM
|
||||||
|
* text itself, no encoding step to get wrong), `privateKeyBase64` (for stores that can't hold a
|
||||||
|
* literal newline), `privateKeyPath` (the original file-on-disk form). Throws a config error if
|
||||||
|
* none is usable.
|
||||||
|
*/
|
||||||
getPrivateKey(): KeyObject {
|
getPrivateKey(): KeyObject {
|
||||||
if (this.privateKey) return this.privateKey;
|
if (this.privateKey) return this.privateKey;
|
||||||
|
|
||||||
const path = this.cfg.privateKeyPath;
|
const { privateKeyPem, privateKeyBase64, privateKeyPath: path } = this.cfg;
|
||||||
if (!path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set");
|
const source = privateKeyPem
|
||||||
|
? "EIMS_PRIVATE_KEY"
|
||||||
|
: privateKeyBase64
|
||||||
|
? "EIMS_PRIVATE_KEY_BASE64"
|
||||||
|
: `EIMS_PRIVATE_KEY_PATH (${path})`;
|
||||||
|
if (!privateKeyPem && !privateKeyBase64 && !path) {
|
||||||
|
throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes: Buffer;
|
||||||
|
try {
|
||||||
|
bytes = privateKeyPem
|
||||||
|
? Buffer.from(privateKeyPem, "utf8")
|
||||||
|
: privateKeyBase64
|
||||||
|
? Buffer.from(privateKeyBase64, "base64")
|
||||||
|
: readFileSync(path);
|
||||||
|
} catch (err) {
|
||||||
|
throw new EimsConfigException(
|
||||||
|
`EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail with a diagnosable message before handing possibly-garbled bytes to OpenSSL, whose own
|
||||||
|
// error ("unsupported") gives no hint whether the problem is truncation, double-encoding, or a
|
||||||
|
// genuinely wrong file — all indistinguishable from outside without seeing the decoded bytes.
|
||||||
|
if (!PEM_HEADER.test(bytes.toString("utf8", 0, 100))) {
|
||||||
|
throw new EimsConfigException(
|
||||||
|
`EIMS private key from ${source} does not look like a PEM key after decoding ` +
|
||||||
|
`(${describeBytes(bytes)}) — check it's base64 of the raw key file with no line-wrapping ` +
|
||||||
|
`or truncation, and not base64 applied twice.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let key: KeyObject;
|
let key: KeyObject;
|
||||||
try {
|
try {
|
||||||
key = createPrivateKey(readFileSync(path));
|
key = createPrivateKey(bytes);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// The path is operational information, not a secret; the key material never appears.
|
// The source is operational information, not a secret; the key material never appears.
|
||||||
throw new EimsConfigException(
|
throw new EimsConfigException(
|
||||||
`EIMS private key at ${path} could not be read or parsed: ${(err as Error).message}`,
|
`EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (key.asymmetricKeyType !== "rsa") {
|
if (key.asymmetricKeyType !== "rsa") {
|
||||||
throw new EimsConfigException(
|
throw new EimsConfigException(
|
||||||
`EIMS private key at ${path} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`,
|
`EIMS private key from ${source} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,11 +98,26 @@ export class EimsCredentialsProvider {
|
|||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Base64 of the certificate file's exact bytes. No parsing, no re-encoding. */
|
/**
|
||||||
|
* Base64 of the certificate file's exact bytes. No parsing, no re-encoding of what MoR issued.
|
||||||
|
* `certificatePem`/`certificateBase64` config win when set (used as-is, or re-encoded from the
|
||||||
|
* pasted text respectively); otherwise read from `certificatePath`.
|
||||||
|
*/
|
||||||
getCertificateBase64(): string {
|
getCertificateBase64(): string {
|
||||||
if (this.certificateBase64) return this.certificateBase64;
|
if (this.certificateBase64) return this.certificateBase64;
|
||||||
|
|
||||||
const path = this.cfg.certificatePath;
|
const { certificatePem: pem, certificateBase64: inline, certificatePath: path } = this.cfg;
|
||||||
|
if (pem) {
|
||||||
|
this.certificateBase64 = Buffer.from(pem, "utf8").toString("base64");
|
||||||
|
this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE`);
|
||||||
|
return this.certificateBase64;
|
||||||
|
}
|
||||||
|
if (inline) {
|
||||||
|
this.certificateBase64 = inline;
|
||||||
|
this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE_BASE64`);
|
||||||
|
return this.certificateBase64;
|
||||||
|
}
|
||||||
|
|
||||||
if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set");
|
if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set");
|
||||||
|
|
||||||
let bytes: Buffer;
|
let bytes: Buffer;
|
||||||
|
|||||||
@@ -206,8 +206,10 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E
|
|||||||
incomeWithholdValue: invoice.incomeWithholdValue!,
|
incomeWithholdValue: invoice.incomeWithholdValue!,
|
||||||
transactionWithholdValue: invoice.transactionWithholdValue!,
|
transactionWithholdValue: invoice.transactionWithholdValue!,
|
||||||
buyerCountryCode: invoice.buyerCountryCode,
|
buyerCountryCode: invoice.buyerCountryCode,
|
||||||
|
buyerCountryCodes: invoice.buyerCountryCodes,
|
||||||
buyerRegionCodes: invoice.buyerRegionCodes,
|
buyerRegionCodes: invoice.buyerRegionCodes,
|
||||||
buyerWeredaCodes: invoice.buyerWeredaCodes,
|
buyerWeredaCodes: invoice.buyerWeredaCodes,
|
||||||
|
buyerCityCodes: invoice.buyerCityCodes,
|
||||||
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
|
// TEMPORARY — see EimsInvoiceConfig.buyerIdType.
|
||||||
buyerIdType: invoice.buyerIdType,
|
buyerIdType: invoice.buyerIdType,
|
||||||
buyerIdNumber: invoice.buyerIdNumber,
|
buyerIdNumber: invoice.buyerIdNumber,
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import { NotificationInboxService } from "../notification-inbox/notification-inb
|
|||||||
import { NotificationsService } from "../notifications/notifications.service";
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
import { EimsClientService } from "./eims-client.service";
|
import { EimsClientService } from "./eims-client.service";
|
||||||
import { EimsApiException } from "./eims.errors";
|
import { EimsApiException, EimsConfigException } from "./eims.errors";
|
||||||
|
import { buildEimsSeller } from "./eims-invoice-context";
|
||||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||||
|
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||||
import { EimsInvoiceStatus } from "./eims-registration.types";
|
import { EimsInvoiceStatus } from "./eims-registration.types";
|
||||||
|
|
||||||
@@ -172,6 +174,9 @@ const build = (
|
|||||||
} as unknown as EimsAuthService,
|
} as unknown as EimsAuthService,
|
||||||
{ notify } as unknown as NotificationInboxService,
|
{ notify } as unknown as NotificationInboxService,
|
||||||
{ directSend } as unknown as NotificationsService,
|
{ directSend } as unknown as NotificationsService,
|
||||||
|
// Same static-config seller the real EimsSellerCacheService falls back to when it has never
|
||||||
|
// successfully fetched e-Trade — matches prior behavior for every test in this file.
|
||||||
|
{ getSellerDetails: (c: EimsConfig) => buildEimsSeller(c) } as unknown as EimsSellerCacheService,
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -474,6 +479,59 @@ describe("EimsInvoiceRegistrationService.registerInvoiceWithEims", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a config error (bad key, never reached MoR) rolls back both counters, no system block", async () => {
|
||||||
|
const db = new FakeDb([invoiceRow()]);
|
||||||
|
const postSigned = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new EimsConfigException("EIMS private key ... could not be read or parsed"));
|
||||||
|
|
||||||
|
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toBeInstanceOf(
|
||||||
|
EimsConfigException,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
||||||
|
eimsStatus: EimsInvoiceStatus.Failed,
|
||||||
|
eimsIrn: null,
|
||||||
|
eimsLastError: expect.objectContaining({ kind: "CONFIG" }),
|
||||||
|
});
|
||||||
|
expect(db.state).toMatchObject({
|
||||||
|
inFlightInvoiceId: null,
|
||||||
|
blockedReason: null,
|
||||||
|
previousIrn: null,
|
||||||
|
nextInvoiceCounter: 7,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a mapper failure after reservation (e.g. unmapped buyer country) also releases the reservation", async () => {
|
||||||
|
// Regression: toEimsInvoice/buildEimsContext used to sit outside the try/catch that calls
|
||||||
|
// settleFailure — a throw here left the reservation permanently orphaned (a real live incident:
|
||||||
|
// 500 on register, then every subsequent attempt 409'd "already in flight" until manually
|
||||||
|
// resolved). This never reaches postSigned at all — the mapper throws before submit() is called.
|
||||||
|
const db = new FakeDb([
|
||||||
|
invoiceRow({ company: { ...invoiceRow().company, country: "France" } as never }),
|
||||||
|
]);
|
||||||
|
const postSigned = jest.fn();
|
||||||
|
|
||||||
|
// The mapper throws a plain Error (it's a pure function, not a NestJS layer) — that's the
|
||||||
|
// point: settleFailure must treat *any* non-EimsApiException as pre-wire, not just its own
|
||||||
|
// known exception types.
|
||||||
|
await expect(build(db, postSigned).registerInvoiceWithEims(INVOICE_ID)).rejects.toThrow(
|
||||||
|
/no MoR country code mapping/,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(postSigned).not.toHaveBeenCalled();
|
||||||
|
expect(db.invoices.get(INVOICE_ID)).toMatchObject({
|
||||||
|
eimsStatus: EimsInvoiceStatus.Failed,
|
||||||
|
eimsLastError: expect.objectContaining({ kind: "LOCAL" }),
|
||||||
|
});
|
||||||
|
expect(db.state).toMatchObject({
|
||||||
|
inFlightInvoiceId: null,
|
||||||
|
blockedReason: null,
|
||||||
|
previousIrn: null,
|
||||||
|
nextInvoiceCounter: 7,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("treats a success response with no IRN as a failed registration", async () => {
|
it("treats a success response with no IRN as a failed registration", async () => {
|
||||||
const db = new FakeDb([invoiceRow()]);
|
const db = new FakeDb([invoiceRow()]);
|
||||||
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });
|
const postSigned = jest.fn().mockResolvedValue({ statusCode: 200, body: { irn: "" } });
|
||||||
|
|||||||
@@ -26,13 +26,10 @@ import { toEimsInvoiceStatusView } from "./eims-invoice-view.util";
|
|||||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
import { EimsClientService } from "./eims-client.service";
|
import { EimsClientService } from "./eims-client.service";
|
||||||
import { EimsApiException } from "./eims.errors";
|
import { EimsApiException, EimsConfigException } from "./eims.errors";
|
||||||
|
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||||
import {
|
import { assertEimsInvoiceConfig, buildEimsContext } from "./eims-invoice-context";
|
||||||
assertEimsInvoiceConfig,
|
|
||||||
buildEimsContext,
|
|
||||||
buildEimsSeller,
|
|
||||||
} from "./eims-invoice-context";
|
|
||||||
import {
|
import {
|
||||||
EimsInvoiceError,
|
EimsInvoiceError,
|
||||||
EimsInvoiceStatus,
|
EimsInvoiceStatus,
|
||||||
@@ -83,6 +80,7 @@ export class EimsInvoiceRegistrationService {
|
|||||||
private readonly auth: EimsAuthService,
|
private readonly auth: EimsAuthService,
|
||||||
private readonly inbox: NotificationInboxService,
|
private readonly inbox: NotificationInboxService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly sellerCache: EimsSellerCacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private get cfg(): EimsConfig {
|
private get cfg(): EimsConfig {
|
||||||
@@ -125,27 +123,29 @@ export class EimsInvoiceRegistrationService {
|
|||||||
const reservation = await this.reserve(invoiceId, session.systemNumber);
|
const reservation = await this.reserve(invoiceId, session.systemNumber);
|
||||||
if (!reservation) return this.getEimsStatus(invoiceId);
|
if (!reservation) return this.getEimsStatus(invoiceId);
|
||||||
|
|
||||||
// The request can only be built now: InvoiceCounter and PreviousIrn come from the reservation.
|
|
||||||
const request = toEimsInvoice(
|
|
||||||
invoice,
|
|
||||||
buildEimsSeller(cfg),
|
|
||||||
buildEimsContext(cfg, {
|
|
||||||
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
|
|
||||||
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
|
|
||||||
documentNumber: reservation.documentNumber,
|
|
||||||
invoiceCounter: reservation.invoiceCounter,
|
|
||||||
previousIrn: reservation.previousIrn,
|
|
||||||
session,
|
|
||||||
documentType,
|
|
||||||
reason: invoice.eimsReason,
|
|
||||||
relatedDocument,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
let irn: string;
|
let irn: string;
|
||||||
let ackDate: string | undefined;
|
let ackDate: string | undefined;
|
||||||
let signedQR: string | undefined;
|
let signedQR: string | undefined;
|
||||||
try {
|
try {
|
||||||
|
// The request can only be built now: InvoiceCounter and PreviousIrn come from the
|
||||||
|
// reservation. Building it — and everything after — stays inside this try: a reservation is
|
||||||
|
// held from here on, and *any* failure past this point, mapper or wire, must release it
|
||||||
|
// through settleFailure rather than leave it orphaned as a permanent system-wide block.
|
||||||
|
const request = toEimsInvoice(
|
||||||
|
invoice,
|
||||||
|
this.sellerCache.getSellerDetails(cfg),
|
||||||
|
buildEimsContext(cfg, {
|
||||||
|
// Allocated from the system state, not our invoiceNumber: MoR validates DocumentNumber
|
||||||
|
// against ^(0|[1-9][0-9]{0,8})$, which "INV-20260807-00006" can never satisfy.
|
||||||
|
documentNumber: reservation.documentNumber,
|
||||||
|
invoiceCounter: reservation.invoiceCounter,
|
||||||
|
previousIrn: reservation.previousIrn,
|
||||||
|
session,
|
||||||
|
documentType,
|
||||||
|
reason: invoice.eimsReason,
|
||||||
|
relatedDocument,
|
||||||
|
}),
|
||||||
|
);
|
||||||
// Deliberately outside every transaction — no DB lock is held across the wire.
|
// Deliberately outside every transaction — no DB lock is held across the wire.
|
||||||
const result = await this.submit(request);
|
const result = await this.submit(request);
|
||||||
irn = result.irn;
|
irn = result.irn;
|
||||||
@@ -471,6 +471,15 @@ export class EimsInvoiceRegistrationService {
|
|||||||
* when two rejected self-test attempts deadlocked the sequence until a manual DB reset.
|
* when two rejected self-test attempts deadlocked the sequence until a manual DB reset.
|
||||||
*
|
*
|
||||||
* An ambiguous result keeps both: MoR may have counted and stored the document.
|
* An ambiguous result keeps both: MoR may have counted and stored the document.
|
||||||
|
*
|
||||||
|
* Any error that is *not* an `EimsApiException` is also deterministic, on a different basis:
|
||||||
|
* every error that actually touches the wire is normalized to `EimsApiException` before it gets
|
||||||
|
* here (`EimsClientService.send()`'s catch calls `toEimsApiException` on whatever the HTTP call
|
||||||
|
* threw). The try block this feeds covers request-building (`toEimsInvoice`/`buildEimsContext` —
|
||||||
|
* pure, no I/O) and `submit()`; nothing in that span can produce another exception shape by
|
||||||
|
* touching MoR. So a non-`EimsApiException` here — a mapper validation error (unmapped buyer
|
||||||
|
* country, say), `EimsConfigException` from a bad signing key, or a bug — failed strictly before
|
||||||
|
* any HTTP call went out, and releasing the reservation is always safe, never a guess.
|
||||||
*/
|
*/
|
||||||
private async settleFailure(
|
private async settleFailure(
|
||||||
invoiceId: string,
|
invoiceId: string,
|
||||||
@@ -478,10 +487,12 @@ export class EimsInvoiceRegistrationService {
|
|||||||
err: unknown,
|
err: unknown,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const api = err instanceof EimsApiException ? err : null;
|
const api = err instanceof EimsApiException ? err : null;
|
||||||
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : false;
|
// Never touched the wire (see the doc comment above) — always safe to release, whatever it is.
|
||||||
|
const deterministic = api ? DETERMINISTIC_KINDS.has(api.kind) : true;
|
||||||
const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown;
|
const status = deterministic ? EimsInvoiceStatus.Failed : EimsInvoiceStatus.Unknown;
|
||||||
|
const localKind = err instanceof EimsConfigException ? "CONFIG" : "LOCAL";
|
||||||
const lastError: EimsInvoiceError = {
|
const lastError: EimsInvoiceError = {
|
||||||
kind: api?.kind ?? "UNKNOWN",
|
kind: api?.kind ?? localKind,
|
||||||
message: (err as Error)?.message ?? "unknown error",
|
message: (err as Error)?.message ?? "unknown error",
|
||||||
httpStatus: api?.httpStatus,
|
httpStatus: api?.httpStatus,
|
||||||
details: api?.details,
|
details: api?.details,
|
||||||
@@ -588,10 +599,14 @@ export class EimsInvoiceRegistrationService {
|
|||||||
type: NotificationType.GENERIC,
|
type: NotificationType.GENERIC,
|
||||||
priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH,
|
priority: deterministic ? NotificationPriority.NORMAL : NotificationPriority.HIGH,
|
||||||
title: deterministic
|
title: deterministic
|
||||||
? "EIMS rejected an invoice"
|
? error.kind === "CONFIG" || error.kind === "LOCAL"
|
||||||
|
? "EIMS filing failed before reaching MoR"
|
||||||
|
: "EIMS rejected an invoice"
|
||||||
: "EIMS filing unresolved — all further filing is blocked",
|
: "EIMS filing unresolved — all further filing is blocked",
|
||||||
body: deterministic
|
body: deterministic
|
||||||
? `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.`
|
? error.kind === "CONFIG" || error.kind === "LOCAL"
|
||||||
|
? `${error.kind === "CONFIG" ? "EIMS is misconfigured" : "Filing failed locally"}: ${error.message}. Nothing was sent to MoR; fix it and file again.`
|
||||||
|
: `MoR rejected the filing (${error.kind}): ${error.message}. The invoice is marked FAILED; correct it and file again.`
|
||||||
: `A submission was sent but never acknowledged (${error.kind}). Its IRN is unknown, so no further invoice can be filed until it is resolved with MoR.`,
|
: `A submission was sent but never acknowledged (${error.kind}). Its IRN is unknown, so no further invoice can be filed until it is resolved with MoR.`,
|
||||||
link: `/dashboard/invoices/${invoiceId}`,
|
link: `/dashboard/invoices/${invoiceId}`,
|
||||||
data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" },
|
data: { invoiceId, eimsStatus: status, kind: error.kind, action: "EIMS_FILING_FAILED" },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Response } from "express";
|
|||||||
import { BookingStaff } from "../../common/booking-guards";
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
import { sendPdf } from "../billing/billing.controller";
|
import { sendPdf } from "../billing/billing.controller";
|
||||||
|
import { BulkCancelEimsRegistrationDto } from "./dto/bulk-cancel-eims-registration.dto";
|
||||||
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
|
import { CancelEimsRegistrationDto } from "./dto/cancel-eims-registration.dto";
|
||||||
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
import { RegisterSalesReceiptDto } from "./dto/register-sales-receipt.dto";
|
||||||
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
import { RegisterWithholdingReceiptDto } from "./dto/register-withholding-receipt.dto";
|
||||||
@@ -89,6 +90,17 @@ export class EimsInvoiceController {
|
|||||||
return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark);
|
return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("eims/bulk-cancel")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.invoices.eimsCancel)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Cancel multiple invoices' registered EIMS documents in one call. Each invoice's outcome is " +
|
||||||
|
"reported independently — one failure never blocks the rest.",
|
||||||
|
})
|
||||||
|
bulkCancel(@Body() dto: BulkCancelEimsRegistrationDto) {
|
||||||
|
return this.cancellation.cancelBulkWithEims(dto.items);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(":id/eims/receipt/sales")
|
@Post(":id/eims/receipt/sales")
|
||||||
@BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister)
|
@BookingStaff(FREIGHT_PERMS.invoices.eimsReceiptRegister)
|
||||||
@ApiOperation({ summary: "Register a sales receipt with MoR EIMS against a registered invoice" })
|
@ApiOperation({ summary: "Register a sales receipt with MoR EIMS against a registered invoice" })
|
||||||
|
|||||||
@@ -89,6 +89,46 @@ export interface EimsCancelResponse {
|
|||||||
body?: EimsCancelResponseBody;
|
body?: EimsCancelResponseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `POST /v1/bulkCancel` — an array of the same `Irn`/`ReasonCode`/`Remark` shape as single cancel. */
|
||||||
|
export type EimsBulkCancelRequest = EimsCancelRequest[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One element of a `/v1/bulkCancel` response array — MoR mixes success and error shapes in the same
|
||||||
|
* array, one entry per submitted IRN, disambiguated by `Status` (capital, error) vs `status`
|
||||||
|
* (lowercase, success — always `"C"`). Unlike single cancel, a bulk success carries no
|
||||||
|
* `cancellationDate` at all.
|
||||||
|
*/
|
||||||
|
export interface EimsBulkCancelSuccessItem {
|
||||||
|
id?: number;
|
||||||
|
tin?: string;
|
||||||
|
status: string;
|
||||||
|
mode?: string;
|
||||||
|
Irn: string;
|
||||||
|
ReasonCode?: string;
|
||||||
|
Remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EimsBulkCancelErrorItem {
|
||||||
|
Status: string;
|
||||||
|
msg: string;
|
||||||
|
Irn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EimsBulkCancelResponseItem = EimsBulkCancelSuccessItem | EimsBulkCancelErrorItem;
|
||||||
|
|
||||||
|
export interface EimsBulkCancelResponse {
|
||||||
|
statusCode?: number;
|
||||||
|
message?: string;
|
||||||
|
body?: EimsBulkCancelResponseItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One invoice's outcome from `cancelBulkWithEims` — local eligibility failure or MoR's own result. */
|
||||||
|
export interface EimsBulkCancelItemResult {
|
||||||
|
invoiceId: string;
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
|
/** Persisted failure detail. Carries the gateway's own error fields only — never our envelope. */
|
||||||
export interface EimsInvoiceError {
|
export interface EimsInvoiceError {
|
||||||
kind: string;
|
kind: string;
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
|
import { ETradeService } from "../companies/services/etrade.service";
|
||||||
|
import { eimsConfig, eimsInvoiceConfig } from "./eims-test-fixtures";
|
||||||
|
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||||
|
|
||||||
|
const registrationData = (over: Record<string, unknown> = {}) => ({
|
||||||
|
companyName: "Ethio-Djibouti Railway PLC (eTrade)",
|
||||||
|
region: "Addis Ababa",
|
||||||
|
zone: "Bole",
|
||||||
|
woreda: "Yeka",
|
||||||
|
mobilePhone: "0911000000",
|
||||||
|
regularPhone: "",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const build = (cfg: EimsConfig = eimsConfig()) => {
|
||||||
|
const resolveCompanyData = jest.fn();
|
||||||
|
const extractRegistrationData = jest.fn().mockReturnValue(registrationData());
|
||||||
|
const etrade = { resolveCompanyData, extractRegistrationData } as unknown as ETradeService;
|
||||||
|
const config = { get: () => cfg } as unknown as ConfigService;
|
||||||
|
const service = new EimsSellerCacheService(etrade, config);
|
||||||
|
return { service, resolveCompanyData, extractRegistrationData, cfg };
|
||||||
|
};
|
||||||
|
|
||||||
|
const CODES = {
|
||||||
|
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||||
|
buyerWeredaCodes: { Yeka: "99" },
|
||||||
|
buyerCityCodes: { Bole: "101" },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("EimsSellerCacheService.getSellerDetails", () => {
|
||||||
|
it("static config wins over a conflicting e-Trade value", async () => {
|
||||||
|
const cfg = eimsConfig({
|
||||||
|
invoice: eimsInvoiceConfig({ sellerLegalName: "Ethio-Djibouti Railway S.C.", ...CODES }),
|
||||||
|
});
|
||||||
|
const { service, resolveCompanyData } = build(cfg);
|
||||||
|
resolveCompanyData.mockResolvedValue({
|
||||||
|
companyInfo: {},
|
||||||
|
businessInfo: {}, // presence is all that matters — extractRegistrationData is mocked
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.refresh();
|
||||||
|
const seller = service.getSellerDetails(cfg);
|
||||||
|
|
||||||
|
// The static sellerLegalName ("Ethio-Djibouti Railway S.C.") must survive, not e-Trade's
|
||||||
|
// differently-punctuated "Ethio-Djibouti Railway PLC (eTrade)".
|
||||||
|
expect(seller.LegalName).toBe("Ethio-Djibouti Railway S.C.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("e-Trade fills a field only when the static value is blank", async () => {
|
||||||
|
const cfg = eimsConfig({
|
||||||
|
invoice: eimsInvoiceConfig({
|
||||||
|
sellerLegalName: "",
|
||||||
|
sellerRegion: "",
|
||||||
|
sellerWereda: "",
|
||||||
|
sellerCity: null,
|
||||||
|
...CODES,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { service, resolveCompanyData } = build(cfg);
|
||||||
|
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
|
||||||
|
|
||||||
|
await service.refresh();
|
||||||
|
const seller = service.getSellerDetails(cfg);
|
||||||
|
|
||||||
|
expect(seller.LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
|
||||||
|
expect(seller.Region).toBe("13");
|
||||||
|
expect(seller.Wereda).toBe("99");
|
||||||
|
expect(seller.City).toBe("101");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("VatNumber and Email are always the static value, never touched by e-Trade", async () => {
|
||||||
|
const cfg = eimsConfig({
|
||||||
|
invoice: eimsInvoiceConfig({ sellerVatNumber: "0000000000", sellerEmail: "finance@example.et", ...CODES }),
|
||||||
|
});
|
||||||
|
const { service, resolveCompanyData } = build(cfg);
|
||||||
|
resolveCompanyData.mockResolvedValue({ companyInfo: {}, businessInfo: {} });
|
||||||
|
|
||||||
|
await service.refresh();
|
||||||
|
const seller = service.getSellerDetails(cfg);
|
||||||
|
|
||||||
|
expect(seller.VatNumber).toBe("0000000000");
|
||||||
|
expect(seller.Email).toBe("finance@example.et");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the static config entirely when e-Trade has never been reachable", () => {
|
||||||
|
const cfg = eimsConfig();
|
||||||
|
const { service } = build(cfg);
|
||||||
|
|
||||||
|
// No refresh() ever called/succeeded — cached stays null.
|
||||||
|
const seller = service.getSellerDetails(cfg);
|
||||||
|
|
||||||
|
expect(seller.LegalName).toBe(cfg.invoice.sellerLegalName);
|
||||||
|
expect(seller.Region).toBe(cfg.invoice.sellerRegion);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does no I/O at all — filing never triggers an e-Trade request", () => {
|
||||||
|
const { service, resolveCompanyData, cfg } = build();
|
||||||
|
|
||||||
|
service.getSellerDetails(cfg);
|
||||||
|
service.getSellerDetails(cfg);
|
||||||
|
|
||||||
|
expect(resolveCompanyData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EimsSellerCacheService.refresh", () => {
|
||||||
|
it("keeps the previous snapshot when a refresh fails", async () => {
|
||||||
|
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
|
||||||
|
const { service, resolveCompanyData } = build(cfg);
|
||||||
|
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
|
||||||
|
await service.refresh();
|
||||||
|
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
|
||||||
|
|
||||||
|
resolveCompanyData.mockRejectedValueOnce(new Error("eTrade down"));
|
||||||
|
await service.refresh();
|
||||||
|
|
||||||
|
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the previous snapshot on timeout, without waiting for the slow request", async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const cfg = eimsConfig({ invoice: eimsInvoiceConfig({ sellerLegalName: "", ...CODES }) });
|
||||||
|
const { service, resolveCompanyData } = build(cfg);
|
||||||
|
resolveCompanyData.mockResolvedValueOnce({ companyInfo: {}, businessInfo: {} });
|
||||||
|
await service.refresh();
|
||||||
|
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
|
||||||
|
|
||||||
|
resolveCompanyData.mockReturnValueOnce(new Promise(() => {})); // never resolves
|
||||||
|
const refreshing = service.refresh();
|
||||||
|
await jest.advanceTimersByTimeAsync(10_000);
|
||||||
|
await refreshing;
|
||||||
|
|
||||||
|
expect(service.getSellerDetails(cfg).LegalName).toBe("Ethio-Djibouti Railway PLC (eTrade)");
|
||||||
|
} finally {
|
||||||
|
jest.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not start a second e-Trade request while one is already in flight", async () => {
|
||||||
|
const { service, resolveCompanyData } = build();
|
||||||
|
let resolveCall: (value: unknown) => void = () => {};
|
||||||
|
resolveCompanyData.mockReturnValue(new Promise((resolve) => (resolveCall = resolve)));
|
||||||
|
|
||||||
|
const first = service.refresh();
|
||||||
|
const second = service.refresh();
|
||||||
|
resolveCall({ companyInfo: {}, businessInfo: {} });
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
|
||||||
|
expect(resolveCompanyData).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
|
import { EimsConfig } from "../../config/eims.config";
|
||||||
|
import { ETradeService } from "../companies/services/etrade.service";
|
||||||
|
import { EimsSellerDetails, resolveOptionalCode } from "../billing/eims-invoice.mapper";
|
||||||
|
import { buildEimsSeller } from "./eims-invoice-context";
|
||||||
|
|
||||||
|
const has = (value: string | null | undefined): value is string => Boolean(value && value.trim());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EDR's own EIMS seller identity (LegalName/Phone/Region/Wereda/City), enriched from the same
|
||||||
|
* e-Trade business-registry lookup already used for every customer company at onboarding — instead
|
||||||
|
* of the whole thing being hand-maintained `EIMS_SELLER_*` config.
|
||||||
|
*
|
||||||
|
* **Static config is the source of truth, e-Trade is bootstrap/enrichment only.** MoR validates
|
||||||
|
* `SellerDetails` against its own taxpayer registry (rule 7017, already cleared and live-tested
|
||||||
|
* with the current static values) — e-Trade filling a gap is fine, e-Trade silently overriding a
|
||||||
|
* value already confirmed against MoR is not. `getSellerDetails` therefore only reaches for the
|
||||||
|
* e-Trade-derived value when the static one is blank; a static value, once set, is never replaced.
|
||||||
|
* This also means the durable fallback is the static config, not this cache — the in-memory
|
||||||
|
* snapshot disappearing on a process restart is harmless, not a reliability gap: every field it
|
||||||
|
* could supply already has a working static value today, so filing is unaffected either way.
|
||||||
|
*
|
||||||
|
* `VatNumber` and `Email` are never sourced here — confirmed by reading e-Trade's actual response
|
||||||
|
* shapes (`ETradeCompanyInfo`, `ETradeBusinessInfo`, `CompanyRegistrationData`): neither field
|
||||||
|
* exists anywhere in what e-Trade returns. They stay on static config permanently, same as
|
||||||
|
* `SubCity`/`Locality`/`HouseNumber`, which this pass doesn't touch.
|
||||||
|
*
|
||||||
|
* Cache shape follows `PositionTypePermissionsCache`'s precedent (`src/common/
|
||||||
|
* position-type-permissions.cache.ts`) for "external/slow data, not fetched per request": a plain
|
||||||
|
* field refreshed on a raw `setInterval`, `unref()`'d so it never holds the process open, and a
|
||||||
|
* refresh failure keeps serving the previous snapshot rather than clearing it. Two deliberate
|
||||||
|
* deviations from that precedent, both because `ETradeService` has no request timeout configured
|
||||||
|
* at all (confirmed by reading it) and is a third-party dependency, unlike the DB:
|
||||||
|
* - the first fetch is fire-and-forget in `onModuleInit`, never awaited by boot;
|
||||||
|
* - `refresh()` is wrapped in a local timeout, and a second call while one is already in flight
|
||||||
|
* returns the same in-flight promise instead of starting a duplicate request.
|
||||||
|
*
|
||||||
|
* `getSellerDetails` is fully synchronous — zero I/O at call time — so a live invoice registration
|
||||||
|
* never depends on e-Trade being reachable at that moment, whether or not it ever has been.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EimsSellerCacheService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(EimsSellerCacheService.name);
|
||||||
|
|
||||||
|
/** Only the e-Trade-derived fields, used solely to fill a blank static value. */
|
||||||
|
private cached: Partial<EimsSellerDetails> | null = null;
|
||||||
|
/** Concurrency guard — a second `refresh()` call while one is running joins it. */
|
||||||
|
private refreshing: Promise<void> | null = null;
|
||||||
|
|
||||||
|
// ponytail: daily refresh, no invalidation hook — a change at e-Trade takes up to 24h to reach a
|
||||||
|
// filed invoice. Wire a manual refresh() call (e.g. from an admin action) if that lag ever
|
||||||
|
// matters; EDR's own business registration changes rarely enough that this is a generous
|
||||||
|
// ceiling, not a real one.
|
||||||
|
private static readonly REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
/** Bounded locally since `ETradeService` itself sets none — see the class comment. */
|
||||||
|
private static readonly REFRESH_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly etrade: ETradeService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
void this.refresh();
|
||||||
|
const timer = setInterval(() => void this.refresh(), EimsSellerCacheService.REFRESH_INTERVAL_MS);
|
||||||
|
timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static config wins whenever it's non-blank — that's the value already confirmed against MoR.
|
||||||
|
* e-Trade fills a field only when the static one is empty. Synchronous, no I/O: safe to call on
|
||||||
|
* every registration.
|
||||||
|
*/
|
||||||
|
getSellerDetails(cfg: EimsConfig): EimsSellerDetails {
|
||||||
|
const fallback = buildEimsSeller(cfg);
|
||||||
|
const e = this.cached;
|
||||||
|
return {
|
||||||
|
...fallback,
|
||||||
|
LegalName: has(fallback.LegalName) ? fallback.LegalName : (e?.LegalName ?? fallback.LegalName),
|
||||||
|
Phone: has(fallback.Phone) ? fallback.Phone : (e?.Phone ?? fallback.Phone),
|
||||||
|
Region: has(fallback.Region) ? fallback.Region : (e?.Region ?? fallback.Region),
|
||||||
|
Wereda: has(fallback.Wereda) ? fallback.Wereda : (e?.Wereda ?? fallback.Wereda),
|
||||||
|
City: has(fallback.City) ? fallback.City : (e?.City ?? fallback.City),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reload the cache. Concurrency-safe (see class comment); public so a caller can force one. */
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
if (this.refreshing) return this.refreshing;
|
||||||
|
this.refreshing = this.doRefresh().finally(() => {
|
||||||
|
this.refreshing = null;
|
||||||
|
});
|
||||||
|
return this.refreshing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doRefresh(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const cfg = this.config.get<EimsConfig>("eims")!;
|
||||||
|
const { companyInfo, businessInfo } = await this.withTimeout(
|
||||||
|
this.etrade.resolveCompanyData(cfg.tin),
|
||||||
|
EimsSellerCacheService.REFRESH_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
if (!businessInfo) return; // no licence on file yet — keep the previous snapshot
|
||||||
|
const data = this.etrade.extractRegistrationData(businessInfo, companyInfo);
|
||||||
|
const codes = cfg.invoice;
|
||||||
|
this.cached = {
|
||||||
|
LegalName: data.companyName || undefined,
|
||||||
|
Phone: data.mobilePhone || data.regularPhone || undefined,
|
||||||
|
// e-Trade returns region/zone/woreda as names ("Addis Ababa", "Bole") — resolved via the
|
||||||
|
// same buyer code maps, since the geography is objective, not buyer-specific, despite the
|
||||||
|
// env var's "BUYER_" prefix. Never throws: an unmapped name just leaves that field to
|
||||||
|
// getSellerDetails' static-config fallback.
|
||||||
|
Region: resolveOptionalCode(data.region, codes.buyerRegionCodes),
|
||||||
|
Wereda: resolveOptionalCode(data.woreda, codes.buyerWeredaCodes),
|
||||||
|
City: resolveOptionalCode(data.zone, codes.buyerCityCodes),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`EIMS seller e-Trade refresh failed, keeping previous snapshot: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ETradeService` sets no request timeout of its own, so one is enforced here. Note this only
|
||||||
|
* stops *waiting* on the request — nothing cancels the underlying HTTP call (no
|
||||||
|
* `AbortController` wired into `ETradeService`), so a timed-out request may still complete in
|
||||||
|
* the background; its result is simply never read.
|
||||||
|
*/
|
||||||
|
private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error(`e-Trade lookup timed out after ${ms}ms`)), ms);
|
||||||
|
promise.then(
|
||||||
|
(value) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,8 +97,14 @@ describe("EimsSignerService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("EimsCredentialsProvider", () => {
|
describe("EimsCredentialsProvider", () => {
|
||||||
const providerFor = (paths: { privateKeyPath?: string; certificatePath?: string }) =>
|
const providerFor = (cfg: {
|
||||||
new EimsCredentialsProvider({ get: () => paths } as unknown as ConfigService);
|
privateKeyPath?: string;
|
||||||
|
certificatePath?: string;
|
||||||
|
privateKeyBase64?: string;
|
||||||
|
certificateBase64?: string;
|
||||||
|
privateKeyPem?: string;
|
||||||
|
certificatePem?: string;
|
||||||
|
}) => new EimsCredentialsProvider({ get: () => cfg } as unknown as ConfigService);
|
||||||
|
|
||||||
it("fails clearly when the key path is unset", () => {
|
it("fails clearly when the key path is unset", () => {
|
||||||
expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/);
|
expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/);
|
||||||
@@ -115,4 +121,52 @@ describe("EimsCredentialsProvider", () => {
|
|||||||
writeFileSync(emptyPath, "");
|
writeFileSync(emptyPath, "");
|
||||||
expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/);
|
expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads the key from inline base64, no file involved", () => {
|
||||||
|
const keyBase64 = readFileSync(keyPath).toString("base64");
|
||||||
|
const key = providerFor({ privateKeyBase64: keyBase64 }).getPrivateKey();
|
||||||
|
expect(key.asymmetricKeyType).toBe("rsa");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers inline base64 over the path when both are set", () => {
|
||||||
|
const keyBase64 = readFileSync(keyPath).toString("base64");
|
||||||
|
// A path that would fail if it were ever actually read.
|
||||||
|
const key = providerFor({ privateKeyBase64: keyBase64, privateKeyPath: join(dir, "nope.key") }).getPrivateKey();
|
||||||
|
expect(key.asymmetricKeyType).toBe("rsa");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads the certificate from inline base64 as-is, no re-encoding", () => {
|
||||||
|
const certBase64 = Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64");
|
||||||
|
expect(providerFor({ certificateBase64: certBase64 }).getCertificateBase64()).toBe(certBase64);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails with a decoded-bytes preview when the base64 doesn't decode to a PEM key", () => {
|
||||||
|
// Simulates the real failure this guards against: a truncated/mangled env var still decodes
|
||||||
|
// as *some* bytes, but not a key — OpenSSL's own error here gives no hint why.
|
||||||
|
const notAKey = Buffer.from("not actually a pem file", "utf8").toString("base64");
|
||||||
|
expect(() => providerFor({ privateKeyBase64: notAKey }).getPrivateKey()).toThrow(
|
||||||
|
/does not look like a PEM key.*23 bytes, starts with "not actually a pem file"/s,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads the key from the raw PEM env var directly, no encoding step", () => {
|
||||||
|
const pem = readFileSync(keyPath).toString("utf8");
|
||||||
|
const key = providerFor({ privateKeyPem: pem }).getPrivateKey();
|
||||||
|
expect(key.asymmetricKeyType).toBe("rsa");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the raw PEM var over base64 and path when all three are set", () => {
|
||||||
|
const pem = readFileSync(keyPath).toString("utf8");
|
||||||
|
const key = providerFor({
|
||||||
|
privateKeyPem: pem,
|
||||||
|
privateKeyBase64: Buffer.from("garbage").toString("base64"),
|
||||||
|
privateKeyPath: join(dir, "nope.key"),
|
||||||
|
}).getPrivateKey();
|
||||||
|
expect(key.asymmetricKeyType).toBe("rsa");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads the certificate from the raw PEM env var, re-encoded to base64", () => {
|
||||||
|
const base64 = providerFor({ certificatePem: CERTIFICATE_FIXTURE }).getCertificateBase64();
|
||||||
|
expect(base64).toBe(Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,8 +33,10 @@ export const eimsInvoiceConfig = (over: Partial<EimsInvoiceConfig> = {}): EimsIn
|
|||||||
paymentTerm: "IMMIDIATE",
|
paymentTerm: "IMMIDIATE",
|
||||||
unitDefault: "PCS",
|
unitDefault: "PCS",
|
||||||
buyerCountryCode: null,
|
buyerCountryCode: null,
|
||||||
|
buyerCountryCodes: { Ethiopia: "231" }, // test-only, not a confirmed real MoR code
|
||||||
buyerRegionCodes: { "Addis Ababa": "13" },
|
buyerRegionCodes: { "Addis Ababa": "13" },
|
||||||
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
|
buyerWeredaCodes: { Yeka: "99" }, // test-only, not a real MoR code
|
||||||
|
buyerCityCodes: { Kirkos: "101" }, // test-only, not a confirmed real MoR code
|
||||||
taxCodeByChargeType: {},
|
taxCodeByChargeType: {},
|
||||||
taxRateByChargeType: {},
|
taxRateByChargeType: {},
|
||||||
exciseByChargeType: {},
|
exciseByChargeType: {},
|
||||||
@@ -57,6 +59,10 @@ export const eimsConfig = (over: Partial<EimsConfig> = {}): EimsConfig => ({
|
|||||||
systemType: EIMS_SYSTEM_TYPE,
|
systemType: EIMS_SYSTEM_TYPE,
|
||||||
privateKeyPath: "/dev/null",
|
privateKeyPath: "/dev/null",
|
||||||
certificatePath: "/dev/null",
|
certificatePath: "/dev/null",
|
||||||
|
privateKeyBase64: "",
|
||||||
|
certificateBase64: "",
|
||||||
|
privateKeyPem: "",
|
||||||
|
certificatePem: "",
|
||||||
httpTimeoutMs: 30_000,
|
httpTimeoutMs: 30_000,
|
||||||
tokenSkewMs: 45_000,
|
tokenSkewMs: 45_000,
|
||||||
autoSubmit: false,
|
autoSubmit: false,
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ export type EimsFailureKind =
|
|||||||
| "FORBIDDEN"
|
| "FORBIDDEN"
|
||||||
| "RULE_VALIDATION"
|
| "RULE_VALIDATION"
|
||||||
| "SERVER"
|
| "SERVER"
|
||||||
| "UNKNOWN";
|
| "UNKNOWN"
|
||||||
|
| "CONFIG"
|
||||||
|
| "LOCAL";
|
||||||
|
|
||||||
/** Raised when EIMS is disabled or its credential files are unusable. */
|
/** Raised when EIMS is disabled or its credential files are unusable. */
|
||||||
export class EimsConfigException extends ServiceUnavailableException {
|
export class EimsConfigException extends ServiceUnavailableException {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
|||||||
|
|
||||||
import { Invoice } from "../billing/entities/invoice.entity";
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
import { DocumentsModule } from "../billing/documents/documents.module";
|
import { DocumentsModule } from "../billing/documents/documents.module";
|
||||||
|
import { CompaniesModule } from "../companies/companies.module";
|
||||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||||
import { NotificationsModule } from "../notifications/notifications.module";
|
import { NotificationsModule } from "../notifications/notifications.module";
|
||||||
import { EimsAuthService } from "./eims-auth.service";
|
import { EimsAuthService } from "./eims-auth.service";
|
||||||
@@ -14,6 +15,7 @@ import { EimsCredentialsProvider } from "./eims-credentials.provider";
|
|||||||
import { EimsInvoiceController } from "./eims-invoice.controller";
|
import { EimsInvoiceController } from "./eims-invoice.controller";
|
||||||
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
import { EimsInvoiceRegistrationService } from "./eims-invoice-registration.service";
|
||||||
import { EimsReceiptService } from "./eims-receipt.service";
|
import { EimsReceiptService } from "./eims-receipt.service";
|
||||||
|
import { EimsSellerCacheService } from "./eims-seller-cache.service";
|
||||||
import { EimsSignerService } from "./eims-signer.service";
|
import { EimsSignerService } from "./eims-signer.service";
|
||||||
import { EimsReceipt } from "./entities/eims-receipt.entity";
|
import { EimsReceipt } from "./entities/eims-receipt.entity";
|
||||||
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
import { EimsSystemState } from "./entities/eims-system-state.entity";
|
||||||
@@ -33,6 +35,10 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
|||||||
// For EimsReceiptService.document() — the shared sealed invoice/receipt PDF layout. No domain
|
// For EimsReceiptService.document() — the shared sealed invoice/receipt PDF layout. No domain
|
||||||
// deps of its own (StampSettingsService/LogoSettingsService are both @Global), so no cycle.
|
// deps of its own (StampSettingsService/LogoSettingsService are both @Global), so no cycle.
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
|
// For EimsSellerCacheService's ETradeService — CompaniesModule has a forwardRef cycle with
|
||||||
|
// ShippingLineCompaniesModule -> BillingModule, but nothing in that chain imports EimsModule,
|
||||||
|
// so this stays a plain one-directional import, not a new cycle.
|
||||||
|
CompaniesModule,
|
||||||
],
|
],
|
||||||
controllers: [EimsInvoiceController],
|
controllers: [EimsInvoiceController],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -44,6 +50,7 @@ import { EimsSystemState } from "./entities/eims-system-state.entity";
|
|||||||
EimsAutoSubmitService,
|
EimsAutoSubmitService,
|
||||||
EimsCancellationService,
|
EimsCancellationService,
|
||||||
EimsReceiptService,
|
EimsReceiptService,
|
||||||
|
EimsSellerCacheService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
EimsAuthService,
|
EimsAuthService,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entit
|
|||||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
|
|
||||||
import { BackofficeModule } from "../backoffice/backoffice.module";
|
import { BackofficeModule } from "../backoffice/backoffice.module";
|
||||||
|
import { ChatModule } from "../chat/chat.module";
|
||||||
import { CompaniesModule } from "../companies/companies.module";
|
import { CompaniesModule } from "../companies/companies.module";
|
||||||
import { NotificationsModule } from "../notifications/notifications.module";
|
import { NotificationsModule } from "../notifications/notifications.module";
|
||||||
import { Notification } from "./entities/notification.entity";
|
import { Notification } from "./entities/notification.entity";
|
||||||
@@ -24,6 +25,8 @@ import { WsAuthService } from "./ws-auth.service";
|
|||||||
BackofficeModule,
|
BackofficeModule,
|
||||||
// EmailClientService + SmsClientService (HIGH-priority fan-out)
|
// EmailClientService + SmsClientService (HIGH-priority fan-out)
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
// ChatBridgeService (mirrors BACKOFFICE notifications into chat)
|
||||||
|
ChatModule,
|
||||||
],
|
],
|
||||||
controllers: [NotificationInboxController],
|
controllers: [NotificationInboxController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
NotificationAudience,
|
||||||
NotificationChannels,
|
NotificationChannels,
|
||||||
NotificationChannelsSent,
|
NotificationChannelsSent,
|
||||||
NotificationDto,
|
NotificationDto,
|
||||||
@@ -11,6 +12,7 @@ import { InjectRepository } from "@nestjs/typeorm";
|
|||||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
import { Repository } from "typeorm";
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { ChatBridgeService } from "../chat/chat-bridge.service";
|
||||||
import { EmailClientService } from "../notifications/email-client.service";
|
import { EmailClientService } from "../notifications/email-client.service";
|
||||||
import { SmsClientService } from "../notifications/sms-client.service";
|
import { SmsClientService } from "../notifications/sms-client.service";
|
||||||
import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto";
|
import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto";
|
||||||
@@ -37,6 +39,7 @@ export class NotificationInboxService {
|
|||||||
private readonly gateway: NotificationsGateway,
|
private readonly gateway: NotificationsGateway,
|
||||||
private readonly emailClient: EmailClientService,
|
private readonly emailClient: EmailClientService,
|
||||||
private readonly smsClient: SmsClientService,
|
private readonly smsClient: SmsClientService,
|
||||||
|
private readonly chatBridge: ChatBridgeService,
|
||||||
@InjectRepository(User)
|
@InjectRepository(User)
|
||||||
private readonly users: Repository<User>,
|
private readonly users: Repository<User>,
|
||||||
) {}
|
) {}
|
||||||
@@ -44,11 +47,18 @@ export class NotificationInboxService {
|
|||||||
/**
|
/**
|
||||||
* Fan a logical notification out to every resolved recipient: persist one row
|
* Fan a logical notification out to every resolved recipient: persist one row
|
||||||
* each, push it live over WebSocket, and (for HIGH priority) also queue
|
* each, push it live over WebSocket, and (for HIGH priority) also queue
|
||||||
* email/SMS via the existing clients.
|
* email/SMS via the existing clients. BACKOFFICE-audience notifications are
|
||||||
|
* also mirrored into internal chat (ChatBridgeService) — a shared-room
|
||||||
|
* broadcast, not per-recipient, so it runs once regardless of how many (if
|
||||||
|
* any) in-app rows get created below. Never PORTAL — that's customer-facing
|
||||||
|
* and must never reach a staff room.
|
||||||
*/
|
*/
|
||||||
async notify(input: NotifyInput): Promise<void> {
|
async notify(input: NotifyInput): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const userIds = await this.recipients.resolve(input.recipients);
|
const userIds = await this.recipients.resolve(input.recipients);
|
||||||
|
if (input.audience === NotificationAudience.BACKOFFICE) {
|
||||||
|
await this.chatBridge.bridge(input);
|
||||||
|
}
|
||||||
if (userIds.length === 0) {
|
if (userIds.length === 0) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`notify(${input.type}) resolved 0 recipients — skipped`,
|
`notify(${input.type}) resolved 0 recipients — skipped`,
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsBoolean, IsOptional } from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partial update: the UI flips one currency at a time, so an omitted field
|
||||||
|
* leaves that currency's channel exactly as it was.
|
||||||
|
*/
|
||||||
|
export class UpdateManualPaymentSettingDto {
|
||||||
|
@ApiPropertyOptional({ description: "Allow manual settlement of ETB invoices" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
etbEnabled?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Allow manual settlement of USD invoices" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
usdEnabled?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import { Column, Entity } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-row table controlling whether Finance may settle invoices by hand
|
||||||
|
* (bank transfer / counter payment) instead of the customer paying online.
|
||||||
|
*
|
||||||
|
* Per currency on purpose: the two channels are operationally different — USD
|
||||||
|
* bookings have always been bank-transfer-only, while ETB normally goes
|
||||||
|
* through the gateway and manual settlement is the exception. Switching one
|
||||||
|
* off must not switch off the other.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: "freight", name: "manual_payment_settings" })
|
||||||
|
export class ManualPaymentSetting extends BaseEntity {
|
||||||
|
/** Manual settlement allowed for ETB invoices. */
|
||||||
|
@Column({ name: "etb_enabled", type: "boolean", default: false })
|
||||||
|
etbEnabled!: boolean;
|
||||||
|
|
||||||
|
/** Manual settlement allowed for USD invoices. */
|
||||||
|
@Column({ name: "usd_enabled", type: "boolean", default: true })
|
||||||
|
usdEnabled!: boolean;
|
||||||
|
|
||||||
|
/** IAM user id of the last operator to change either toggle. */
|
||||||
|
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
|
||||||
|
updatedById?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Body, Controller, Get, Patch } from "@nestjs/common";
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { CurrentUser } from "@edr/api-common";
|
||||||
|
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||||
|
|
||||||
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
|
import { UpdateManualPaymentSettingDto } from "./dto/update-manual-payment-setting.dto";
|
||||||
|
import { ManualPaymentSettingsService } from "./manual-payment-settings.service";
|
||||||
|
|
||||||
|
@ApiTags("payment-settings")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller("payment-settings/manual")
|
||||||
|
export class ManualPaymentSettingsController {
|
||||||
|
constructor(private readonly service: ManualPaymentSettingsService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read is gated on `manual_payment:view`, which Finance also holds — the
|
||||||
|
* Manual Payments worklist reads this to know which currency tabs to offer.
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.settings.manualPayment.view,
|
||||||
|
FREIGHT_PERMS.admin,
|
||||||
|
])
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Whether manual (offline) invoice settlement is enabled, per currency",
|
||||||
|
})
|
||||||
|
get() {
|
||||||
|
return this.service.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch()
|
||||||
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.settings.manualPayment.manage,
|
||||||
|
FREIGHT_PERMS.admin,
|
||||||
|
])
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Enable or disable manual invoice settlement for ETB and/or USD",
|
||||||
|
})
|
||||||
|
update(
|
||||||
|
@Body() dto: UpdateManualPaymentSettingDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.service.update(dto, user?.id ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity";
|
||||||
|
|
||||||
|
/** The two currencies an invoice can be settled by hand in. */
|
||||||
|
export type ManualPaymentCurrency = "ETB" | "USD";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the single `manual_payment_settings` row: whether Finance may settle
|
||||||
|
* invoices by hand, per currency.
|
||||||
|
*
|
||||||
|
* Defaults mirror how the platform behaved before the toggles existed — USD
|
||||||
|
* has always been bank-transfer-only so it starts ON; ETB manual settlement is
|
||||||
|
* the new capability and starts OFF, so enabling it is a deliberate act.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ManualPaymentSettingsService {
|
||||||
|
private readonly logger = new Logger(ManualPaymentSettingsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ManualPaymentSetting)
|
||||||
|
private readonly repository: Repository<ManualPaymentSetting>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** The settings row, created at the defaults on first access. */
|
||||||
|
async get(): Promise<ManualPaymentSetting> {
|
||||||
|
const existing = await this.repository.findOne({ where: {} });
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
return this.repository.save(
|
||||||
|
this.repository.create({ etbEnabled: false, usdEnabled: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Currencies manual settlement is currently allowed for. */
|
||||||
|
async enabledCurrencies(): Promise<ManualPaymentCurrency[]> {
|
||||||
|
const setting = await this.get();
|
||||||
|
const enabled: ManualPaymentCurrency[] = [];
|
||||||
|
if (setting.etbEnabled) enabled.push("ETB");
|
||||||
|
if (setting.usdEnabled) enabled.push("USD");
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether one currency may be settled by hand right now. */
|
||||||
|
async isEnabled(currency: string | null | undefined): Promise<boolean> {
|
||||||
|
const upper = currency?.toUpperCase();
|
||||||
|
if (upper !== "ETB" && upper !== "USD") return false;
|
||||||
|
const setting = await this.get();
|
||||||
|
return upper === "ETB" ? setting.etbEnabled : setting.usdEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flip either toggle; an omitted field leaves that currency unchanged. */
|
||||||
|
async update(
|
||||||
|
patch: { etbEnabled?: boolean; usdEnabled?: boolean },
|
||||||
|
updatedById?: string | null,
|
||||||
|
): Promise<ManualPaymentSetting> {
|
||||||
|
const current = await this.get();
|
||||||
|
await this.repository.update(current.id, {
|
||||||
|
...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }),
|
||||||
|
...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }),
|
||||||
|
updatedById: updatedById ?? null,
|
||||||
|
});
|
||||||
|
const updated = await this.get();
|
||||||
|
this.logger.warn(
|
||||||
|
`Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} by ${updatedById ?? "unknown user"}`,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Global, Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
|
import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity";
|
||||||
|
import { ManualPaymentSettingsController } from "./manual-payment-settings.controller";
|
||||||
|
import { ManualPaymentSettingsService } from "./manual-payment-settings.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global so billing can inject {@link ManualPaymentSettingsService} to gate
|
||||||
|
* the manual-settlement worklist and confirmation endpoint without importing
|
||||||
|
* this module (and without a cycle, since this module needs nothing back).
|
||||||
|
*/
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([ManualPaymentSetting])],
|
||||||
|
controllers: [ManualPaymentSettingsController],
|
||||||
|
providers: [ManualPaymentSettingsService],
|
||||||
|
exports: [ManualPaymentSettingsService],
|
||||||
|
})
|
||||||
|
export class PaymentSettingsModule {}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Put, Query } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import { StaffReference } from '../../../common/booking-guards';
|
||||||
|
import { RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
|
import {
|
||||||
|
ListYardPositionsQueryDto,
|
||||||
|
SetPositionYardsDto,
|
||||||
|
SetYardPositionsDto,
|
||||||
|
} from '../dto/yard-positions.dto';
|
||||||
|
import { YardPositionsService } from '../services/yard-positions.service';
|
||||||
|
import { YardScopeService } from '../services/yard-scope.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desk↔yard mapping — which positions ("departments" in the user-management
|
||||||
|
* tree) staff which yard. It is yard configuration, so it is gated by the same
|
||||||
|
* rule-engine yard keys as the rest of the yards screen.
|
||||||
|
*
|
||||||
|
* Writes REPLACE the whole set for the side being edited. The admin UI submits
|
||||||
|
* the full multi-select value; a caller sending a delta will drop everything it
|
||||||
|
* omits. Both write paths flush the scope resolver's cache so a mapping change
|
||||||
|
* takes effect on the next request instead of up to a minute later.
|
||||||
|
*/
|
||||||
|
@ApiTags('yard-positions')
|
||||||
|
@Controller('yard-positions')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class YardPositionsController {
|
||||||
|
constructor(
|
||||||
|
private readonly service: YardPositionsService,
|
||||||
|
private readonly scope: YardScopeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RuleEngineView('yards')
|
||||||
|
@ApiOperation({ summary: 'List desk↔yard mappings, optionally by yard or position' })
|
||||||
|
list(@Query() query: ListYardPositionsQueryDto) {
|
||||||
|
return this.service.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('positions')
|
||||||
|
@RuleEngineView('yards')
|
||||||
|
@ApiOperation({ summary: 'Positions selectable as yard desks' })
|
||||||
|
listPositions() {
|
||||||
|
return this.service.listSelectablePositions();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('my-yards')
|
||||||
|
// Any signed-in staff member, NOT gated on the yards keys: this returns the
|
||||||
|
// caller's own access and nothing else, and the frontend needs it to
|
||||||
|
// preselect yard filters. Gating it on `rule_engine:yards:view` 403'd every
|
||||||
|
// desk that does not administer yards — i.e. exactly the users it is for.
|
||||||
|
@StaffReference()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "The caller's own yard scope (null yardIds = unrestricted)",
|
||||||
|
})
|
||||||
|
async myYards(@CurrentUser() user: unknown) {
|
||||||
|
const yardIds = await this.scope.getScopedYardIds(user as never);
|
||||||
|
return { yardIds, unrestricted: yardIds === null, enforced: this.scope.enforced };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('yard/:yardId')
|
||||||
|
@RuleEngineUpdate('yards')
|
||||||
|
@ApiOperation({ summary: "Replace a yard's whole position set" })
|
||||||
|
async setPositionsForYard(
|
||||||
|
@Param('yardId', ParseUUIDPipe) yardId: string,
|
||||||
|
@Body() dto: SetYardPositionsDto,
|
||||||
|
) {
|
||||||
|
const rows = await this.service.setPositionsForYard(yardId, dto.positionIds);
|
||||||
|
this.scope.invalidate();
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('position/:positionId')
|
||||||
|
@RuleEngineUpdate('yards')
|
||||||
|
@ApiOperation({ summary: "Replace a position's whole yard set" })
|
||||||
|
async setYardsForPosition(
|
||||||
|
@Param('positionId', ParseUUIDPipe) positionId: string,
|
||||||
|
@Body() dto: SetPositionYardsDto,
|
||||||
|
) {
|
||||||
|
const rows = await this.service.setYardsForPosition(positionId, dto.yardIds);
|
||||||
|
this.scope.invalidate();
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsArray, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class ListYardPositionsQueryDto {
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
yardId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
positionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the yard's whole position set — see the controller's PUT docs. */
|
||||||
|
export class SetYardPositionsDto {
|
||||||
|
@ApiProperty({ type: [String], format: 'uuid' })
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
positionIds!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the position's whole yard set. */
|
||||||
|
export class SetPositionYardsDto {
|
||||||
|
@ApiProperty({ type: [String], format: 'uuid' })
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
yardIds!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
|
||||||
|
import { Yard } from './yard.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One desk staffed at one yard.
|
||||||
|
*
|
||||||
|
* The pairing that yard access scoping resolves against: a caller's active
|
||||||
|
* position decides which yards they may touch. Position rows live in `iam`
|
||||||
|
* (`iam.positions` — what the user-management tree labels "departments"), so
|
||||||
|
* `positionId` is an unconstrained uuid by design; see the migration for why.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'yard_positions' })
|
||||||
|
@Index(['yardId'])
|
||||||
|
@Index(['positionId'])
|
||||||
|
export class YardPosition extends BaseEntity {
|
||||||
|
@Column({ name: 'yard_id', type: 'uuid' })
|
||||||
|
yardId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Yard, { nullable: false, onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'yard_id' })
|
||||||
|
yard?: Yard;
|
||||||
|
|
||||||
|
/** `iam.positions.id`. No FK — IAM is package-owned and soft-deletes. */
|
||||||
|
@Column({ name: 'position_id', type: 'uuid' })
|
||||||
|
positionId!: string;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { ShippingLinesController } from './controllers/shipping-lines.controller
|
|||||||
import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller';
|
import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller';
|
||||||
import { YardDistancesController } from './controllers/yard-distances.controller';
|
import { YardDistancesController } from './controllers/yard-distances.controller';
|
||||||
import { YardsController } from './controllers/yards.controller';
|
import { YardsController } from './controllers/yards.controller';
|
||||||
|
import { YardPositionsController } from './controllers/yard-positions.controller';
|
||||||
|
|
||||||
import { ApprovalRule } from './entities/approval-rule.entity';
|
import { ApprovalRule } from './entities/approval-rule.entity';
|
||||||
import { CargoType } from './entities/cargo-type.entity';
|
import { CargoType } from './entities/cargo-type.entity';
|
||||||
@@ -28,6 +29,7 @@ import { Yard } from './entities/yard.entity';
|
|||||||
import { YardDistance } from './entities/yard-distance.entity';
|
import { YardDistance } from './entities/yard-distance.entity';
|
||||||
import { YardFacility } from './entities/yard-facility.entity';
|
import { YardFacility } from './entities/yard-facility.entity';
|
||||||
import { YardLocation } from './entities/yard-location.entity';
|
import { YardLocation } from './entities/yard-location.entity';
|
||||||
|
import { YardPosition } from './entities/yard-position.entity';
|
||||||
|
|
||||||
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
|
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
|
||||||
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface';
|
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface';
|
||||||
@@ -65,6 +67,8 @@ import { WeightLimitRulesService } from './services/weight-limit-rules.service';
|
|||||||
import { YardsService } from './services/yards.service';
|
import { YardsService } from './services/yards.service';
|
||||||
import { YardDistancesService } from './services/yard-distances.service';
|
import { YardDistancesService } from './services/yard-distances.service';
|
||||||
import { YardFacilitiesService } from './services/yard-facilities.service';
|
import { YardFacilitiesService } from './services/yard-facilities.service';
|
||||||
|
import { YardPositionsService } from './services/yard-positions.service';
|
||||||
|
import { YardScopeService } from './services/yard-scope.service';
|
||||||
|
|
||||||
import { RuleEngineService } from './rule-engine.service';
|
import { RuleEngineService } from './rule-engine.service';
|
||||||
|
|
||||||
@@ -91,6 +95,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
YardDistance,
|
YardDistance,
|
||||||
YardFacility,
|
YardFacility,
|
||||||
YardLocation,
|
YardLocation,
|
||||||
|
YardPosition,
|
||||||
ShippingLine,
|
ShippingLine,
|
||||||
Rate,
|
Rate,
|
||||||
ApprovalRule,
|
ApprovalRule,
|
||||||
@@ -116,6 +121,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
ServiceTypesController,
|
ServiceTypesController,
|
||||||
WeightLimitRulesController,
|
WeightLimitRulesController,
|
||||||
YardsController,
|
YardsController,
|
||||||
|
YardPositionsController,
|
||||||
YardDistancesController,
|
YardDistancesController,
|
||||||
ShippingLinesController,
|
ShippingLinesController,
|
||||||
RatesController,
|
RatesController,
|
||||||
@@ -152,6 +158,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
YardsService,
|
YardsService,
|
||||||
YardDistancesService,
|
YardDistancesService,
|
||||||
YardFacilitiesService,
|
YardFacilitiesService,
|
||||||
|
YardPositionsService,
|
||||||
|
YardScopeService,
|
||||||
ShippingLinesService,
|
ShippingLinesService,
|
||||||
RatesService,
|
RatesService,
|
||||||
ApprovalRulesService,
|
ApprovalRulesService,
|
||||||
@@ -168,6 +176,10 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
|||||||
YardsService,
|
YardsService,
|
||||||
YardDistancesService,
|
YardDistancesService,
|
||||||
YardFacilitiesService,
|
YardFacilitiesService,
|
||||||
|
YardPositionsService,
|
||||||
|
// Exported so any module can narrow its yard queries through the one
|
||||||
|
// resolver — the module is @Global, so no import is needed to inject it.
|
||||||
|
YardScopeService,
|
||||||
ShippingLinesService,
|
ShippingLinesService,
|
||||||
RatesService,
|
RatesService,
|
||||||
ApprovalRulesService,
|
ApprovalRulesService,
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource, In, IsNull } from 'typeorm';
|
||||||
|
|
||||||
|
import { YardPosition } from '../entities/yard-position.entity';
|
||||||
|
import { Yard } from '../entities/yard.entity';
|
||||||
|
|
||||||
|
/** A mapped desk, joined to its IAM position for display. */
|
||||||
|
export interface YardPositionRow {
|
||||||
|
id: string;
|
||||||
|
yardId: string;
|
||||||
|
yardCode: string;
|
||||||
|
yardLabel: string;
|
||||||
|
positionId: string;
|
||||||
|
/** Localised name from `iam.positions.name` — null if the position is gone. */
|
||||||
|
positionName: { am?: string; en?: string } | null;
|
||||||
|
positionTypeKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The desk↔yard mapping behind yard access scoping.
|
||||||
|
*
|
||||||
|
* Reads always join `iam.positions` and drop soft-deleted rows: the mapping has
|
||||||
|
* no FK to IAM (see the migration), so a position deleted in the admin UI leaves
|
||||||
|
* an orphan row here. Dropping it on read means the orphan can never widen
|
||||||
|
* someone's scope — it just disappears.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class YardPositionsService {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
/** Mapping rows, optionally narrowed to one yard or one position. */
|
||||||
|
async list(filter: {
|
||||||
|
yardId?: string;
|
||||||
|
positionId?: string;
|
||||||
|
}): Promise<YardPositionRow[]> {
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const where: string[] = ['yp.deleted_at IS NULL', 'y.deleted_at IS NULL'];
|
||||||
|
|
||||||
|
if (filter.yardId) {
|
||||||
|
params.push(filter.yardId);
|
||||||
|
where.push(`yp.yard_id = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (filter.positionId) {
|
||||||
|
params.push(filter.positionId);
|
||||||
|
where.push(`yp.position_id = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dataSource.query(
|
||||||
|
`SELECT yp.id AS "id",
|
||||||
|
yp.yard_id AS "yardId",
|
||||||
|
y.code AS "yardCode",
|
||||||
|
y.label AS "yardLabel",
|
||||||
|
yp.position_id AS "positionId",
|
||||||
|
p.name AS "positionName",
|
||||||
|
pt.key AS "positionTypeKey"
|
||||||
|
FROM freight.yard_positions yp
|
||||||
|
JOIN freight.yards y ON y.id = yp.yard_id
|
||||||
|
-- INNER join: a mapping whose position was deleted grants nothing and
|
||||||
|
-- is not shown. The row stays for audit until someone re-saves the set.
|
||||||
|
JOIN iam.positions p ON p.id = yp.position_id AND p.deleted_at IS NULL
|
||||||
|
LEFT JOIN iam.position_types pt ON pt.id = p.position_type_id
|
||||||
|
WHERE ${where.join(' AND ')}
|
||||||
|
ORDER BY y.display_order ASC, y.label ASC, p.name->>'en' ASC`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the yard's entire position set.
|
||||||
|
*
|
||||||
|
* Replace, not append — the admin UI submits the full multi-select value, so a
|
||||||
|
* partial payload would silently keep desks the user just unticked. Callers
|
||||||
|
* sending a delta will remove everything they omit.
|
||||||
|
*/
|
||||||
|
async setPositionsForYard(
|
||||||
|
yardId: string,
|
||||||
|
positionIds: string[],
|
||||||
|
): Promise<YardPositionRow[]> {
|
||||||
|
await this.assertYardExists(yardId);
|
||||||
|
await this.assertPositionsExist(positionIds);
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(YardPosition);
|
||||||
|
await repo.delete({ yardId });
|
||||||
|
if (positionIds.length) {
|
||||||
|
await repo.insert(
|
||||||
|
[...new Set(positionIds)].map((positionId) => ({ yardId, positionId })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.list({ yardId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the position's entire yard set. Same replace semantics. */
|
||||||
|
async setYardsForPosition(
|
||||||
|
positionId: string,
|
||||||
|
yardIds: string[],
|
||||||
|
): Promise<YardPositionRow[]> {
|
||||||
|
await this.assertPositionsExist([positionId]);
|
||||||
|
await this.assertYardsExist(yardIds);
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(YardPosition);
|
||||||
|
await repo.delete({ positionId });
|
||||||
|
if (yardIds.length) {
|
||||||
|
await repo.insert(
|
||||||
|
[...new Set(yardIds)].map((yardId) => ({ yardId, positionId })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.list({ positionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Positions offered by the mapping picker.
|
||||||
|
*
|
||||||
|
* Reads `iam.positions` directly rather than going through IAM's
|
||||||
|
* `/positions/list/{unitId}`: that endpoint needs the caller to resolve a unit
|
||||||
|
* first, and the picker wants every desk that could staff a yard regardless of
|
||||||
|
* which unit it hangs under.
|
||||||
|
*/
|
||||||
|
async listSelectablePositions(): Promise<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
name: { am?: string; en?: string } | null;
|
||||||
|
positionTypeKey: string | null;
|
||||||
|
unitKey: string | null;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
return this.dataSource.query(
|
||||||
|
`SELECT p.id AS "id",
|
||||||
|
p.name AS "name",
|
||||||
|
pt.key AS "positionTypeKey",
|
||||||
|
u.key AS "unitKey"
|
||||||
|
FROM iam.positions p
|
||||||
|
LEFT JOIN iam.position_types pt ON pt.id = p.position_type_id
|
||||||
|
LEFT JOIN iam.units u ON u.id = p.unit_id
|
||||||
|
WHERE p.deleted_at IS NULL
|
||||||
|
ORDER BY p.name->>'en' ASC`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yard ids mapped to any of these positions — the scope resolver's read. */
|
||||||
|
async yardIdsForPositions(positionIds: string[]): Promise<string[]> {
|
||||||
|
if (!positionIds.length) return [];
|
||||||
|
const rows: { yardId: string }[] = await this.dataSource.query(
|
||||||
|
`SELECT DISTINCT yp.yard_id AS "yardId"
|
||||||
|
FROM freight.yard_positions yp
|
||||||
|
JOIN freight.yards y ON y.id = yp.yard_id AND y.deleted_at IS NULL
|
||||||
|
WHERE yp.deleted_at IS NULL
|
||||||
|
AND yp.position_id = ANY($1)`,
|
||||||
|
[positionIds],
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.yardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertYardExists(yardId: string): Promise<void> {
|
||||||
|
const yard = await this.dataSource
|
||||||
|
.getRepository(Yard)
|
||||||
|
.findOne({ where: { id: yardId, deletedAt: IsNull() } });
|
||||||
|
if (!yard) throw new NotFoundException(`Yard ${yardId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertYardsExist(yardIds: string[]): Promise<void> {
|
||||||
|
if (!yardIds.length) return;
|
||||||
|
const found = await this.dataSource
|
||||||
|
.getRepository(Yard)
|
||||||
|
.count({ where: { id: In(yardIds), deletedAt: IsNull() } });
|
||||||
|
if (found !== new Set(yardIds).size) {
|
||||||
|
throw new BadRequestException('One or more yards do not exist');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validated in the service because the database cannot: there is no FK to
|
||||||
|
* `iam.positions`, so an unchecked payload would happily store a typo'd uuid
|
||||||
|
* that silently grants nothing and reads as a configuration bug later.
|
||||||
|
*/
|
||||||
|
private async assertPositionsExist(positionIds: string[]): Promise<void> {
|
||||||
|
if (!positionIds.length) return;
|
||||||
|
const unique = [...new Set(positionIds)];
|
||||||
|
const rows: { count: string }[] = await this.dataSource.query(
|
||||||
|
`SELECT COUNT(*)::text AS count
|
||||||
|
FROM iam.positions
|
||||||
|
WHERE id = ANY($1) AND deleted_at IS NULL`,
|
||||||
|
[unique],
|
||||||
|
);
|
||||||
|
if (Number(rows[0]?.count ?? 0) !== unique.length) {
|
||||||
|
throw new BadRequestException('One or more positions do not exist');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { YardScopeService } from './yard-scope.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The resolver answers "which yards", never "may they act at all" — that stays
|
||||||
|
* with the permission guard. So a mapped desk is narrowed to its yards, and an
|
||||||
|
* unmapped one keeps the reach its permissions already gave it.
|
||||||
|
*/
|
||||||
|
describe('YardScopeService', () => {
|
||||||
|
const yardIdsForPositions = jest.fn();
|
||||||
|
const service = () =>
|
||||||
|
new YardScopeService({ yardIdsForPositions } as never);
|
||||||
|
|
||||||
|
const staff = (positionId: string, permissions: string[] = []) => ({
|
||||||
|
roles: [{ key: 'staff' }],
|
||||||
|
permissions: permissions.map((key) => ({ key })),
|
||||||
|
employee: { position: { id: positionId, permissions: [] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
delete process.env.YARD_SCOPE_ENFORCE;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves a mapped position to its yards', async () => {
|
||||||
|
yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']);
|
||||||
|
|
||||||
|
const scope = await service().getScopedYardIds(staff('pos-officer'));
|
||||||
|
|
||||||
|
expect(scope).toEqual(['yard-kality', 'yard-mojo']);
|
||||||
|
expect(yardIdsForPositions).toHaveBeenCalledWith(['pos-officer']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an unmapped position unrestricted — permissions still gate the action', async () => {
|
||||||
|
yardIdsForPositions.mockResolvedValue([]);
|
||||||
|
|
||||||
|
expect(await service().getScopedYardIds(staff('pos-unmapped'))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a caller with no resolvable position unrestricted', async () => {
|
||||||
|
const noPosition = { roles: [{ key: 'staff' }], employee: { position: {} } };
|
||||||
|
|
||||||
|
expect(await service().getScopedYardIds(noPosition)).toBeNull();
|
||||||
|
expect(yardIdsForPositions).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows nothing for an anonymous caller but grants nothing either', async () => {
|
||||||
|
expect(await service().getScopedYardIds(null)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unrestricted only for super admins and view_all holders', async () => {
|
||||||
|
const superAdmin = { roles: [{ key: 'super_admin' }] };
|
||||||
|
const hqDesk = staff('pos-occ', ['edr_freight_app:yards:view_all']);
|
||||||
|
|
||||||
|
expect(await service().getScopedYardIds(superAdmin)).toBeNull();
|
||||||
|
expect(await service().getScopedYardIds(hqDesk)).toBeNull();
|
||||||
|
expect(yardIdsForPositions).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes delegated positions — standing in must not lose the yard', async () => {
|
||||||
|
yardIdsForPositions.mockResolvedValue(['yard-kality']);
|
||||||
|
|
||||||
|
await service().getScopedYardIds({
|
||||||
|
roles: [{ key: 'staff' }],
|
||||||
|
employee: {
|
||||||
|
position: { id: 'pos-own' },
|
||||||
|
delegatedPositions: [{ id: 'pos-gelan-director' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(yardIdsForPositions).toHaveBeenCalledWith([
|
||||||
|
'pos-own',
|
||||||
|
'pos-gelan-director',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listFilterYardIds', () => {
|
||||||
|
it('narrows nothing while shadow-logging', async () => {
|
||||||
|
yardIdsForPositions.mockResolvedValue(['yard-kality']);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await service().listFilterYardIds(staff('pos-officer'), undefined, 'list'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows to the mapped yards once enforcing', async () => {
|
||||||
|
process.env.YARD_SCOPE_ENFORCE = 'true';
|
||||||
|
yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await service().listFilterYardIds(staff('pos-officer'), undefined, 'list'),
|
||||||
|
).toEqual(['yard-kality', 'yard-mojo']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an in-scope yard filter as the caller asked', async () => {
|
||||||
|
process.env.YARD_SCOPE_ENFORCE = 'true';
|
||||||
|
yardIdsForPositions.mockResolvedValue(['yard-kality', 'yard-mojo']);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await service().listFilterYardIds(staff('pos-officer'), 'yard-mojo', 'list'),
|
||||||
|
).toEqual(['yard-mojo']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty set — not everything — for an out-of-scope yard filter', async () => {
|
||||||
|
process.env.YARD_SCOPE_ENFORCE = 'true';
|
||||||
|
yardIdsForPositions.mockResolvedValue(['yard-kality']);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await service().listFilterYardIds(staff('pos-officer'), 'yard-djibouti', 'list'),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never narrows an unmapped desk', async () => {
|
||||||
|
process.env.YARD_SCOPE_ENFORCE = 'true';
|
||||||
|
yardIdsForPositions.mockResolvedValue([]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await service().listFilterYardIds(staff('pos-unmapped'), undefined, 'list'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only logs an out-of-scope yard until YARD_SCOPE_ENFORCE is set', async () => {
|
||||||
|
yardIdsForPositions.mockResolvedValue(['yard-kality']);
|
||||||
|
const shadow = service();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
shadow.assertYardInScope(staff('pos-officer'), 'yard-mojo', 'test'),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
process.env.YARD_SCOPE_ENFORCE = 'true';
|
||||||
|
const enforcing = service();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
enforcing.assertYardInScope(staff('pos-officer'), 'yard-mojo', 'test'),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { ForbiddenException, Injectable, Logger } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { hasFreightPermission, isSuperAdmin } from "../../../common/freight-permission.util";
|
||||||
|
import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry";
|
||||||
|
import { YardPositionsService } from "./yard-positions.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caller shape the resolver reads — the `/auth/me` user in either of its two
|
||||||
|
* shapes. Structurally compatible with what `freight-permission.util` accepts,
|
||||||
|
* so the same object serves both the permission checks and the position walk.
|
||||||
|
*/
|
||||||
|
type PositionLike = {
|
||||||
|
id?: string;
|
||||||
|
permissions?: { key?: string }[];
|
||||||
|
positionType?: { key?: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ScopeUser = {
|
||||||
|
roles?: { key?: string }[];
|
||||||
|
permissions?: { key?: string }[];
|
||||||
|
employee?:
|
||||||
|
| {
|
||||||
|
position?: PositionLike;
|
||||||
|
delegatedPositions?: PositionLike[];
|
||||||
|
}
|
||||||
|
| { positions?: PositionLike[] }[]
|
||||||
|
| null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which yards a caller may touch.
|
||||||
|
*
|
||||||
|
* Scope follows the caller's ACTIVE position, not a union of every position they
|
||||||
|
* have ever held: the frontends already send `x-current-position-id` and the
|
||||||
|
* token snapshots that one position, so switching desks switches yards — which
|
||||||
|
* is what staff covering two yards actually do. Delegated positions are added on
|
||||||
|
* top, otherwise standing in for the Gelan director silently loses Gelan.
|
||||||
|
*
|
||||||
|
* `null` means unrestricted, and an UNMAPPED caller gets it. Scoping narrows a
|
||||||
|
* desk that has been given yards; it does not hand out access. Whether the
|
||||||
|
* caller may perform the action at all is the permission guard's job — this
|
||||||
|
* resolver only answers "which yards", so a desk with the permission and no
|
||||||
|
* mapping keeps the reach it had before the mapping existed.
|
||||||
|
*
|
||||||
|
* The trade-off is deliberate and worth knowing: an accidentally-cleared
|
||||||
|
* mapping widens access rather than blocking work, so the mapping is not a
|
||||||
|
* containment barrier on its own — the permission keys still are. Super admins
|
||||||
|
* and holders of `yards:view_all` are unrestricted regardless of mapping.
|
||||||
|
*
|
||||||
|
* ENFORCEMENT IS OFF until `YARD_SCOPE_ENFORCE=true`. Until then
|
||||||
|
* {@link assertYardInScope} logs what it would have blocked and returns. Flip it
|
||||||
|
* only once the mapping table is populated and the log is quiet — on an empty
|
||||||
|
* table, enforcing locks out every staff member at once.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class YardScopeService {
|
||||||
|
private readonly logger = new Logger(YardScopeService.name);
|
||||||
|
|
||||||
|
// ponytail: 60s cache keyed by the position-id set, no invalidation hook. A
|
||||||
|
// mapping change takes up to a minute to reach the resolver. Call
|
||||||
|
// `invalidate()` from the mutation if that lag ever matters.
|
||||||
|
private static readonly CACHE_TTL_MS = 60_000;
|
||||||
|
private readonly cache = new Map<string, { yardIds: string[]; at: number }>();
|
||||||
|
|
||||||
|
constructor(private readonly yardPositions: YardPositionsService) {}
|
||||||
|
|
||||||
|
/** True when the deny path is live; false while shadow-logging. */
|
||||||
|
get enforced(): boolean {
|
||||||
|
return process.env.YARD_SCOPE_ENFORCE === "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yard ids the caller is scoped to, or `null` for unrestricted. */
|
||||||
|
async getScopedYardIds(user: ScopeUser | null | undefined): Promise<string[] | null> {
|
||||||
|
// No user at all is an unauthenticated call the guards should already have
|
||||||
|
// rejected — narrow to nothing rather than trusting it.
|
||||||
|
if (!user) return [];
|
||||||
|
if (isSuperAdmin(user)) return null;
|
||||||
|
if (hasFreightPermission(user, FREIGHT_PERMS.yards.viewAll)) return null;
|
||||||
|
|
||||||
|
const positionIds = this.effectivePositionIds(user);
|
||||||
|
// No resolvable position — nothing to narrow by, so nothing is narrowed.
|
||||||
|
if (!positionIds.length) return null;
|
||||||
|
|
||||||
|
const key = positionIds.join(",");
|
||||||
|
const hit = this.cache.get(key);
|
||||||
|
if (hit && Date.now() - hit.at < YardScopeService.CACHE_TTL_MS) {
|
||||||
|
return hit.yardIds.length ? hit.yardIds : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const yardIds = await this.yardPositions.yardIdsForPositions(positionIds);
|
||||||
|
this.cache.set(key, { yardIds, at: Date.now() });
|
||||||
|
// Unmapped desk → unrestricted. Mapping narrows; absence of one does not.
|
||||||
|
return yardIds.length ? yardIds : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async isYardInScope(
|
||||||
|
user: ScopeUser | null | undefined,
|
||||||
|
yardId: string | null | undefined,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!yardId) return true;
|
||||||
|
const scope = await this.getScopedYardIds(user);
|
||||||
|
return scope === null || scope.includes(yardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate an action on a yard. While `YARD_SCOPE_ENFORCE` is unset this only
|
||||||
|
* logs — wire it into write paths first and read filters second, so the
|
||||||
|
* shadow log shows what enforcement would break before it breaks it.
|
||||||
|
*/
|
||||||
|
async assertYardInScope(
|
||||||
|
user: ScopeUser | null | undefined,
|
||||||
|
yardId: string | null | undefined,
|
||||||
|
context: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (await this.isYardInScope(user, yardId)) return;
|
||||||
|
|
||||||
|
const positions = this.effectivePositionIds(user).join(",") || "none";
|
||||||
|
if (!this.enforced) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[yard-scope shadow] would block ${context}: yard=${yardId} positions=${positions}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ForbiddenException("This yard is outside your assigned yards");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yard ids a list query should be narrowed to, or `null` for no narrowing.
|
||||||
|
*
|
||||||
|
* Returns an EMPTY array only when the caller explicitly asked for a yard
|
||||||
|
* outside their scope and enforcement is on — the caller should answer with an
|
||||||
|
* empty result rather than silently widening back to everything.
|
||||||
|
*
|
||||||
|
* While `YARD_SCOPE_ENFORCE` is unset this always returns `null` and logs what
|
||||||
|
* it would have narrowed, so the mapping can be populated against real traffic
|
||||||
|
* before it starts hiding rows.
|
||||||
|
*/
|
||||||
|
async listFilterYardIds(
|
||||||
|
user: ScopeUser | null | undefined,
|
||||||
|
requestedYardId: string | null | undefined,
|
||||||
|
context: string,
|
||||||
|
): Promise<string[] | null> {
|
||||||
|
const scope = await this.getScopedYardIds(user);
|
||||||
|
if (scope === null) return null;
|
||||||
|
|
||||||
|
const outOfScope = !!requestedYardId && !scope.includes(requestedYardId);
|
||||||
|
|
||||||
|
if (!this.enforced) {
|
||||||
|
this.logger.warn(
|
||||||
|
`[yard-scope shadow] would narrow ${context} to [${scope.join(", ")}]` +
|
||||||
|
(outOfScope ? ` and reject yard=${requestedYardId}` : ""),
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outOfScope) return [];
|
||||||
|
return requestedYardId ? [requestedYardId] : scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops the memoised scopes — call after editing the mapping. */
|
||||||
|
invalidate(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active position plus any delegated ones, across both `employee` shapes. */
|
||||||
|
private effectivePositionIds(user: ScopeUser | null | undefined): string[] {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const employee = user?.employee;
|
||||||
|
if (!employee) return [];
|
||||||
|
|
||||||
|
if (Array.isArray(employee)) {
|
||||||
|
for (const emp of employee) {
|
||||||
|
for (const position of emp.positions ?? []) {
|
||||||
|
if (position?.id) ids.add(position.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (employee.position?.id) ids.add(employee.position.id);
|
||||||
|
for (const delegated of employee.delegatedPositions ?? []) {
|
||||||
|
if (delegated?.id) ids.add(delegated.id);
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1221,13 +1221,21 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const ledger = new WagonStockLedger(
|
const ledger = new WagonStockLedger(
|
||||||
stock.remainingByTypeId,
|
stock.remainingByTypeId,
|
||||||
Math.max(1, budget.stops.length - 1),
|
Math.max(1, budget.stops.length - 1),
|
||||||
|
stock.byYardId,
|
||||||
|
budget.stops,
|
||||||
);
|
);
|
||||||
|
// On a multi-yard consist the pool that matters is the one standing at
|
||||||
|
// the booking's own boarding yard — a type carried only in Mojo must not
|
||||||
|
// be advertised to a customer boarding at Dire.
|
||||||
|
const carriedAtBoardYard = (wagonTypeId: string): number => {
|
||||||
|
const boardYardId = stock.byYardId ? budget.stops[leg.fromEdge] : null;
|
||||||
|
if (boardYardId) return stock.byYardId?.get(boardYardId)?.get(wagonTypeId) ?? 0;
|
||||||
|
return stock.remainingByTypeId.get(wagonTypeId) ?? 0;
|
||||||
|
};
|
||||||
const byWagonType = allowed
|
const byWagonType = allowed
|
||||||
.filter(
|
.filter(
|
||||||
({ wagonTypeId }) =>
|
({ wagonTypeId }) =>
|
||||||
stock.mode !== 'TRAIN' ||
|
stock.mode !== 'TRAIN' || !wagonTypeId || carriedAtBoardYard(wagonTypeId) > 0,
|
||||||
!wagonTypeId ||
|
|
||||||
(stock.remainingByTypeId.get(wagonTypeId) ?? 0) > 0,
|
|
||||||
)
|
)
|
||||||
.map(({ wagonTypeId, dims }) => {
|
.map(({ wagonTypeId, dims }) => {
|
||||||
const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined;
|
const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined;
|
||||||
@@ -4783,6 +4791,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return new WagonStockLedger(
|
return new WagonStockLedger(
|
||||||
stock.remainingByTypeId,
|
stock.remainingByTypeId,
|
||||||
Math.max(1, budget.stops.length - 1),
|
Math.max(1, budget.stops.length - 1),
|
||||||
|
stock.byYardId,
|
||||||
|
budget.stops,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1589,6 +1589,7 @@ export class TrainSchedulingService {
|
|||||||
`Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
|
`Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await this.assertRouteCoversWagonYards(builtTrain, route);
|
||||||
const conflict = await this.findTrainRouteDayConflict(
|
const conflict = await this.findTrainRouteDayConflict(
|
||||||
builtTrain.id,
|
builtTrain.id,
|
||||||
route.id,
|
route.id,
|
||||||
@@ -4274,12 +4275,26 @@ export class TrainSchedulingService {
|
|||||||
.getRepository(Locomotive)
|
.getRepository(Locomotive)
|
||||||
.update({ id: In(locoIds) }, { currentYardId: station.yardId });
|
.update({ id: In(locoIds) }, { currentYardId: station.yardId });
|
||||||
}
|
}
|
||||||
|
// Only wagons the train has actually COLLECTED move with it. On a
|
||||||
|
// consist spread across yards (20 in Dire, 33 in Mojo), reaching Mojo
|
||||||
|
// moves the Dire wagons — the ones already aboard — and picks up the
|
||||||
|
// Mojo ones standing here. Wagons waiting at yards further down the
|
||||||
|
// line stay where they are until the train physically gets to them.
|
||||||
|
const passedYardIds = stations
|
||||||
|
.filter((s) => s.sequenceNo <= dto.sequenceNo)
|
||||||
|
.map((s) => s.yardId);
|
||||||
await manager
|
await manager
|
||||||
.getRepository(Wagon)
|
.getRepository(Wagon)
|
||||||
.update(
|
.createQueryBuilder()
|
||||||
{ currentTrainScheduleId: scheduleId },
|
.update(Wagon)
|
||||||
{ currentYardId: station.yardId },
|
.set({ currentYardId: station.yardId })
|
||||||
);
|
.where('current_train_schedule_id = :scheduleId', { scheduleId })
|
||||||
|
// A yard-less wagon has no "waiting further down the line" position
|
||||||
|
// to protect, so it rides along as it always did.
|
||||||
|
.andWhere('(current_yard_id IS NULL OR current_yard_id IN (:...passedYardIds))', {
|
||||||
|
passedYardIds,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
if (schedule.trainSet?.trainId) {
|
if (schedule.trainSet?.trainId) {
|
||||||
await manager
|
await manager
|
||||||
.getRepository(Train)
|
.getRepository(Train)
|
||||||
@@ -5286,12 +5301,26 @@ export class TrainSchedulingService {
|
|||||||
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
|
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
|
||||||
const counts = new Map<string, { code: string; available: number }>();
|
const counts = new Map<string, { code: string; available: number }>();
|
||||||
|
|
||||||
|
// A built consist spread across several yards can only offer, at each yard,
|
||||||
|
// the wagons standing there. A single-yard consist keeps the original
|
||||||
|
// behaviour: the whole train counts wherever it currently sits.
|
||||||
|
const consistYards = builtTrainId
|
||||||
|
? new Set(
|
||||||
|
wagons
|
||||||
|
.filter((w) => w.trainId === builtTrainId && w.currentYardId)
|
||||||
|
.map((w) => w.currentYardId as string),
|
||||||
|
)
|
||||||
|
: new Set<string>();
|
||||||
|
const consistIsSplit = consistYards.size > 1;
|
||||||
|
|
||||||
for (const wagon of wagons) {
|
for (const wagon of wagons) {
|
||||||
// Train-bound schedule: the built train's own consist IS the fleet — only
|
// Train-bound schedule: the built train's own consist IS the fleet — only
|
||||||
// its wagons count (wherever they currently sit; they travel with the
|
// its wagons count, and loose yard wagons never do. A single-yard consist
|
||||||
// train), and loose yard wagons never do.
|
// counts wherever it sits (it travels with the train); a split consist is
|
||||||
|
// counted at the yard each wagon actually stands in.
|
||||||
if (builtTrainId) {
|
if (builtTrainId) {
|
||||||
if (wagon.trainId !== builtTrainId) continue;
|
if (wagon.trainId !== builtTrainId) continue;
|
||||||
|
if (consistIsSplit && wagon.currentYardId !== originYardId) continue;
|
||||||
} else {
|
} else {
|
||||||
// Schedule-scoped availability: pins held by OTHER schedules never
|
// Schedule-scoped availability: pins held by OTHER schedules never
|
||||||
// consume a wagon here — the same physical wagon may serve the July 17
|
// consume a wagon here — the same physical wagon may serve the July 17
|
||||||
@@ -5651,12 +5680,23 @@ export class TrainSchedulingService {
|
|||||||
// consist views draw the schedule exactly like the train builder; a schedule
|
// consist views draw the schedule exactly like the train builder; a schedule
|
||||||
// created with reverseWagonOrder pins back-to-front (physically-last wagon
|
// created with reverseWagonOrder pins back-to-front (physically-last wagon
|
||||||
// takes slot #1). Unsequenced wagons sort after every sequenced one.
|
// takes slot #1). Unsequenced wagons sort after every sequenced one.
|
||||||
|
const consistYards = new Set(
|
||||||
|
wagons
|
||||||
|
.filter((w) => w.trainId === builtTrainId && w.currentYardId)
|
||||||
|
.map((w) => w.currentYardId as string),
|
||||||
|
);
|
||||||
|
// Split consist: a slot boarding at a given yard must take a wagon that
|
||||||
|
// physically stands there — the train cannot load a Mojo wagon at Dire.
|
||||||
|
// A single-yard consist ignores this (the whole train is at one place).
|
||||||
|
const requiredYardId =
|
||||||
|
consistYards.size > 1 ? (slot.boardYardId ?? originYardId) : null;
|
||||||
const candidates = wagons
|
const candidates = wagons
|
||||||
.filter(
|
.filter(
|
||||||
(w) =>
|
(w) =>
|
||||||
w.trainId === builtTrainId &&
|
w.trainId === builtTrainId &&
|
||||||
w.wagonTypeId === slot.wagonTypeId &&
|
w.wagonTypeId === slot.wagonTypeId &&
|
||||||
spanFree(w.id),
|
spanFree(w.id) &&
|
||||||
|
(!requiredYardId || w.currentYardId === requiredYardId),
|
||||||
)
|
)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (a.sequenceNumber == null || b.sequenceNumber == null) {
|
if (a.sequenceNumber == null || b.sequenceNumber == null) {
|
||||||
@@ -5825,14 +5865,28 @@ export class TrainSchedulingService {
|
|||||||
});
|
});
|
||||||
const remainingByTypeId = new Map<string, number>();
|
const remainingByTypeId = new Map<string, number>();
|
||||||
const codesByTypeId = new Map<string, string>();
|
const codesByTypeId = new Map<string, string>();
|
||||||
|
const byYardId = new Map<string, Map<string, number>>();
|
||||||
for (const wagon of wagons) {
|
for (const wagon of wagons) {
|
||||||
remainingByTypeId.set(
|
remainingByTypeId.set(
|
||||||
wagon.wagonTypeId,
|
wagon.wagonTypeId,
|
||||||
(remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1,
|
(remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1,
|
||||||
);
|
);
|
||||||
if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code);
|
if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code);
|
||||||
|
if (wagon.currentYardId) {
|
||||||
|
const perType = byYardId.get(wagon.currentYardId) ?? new Map<string, number>();
|
||||||
|
perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1);
|
||||||
|
byYardId.set(wagon.currentYardId, perType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { mode: 'TRAIN', remainingByTypeId, codesByTypeId };
|
// Single-yard consist (the overwhelming majority): the whole train is
|
||||||
|
// offered at every boarding yard exactly as before — the per-yard split is
|
||||||
|
// only meaningful once the consist is genuinely spread across yards.
|
||||||
|
return {
|
||||||
|
mode: 'TRAIN',
|
||||||
|
remainingByTypeId,
|
||||||
|
codesByTypeId,
|
||||||
|
...(byYardId.size > 1 ? { byYardId } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -6160,6 +6214,45 @@ export class TrainSchedulingService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A built train's wagons may stand in several yards. The route must pass
|
||||||
|
* through every one of them as origin or an intermediate stop — never only
|
||||||
|
* as the final destination (the train has to pick the wagons up en route).
|
||||||
|
*/
|
||||||
|
private async assertRouteCoversWagonYards(train: Train, route: Route) {
|
||||||
|
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||||||
|
where: { trainId: train.id },
|
||||||
|
select: { id: true, currentYardId: true },
|
||||||
|
});
|
||||||
|
const wagonYards = [...new Set(wagons.map((w) => w.currentYardId).filter((y): y is string => !!y))];
|
||||||
|
if (!wagonYards.length) return;
|
||||||
|
|
||||||
|
const milestones = await this.dataSource
|
||||||
|
.getRepository(RouteMilestone)
|
||||||
|
.find({ where: { routeId: route.id }, order: { sequenceNo: 'ASC' } });
|
||||||
|
const stops = milestones.length >= 2
|
||||||
|
? milestones.map((m) => m.yardId)
|
||||||
|
: [route.originYardId, route.destinationYardId];
|
||||||
|
// Every stop except the last one is a pickup point.
|
||||||
|
const pickupYards = new Set(stops.slice(0, -1));
|
||||||
|
|
||||||
|
const uncovered = wagonYards.filter((y) => !pickupYards.has(y));
|
||||||
|
if (!uncovered.length) return;
|
||||||
|
|
||||||
|
const labels = await this.yardLabelMap(uncovered);
|
||||||
|
const destination = stops[stops.length - 1];
|
||||||
|
const detail = uncovered
|
||||||
|
.map((y) =>
|
||||||
|
y === destination
|
||||||
|
? `${labels.get(y) ?? y} (only as the destination)`
|
||||||
|
: `${labels.get(y) ?? y} (not on route)`,
|
||||||
|
)
|
||||||
|
.join(', ');
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Route ${formatRouteLabel(route)} does not pass through every yard where train ${train.code}'s wagons stand: ${detail}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async getSchedulableRoute(routeId: string) {
|
private async getSchedulableRoute(routeId: string) {
|
||||||
const route = await this.dataSource.getRepository(Route).findOne({
|
const route = await this.dataSource.getRepository(Route).findOne({
|
||||||
where: { id: routeId },
|
where: { id: routeId },
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ export type WagonStock = {
|
|||||||
remainingByTypeId: Map<string, number>;
|
remainingByTypeId: Map<string, number>;
|
||||||
/** Wagon-type code per id, for human-readable shortfall messages. */
|
/** Wagon-type code per id, for human-readable shortfall messages. */
|
||||||
codesByTypeId: Map<string, string>;
|
codesByTypeId: Map<string, string>;
|
||||||
|
/**
|
||||||
|
* Multi-yard consist only: yardId → (wagonTypeId → count) for the wagons
|
||||||
|
* standing at that yard. A train whose wagons are split across yards can
|
||||||
|
* only offer, at each boarding yard, the wagons physically standing there —
|
||||||
|
* a wagon waiting in Mojo is not bookable from Dire, and one picked up at
|
||||||
|
* Dire is not re-offered at Mojo. Absent (undefined) when every wagon sits
|
||||||
|
* in one yard, which keeps single-yard trains on the original whole-train
|
||||||
|
* math.
|
||||||
|
*/
|
||||||
|
byYardId?: Map<string, Map<string, number>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FlexPlanResult = {
|
export type FlexPlanResult = {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user