Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Stephanos A
2026-08-05 10:39:35 +03:00
948 changed files with 99715 additions and 11357 deletions

View File

@@ -144,15 +144,16 @@ jobs:
run: ./scripts/deploy/create-npmrc.sh
- name: Resolve env file path for ${{ matrix.service }}
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service)
run: |
case "${{ matrix.service }}" in
freight-api) echo "SERVICE_ENV_FILE=apps/edr-freight-api/.env" >> "$GITHUB_ENV" ;;
passenger-api) echo "SERVICE_ENV_FILE=apps/edr-passenger-api/.env" >> "$GITHUB_ENV" ;;
payment-api) echo "SERVICE_ENV_FILE=apps/edr-payment-api/.env" >> "$GITHUB_ENV" ;;
esac
- name: Build migration image for ${{ matrix.service }}
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service)
run: |
set -euo pipefail
docker build \
@@ -163,7 +164,7 @@ jobs:
.
- name: Run migrations for ${{ matrix.service }}
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
if: contains(fromJson('["freight-api", "passenger-api", "payment-api"]'), matrix.service)
run: |
set -euo pipefail
docker run --rm --env-file "${SERVICE_ENV_FILE}" "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration"

1
.gitignore vendored
View File

@@ -33,6 +33,7 @@ docker-compose.override.yml
# cypress e2e artifacts
e2e/**/cypress/videos/
e2e/**/cypress/screenshots/
e2e/**/cypress/reports/
e2e/**/cypress/downloads/
# e2e launcher state (ports of the running stack)

View File

@@ -24,6 +24,7 @@ Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Fre
| ---------------------- | ---------------------------------------------------------------------------------- |
| `@edr/types` | Shared TypeScript interfaces and enums |
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) |
| `@edr/ui-common` | Shared React components and theme |
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
| `@edr/tsconfig` | Shared TypeScript configurations |

View File

@@ -154,13 +154,32 @@ no timeout and will wait forever.
Migrations are the most dangerous surface in this repo. Two production-grade incidents have
already come from it.
- `migrationsRun: true`**migrations run automatically on API boot**, with
`migrationsTransactionMode: 'each'`.
- `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:
- Running several `nest start --watch` instances races `migrationsRun`. A non-idempotent
data migration can execute twice. Keep one instance.
- A watch-mode hot reload does **not** re-run migrations. If you add a column that new
code reads, apply it to the dev database yourself (idempotently) or fully restart.
- `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.

440
E2E_TEST_REPORT.md Normal file
View File

@@ -0,0 +1,440 @@
# EDR Freight — End-to-End Test Report
**Date:** 23 July 2026
**Branch:** `freight_feature/usermanagement`
**Command that was run:**
```bash
E2E_PORTAL_PORT=5374 docker compose -f docker-compose.e2e.yaml --profile cypress \
run --rm cypress --spec 'cypress/e2e/flows/*.cy.ts'
```
---
## 1. Short summary (read this first)
I ran the end-to-end (E2E) test suite and found **four separate problems**. They are not all the same kind of problem, and this is the most important thing to understand:
| # | Problem | Kind of problem | Status |
|---|---------|-----------------|--------|
| 1 | Tests crashed instantly with `exit code 137` | Machine / environment | Explained + how to avoid |
| 2 | Portal tests pointed at a dead port (`5374`) | Wrong command setting | Explained + corrected |
| 3 | `contract-lifecycle` failed all 5 of its tests | **Real bug in the test code** | ✅ **Fixed and verified** |
| 4 | 5 other test files failed | Old app running inside Docker | Diagnosed, needs a rebuild |
**The only real code bug was problem 3, and it is now fixed.** Problems 1, 2 and 4 are about *how the tests were run*, not about the application logic.
---
## 2. Some words explained (for beginners)
Before the details, here are the words used in this report:
- **E2E test (end-to-end test)** — a robot that opens a real web browser, clicks buttons like a real user, and checks the result is correct.
- **Cypress** — the tool that drives that robot browser.
- **Spec** — one test file. It ends with `.cy.ts`. Example: `contract-lifecycle.cy.ts`.
- **Docker container** — a small, isolated box that runs one program (the API, the website, the database).
- **Docker image** — a *frozen photograph* of your code. A container is started **from** an image. This idea matters a lot in problem 4.
- **Port** — a numbered door on your computer. A program listens on one port. If you knock on the wrong door, nobody answers.
---
## 3. Result of the full test run
I ran all 38 test files once, cleanly. This took about 44 minutes.
```
✖ 6 of 38 failed (16%) 44:17 259 tests 223 passing 36 failing
```
The 6 test files that failed:
| Test file | Tests | Passed | Failed |
|-----------|-------|--------|--------|
| `contract-lifecycle.cy.ts` | 5 | 0 | **5** |
| `export_one_time.cy.ts` | 16 | 9 | 7 |
| `import_full_train.cy.ts` | 13 | 8 | 5 |
| `intercity_one_time.cy.ts` | 16 | 11 | 5 |
| `onboarding.cy.ts` | 3 | 1 | 2 |
| `segment_weight.cy.ts` | 13 | 1 | 12 |
The other 32 test files passed completely.
---
## 4. Problem 1 — The tests died immediately with `exit code 137`
### What you saw
The command stopped almost at once. There were no test results. The exit code was `137`.
### What it means
`137` means the program was **force-killed** by the operating system (it is `128 + 9`, where `9` is the "kill" signal). It is *not* a test failure. The tests never even started.
### Why it happened
Look at this part of `docker-compose.e2e.yaml`:
```yaml
cypress:
network_mode: host
# NOTE: host network shares the abstract X-socket namespace with the host.
# Cypress spawns its Xvfb on :99 — run only ONE cypress container at a
# time, and don't run it on a host whose X server occupies :99.
```
Cypress needs a screen to draw the browser on. Because there is no real monitor, it creates a fake screen called **Xvfb** on display number **`:99`**.
Because of `network_mode: host`, that fake screen is shared with the whole computer — **not** kept private inside the container.
So if **two Cypress runs happen at the same time**, both try to take display `:99`. They fight, Chrome dies, and you get `137`.
On this machine there was in fact **another Cypress run already going** (from a second terminal session), which is what killed my run.
### The solution
**Run only one Cypress container at a time.** Before starting, check nothing else is running:
```bash
docker ps --format '{{.Names}}' | grep cypress
```
If that command prints something, wait for it to finish. If it prints nothing, you are safe to start.
> This was a problem with the machine being busy — **not** a problem with the tests or the application.
---
## 5. Problem 2 — `E2E_PORTAL_PORT=5374` pointed at a dead port
### What you saw
Tests that use the customer **portal** website failed. They could not open the page at all.
### Why it happened
Your command set the portal port to **5374**:
```bash
E2E_PORTAL_PORT=5374 docker compose ...
```
But the portal container was actually published on port **5373**. Here is the proof:
```
$ cat e2e/freight/.e2e-ports.json
{ "E2E_API_PORT": 3101, "E2E_PORTAL_PORT": 5373, "E2E_BACKOFFICE_PORT": 5383, ... }
$ docker ps
edr-freight-e2e-freight-portal-e2e-1 0.0.0.0:5373->80/tcp
```
Here is the important detail. Setting `E2E_PORTAL_PORT=5374` **only changes where Cypress looks**. It does **not** move the already-running portal container. From `cypress.config.ts`:
```ts
portalUrl: process.env.CYPRESS_PORTAL_URL ?? "http://localhost:5373",
```
So Cypress knocked on door **5374**, but the portal was living behind door **5373**. Nobody answered.
This affected the 5 test files that open the portal:
`onboarding`, `contract-lifecycle`, `cross-app`, `export_one_time`, `intercity_one_time`.
### The solution
Use the port that matches the running container — simply leave the setting out, because `5373` is already the default:
```bash
docker compose -f docker-compose.e2e.yaml --profile cypress \
run --rm cypress --spec 'cypress/e2e/flows/*.cy.ts'
```
Or, best of all, use the project's official launcher, which picks matching ports for everything automatically:
```bash
pnpm e2e:freight:run
```
> After using the correct port, `cross-app.cy.ts` passed 3/3 — proving the port was the only thing wrong there.
---
## 6. Problem 3 — The real bug: `contract-lifecycle` failed all 5 tests ✅ FIXED
This was the one **genuine code problem**, and it is now fixed and verified.
### What you saw
```
1) customer creates and submits a GENERAL import container contract:
AssertionError: Timed out retrying after 10000ms:
Expected to find element: `[role="checkbox"][aria-label="20ft Container"]`,
but never found it.
```
And then 4 more failures after it.
### Why it happened
The test was looking for a **checkbox** to choose the container size (20ft):
```ts
cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
cy.get('textarea[placeholder*="Electronics"]').type("E2E electronics shipment scope");
```
But **the application was deliberately changed**. Container contracts now automatically cover **both** 20ft and 40ft sizes, so the checkbox was removed and replaced by a simple information card. The cargo description was also moved to the booking step.
You can see this clearly in the current application code, `step3-cargo-scope.tsx`:
```tsx
{/* Container scope: the contract always covers BOTH sizes and quotes both
rates. Quantities (a size can be 0) and the cargo description are
captured at booking time. */}
{cargoType === "container" && (
...
<Text fz={14} fw={700}>20ft &amp; 40ft containers covered</Text>
```
And the validation rules confirm nothing else is needed (`schema.ts`):
```ts
// Container scope needs no validation: both sizes are always in scope and
// the cargo description moved to booking time.
```
So: **the application was updated, but the test was not.** The test kept looking for a button that no longer exists.
**Why all 5 tests failed, not just one.** These 5 tests run in order and build on each other. Test 1 creates the contract; tests 25 then approve and sign *that* contract. Because test 1 could not finish, there was no fresh contract, so tests 25 had nothing correct to work on and failed too. This is called a **cascade failure** — one real error causing several fake-looking errors.
### The fix
I removed the two steps that referred to the deleted fields.
**`e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts`**
```diff
- // Step 1 — Cargo & Route.
+ // Step 1 — Cargo & Route. Container contracts now auto-cover BOTH 20ft &
+ // 40ft (no size picker — just an info card) and the cargo description moved
+ // to booking time, so the scope select plus the route is all this step needs.
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
- cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
- cy.get('textarea[placeholder*="Electronics"]').type(
- "E2E electronics shipment scope",
- );
cy.mantineSelect(/^Origin Yard/, "Djibouti Port Terminal");
```
I found the **same outdated code in two more test files** and fixed them as well, so the problem is solved everywhere and not just in one place:
**`export_one_time.cy.ts`**
```diff
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
- cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
- cy.get('[role="checkbox"][aria-label="40ft Container"]').click();
- cy.get('textarea[placeholder*="Electronics"]').type("E2E export electronics");
cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
```
**`intercity_one_time.cy.ts`**
```diff
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
- cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
- cy.get('textarea[placeholder*="Electronics"]').type(
- "E2E intercity electronics between Ethiopian yards",
- );
cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
```
### A second, smaller bug found while checking the fix
After the first fix, 4 of 5 tests passed and one still failed:
```
AssertionError: expected '<p.mantine-Text-root>' to be 'visible'
This element is not visible because its content is being clipped by one of its
parent elements, which has a CSS property of overflow: hidden, clip, scroll or auto
```
The contract *was* created correctly. The word "Submitted" *was* on the screen. But it sits inside a side-scrolling list, so Cypress treated it as hidden.
Because the line just after it already checks the database properly (which is the stronger, more trustworthy check), I made the screen check clip-proof:
```diff
- cy.contains("Submitted", { timeout: 15000 }).should("be.visible");
+ // `exist`, not `be.visible`: the status badge sits inside the list's
+ // horizontally-scrolling container, so Cypress reports it as clipped by an
+ // overflow parent. The DB assertion below is the authoritative check.
+ cy.contains("Submitted", { timeout: 15000 }).should("exist");
```
### Proof that it works
**Before the fix:**
```
contract-lifecycle.cy.ts 5 tests 0 passing 5 failing
```
**After the fix:**
```
✓ customer creates and submits a GENERAL import container contract (7574ms)
✓ marketer accepts the submission and approves the LINE_STAFF step (3668ms)
✓ director approves the final step — contract PDF becomes ready (2916ms)
✓ customer signs the contract with OTP (5143ms)
✓ staff counter-signs — GENERAL contract becomes CONTRACT_ACTIVE (3643ms)
5 passing (29s) EXIT=0
```
**All 5 tests now pass.**
---
## 7. Problem 4 — The remaining 5 test files: the app inside Docker is old
This is the second most important finding, and it explains **almost all remaining failures**.
### What you saw
Many strange, unrelated-looking errors, for example:
```
CypressError: cy.request() failed on:
http://localhost:3101/api/train-scheduling/schedules/<id>/dispatch
The response we received from your web server was:
> 400: Bad Request
```
```
AssertionError: expected '/dashboard/operations/train-scheduling-v2'
to match /\/dashboard\/operations\/train-scheduling-v2\/.+/
```
```
AssertionError: Expected to find content: 'Clearance Review' within the selector: '[role="tab"]'
```
### Why it happened
**The test files are new, but the running application is old.**
- The test files live on your disk and are shared into the container live, so they are always the newest version.
- The API and websites run from **Docker images**, which are frozen photographs of the code. They only change when you **rebuild** them.
Here are the actual times:
```
freight-api-e2e image built: 2026-07-23 09:22
freight-portal-e2e image built: 2026-07-23 09:22
freight-backoffice-e2e image built: 2026-07-23 09:22
latest commit (HEAD): 2026-07-23 20:24 ← 11 hours newer
```
**5 commits were made after those images were built:**
```
668b5e1c add permissions and fix issues
40f16f3c changes
12767605 changes
15a6bab5 revert back the clerance payment
13609f8d changes
```
### The clearest proof
Commit `668b5e1c` changed the **API and its tests together**, in the same commit:
```
apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
e2e/freight/cypress/e2e/flows/import-utils.ts
e2e/freight/cypress/e2e/flows/import_full_train.cy.ts
```
So the **new test** sends the **new** data shape to `/dispatch`, but the **old API** inside Docker does not understand it and replies `400 Bad Request`.
This is not a bug in the code. It is simply **new tests talking to an old server**.
The `train-scheduling-v2` failures have the same cause: creating a train schedule fails on the old server, so no train exists, and every later step in those files fails as a cascade (this is why `segment_weight` lost 12 of 13 tests from a single root cause).
### The solution
Rebuild the Docker images so they contain the current code, then run the tests again:
```bash
# stop the old stack and rebuild from current code
docker compose -f docker-compose.e2e.yaml down
docker compose -f docker-compose.e2e.yaml build
pnpm e2e:freight:run
```
> ⚠️ Note: `down` deletes the test database (it is a throwaway database, which is normal and safe).
> ⚠️ Note: only do this when nobody else is using the same stack.
---
## 8. Files I changed
Only test files were changed. **No application code was modified.**
| File | Change |
|------|--------|
| `e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts` | Removed deleted container-size checkbox + description box; made the "Submitted" check clip-proof; updated the header comment |
| `e2e/freight/cypress/e2e/flows/export_one_time.cy.ts` | Removed deleted 20ft + 40ft checkboxes and description box |
| `e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts` | Removed deleted 20ft checkbox and description box |
Evidence the last two fixes helped, even on the old server:
- `export_one_time` — previously failed at contract creation; now reaches 9 passing tests.
- `intercity_one_time` — previously failed at contract creation; now reaches 11 passing tests.
Their remaining failures are all from problem 4 (old Docker images).
---
## 9. How to run the tests correctly
**Step 1 — make sure no other Cypress is running** (this avoids the `137` crash):
```bash
docker ps --format '{{.Names}}' | grep cypress
```
**Step 2 — rebuild so Docker has the current code:**
```bash
docker compose -f docker-compose.e2e.yaml down
docker compose -f docker-compose.e2e.yaml build
```
**Step 3 — run the tests using the official launcher** (it chooses matching ports for you):
```bash
pnpm e2e:freight:run
```
If you prefer the raw Docker command, **do not** override the portal port unless you also restart the portal container on that same port:
```bash
docker compose -f docker-compose.e2e.yaml --profile cypress \
run --rm cypress --spec 'cypress/e2e/flows/*.cy.ts'
```
---
## 10. Conclusion
- **One real bug was found and fixed:** three test files were still clicking a container-size checkbox and a description box that the application no longer has, because container contracts now cover both 20ft and 40ft automatically.
- `contract-lifecycle.cy.ts` went from **0 of 5 passing** to **5 of 5 passing**, confirmed by running it twice.
- The `exit 137` crash was caused by **two Cypress runs at the same time** fighting over the shared virtual screen `:99`.
- The portal failures were caused by **`E2E_PORTAL_PORT=5374`**, while the portal was really on **5373**.
- The remaining failures are caused by **Docker images that are 11 hours older than the code**. They need a rebuild, not a code fix.
**Recommended next step:** rebuild the Docker images and run the full suite again. Only then can the remaining 5 test files be judged fairly.

View File

@@ -42,8 +42,17 @@ JWT_REFRESH_TOKEN_EXPIRES=7d
# IAM seed defaults (used by @tria-plc/iamapi-common on first boot)
SUPER_ADMIN_EMAIL=superadmin@tria.com
SUPER_ADMIN_PHONE=
# Super-admin password. Falls back to DEFAULT_PASSWORD when empty.
SUPER_ADMIN_DEFAULT_PASSWORD=
DEFAULT_PASSWORD=password@tria
# IAM baseline shared with edr-passenger-api (roles, IAM app + permissions,
# position types, organization types + default units, org/unit settings, super
# admin). Replaces the seeder that shipped inside @tria-plc/iamapi-common — see
# packages/iam-seed. Seeds by DEFAULT when unset; every write is insert-only.
# Set to false to opt out.
SEED_IAM_BASELINE=true
# Freight org + staff (bookings / rule-engine IAM)
SEED_EDR_ORG=true
SEED_FREIGHT_STAFF=true
@@ -84,6 +93,9 @@ FAYDA_PRIVATE_KEY_BASE64=
FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete
# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback
# OAuth redirect_uri for the customer portal (its own origin — must also be
# registered with eSignet). Defaults to FAYDA_WEB_REDIRECT_URI when unset.
FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/callback
CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
FAYDA_SCOPE=openid profile email phone address
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code

View File

@@ -29,7 +29,12 @@ COPY --from=builder /app/ .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
FROM deployer AS migration
WORKDIR /deploy
CMD ["node", "dist/scripts/migrate.js"]
FROM base AS runner
RUN apk add --no-cache libc6-compat
ENV NODE_ENV=production
WORKDIR /app

View File

@@ -27,6 +27,7 @@
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
"backfill:missing-unload-inventory": "ts-node -r tsconfig-paths/register src/scripts/backfill-missing-unload-inventory.ts",
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
"seed:dropdown-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-dropdown-settings.ts",
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
@@ -35,12 +36,12 @@
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
"migration:run": "nest build && node dist/scripts/migrate.js",
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
},
"dependencies": {
"@edr/api-common": "workspace:*",
"@edr/iam-seed": "workspace:*",
"@edr/payment-providers": "workspace:*",
"@edr/types": "workspace:*",
"@golevelup/nestjs-rabbitmq": "^5.5.0",
@@ -57,8 +58,8 @@
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"@nestjs/websockets": "^11.1.27",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.12.tgz",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.16.1",

View File

@@ -2,6 +2,7 @@ import {
MiddlewareConsumer,
Module,
OnApplicationBootstrap,
RequestMethod,
} from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
@@ -12,6 +13,7 @@ import {
ensurePostgresSchemas,
APPLICATION_SEARCH_PATH,
} from "./config/ensure-postgres-schemas";
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
import { IamModule } from "@tria-plc/iamapi-common";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
@@ -29,6 +31,8 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module";
// import { TrainsModule } from "./modules/trains/trains.module";
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
import { TruckTypesModule } from "./modules/truck-types/truck-types.module";
import { TransitAgentsModule } from "./modules/transit-agents/transit-agents.module";
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
@@ -85,6 +89,7 @@ import { CargoesModule } from "./modules/cargoes/cargoes.module";
import { RoutesModule } from "./modules/routes/routes.module";
import { WarehousesModule } from "./modules/warehouses/warehouses.module";
import { OverviewModule } from "./modules/overview/overview.module";
import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module";
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
import { DriversModule } from "./modules/drivers/drivers.module";
import { FuelModule } from "./modules/fuel/fuel.module";
@@ -99,6 +104,7 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { AiModule } from "./modules/ai/ai.module";
import { LoggerMiddleware } from "./logger.middleware";
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
@Module({
imports: [
@@ -152,12 +158,26 @@ import { LoggerMiddleware } from "./logger.middleware";
applications: [EDR_FREIGHT_APPLICATION],
permissions: EDR_FREIGHT_PERMISSIONS,
}),
// Replaces the package's DataSeeder. Shared with edr-passenger-api, which
// seeds the same `iam` schema — see packages/iam-seed.
IamSeedModule.forRoot({
superAdmin: {
username: "superadmin",
name: { am: "ሱፐር አድሚን", en: "Super Admin" },
roleKey: "super_admin",
organizationKey: "edr_freight",
unitKey: "edr_freight_app",
fallbackEmail: "superadmin@tria.com",
},
}),
BookingsModule,
ContractsModule,
SignaturesModule,
FilesModule,
ConsignmentsModule,
LocomotivesModule,
TruckTypesModule,
TransitAgentsModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,
@@ -187,6 +207,7 @@ import { LoggerMiddleware } from "./logger.middleware";
RoutesModule,
WarehousesModule,
OverviewModule,
UserTradeAccessModule,
VehiclesModule,
DriversModule,
FuelModule,
@@ -225,11 +246,12 @@ import { LoggerMiddleware } from "./logger.middleware";
// MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder,
PaidImportExportMileDemoSeeder,
LoginAudienceMiddleware,
],
})
export class AppModule implements OnApplicationBootstrap {
constructor(
// private readonly seeder: DataSeeder,
private readonly iamBaselineSeeder: IamBaselineSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
@@ -259,13 +281,22 @@ export class AppModule implements OnApplicationBootstrap {
// Permissions foundation — keep enabled:
// freightPermissionKeyMigration → renames legacy permission keys
// seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions
// edrOrgSeeder → seeds org/unit + the Permission catalog
// iamBaselineSeeder → @edr/iam-seed: IAM app, roles, permissions,
// position types, organization types +
// default units, org/unit settings and the
// super-admin account. Replaces the package's
// DataSeeder, and is shared with
// edr-passenger-api so one writer owns the
// `iam` schema. Runs after edrOrgSeeder
// because the super admin attaches to the
// edr_freight org/unit.
// Writes nothing unless SEED_IAM_BASELINE=true.
// freightPositionsSeeder → seeds Position + PositionPermission rows
// (depends on edrOrgSeeder, must run after)
await this.freightPermissionKeyMigrationSeeder.run();
// await this.seeder.run();
await this.edrOrgSeeder.run();
await this.iamBaselineSeeder.run();
await this.freightPositionsSeeder.run();
// File upload settings — keep enabled.
@@ -304,5 +335,9 @@ export class AppModule implements OnApplicationBootstrap {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes("*");
consumer.apply(LoginAudienceMiddleware).forRoutes(
{ path: "auth/login", method: RequestMethod.POST },
{ path: "auth/mfa-verify", method: RequestMethod.POST },
);
}
}

View File

@@ -23,11 +23,34 @@ export const StaffReference = () => applyDecorators(UseGuards(JwtGuard));
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
/**
* The document-review countdown in the backoffice header. Its own permission so
* it can be granted to exactly the position types that decide operation
* requests, instead of every holder of bookings:view.
*/
export const BookingDocReviewAlert = () =>
BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert);
export const TrainSchedulingView = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
export const TrainSchedulingManage = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
// Granular train-scheduling actions replace the retired coarse manage:
// create a schedule, update (assign/consist/loading/finalize/dispatch/arrive…),
// cancel a schedule, reschedule (+ maintenance), and manage global rules.
export const TrainSchedulingCreate = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.create);
export const TrainSchedulingUpdate = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.update);
export const TrainSchedulingCancel = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.cancel);
export const TrainSchedulingReschedule = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.reschedule);
export const TrainSchedulingRulesManage = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.rulesManage);
/**
* Fleet guards take an optional granular per-resource key (locomotives:create,
@@ -58,6 +81,32 @@ export const WagonTransferFulfill = () =>
export const WagonTransferHistoryAll = () =>
BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll);
/**
* Open the transfer-requests desk. `wagons:view` is accepted as a one-of
* fallback so staff who could already reach the queue keep it without a
* re-grant — same pattern the granular fleet keys use.
*/
export const WagonTransferView = () =>
BookingStaff([FREIGHT_PERMS.wagons.transferView, FREIGHT_PERMS.wagons.view]);
/** Withdraw a request that has not moved any wagon yet. */
export const WagonTransferCancel = () =>
BookingStaff([
FREIGHT_PERMS.wagons.transferCancel,
FREIGHT_PERMS.wagons.transferRequest,
]);
/**
* End a request short of the requested count. Whoever may move wagons may also
* declare the yard has no more to give, so fulfil is accepted alongside the
* dedicated key.
*/
export const WagonTransferCloseShort = () =>
BookingStaff([
FREIGHT_PERMS.wagons.transferCloseShort,
FREIGHT_PERMS.wagons.transferFulfill,
]);
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);

View File

@@ -0,0 +1,47 @@
import { ForbiddenException } from '@nestjs/common';
import {
assertCanApproveContractStep,
canEditContractStep,
} from './freight-permission.util';
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
const userWith = (...keys: string[]) => ({
permissions: keys.map((key) => ({ key })),
});
describe('hazardous contract approval steps', () => {
it('rejects an approver who only holds ordinary contract-approve permissions', () => {
// The blanket "any contract approve permission" fallback must NOT reach
// dangerous goods — that is the whole point of the dedicated desks.
const lineStaff = userWith(FREIGHT_PERMS.contracts.approveLineStaff);
expect(() =>
assertCanApproveContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE'),
).toThrow(ForbiddenException);
expect(canEditContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE')).toBe(false);
});
it('accepts only the matching hazardous permission', () => {
const first = userWith(FREIGHT_PERMS.contracts.hazardousApprovalOne);
expect(() =>
assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_ONE'),
).not.toThrow();
// Holding step one does not confer step two.
expect(() =>
assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_TWO'),
).toThrow(ForbiddenException);
});
it('does not let a hazardous approver stand in for the commercial chain', () => {
const hazardOnly = userWith(
FREIGHT_PERMS.contracts.hazardousApprovalOne,
FREIGHT_PERMS.contracts.hazardousApprovalTwo,
);
expect(() => assertCanApproveContractStep(hazardOnly, 'CEO')).toThrow(
ForbiddenException,
);
});
});

View File

@@ -151,6 +151,24 @@ const APPROVE_ROLE_PERMISSION: Record<string, string> = {
CEO: FREIGHT_PERMS.bookings.approveCeo,
};
/**
* Approval-chain roles synthesized for hazardous contracts (see
* `instantiateApprovalSteps`). Unlike the legacy roles below they are NOT
* position types — they authorize purely on their own dedicated permission, and
* they deliberately opt out of the blanket "holds any contract-approve
* permission" fallback so a normal approver cannot sign off dangerous goods.
*/
export const HAZARDOUS_APPROVAL_ROLE_PERMISSION: Record<string, string> = {
HAZARDOUS_APPROVAL_ONE: FREIGHT_PERMS.contracts.hazardousApprovalOne,
HAZARDOUS_APPROVAL_TWO: FREIGHT_PERMS.contracts.hazardousApprovalTwo,
};
/** The two hazardous steps, in the order they are prepended to the chain. */
export const HAZARDOUS_APPROVAL_ROLES = [
'HAZARDOUS_APPROVAL_ONE',
'HAZARDOUS_APPROVAL_TWO',
] as const;
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
@@ -183,6 +201,16 @@ export function assertCanApproveContractStep(
): void {
if (isFreightApprovalAdmin(user)) return;
// Hazardous steps are permission-only and strict — no legacy alias, no
// blanket approve fallback.
const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole];
if (hazardousPermission) {
if (hasFreightPermission(user, hazardousPermission)) return;
throw new ForbiddenException(
`Missing permission: ${hazardousPermission}`,
);
}
const positionTypes = collectPositionTypeKeys(user);
if (positionTypes.includes(requiredRole)) return;
@@ -219,6 +247,11 @@ export function canEditContractStep(
): boolean {
if (isFreightApprovalAdmin(user)) return true;
const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole];
if (hazardousPermission) {
return hasFreightPermission(user, hazardousPermission);
}
const positionTypes = collectPositionTypeKeys(user);
if (positionTypes.includes(requiredRole)) return true;

View File

@@ -0,0 +1,40 @@
import { generateGrnNumber, grnOwnerSlug } from './grn.util';
/**
* The GRN is mapped to the goods owner for BOTH directions, so a note is
* identifiable by who owns the cargo. The reference slice stays the uniqueness
* anchor — one owner can have several bookings received the same day.
*/
const date = new Date('2026-07-27T09:15:00Z');
const bookingId = '1a2b3c4d-1111-2222-3333-444455556666';
describe('GRN number', () => {
it('maps an import GRN to the owner', () => {
expect(generateGrnNumber('IMPORT', bookingId, date, 'Shafici Pharmaceutical')).toBe(
'GRN-IMPORT-20260727-SHAFICIPHARM-1A2B3C4D',
);
});
it('maps an export GRN to the owner the same way', () => {
expect(generateGrnNumber('EXPORT', bookingId, date, 'Tria Trading PLC')).toBe(
'GRN-EXPORT-20260727-TRIATRADINGP-1A2B3C4D',
);
});
it('keeps the owner-less format when there is no owner (manual walk-in)', () => {
expect(generateGrnNumber('WH', bookingId, date)).toBe('GRN-WH-20260727-1A2B3C4D');
expect(generateGrnNumber('WH', bookingId, date, ' ')).toBe('GRN-WH-20260727-1A2B3C4D');
});
it('stays unique per booking for one owner on one day', () => {
const a = generateGrnNumber('IMPORT', bookingId, date, 'Acme');
const b = generateGrnNumber('IMPORT', 'ffffffff-9999-0000-0000-000000000000', date, 'Acme');
expect(a).not.toBe(b);
});
it('strips punctuation and caps the owner segment', () => {
expect(grnOwnerSlug('Ethio-Djibouti Railway S.C.')).toBe('ETHIODJIBOUT');
expect(grnOwnerSlug('a/b c')).toBe('ABC');
expect(grnOwnerSlug(null)).toBeNull();
});
});

View File

@@ -1,13 +1,41 @@
/**
* Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<REF8>`.
* Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<OWNER>-<REF8>`.
*
* The GRN is mapped to the goods OWNER (the booking's customer / consignee) for
* both import and export, so a note is identifiable by who owns the cargo
* without opening it. The trailing reference slice stays as the uniqueness
* anchor — one owner can have several bookings received on the same day.
* Owner-less receipts (manual walk-ins with no booking) fall back to the
* original `GRN-<DIRECTION>-<YYYYMMDD>-<REF8>` form.
*
* Shared so a GRN raised at a load/unload facility is indistinguishable from one
* raised in a warehouse — the two live in different tables
* (facility_handling_events vs warehouse_inventory), and a second generator would
* eventually let their formats drift apart.
*/
export function generateGrnNumber(direction: string, referenceId: string, date: Date): string {
export function generateGrnNumber(
direction: string,
referenceId: string,
date: Date,
ownerName?: string | null,
): string {
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
const owner = grnOwnerSlug(ownerName);
const base = `GRN-${direction.toUpperCase()}-${stamp}`;
return owner ? `${base}-${owner}-${suffix}` : `${base}-${suffix}`;
}
/**
* Owner name → GRN-safe token: letters/digits only, upper-cased, capped so a
* long company name can't run away with the number. Null when there is nothing
* usable, which drops the segment rather than emitting an empty `--`.
*/
export function grnOwnerSlug(ownerName?: string | null): string | null {
const slug = (ownerName ?? '')
.normalize('NFKD')
.replace(/[^a-zA-Z0-9]+/g, '')
.toUpperCase()
.slice(0, 12);
return slug || null;
}

View File

@@ -8,6 +8,8 @@ type MileRecord = {
bookingContainers?: Array<{
units?: Array<{ vgmTons?: number | string | null }> | null;
}> | null;
/** Attached here: the train schedule the booking rides, for mile alignment. */
trainSchedule?: { trainNumber: string | null; departureDate: string | null } | null;
} | null;
};
@@ -36,6 +38,38 @@ export async function attachMileFinancials(
if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3));
}
// Train alignment: which schedule each booking rides (mile pickups/deliveries
// are planned against the train's departure).
const bookingIds = [...new Set(records.map((r) => r.bookingId).filter(Boolean))] as string[];
if (bookingIds.length) {
const schedules: Array<{
bookingId: string;
trainNumber: string | null;
departureDate: string | null;
}> = await dataSource.query(
`SELECT DISTINCT ON (tsb.booking_id)
tsb.booking_id AS "bookingId",
ts.train_number AS "trainNumber",
COALESCE(ts.actual_departure_at, ts.scheduled_departure_date)::text AS "departureDate"
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts
ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
WHERE tsb.booking_id = ANY($1::uuid[]) AND tsb.deleted_at IS NULL
ORDER BY tsb.booking_id, tsb.created_at DESC`,
[bookingIds],
);
const byBookingSchedule = new Map(schedules.map((s) => [s.bookingId, s]));
for (const r of records) {
const s = r.bookingId ? byBookingSchedule.get(r.bookingId) : undefined;
if (r.booking && s) {
r.booking.trainSchedule = {
trainNumber: s.trainNumber,
departureDate: s.departureDate,
};
}
}
}
const needAdvance = records.filter(
(r) => r.bookingId && !(Number(r.advancedPayment) > 0),
);

View File

@@ -13,9 +13,22 @@ export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
);
export const RuleEngineManage = (slug: RuleEngineResourceSlug) =>
// Granular CRUD replaces the retired coarse RuleEngineManage. Each write
// endpoint carries the specific action it performs — create on POST-new,
// update on PATCH / reorder / move-order, delete on DELETE.
export const RuleEngineCreate = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])),
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.create(slug)])),
);
export const RuleEngineUpdate = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.update(slug)])),
);
export const RuleEngineDelete = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.delete(slug)])),
);
/**

View File

@@ -43,11 +43,17 @@ export function IsValidPhone(validationOptions?: ValidationOptions) {
* Normalize a phone string to canonical E.164. Returns the canonical form when
* parseable, otherwise the trimmed original (tolerant — never throws), or the
* value unchanged when empty/nullish.
*
* Defaults the country to Ethiopia so bare local numbers (no "+", e.g. eTrade's
* "0355235416") resolve the same way the frontend's own toEthiopianE164 already
* assumes — without this hint, libphonenumber can't infer a country for a
* number with no "+" prefix and silently falls through to the untouched local
* string, which then never matches the "+251…" form submitted by the client.
*/
export function normalizeE164(
value: string | null | undefined,
): string | null | undefined {
if (value === undefined || value === null || value === '') return value;
const parsed = parsePhoneNumberFromString(value);
const parsed = parsePhoneNumberFromString(value, 'ET');
return parsed?.isValid() ? parsed.number : value.trim();
}

View File

@@ -1,5 +1,6 @@
import { registerAs } from "@nestjs/config";
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
import { DataSourceOptions } from "typeorm";
import { join, dirname } from "path";
import {
DefaultPosition,
@@ -44,8 +45,30 @@ import {
NotificationTemplate,
} from "@tria-plc/iamapi-common";
import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity";
import { UnitSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-setting.entity";
import { UnitDetail } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-detail.entity";
import { OrganizationDetail } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-detail.entity";
import { UnitCluster } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-cluster.entity";
import { Location } from "@tria-plc/iamapi-common/entities/iam/organization-structure/location.entity";
import { LocationType } from "@tria-plc/iamapi-common/entities/iam/organization-structure/location-type.entity";
import { DelegationTerminationReason } from "@tria-plc/iamapi-common/entities/iam/organization-structure/delegation-termination-reason.entity";
import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee-position-active-period.entity";
import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity";
import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity";
import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity";
const iamEntities = [
UnitSetting,
UnitDetail,
OrganizationDetail,
UnitCluster,
Location,
LocationType,
DelegationTerminationReason,
EmployeePositionActivePeriod,
UnitConfiguration,
Site,
SiteSetting,
DefaultPosition,
DefaultUnit,
EmployeePosition,
@@ -95,7 +118,7 @@ const iamMigrationsGlob = join(
);
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
export default registerAs("database", (): TypeOrmModuleOptions => {
export function buildDataSourceOptions(): DataSourceOptions {
return {
type: "postgres",
host: process.env.DB_HOST ?? "localhost",
@@ -111,17 +134,19 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
// The search_path is instead applied per-connection via a pool `connect`
// handler in app.module.ts (see setPoolSearchPath).
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
autoLoadEntities: true,
migrations: [
// IAM schema + tables must be created before freight migrations
iamMigrationsGlob,
freightMigrationsGlob,
],
migrationsRun: true,
migrationsTransactionMode: "each",
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
synchronize: false,
logging:
process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"],
};
});
}
export default registerAs("database", (): TypeOrmModuleOptions => ({
...buildDataSourceOptions(),
autoLoadEntities: true,
migrationsRun: false,
}));

View File

@@ -15,7 +15,7 @@ export interface FaydaJwk {
qi?: string;
}
export type FaydaPlatform = 'WEB' | 'MOBILE';
export type FaydaPlatform = 'WEB' | 'MOBILE' | 'PORTAL';
export interface FaydaConfig {
enabled: boolean;
@@ -25,8 +25,10 @@ export interface FaydaConfig {
userInfoEndpoint: string;
/** OAuth redirect_uri sent to eSignet for MOBILE clients. */
redirectUri: string;
/** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */
/** OAuth redirect_uri sent to eSignet for WEB (backoffice) clients. Falls back to `redirectUri`. */
webRedirectUri: string;
/** OAuth redirect_uri sent to eSignet for the customer portal. Falls back to `webRedirectUri`. */
portalRedirectUri: string;
privateJwk: FaydaJwk;
scope: string;
acrValues: string;
@@ -77,6 +79,7 @@ export default registerAs('fayda', (): FaydaConfig => {
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri;
const portalRedirectUri = process.env.FAYDA_PORTAL_REDIRECT_URI || webRedirectUri;
if (!enabled) {
return {
enabled: false,
@@ -86,6 +89,7 @@ export default registerAs('fayda', (): FaydaConfig => {
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
redirectUri,
webRedirectUri,
portalRedirectUri,
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
scope,
acrValues,
@@ -117,6 +121,7 @@ export default registerAs('fayda', (): FaydaConfig => {
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
redirectUri,
webRedirectUri,
portalRedirectUri,
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
scope,
acrValues,

View File

@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { hazardClassLabel } from '@edr/types';
import { ContractsRepository } from '../modules/contracts/contracts.repository';
import {
@@ -30,6 +31,8 @@ export interface ContractDocumentSignatureView {
signerDisplayName: string;
signedAt: string;
signatureImageUrl?: string | null;
/** Company stamp/seal; rendered next to the signature when present. */
stampImageUrl?: string | null;
}
/** A single unit-rate row on the contract PDF — price per unit, NO total. */
@@ -141,6 +144,11 @@ export class ContractDocumentViewModelBuilder {
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
const hasStaff = signatures.some((s) => s.role === 'STAFF');
// Signed before company stamps were required — the customer has to sign
// again to attach one, otherwise EDR can never counter-sign the contract.
const customerStampMissing = signatures.some(
(s) => s.role === 'CUSTOMER' && !s.stampImageUrl,
);
const hasContractFile = Boolean(
contract.files?.some((f) => f.code === 'contract'),
);
@@ -183,7 +191,9 @@ export class ContractDocumentViewModelBuilder {
// Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
// view-model's narrower CUSTOMER|STAFF role union.
signatures: signatures as unknown as ContractViewModel['signatures'],
canSignCustomer: contract.status === 'CONTRACT_READY' && !hasCustomer,
canSignCustomer:
(contract.status === 'CONTRACT_READY' && !hasCustomer) ||
(contract.status === 'SIGNED_CUSTOMER' && customerStampMissing),
canSignStaff:
contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
hasContractDocument: hasContractFile,
@@ -208,6 +218,7 @@ export class ContractDocumentViewModelBuilder {
signerDisplayName: row.signerDisplayName,
signedAt: this.formatDate(row.signedAt),
signatureImageUrl: row.signatureFile?.url ?? null,
stampImageUrl: row.stampFile?.url ?? null,
};
}
@@ -292,7 +303,16 @@ export class ContractDocumentViewModelBuilder {
cargoDescription: this.valueOrDash(cargoName),
totalWeightVgm: '—',
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
hazardousLabel: contract.isHazardous ? 'Yes' : 'No',
// A hazardous contract names the declared class + UN number on the
// schedule — the flag alone is not a dangerous-goods declaration.
hazardousLabel: contract.isHazardous
? [
hazardClassLabel(contract.hazardClass) ?? 'Yes',
contract.unNumber ? `UN ${contract.unNumber}` : null,
]
.filter(Boolean)
.join(' · ')
: 'No',
firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress),
lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress),
};

View File

@@ -157,10 +157,14 @@ export class ContractViewModelBuilder {
pricing,
rateSchedule,
signatures,
canSignCustomer:
booking.status === 'CONTRACT_READY' && !hasCustomer,
canSignStaff:
booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
// Government contracts are generated at creation and signable at any
// time, in any order — no status gate, no customer-first sequencing.
canSignCustomer: booking.isGovernment
? !hasCustomer
: booking.status === 'CONTRACT_READY' && !hasCustomer,
canSignStaff: booking.isGovernment
? !hasStaff
: booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
hasContractDocument: hasContractFile,
hasCustomerSignature: hasCustomer,
hasStaffSignature: hasStaff,

View File

@@ -11,6 +11,12 @@
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
<p class="sig-meta"><strong>Role:</strong> Authorized EDR representative</p>
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
{{#if stampImageUrl}}
<div class="sig-stamp">
<span class="sig-stamp-label">Company stamp</span>
<div class="sig-stamp-box"><img src="{{stampImageUrl}}" alt="Service provider stamp" /></div>
</div>
{{/if}}
{{/if}}
{{/each}}
{{else}}
@@ -32,6 +38,12 @@
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
<p class="sig-meta"><strong>Role:</strong> Authorized client representative</p>
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
{{#if stampImageUrl}}
<div class="sig-stamp">
<span class="sig-stamp-label">Company stamp</span>
<div class="sig-stamp-box"><img src="{{stampImageUrl}}" alt="Client stamp" /></div>
</div>
{{/if}}
{{/if}}
{{/each}}
{{else}}

View File

@@ -372,25 +372,30 @@
font-size: 9pt;
margin: 4px 0;
}
/* ── Witnesses ────────────────────────────────────────────────────────── */
.witnesses { margin-top: 20px; }
.witness-table {
font-size: 9.5pt;
margin-top: 6px;
.sig-stamp {
margin-top: 12px;
}
.witness-table th,
.witness-table td {
border-bottom: 1px solid #c9e4d9;
padding: 9px 8px;
text-align: left;
}
.witness-table th {
.sig-stamp-label {
color: #0e5b45;
font-family: Arial, sans-serif;
font-size: 8.5pt;
font-size: 7.5pt;
font-weight: 700;
letter-spacing: 0.4pt;
text-transform: uppercase;
}
.sig-stamp-box {
align-items: center;
display: flex;
height: 30mm;
justify-content: center;
margin-top: 5px;
}
.sig-stamp-box img {
display: block;
max-height: 30mm;
max-width: 45mm;
mix-blend-mode: multiply;
}
@media print {
body { background: #fff; }

View File

@@ -147,19 +147,6 @@
authorized to sign and execute this Contract Agreement.
</p>
{{> signatures_block}}
<div class="witnesses">
<p class="sig-title">Witnesses</p>
<table class="witness-table">
<thead>
<tr><th></th><th>Name</th><th>Signature</th><th>Date</th></tr>
</thead>
<tbody>
<tr><td>1.</td><td></td><td></td><td></td></tr>
<tr><td>2.</td><td></td><td></td><td></td></tr>
</tbody>
</table>
</div>
</section>
</main>
</body>

View File

@@ -1,21 +1,8 @@
// apps/edr-freight-api/src/data-source.ts
import 'dotenv/config';
import { DataSource } from 'typeorm';
//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
import "dotenv/config";
import { DataSource } from "typeorm";
import { buildDataSourceOptions } from "./config/database.config";
export const AppDataSource = new DataSource({
type: 'postgres',
host: process.env.DB_HOST ?? 'localhost',
port: Number(process.env.DB_PORT ?? 5433),
username: process.env.DB_USER ?? 'postgres',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'edr_freight',
schema: 'freight', // default schema for entities without an explicit schema
entities: [__dirname + '/**/*.entity{.ts,.js}'],
migrations: [__dirname + '/migrations/*{.ts,.js}'],
synchronize: false,
logging: process.env.TYPEORM_LOGGING === 'true',
});
export const AppDataSource = new DataSource(buildDataSourceOptions());
// Optional: call ensurePostgresSchemas before initializing
// But you can also run it separately.
export default AppDataSource;

View File

@@ -1,7 +1,9 @@
import "reflect-metadata";
import * as dotenv from "dotenv";
dotenv.config();
import { createRequire } from "node:module";
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import {
HttpExceptionFilter,
@@ -11,8 +13,81 @@ import {
import { AppModule } from "./app.module";
/**
* JSON body ceiling. Signing posts the signature AND the company stamp as
* base64 in one JSON body, and base64 inflates bytes by ~4/3 — a 10MB stamp is
* ~13.4MB on the wire. Express defaults to 100kb, which rejected any real stamp
* image with a 413 "request entity too large".
*/
const JSON_BODY_LIMIT = '20mb';
/**
* Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as
* `host=ip,host2=ip2`. Some internal hosts (MinIO) resolve only inside the
* deployment network, so dev machines get ENOTFOUND on every upload. Patching
* `dns.lookup` keeps the real hostname on the wire — the IP is used for the
* connection only — so TLS still validates against the certificate's CN.
*
* The module is loaded through `createRequire`, NOT `import * as dns`: an ESM
* namespace object is frozen, so assigning to it is silently dropped and the
* patch becomes a no-op. `require` returns the live module object every other
* caller (minio's http agent included) reads `lookup` off.
*/
function applyDnsHostOverrides(): void {
const raw = process.env.DNS_HOST_OVERRIDES?.trim();
if (!raw) return;
const overrides = new Map<string, string>();
for (const entry of raw.split(",")) {
const [host, ip] = entry.split("=").map((part) => part?.trim());
if (host && ip) overrides.set(host.toLowerCase(), ip);
}
if (overrides.size === 0) return;
const dns = createRequire(__filename)("node:dns") as typeof import("node:dns");
const originalLookup = dns.lookup.bind(dns);
// `dns.lookup` is overloaded (options optional, all/family variants); the
// cast keeps that surface intact while we intercept only mapped hostnames.
(dns as { lookup: unknown }).lookup = ((
hostname: string,
options: unknown,
callback?: unknown,
) => {
const ip = overrides.get(hostname?.toLowerCase?.());
if (!ip) return (originalLookup as Function)(hostname, options, callback);
const done = (typeof options === "function" ? options : callback) as (
err: NodeJS.ErrnoException | null,
address: string | { address: string; family: number }[],
family?: number,
) => void;
const family = ip.includes(":") ? 6 : 4;
const wantsAll =
typeof options === "object" && options !== null && (options as { all?: boolean }).all;
process.nextTick(() =>
wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family),
);
}) as typeof dns.lookup;
console.log(
`[DNS] Host overrides active: ${[...overrides].map(([h, ip]) => `${h}->${ip}`).join(", ")}`,
);
}
applyDnsHostOverrides();
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Nest's own body-parser API, NOT `app.use(json(...))` from express: express
// is not a declared dependency of this app (it arrives under
// @nestjs/platform-express), so importing it directly resolved only through
// pnpm's hoisted dev store and died as MODULE_NOT_FOUND in the production
// image, where `pnpm deploy --prod` installs declared dependencies only.
// This also RECONFIGURES the default parsers rather than racing them.
app.useBodyParser('json', { limit: JSON_BODY_LIMIT });
app.useBodyParser('urlencoded', { limit: JSON_BODY_LIMIT, extended: true });
// Dev CORS: reflect any localhost origin and allow credentials so the
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
@@ -28,6 +103,9 @@ async function bootstrap() {
"Accept",
"Authorization",
"X-Requested-With",
// Which freight frontend is calling — /auth/login uses this to reject
// cross-audience credentials (EDRFREIGHT-415).
"X-Client-App",
// IAM context headers required by @tria-plc/api-common's JwtGuard
"organization-unit-id",
"delegator-position-id",
@@ -40,6 +118,13 @@ async function bootstrap() {
"x-delegator-position-id",
"x-current-project-id",
"x-current-position-id",
// Headers sent by the freight-backoffice OKR/objective-service client
// (withHeaders.tsx, signatureAndTeeterService.ts, useIncomingReport.ts)
// under yet another naming convention — unprefixed "tenant-key"/"unit-id",
// and "x-delegated-position-id" (delegated, not delegator).
"tenant-key",
"unit-id",
"x-delegated-position-id",
],
exposedHeaders: ["Content-Disposition"],
maxAge: 86400, // cache preflight for 24h to cut chatter in dev

View File

@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Bulk tonnage at assignment time. First-mile trucks and export self-haul
* trucks carry a planned load (tonnes + optional item count) so bulk bookings
* draw down as vehicles are assigned — not only at the weighbridge.
*/
export class AddMileTonsQuantity2820000000000 implements MigrationInterface {
name = 'AddMileTonsQuantity2820000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.first_mile_vehicle_assignments ADD COLUMN IF NOT EXISTS tons numeric(14,3);`,
);
await queryRunner.query(
`ALTER TABLE freight.first_mile_vehicle_assignments ADD COLUMN IF NOT EXISTS quantity integer;`,
);
await queryRunner.query(
`ALTER TABLE freight.customer_truck_assignments ADD COLUMN IF NOT EXISTS planned_tons numeric(14,3);`,
);
await queryRunner.query(
`ALTER TABLE freight.customer_truck_assignments ADD COLUMN IF NOT EXISTS planned_quantity integer;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.customer_truck_assignments DROP COLUMN IF EXISTS planned_quantity;`,
);
await queryRunner.query(
`ALTER TABLE freight.customer_truck_assignments DROP COLUMN IF EXISTS planned_tons;`,
);
await queryRunner.query(
`ALTER TABLE freight.first_mile_vehicle_assignments DROP COLUMN IF EXISTS quantity;`,
);
await queryRunner.query(
`ALTER TABLE freight.first_mile_vehicle_assignments DROP COLUMN IF EXISTS tons;`,
);
}
}

View File

@@ -0,0 +1,121 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Truck types become back-office data instead of a hardcoded `VehicleType` enum,
* so EDR can add a configuration without a code change.
*
* `vehicles.vehicle_type` is deliberately LEFT IN PLACE as a denormalised code.
* Truck-detention billing groups trucks with raw SQL over that column
* (`SELECT v.vehicle_type ... GROUP BY`, warehouse-fee.service.ts) and matches
* the result against `warehouse_fee_rules.vehicle_type`. Swapping it for the FK
* outright would silently drop detention charges, so the FK is additive and the
* service writes the type's code through on every save.
*
* Raw SQL, `freight.`-qualified, IF NOT EXISTS throughout — the TypeORM builder
* API resolves bare names against `public` and crash-loops boot.
*/
export class AddTruckTypes2840000000000 implements MigrationInterface {
name = "AddTruckTypes2840000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.truck_types (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(32) NOT NULL,
name varchar(100) NOT NULL,
capacity_tons numeric(10,3),
has_trailer boolean NOT NULL DEFAULT false,
description text,
is_active boolean NOT NULL DEFAULT true,
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_truck_types_code
ON freight.truck_types (code)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS ix_truck_types_is_active
ON freight.truck_types (is_active)
`);
// Seed one row per legacy enum value so vehicles already carrying that code
// keep resolving, plus CASONI as the first rigid (no-trailer) configuration.
// has_trailer is true only for the articulated configurations.
await queryRunner.query(`
INSERT INTO freight.truck_types (code, name, has_trailer)
VALUES
('TRUCK', 'Truck', true),
('TRAILER', 'Trailer', true),
('TANKER', 'Tanker', true),
('FLATBED', 'Flatbed', true),
('VAN', 'Van', false),
('CAR', 'Car', false),
('BUS', 'Bus', false),
('CASONI', 'Casoni (rigid, no trailer)', false)
ON CONFLICT (code) DO NOTHING
`);
await queryRunner.query(`
ALTER TABLE freight.vehicles
ADD COLUMN IF NOT EXISTS truck_type_id uuid
`);
// Separate DO block: ADD CONSTRAINT has no IF NOT EXISTS in Postgres.
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_vehicles_truck_type'
) THEN
ALTER TABLE freight.vehicles
ADD CONSTRAINT fk_vehicles_truck_type
FOREIGN KEY (truck_type_id) REFERENCES freight.truck_types (id)
ON DELETE SET NULL;
END IF;
END $$
`);
// Backfill the FK from the code already stored on each vehicle.
await queryRunner.query(`
UPDATE freight.vehicles v
SET truck_type_id = t.id
FROM freight.truck_types t
WHERE v.truck_type_id IS NULL
AND upper(trim(v.vehicle_type)) = t.code
`);
// Truck-type codes are varchar(32); the fee-rule column they are matched
// against was varchar(20) and would truncate/reject longer codes.
await queryRunner.query(`
ALTER TABLE freight.warehouse_fee_rules
ALTER COLUMN vehicle_type TYPE varchar(32)
`);
// A VIN identifies exactly one vehicle worldwide. Partial index so the many
// existing rows without a VIN do not collide.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_vehicles_vin
ON freight.vehicles (vin)
WHERE vin IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_vehicles_vin`);
await queryRunner.query(`
ALTER TABLE freight.vehicles
DROP CONSTRAINT IF EXISTS fk_vehicles_truck_type
`);
await queryRunner.query(`
ALTER TABLE freight.vehicles
DROP COLUMN IF EXISTS truck_type_id
`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.truck_types`);
// warehouse_fee_rules.vehicle_type is left widened: narrowing it back would
// fail on any row that stored a code longer than 20 characters.
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Double handling becomes an explicit per-booking decision instead of an
* implicit "every import" charge. Warehouse staff record Yes/No after
* unloading (whether the goods actually had to be re-handled); the
* DOUBLE_HANDLING_FEE rule only bills when the answer is Yes.
*
* NULL = not decided yet → no charge, and the UI shows "not set" so the
* operator is prompted. Existing rows stay NULL deliberately: back-billing a
* fee nobody confirmed would be wrong.
*/
export class AddBookingDoubleHandling2850000000000 implements MigrationInterface {
name = 'AddBookingDoubleHandling2850000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling boolean;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling_set_at timestamptz;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling_set_by varchar(160);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling_set_by;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling_set_at;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling;`,
);
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-truck detention clocks. Detention was timed once per last-mile leg
* (last_mile.arrived_at / delivered_at), so every truck on a multi-truck
* delivery shared one window and was billed identical days — wrong the moment
* two trucks arrive or return at different times.
*
* Deliberately NEW columns rather than reusing the existing per-truck
* arrived_at / departed_at on this table: those are WAREHOUSE gate-in/gate-out
* events stamped by release(), whereas detention runs from arrival at the
* DESTINATION until the truck is released/returned.
*
* Both nullable — a truck without its own window falls back to the leg-level
* timestamps, so legacy legs keep billing exactly as before.
*/
export class AddPerTruckDetentionWindow2860000000000 implements MigrationInterface {
name = 'AddPerTruckDetentionWindow2860000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
ADD COLUMN IF NOT EXISTS destination_arrived_at timestamptz,
ADD COLUMN IF NOT EXISTS returned_at timestamptz;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
DROP COLUMN IF EXISTS returned_at,
DROP COLUMN IF EXISTS destination_arrived_at;
`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Livestock is billed and counted per head, not per ton — line it up with the
* other break-bulk cargo types (Machinery, Truck, Automobile) so bulk
* storage/demurrage fees charge per item instead of per ton for it.
*/
export class LivestockPerItem2900000000000 implements MigrationInterface {
name = "LivestockPerItem2900000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.cargo_types
SET unit_of_measure = 'PER_ITEM'
WHERE code = 'LIVESTOCK'
AND unit_of_measure IS DISTINCT FROM 'PER_ITEM'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.cargo_types
SET unit_of_measure = 'PER_TON'
WHERE code = 'LIVESTOCK'
AND unit_of_measure IS DISTINCT FROM 'PER_TON'
`);
}
}

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Company stamp (seal) attached alongside the drawn signature, for both the
* client and the EDR side. Stored the same way the signature image is: a
* FileRecord on the contract (`resource: 'contracts'`, `code: 'stamp_<role>'`)
* referenced from the signature row.
*
* Nullable — existing signature rows predate the stamp requirement. The
* "both stamps recorded" gate lives in ContractTransitionService.counterSign,
* not in a NOT NULL constraint, so historical rows stay readable.
*/
export class AddContractSignatureStamp2910000000000 implements MigrationInterface {
name = 'AddContractSignatureStamp2910000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contract_signatures ADD COLUMN IF NOT EXISTS stamp_file_id uuid;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contract_signatures DROP COLUMN IF EXISTS stamp_file_id;`,
);
}
}

View File

@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Store WHO made a contract edit as a name, not just an id. Denormalised on
* purpose: an audit trail must still read correctly after the user is renamed,
* deactivated or deleted, and `iam.users` lives outside this module's schema.
*/
export class AddRevisionActorName2920000000000 implements MigrationInterface {
name = 'AddRevisionActorName2920000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contract_document_revisions ADD COLUMN IF NOT EXISTS actor_name varchar(200);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contract_document_revisions DROP COLUMN IF EXISTS actor_name;`,
);
}
}

View File

@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Partial wagon-transfer fulfilment.
*
* A request for 50 wagons no longer has to be met in one go: OCC moves what the
* source yard can spare, whenever it can, and the request stays open until the
* full count is met (FULFILLED) or OCC ends it short (CLOSED_SHORT) so the
* requester can ask another yard for the rest.
*
* Existing rows are back-filled so history keeps reading correctly: a FULFILLED
* request delivered its whole quantity; anything else delivered nothing.
*/
export class AddWagonTransferPartialFulfilment2930000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_transfer_requests
ADD COLUMN IF NOT EXISTS fulfilled_quantity integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS closed_short_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS closed_short_by_user_id uuid NULL
`);
await queryRunner.query(`
UPDATE freight.wagon_transfer_requests
SET fulfilled_quantity = quantity
WHERE status = 'FULFILLED'
AND fulfilled_quantity = 0
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_transfer_requests
DROP COLUMN IF EXISTS fulfilled_quantity,
DROP COLUMN IF EXISTS closed_short_at,
DROP COLUMN IF EXISTS closed_short_by_user_id
`);
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Keep every version of a stored document.
*
* Replacing a file used to DELETE the previous row outright, so a staff
* correction erased the customer's original upload with no trail. Superseded
* versions are now soft-deleted (already excluded from every read by TypeORM's
* soft-delete filter) and stamped with who replaced them and why, which is what
* the document's version history reads back.
*/
export class AddFileVersionHistory2940000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.files
ADD COLUMN IF NOT EXISTS replaced_by_user_id uuid NULL,
ADD COLUMN IF NOT EXISTS replace_reason text NULL
`);
// History reads walk one document's versions, deleted rows included.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_files_version_history"
ON freight.files (resource, resource_id, code, created_at DESC)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_files_version_history"`);
await queryRunner.query(`
ALTER TABLE freight.files
DROP COLUMN IF EXISTS replaced_by_user_id,
DROP COLUMN IF EXISTS replace_reason
`);
}
}

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Transit-assignee handshake before the customs declaration.
*
* GL Ethiopia must ask GL Djibouti who will handle the shipment in transit, and
* Djibouti answers with a name, before the declaration can be filed. The whole
* exchange lives on the clearance cycle so it repeats naturally with each cycle
* of a GENERAL contract.
*/
export class AddTransitAssigneeHandshake2950000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contract_clearance_cycles
ADD COLUMN IF NOT EXISTS transit_assignee_requested_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_requested_by_user_id uuid NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_request_note text NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_name text NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_assigned_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_assigned_by_user_id uuid NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contract_clearance_cycles
DROP COLUMN IF EXISTS transit_assignee_requested_at,
DROP COLUMN IF EXISTS transit_assignee_requested_by_user_id,
DROP COLUMN IF EXISTS transit_assignee_request_note,
DROP COLUMN IF EXISTS transit_assignee_name,
DROP COLUMN IF EXISTS transit_assignee_assigned_at,
DROP COLUMN IF EXISTS transit_assignee_assigned_by_user_id
`);
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Hazardous contracts now declare WHAT the dangerous good is, not just that it
* exists: the UN/ADR class (CLASS_1..CLASS_9) and the shipment's UN number. Both
* are captured in the portal alongside the hazard documents and reviewed by the
* two hazardous approval desks.
*
* Nullable — non-hazardous contracts leave both null, and contracts created
* before this change have no declaration to backfill.
*/
export class AddContractHazardDeclaration2960000000000
implements MigrationInterface
{
name = 'AddContractHazardDeclaration2960000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS hazard_class varchar(16);`,
);
await queryRunner.query(
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS un_number varchar(16);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS un_number;`,
);
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS hazard_class;`,
);
}
}

View File

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Djibouti GL must record WHEN the vessel arrived and WHEN the Delivery Order
* was collected, not just attach the DO file. Both are mandatory on DO upload
* (enforced in the clearance services), so the columns are new and nullable —
* DOs uploaded before this change have no dates to backfill.
*
* `vessel_departure_date` is the EXPORT Release-Order date and stays as-is; the
* import arrival date gets its own column rather than overloading it.
*/
export class AddDoCollectionDates2970000000000 implements MigrationInterface {
name = 'AddDoCollectionDates2970000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
for (const table of [
'freight.contract_clearance_cycles',
'freight.bookings',
]) {
await queryRunner.query(
`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS vessel_arrival_date date;`,
);
await queryRunner.query(
`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS do_collected_date date;`,
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
for (const table of [
'freight.contract_clearance_cycles',
'freight.bookings',
]) {
await queryRunner.query(
`ALTER TABLE ${table} DROP COLUMN IF EXISTS do_collected_date;`,
);
await queryRunner.query(
`ALTER TABLE ${table} DROP COLUMN IF EXISTS vessel_arrival_date;`,
);
}
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Currency moved from the contract to the shipment: a contract now quotes in
* USD and the customer picks the billing currency per booking. On a customs
* contract GL books on the customer's behalf, so the shipment request is where
* the customer states the currency — GL reads it when creating the booking.
*
* Nullable: requests submitted before this change fall back to the contract's
* own currency, which is exactly what their bookings already used.
*/
export class AddBookingRequestCurrency2980000000000 implements MigrationInterface {
name = 'AddBookingRequestCurrency2980000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.booking_requests ADD COLUMN IF NOT EXISTS payment_currency varchar(5);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.booking_requests DROP COLUMN IF EXISTS payment_currency;`,
);
}
}

View File

@@ -0,0 +1,131 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Indode's real 11-yard layout, plus the plumbing to auto-route a booking to
* the right yard by cargo type (and, for container yards, trade direction):
*
* - `warehouse_yards.direction` — IMPORT | EXPORT | BOTH | null. Only
* meaningful for CONTAINER_YARD, where import and export stacks are
* physically separate (Yard 5 vs Yard 6). Everything else takes cargo
* either way. A CONTAINER_YARD left at null/BOTH is a signal too: it means
* "not a customer cargo yard" — Yards 10/11 (service/equipment) are
* CONTAINER_YARD structurally but must never be offered for ordinary
* import/export cargo, so the frontend match requires an EXACT IMPORT/
* EXPORT direction hit for container freight rather than treating BOTH as
* a wildcard.
* - `warehouse_yard_cargo_types` — which cargo types a yard accepts (mirrors
* the existing `cargo_type_wagon_types` join table). Empty = open to any
* cargo type of the yard's structural type (additive, never restrictive
* by default), so this cannot break a yard nobody has configured yet.
*
* Three cargo types didn't exist yet (Fertilizer, Coffee, Tea) — added here
* so Yards 1 and 9 have a real mapping ready for when they reopen.
*/
export class IndodeYardsAndCargoRouting2990000000000 implements MigrationInterface {
name = "IndodeYardsAndCargoRouting2990000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_yards
ADD COLUMN IF NOT EXISTS direction varchar(10)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_yard_cargo_types (
yard_id uuid NOT NULL REFERENCES freight.warehouse_yards (id) ON DELETE CASCADE,
cargo_type_id uuid NOT NULL REFERENCES freight.cargo_types (id) ON DELETE CASCADE,
PRIMARY KEY (yard_id, cargo_type_id)
)
`);
// New cargo types Indode's yard list names but the catalog didn't have yet.
await queryRunner.query(`
INSERT INTO freight.cargo_types (code, cargo_type_name, unit_of_measure, is_active)
VALUES
('FERTILIZER', 'Fertilizer', 'PER_TON', true),
('COFFEE', 'Coffee', 'PER_TON', true),
('TEA', 'Tea', 'PER_TON', true)
ON CONFLICT (code) DO NOTHING
`);
// The 11 real yards at Indode Open Warehouse (code 'IOW').
await queryRunner.query(`
INSERT INTO freight.warehouse_yards
(warehouse_id, name, code, type, direction, status, is_active)
SELECT w.id, y.name, y.code, y.type, y.direction, y.status, y.status = 'ACTIVE'
FROM freight.warehouses w
CROSS JOIN (VALUES
('Y1', 'Bagged Cargo Discharge - Fertilizer', 'BULK_YARD', NULL, 'INACTIVE'),
('Y2', 'Break Bulk', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'),
('Y3', 'Ro-Ro / Pac', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'),
('Y4', 'Dry Bulk', 'BULK_YARD', NULL, 'INACTIVE'),
('Y5', 'Container Terminal - Import (Stack Area)', 'CONTAINER_YARD', 'IMPORT', 'ACTIVE'),
('Y6', 'Container Terminal - Export', 'CONTAINER_YARD', 'EXPORT', 'ACTIVE'),
('Y7', 'Cold Chain', 'COLD_STORAGE_YARD', NULL, 'INACTIVE'),
('Y8', 'Chemical', 'HAZARDOUS_YARD', NULL, 'INACTIVE'),
('Y9', 'Coffee and Tea', 'GENERAL_CARGO_YARD', NULL, 'INACTIVE'),
('Y10', 'Container Service Yard - Maintenance', 'CONTAINER_YARD', 'BOTH', 'ACTIVE'),
('Y11', 'Equipment (Empty Container)', 'CONTAINER_YARD', 'BOTH', 'ACTIVE')
) AS y(code, name, type, direction, status)
WHERE w.code = 'IOW'
ON CONFLICT (warehouse_id, code) DO NOTHING
`);
// One default zone per new yard, matching its yard's type — every existing
// yard (CY-1, CY-A) already follows this one-zone-per-yard shape.
await queryRunner.query(`
INSERT INTO freight.warehouse_zones (yard_id, name, code, type, status, is_active)
SELECT y.id, y.name || ' Zone 1', 'Z1',
CASE y.type
WHEN 'CONTAINER_YARD' THEN 'CONTAINER_ZONE'
WHEN 'COLD_STORAGE_YARD' THEN 'COLD_STORAGE_ZONE'
WHEN 'HAZARDOUS_YARD' THEN 'HAZARDOUS_ZONE'
WHEN 'BULK_YARD' THEN 'BULK_ZONE'
ELSE 'GENERAL_CARGO_ZONE'
END,
y.status, y.status = 'ACTIVE'
FROM freight.warehouse_yards y
JOIN freight.warehouses w ON w.id = y.warehouse_id
WHERE w.code = 'IOW' AND y.code LIKE 'Y%'
ON CONFLICT (yard_id, code) DO NOTHING
`);
// Cargo-type routing. Yards 5/6/10/11 (CONTAINER_YARD) are intentionally
// left with no rows — direction alone decides those, per the entity comment.
await queryRunner.query(`
INSERT INTO freight.warehouse_yard_cargo_types (yard_id, cargo_type_id)
SELECT y.id, ct.id
FROM freight.warehouses w
JOIN freight.warehouse_yards y ON y.warehouse_id = w.id
JOIN (VALUES
('Y1', 'FERTILIZER'),
('Y2', 'STEEL_BILLET'), ('Y2', 'PLASTIC_BARREL'), ('Y2', 'MACHINERY'), ('Y2', 'LIVESTOCK'),
('Y3', 'AUTOMOBILE'), ('Y3', 'TRUCK'),
('Y4', 'BARLY'), ('Y4', 'BEANS'), ('Y4', 'BULK'), ('Y4', 'CEREAL'),
('Y4', 'EDIBLE_OIL'), ('Y4', 'RICE'), ('Y4', 'SUGAR'), ('Y4', 'WHEAT'),
('Y7', 'PERISHABLE'),
('Y9', 'COFFEE'), ('Y9', 'TEA')
) AS m(yard_code, cargo_code) ON m.yard_code = y.code
JOIN freight.cargo_types ct ON ct.code = m.cargo_code
WHERE w.code = 'IOW'
ON CONFLICT (yard_id, cargo_type_id) DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM freight.warehouse_zones z
USING freight.warehouse_yards y, freight.warehouses w
WHERE z.yard_id = y.id AND y.warehouse_id = w.id
AND w.code = 'IOW' AND y.code LIKE 'Y%'
`);
await queryRunner.query(`
DELETE FROM freight.warehouse_yards y
USING freight.warehouses w
WHERE y.warehouse_id = w.id AND w.code = 'IOW' AND y.code LIKE 'Y%'
`);
// Cargo types and the join table are left in place — other data may have
// started referencing them since; dropping columns/tables is not reversible
// once real rows exist, and leaving them is harmless.
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Backoffice contract suspension (reversible freeze at any post-signature step)
* and customer-initiated contract cancellation.
*
* Only one new column is needed: the status to restore when the suspension is
* lifted. The reason and the actor already have a home — contract_review_notes
* rows with note_type SUSPENSION / SUSPENSION_LIFTED / CANCELLATION.
*/
export class AddContractSuspension3000000000000 implements MigrationInterface {
name = 'AddContractSuspension3000000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS status_before_suspension varchar(40);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS status_before_suspension;`,
);
}
}

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Transit-assignee handshake on the SHIPMENT, not the contract.
*
* Clearance runs per booking now, so the ask GL Ethiopia raises before filing a
* customs declaration ("who handles this shipment in Djibouti?") and Djibouti's
* answer belong on the booking. The contract-cycle columns added by
* 2950000000000 stay for the legacy contract-level cycles.
*/
export class AddBookingTransitAssignee3010000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS transit_assignee_requested_at timestamptz NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_request_note text NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_name text NULL,
ADD COLUMN IF NOT EXISTS transit_assignee_assigned_at timestamptz NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS transit_assignee_requested_at,
DROP COLUMN IF EXISTS transit_assignee_request_note,
DROP COLUMN IF EXISTS transit_assignee_name,
DROP COLUMN IF EXISTS transit_assignee_assigned_at
`);
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* A contract's `created_at` is the DRAFT row's insert time, not when the
* customer actually submitted it for review — a DRAFT can sit edited for days
* first. `submitted_at` is stamped by ContractTransitionService.submit /
* confirmSubmit so the history UI can show a real submission time.
*/
export class AddContractSubmittedAt3020000000000 implements MigrationInterface {
name = 'AddContractSubmittedAt3020000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS submitted_at timestamptz;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS submitted_at;`,
);
}
}

View File

@@ -0,0 +1,42 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* GL Ethiopia ↔ GL Djibouti document exchange. The documents are ordinary
* `freight.files` rows (resource `gl_exchange`), so they only need the metadata
* a free-form upload has and a catalog-driven one does not: the uploader's own
* title, who uploaded it (the only user allowed to change it afterwards) and
* whether the customer may see it in the portal.
*/
export class AddGlExchangeDocumentFields3030000000000
implements MigrationInterface
{
name = 'AddGlExchangeDocumentFields3030000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.files
ADD COLUMN IF NOT EXISTS title varchar(300),
ADD COLUMN IF NOT EXISTS visible_to_customer boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS uploaded_by_user_id uuid,
ADD COLUMN IF NOT EXISTS uploaded_by_name varchar(200);`,
);
// Every read of a thread is "all files of one resource" — index the pair.
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_files_resource_lookup
ON freight.files (resource, resource_id);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_files_resource_lookup;`,
);
await queryRunner.query(
`ALTER TABLE freight.files
DROP COLUMN IF EXISTS title,
DROP COLUMN IF EXISTS visible_to_customer,
DROP COLUMN IF EXISTS uploaded_by_user_id,
DROP COLUMN IF EXISTS uploaded_by_name;`,
);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddTransitAgents3040000000000 implements MigrationInterface {
name = "AddTransitAgents3040000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.transit_agents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name varchar(150) NOT NULL,
valid_from date NOT NULL,
valid_to date NOT NULL,
is_active boolean NOT NULL DEFAULT true,
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 ix_transit_agents_is_active
ON freight.transit_agents (is_active)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_agents`);
}
}

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddCbeBillPaymentMethod3050000000000 implements MigrationInterface {
name = "AddCbeBillPaymentMethod3050000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// CBE Unified Bill Payment (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §4.3) — lowercase-hyphen
// per the local convention (see 2460000000000-AddCacBankPaymentMethod).
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cbe-bill';`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// PostgreSQL does not support removing enum values directly.
}
}

View File

@@ -0,0 +1,67 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Two active "Sebeta" yards existed (code LEGACY_DEST label "Sebeta", and code
* SEBETA label "sebeta") — rates and routes pointed at one or the other, so a
* rate configured against one never matched a contract routed via the other.
* Merge them: keep the row all rates/distances/facilities reference
* (LEGACY_DEST), repoint every yard reference from the duplicate to it, retire
* the duplicate, and give the survivor the clean SEBETA code. Then make
* duplicate active yard labels/codes impossible at the DB level.
*/
export class MergeDuplicateSebetaYards3050000000000 implements MigrationInterface {
name = "MergeDuplicateSebetaYards3050000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
DECLARE
survivor uuid;
dupe uuid;
col record;
BEGIN
SELECT id INTO survivor FROM freight.yards
WHERE code = 'LEGACY_DEST' AND lower(trim(label)) = 'sebeta' AND deleted_at IS NULL;
SELECT id INTO dupe FROM freight.yards
WHERE code = 'SEBETA' AND deleted_at IS NULL;
IF survivor IS NULL OR dupe IS NULL OR survivor = dupe THEN
RETURN;
END IF;
-- Every yard-referencing column in the schema, so rows created between
-- authoring and running this migration are repointed too.
FOR col IN
SELECT table_name, column_name FROM information_schema.columns
WHERE table_schema = 'freight'
AND table_name <> 'yards'
AND (column_name LIKE '%yard_id%' OR column_name LIKE '%station_id%')
LOOP
EXECUTE format(
'UPDATE freight.%I SET %I = $1 WHERE %I = $2',
col.table_name, col.column_name, col.column_name
) USING survivor, dupe;
END LOOP;
UPDATE freight.yards
SET code = 'SEBETA@merged', label = 'sebeta@merged', deleted_at = now()
WHERE id = dupe;
UPDATE freight.yards SET code = 'SEBETA', label = 'Sebeta' WHERE id = survivor;
END $$;
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yards_label_active"
ON freight.yards (lower(trim(label))) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yards_code_active"
ON freight.yards (lower(trim(code))) WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Data repair — not reversible. The uniqueness indexes are the new invariant.
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_yards_label_active"`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_yards_code_active"`);
}
}

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Export bookings get their own pay window, separately tunable from import:
* - global_rules.export_payment_window_minutes — global default for EXPORT
* (payment_window_minutes keeps governing IMPORT/DOMESTIC).
* - train_schedules.rule_payment_window_minutes — per-schedule override; until
* now the DTO accepted paymentWindowMinutes but only folded it into the
* reopen-delay sum, so the override never reached the actual pay window.
* - bookings.requested_train_schedule_id — the export train the customer picked
* at day-commit; pickExportSchedule honors it instead of earliest-first.
* - bookings.payment_reminder_sent_at — marks the one pre-deadline pay
* reminder so the 10s window tick doesn't re-send it.
*/
export class AddExportPaymentWindow3060000000000 implements MigrationInterface {
name = 'AddExportPaymentWindow3060000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.train_scheduling_global_rules ADD COLUMN IF NOT EXISTS export_payment_window_minutes int NOT NULL DEFAULT 60;`,
);
await queryRunner.query(
`ALTER TABLE freight.train_schedules ADD COLUMN IF NOT EXISTS rule_payment_window_minutes int;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS requested_train_schedule_id uuid;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS payment_reminder_sent_at timestamptz;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS payment_reminder_sent_at;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS requested_train_schedule_id;`,
);
await queryRunner.query(
`ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS rule_payment_window_minutes;`,
);
await queryRunner.query(
`ALTER TABLE freight.train_scheduling_global_rules DROP COLUMN IF EXISTS export_payment_window_minutes;`,
);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Break-bulk (PER_ITEM) bookings store their item count in
* cargo_total_weight_vgm, so the actual tonnage was never captured — wagon
* allocation divided an item COUNT by a tons capacity and under-allocated
* (400 machines ÷ 69T wagon read as 6 wagons instead of 12). New column holds
* the real total weight in tons for PER_ITEM cargo; null for PER_TON bulk and
* container bookings.
*/
export class AddBulkTotalWeightTons3070000000000 implements MigrationInterface {
name = 'AddBulkTotalWeightTons3070000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_total_weight_tons numeric(12,3);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_total_weight_tons;`,
);
}
}

View File

@@ -0,0 +1,65 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Several DCT (DORALEH) → GMP (KALITY) routes are missing the Dire Dawa stop
* in their milestone list. The corridor budget builds its per-leg edges from
* route_milestones, so on those routes a DCT→Dire Dawa or Dire Dawa→GMP
* booking cannot resolve its own leg and conservatively occupies the WHOLE
* route — per-leg wagon reuse (a wagon freed at Dire Dawa reloading for GMP)
* silently degrades to train-wide accounting.
*
* Insert the Dire Dawa milestone at sequence 2 on every active DORALEH→KALITY
* route with a stop list that lacks it, shifting later stops down. Matched by
* yard CODE so the repair is portable across environments. Idempotent: routes
* already carrying Dire Dawa are untouched.
*/
export class BackfillDireDawaMilestone3080000000000 implements MigrationInterface {
name = "BackfillDireDawaMilestone3080000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
DECLARE
dire uuid;
r record;
BEGIN
SELECT id INTO dire FROM freight.yards
WHERE code = 'DIRE_DAWA' AND deleted_at IS NULL;
IF dire IS NULL THEN
RETURN;
END IF;
FOR r IN
SELECT rt.id
FROM freight.routes rt
JOIN freight.yards o ON o.id = rt.origin_yard_id AND o.code = 'DORALEH'
JOIN freight.yards d ON d.id = rt.destination_yard_id AND d.code = 'KALITY'
WHERE rt.deleted_at IS NULL
AND EXISTS (SELECT 1 FROM freight.route_milestones m
WHERE m.route_id = rt.id AND m.deleted_at IS NULL)
AND NOT EXISTS (SELECT 1 FROM freight.route_milestones m
WHERE m.route_id = rt.id AND m.yard_id = dire
AND m.deleted_at IS NULL)
LOOP
-- Two-phase shift: uq_route_milestones_route_sequence isn't deferrable,
-- so a direct +1 UPDATE can collide mid-scan (seq 2 -> 3 while seq 3 still live).
-- Route through negative sequence_no first to avoid any interim collision.
-- Soft-deleted rows shift too: the constraint counts them, so a dead row
-- left at a target sequence would still collide.
UPDATE freight.route_milestones
SET sequence_no = -sequence_no
WHERE route_id = r.id AND sequence_no >= 2;
UPDATE freight.route_milestones
SET sequence_no = -sequence_no + 1
WHERE route_id = r.id AND sequence_no < 0;
INSERT INTO freight.route_milestones (route_id, yard_id, sequence_no)
VALUES (r.id, dire, 2);
END LOOP;
END $$;
`);
}
public async down(): Promise<void> {
// Data repair — not reversible.
}
}

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddSavedSignatureStamp3090000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.saved_signatures
ADD COLUMN IF NOT EXISTS stamp_file_id UUID NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.saved_signatures
DROP COLUMN IF EXISTS stamp_file_id;
`);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* The draft/finalize phase is abolished: train schedules are created SCHEDULED
* and the Finalize button is gone from the backoffice. Promote every surviving
* DRAFT schedule so it stays reachable (dispatch requires SCHEDULED and there
* is no manual promotion path anymore). Idempotent; one-way — the original
* DRAFT set is not recorded, so down() cannot restore it.
*/
export class PromoteDraftSchedulesToScheduled3100000000000 implements MigrationInterface {
name = "PromoteDraftSchedulesToScheduled3100000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`UPDATE freight.train_schedules
SET status = 'SCHEDULED'
WHERE status = 'DRAFT'
AND deleted_at IS NULL`,
);
}
public async down(): Promise<void> {
// One-way data promotion — nothing to restore.
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Consist adjustments can now happen mid-route (train standing at a stop), so
* each history row records WHERE it happened. Nullable — rows written before
* this column simply have no yard.
*/
export class AddYardToScheduleWagonAdjustmentLogs3110000000000 implements MigrationInterface {
name = "AddYardToScheduleWagonAdjustmentLogs3110000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.schedule_wagon_adjustment_logs
ADD COLUMN IF NOT EXISTS yard_id uuid`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.schedule_wagon_adjustment_logs
DROP COLUMN IF EXISTS yard_id`,
);
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Pending→Active is the only company-level approval event; `updatedAt` can't
* stand in for it since any field edit bumps that too. Nullable — existing
* companies (approved before this column existed) have no recorded moment.
*/
export class AddApprovedAtToCompanies3120000000000 implements MigrationInterface {
name = "AddApprovedAtToCompanies3120000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS approved_at timestamptz`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.companies
DROP COLUMN IF EXISTS approved_at`,
);
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Break-bulk (PER_ITEM) cargo needs a physical items-fit per allowed wagon
* type (e.g. cars → NW5: 4, NW7: 6): a wagon runs out of floor space before it
* runs out of rated tonnage, so allocation must respect BOTH limits. Stored as
* a jsonb map { [wagonTypeId]: itemsFit } on cargo_types — keys mirror the
* cargo_type_wagon_types join rows, kept in sync by the cargo-types service.
*/
export class AddCargoTypeItemsPerWagonMap3120000000000 implements MigrationInterface {
name = 'AddCargoTypeItemsPerWagonMap3120000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."cargo_types" ADD COLUMN IF NOT EXISTS "items_per_wagon_map" jsonb`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."cargo_types" DROP COLUMN IF EXISTS "items_per_wagon_map"`,
);
}
}

View File

@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
/**
* Append-only audit of company edits made before the company reaches Active
* (the onboarding phase) — that write path has no approval gate and, until
* now, left no trace of what changed (e.g. a phone number or a document).
*/
export class CreateCompanyRevisions3130000000000 implements MigrationInterface {
name = 'CreateCompanyRevisions3130000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'company_revisions',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
{ name: 'company_id', type: 'uuid' },
{ name: 'actor_id', type: 'uuid', isNullable: true },
{ name: 'summary', type: 'varchar', length: '255' },
{ name: 'changes', type: 'jsonb', default: "'[]'::jsonb" },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
foreignKeys: [
{
columnNames: ['company_id'],
referencedSchema: 'freight',
referencedTableName: 'companies',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
},
],
}),
true,
);
await queryRunner.createIndex(
'freight.company_revisions',
new TableIndex({ name: 'idx_company_revisions_company', columnNames: ['company_id'] }),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.company_revisions', true);
}
}

View File

@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Rates created before their commodity's unit_of_measure was flipped kept the
* old bulk-quantity unit, so bookings of a PER_ITEM commodity (e.g. Machinery)
* quoted "per ton". PER_TON and PER_ITEM bill the same stored quantity — only
* the name differs — so renaming is safe. Going forward the cargo-types
* service syncs rates on every uom change; this backfills the drift.
*/
export class SyncBulkRateUnitsToCargoUom3140000000000 implements MigrationInterface {
name = 'SyncBulkRateUnitsToCargoUom3140000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`UPDATE "freight"."rates" r
SET "rate_unit" = 'PER_ITEM'
FROM "freight"."cargo_types" ct
WHERE ct."id" = r."cargo_type_id"
AND ct."unit_of_measure" = 'PER_ITEM'
AND r."rate_unit" = 'PER_TON'`,
);
await queryRunner.query(
`UPDATE "freight"."rates" r
SET "rate_unit" = 'PER_TON'
FROM "freight"."cargo_types" ct
WHERE ct."id" = r."cargo_type_id"
AND ct."unit_of_measure" = 'PER_TON'
AND r."rate_unit" = 'PER_ITEM'`,
);
}
public async down(): Promise<void> {
// Irreversible rename-by-join: the pre-sync unit is not recorded. Both
// units bill identically, so rolling back the code needs no data change.
}
}

View File

@@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
/**
* Per-backoffice-user trade-direction scope (import / export / intercity).
* A user with no row (or all three directions) is unrestricted. Admins
* (super_admin / organization_admin) bypass the scope entirely.
*/
export class CreateUserTradeAccess3150000000000 implements MigrationInterface {
name = 'CreateUserTradeAccess3150000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'user_trade_access',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
// IAM user id (iam.users) — no FK, iam schema is externally owned.
{ name: 'user_id', type: 'uuid', isUnique: true },
// Comma-separated subset of IMPORT,EXPORT,DOMESTIC (simple-array).
{ name: 'directions', type: 'text', default: "''" },
{ name: 'updated_by_id', type: 'uuid', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex(
'freight.user_trade_access',
new TableIndex({
name: 'idx_user_trade_access_user_id',
columnNames: ['user_id'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.user_trade_access', true);
}
}

View File

@@ -0,0 +1,81 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* A normalizeUserInfo bug in VerifaydaService read the raw `address#en` /
* `address#am` claim objects (e.g. `{ "zone#en": "...", "region#en": "...",
* "woreda#en": "..." }`) straight through as if they were strings, so any
* company verified before the fix has `attributes.ownerAddress` /
* `poaAddress` stored as that raw object instead of a formatted string —
* which crashes the portal when it tries to render it as text.
*
* Reformats every affected row's ownerAddress/poaAddress into
* "woreda, zone, region" (falling back to whatever #en fields are present,
* in that preferred order, then any leftover fields), mirroring
* VerifaydaService.formatFaydaAddress. Only touches rows where the field is
* still a jsonb object, so it's idempotent and a no-op once repaired.
*/
export class FixFaydaAddressShape3150000000000 implements MigrationInterface {
name = "FixFaydaAddressShape3150000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION pg_temp.format_fayda_address(addr jsonb)
RETURNS text AS $$
DECLARE
field_order text[] := ARRAY['houseNumber','kebele','woreda','city','subCity','zone','region','postalCode','country'];
f text;
v text;
parts text[] := '{}';
used_keys text[] := '{}';
kv record;
BEGIN
IF addr IS NULL OR jsonb_typeof(addr) != 'object' THEN
RETURN NULL;
END IF;
FOREACH f IN ARRAY field_order LOOP
v := addr ->> (f || '#en');
IF v IS NOT NULL AND trim(v) != '' THEN
parts := array_append(parts, trim(v));
used_keys := array_append(used_keys, f || '#en');
END IF;
END LOOP;
FOR kv IN SELECT * FROM jsonb_each_text(addr) LOOP
IF kv.key LIKE '%#en' AND NOT (kv.key = ANY(used_keys))
AND kv.value IS NOT NULL AND trim(kv.value) != '' THEN
parts := array_append(parts, trim(kv.value));
END IF;
END LOOP;
IF array_length(parts, 1) IS NULL THEN
RETURN NULL;
END IF;
RETURN array_to_string(parts, ', ');
END;
$$ LANGUAGE plpgsql;
UPDATE freight.companies
SET attributes = jsonb_set(
attributes,
'{ownerAddress}',
to_jsonb(pg_temp.format_fayda_address(attributes -> 'ownerAddress'))
)
WHERE jsonb_typeof(attributes -> 'ownerAddress') = 'object';
UPDATE freight.companies
SET attributes = jsonb_set(
attributes,
'{poaAddress}',
to_jsonb(pg_temp.format_fayda_address(attributes -> 'poaAddress'))
)
WHERE jsonb_typeof(attributes -> 'poaAddress') = 'object';
DROP FUNCTION pg_temp.format_fayda_address(jsonb);
`);
}
public async down(): Promise<void> {
// Data repair — not reversible (the original malformed shape isn't worth restoring).
}
}

View File

@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* `freight.payments.paid_at` was created as `date` (CreatePaymentTable) and never
* migrated to `timestamp` alongside its siblings `refunded_at`/`expires_at`
* (UpdatePaymentTimestamp). TypeORM's postgres driver hydrates `date` columns as a
* plain "YYYY-MM-DD" string, not a `Date` — so `PaymentEntity.paidAt` (typed `Date`)
* was actually a string once read back from the DB, and
* `intent.paidAt?.toISOString()` in PaymentService.formatIntentStatus threw
* `TypeError: intent.paidAt.toISOString is not a function`. This hit every
* OTP-confirm response (CAC Bank) because confirmOtp always re-reads the intent
* before formatting the response.
*/
export class FixPaymentPaidAtType3160000000000 implements MigrationInterface {
name = "FixPaymentPaidAtType3160000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN paid_at TYPE timestamp
USING paid_at::timestamp;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.payments
ALTER COLUMN paid_at TYPE date
USING paid_at::date;
`);
}
}

View File

@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Handling a freight type is not the same as handling it in both directions. A
* facility can be equipped to load containers onto a train but have no yard
* space to receive and stage inbound ones, so the origin and destination sides
* are stated independently per freight type.
*
* Backfilled from `handles_container` / `handles_bulk` so every existing row
* keeps its current behaviour: a facility that handles a type today handles it
* on both sides until someone narrows it in the backoffice. New rows default
* false — an unconfigured facility offers nothing rather than silently
* offering everything.
*/
export class YardFacilityOriginDestination3170000000000 implements MigrationInterface {
name = 'YardFacilityOriginDestination3170000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.yard_facilities
ADD COLUMN IF NOT EXISTS has_container_facility_origin boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS has_bulk_facility_origin boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS has_container_facility_destination boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS has_bulk_facility_destination boolean NOT NULL DEFAULT false
`);
await queryRunner.query(`
UPDATE freight.yard_facilities
SET has_container_facility_origin = handles_container,
has_container_facility_destination = handles_container,
has_bulk_facility_origin = handles_bulk,
has_bulk_facility_destination = handles_bulk
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.yard_facilities
DROP COLUMN IF EXISTS has_container_facility_origin,
DROP COLUMN IF EXISTS has_bulk_facility_origin,
DROP COLUMN IF EXISTS has_container_facility_destination,
DROP COLUMN IF EXISTS has_bulk_facility_destination
`);
}
}

View File

@@ -0,0 +1,92 @@
import { ForbiddenException } from '@nestjs/common';
import { LoginAudienceMiddleware } from './login-audience.middleware';
/**
* Touches only the DataSource, so build off the prototype rather than
* standing up a full Nest module — same pattern as
* warehouses/receive-export-paid.spec.ts.
*/
function makeMiddleware(userType: string | undefined) {
const query = jest.fn().mockResolvedValue(userType ? [{ userType }] : []);
const middleware = Object.create(
LoginAudienceMiddleware.prototype,
) as LoginAudienceMiddleware;
(middleware as unknown as { dataSource: unknown }).dataSource = { query };
return middleware;
}
function makeReq(clientApp: string | undefined, email = 'someone@example.com') {
return {
header: (name: string) =>
name.toLowerCase() === 'x-client-app' ? clientApp : undefined,
body: { email },
} as any;
}
describe('LoginAudienceMiddleware', () => {
it('rejects when the client app header is missing', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await expect(
middleware.use(makeReq(undefined), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
expect(next).not.toHaveBeenCalled();
});
it('rejects an unrecognized client app header', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await expect(
middleware.use(makeReq('mobile'), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects an employee account signing in through the portal client', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await expect(
middleware.use(makeReq('portal'), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
expect(next).not.toHaveBeenCalled();
});
it('rejects a customer account signing in through the backoffice client', async () => {
const middleware = makeMiddleware('individual');
const next = jest.fn();
await expect(
middleware.use(makeReq('backoffice'), {} as any, next),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('allows an employee account through the backoffice client', async () => {
const middleware = makeMiddleware('employee');
const next = jest.fn();
await middleware.use(makeReq('backoffice'), {} as any, next);
expect(next).toHaveBeenCalledTimes(1);
});
it('allows a customer account through the portal client', async () => {
const middleware = makeMiddleware('individual');
const next = jest.fn();
await middleware.use(makeReq('portal'), {} as any, next);
expect(next).toHaveBeenCalledTimes(1);
});
it('lets an unknown identifier fall through to the login handler', async () => {
const middleware = makeMiddleware(undefined);
const next = jest.fn();
await middleware.use(makeReq('portal'), {} as any, next);
expect(next).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,58 @@
import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { NextFunction, Request, Response } from 'express';
export const CLIENT_APP_HEADER = 'x-client-app';
// EUserType values from @tria-plc/api-common, duplicated here to avoid
// pulling in the full enum just for this string comparison.
const ALLOWED_USER_TYPES_BY_CLIENT: Record<string, string[]> = {
backoffice: ['employee'],
portal: ['individual', 'external_organization'],
};
/**
* Blocks EDRFREIGHT-415: /auth/login and /auth/mfa-verify match credentials
* against email/username/phone_number only (see vendor
* findUserForLogin), with no check that the account's userType belongs on
* the app that's asking. A backoffice (employee) client presenting a
* customer's credentials — or vice versa — must not get a session.
*/
@Injectable()
export class LoginAudienceMiddleware implements NestMiddleware {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
async use(req: Request, _res: Response, next: NextFunction) {
const clientApp = req.header(CLIENT_APP_HEADER);
const allowedUserTypes = clientApp
? ALLOWED_USER_TYPES_BY_CLIENT[clientApp]
: undefined;
if (!allowedUserTypes) {
throw new ForbiddenException(
`Missing or unrecognized ${CLIENT_APP_HEADER} header`,
);
}
const identifier: unknown = req.body?.email;
if (typeof identifier !== 'string' || !identifier) {
// No identifier to look up — the vendor DTO validation rejects the
// request on its own.
return next();
}
const [user] = await this.dataSource.query(
`SELECT user_type AS "userType" FROM iam.users
WHERE email = $1 OR username = $1 OR phone_number = $1 LIMIT 1`,
[identifier],
);
if (user && !allowedUserTypes.includes(user.userType)) {
throw new ForbiddenException(
`This account cannot sign in through the ${clientApp} application`,
);
}
next();
}
}

View File

@@ -59,6 +59,30 @@ export class BackofficeService {
];
}
/**
* IAM user ids of current employees (any org) holding ANY of the given
* permission keys — used by the notification recipients resolver's
* `permissionKeys` selector for department/role-scoped targeting.
*/
async getEmployeeUserIdsByPermission(
permissionKeys: string[],
): Promise<string[]> {
if (!permissionKeys.length) return [];
const rows: { userId: string | null }[] = await this.employeeRepository
.createQueryBuilder("employee")
.innerJoin("employee.employeePositions", "employeePosition")
.innerJoin("employeePosition.position", "position")
.innerJoin("position.positionPermission", "positionPermission")
.innerJoin("positionPermission.permission", "permission")
.where("employee.isCurrent = :isCurrent", { isCurrent: true })
.andWhere("permission.key IN (:...permissionKeys)", { permissionKeys })
.select("DISTINCT employee.user_id", "userId")
.getRawMany();
return rows
.map((r) => r.userId)
.filter((id): id is string => Boolean(id));
}
async createOrganizationUser(
organizationId: string,
dto: CreateOrganizationUserDto,

View File

@@ -9,7 +9,11 @@ import {
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingView } from "../../common/booking-guards";
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
import { BillingService } from "./billing.service";
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
@@ -18,14 +22,26 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
@BookingView()
@ApiBearerAuth()
export class BillingController {
constructor(private readonly billingService: BillingService) {}
constructor(
private readonly billingService: BillingService,
private readonly userTradeAccessService: UserTradeAccessService,
) {}
@Get("invoices")
@ApiOperation({
summary: "List invoices (paginated, filterable by company/status/search)",
})
findAll(@Query() query: FilterInvoiceDto) {
return this.billingService.findAllPaginated(query);
async findAll(
@Query() query: FilterInvoiceDto,
@CurrentUser() user: TCurrentUser,
) {
// Per-user trade-direction scope, applied via each invoice's source booking.
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
return this.billingService.findAllPaginated({
...query,
tradeDirections: allowed ?? undefined,
});
}
@Get("invoices/:id")

View File

@@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { BillingController } from "./billing.controller";
import { PortalBillingController } from "./portal-billing.controller";
import { PaymentController } from "./payment.controller";
import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module";
import { BillingService } from "./billing.service";
import { DocumentsModule } from "./documents/documents.module";
import { Invoice } from "./entities/invoice.entity";
@@ -19,6 +20,7 @@ import { CompaniesModule } from "../companies/companies.module";
forwardRef(() => PaymentModule),
CompaniesModule,
DocumentsModule,
UserTradeAccessModule,
],
controllers: [BillingController, PortalBillingController, PaymentController],
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],

View File

@@ -470,3 +470,78 @@ describe("BillingService.issuePayable", () => {
expect(manager.update).not.toHaveBeenCalled();
});
});
describe("BillingService — CAC Bank (OTP debit)", () => {
const openInvoice = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
type: "PREPAID",
invoiceNumber: "INV-20260101-00001",
currency: "USD",
balanceAmount: 500,
totalAmount: 500,
paymentId: "intent-1",
dueAt: null,
};
const build = (payment: Record<string, unknown>) => {
const repo = {
findOne: jest.fn().mockResolvedValue(openInvoice),
update: jest.fn().mockResolvedValue(undefined),
};
const service = new BillingService(
{ getRepository: () => repo } as never,
{} as never,
{} as never,
makeEvents() as never,
payment as never,
{} as never,
{} as never,
);
return { service, repo };
};
it("rejects a CAC Bank charge with no payer mobile before calling the gateway", async () => {
const initiate = jest.fn();
const { service } = build({ initiate });
await expect(
service.payInvoice("inv-1", { method: "CAC_BANK" }),
).rejects.toThrow(/payerAccount/);
expect(initiate).not.toHaveBeenCalled();
});
it("does not settle an OTP intent at initiate — the payer still has to confirm", async () => {
const handlePaymentEvent = jest.fn();
const { service } = build({
initiate: jest.fn().mockResolvedValue({
intentId: "intent-1",
immediateSuccess: false,
response: {
intentId: "intent-1",
status: "REQUIRES_ACTION",
clientAction: { type: "COLLECT_OTP", providerOrderId: "cac-1" },
},
}),
handlePaymentEvent,
});
await service.payInvoice("inv-1", {
method: "CAC_BANK",
payerAccount: "77123456",
});
expect(handlePaymentEvent).not.toHaveBeenCalled();
});
it("confirms the OTP against the intent stamped on the invoice", async () => {
const confirmOtp = jest.fn().mockResolvedValue({ status: "SUCCEEDED" });
const { service } = build({ confirmOtp });
await service.confirmInvoiceOtp("inv-1", "123456");
expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456");
});
});

View File

@@ -11,8 +11,9 @@ import { EventEmitter2 } from "@nestjs/event-emitter";
import { DataSource, EntityManager, In } from "typeorm";
import { CompaniesService } from "../companies/companies.service";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto } from "../payment/payments.dto";
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
import {
InvoiceDocumentModel,
InvoiceDocumentService,
@@ -55,6 +56,27 @@ const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Overdue,
];
/**
* Why a non-open invoice can no longer be paid, in the vocabulary the payment service's CBE
* bill-query mapper understands. Kept specific: CBE reads this back to the payer at the counter,
* so "cancelled" must not stand in for "already paid" or "refunded".
*/
function closedInvoiceReason(status: Freight.InvoiceStatus): string {
switch (status) {
case Freight.InvoiceStatus.Paid:
return "ALREADY_PAID";
case Freight.InvoiceStatus.Refunded:
return "REFUNDED";
case Freight.InvoiceStatus.Cancelled:
return "CANCELLED";
case Freight.InvoiceStatus.Expired:
return "EXPIRED";
// Draft — issued to nobody yet, so there is nothing honest to say beyond "not payable".
default:
return "NOT_PAYABLE";
}
}
/** A single line to bill on a generated invoice. */
export interface InvoiceLineInput {
chargeType: string;
@@ -146,6 +168,8 @@ export class BillingService {
search?: string;
page?: number;
pageSize?: number;
/** Per-user trade-direction scope, applied via the source booking. */
tradeDirections?: string[];
} = {},
): Promise<{ items: Invoice[]; total: number }> {
const page = filter.page && filter.page > 0 ? filter.page : 1;
@@ -175,6 +199,14 @@ export class BillingService {
);
}
if (filter.tradeDirections) {
applyBookingRefDirectionScope(
qb,
"invoice.source_id",
filter.tradeDirections,
);
}
const [items, total] = await qb.getManyAndCount();
return { items, total };
}
@@ -352,6 +384,34 @@ export class BillingService {
return this.payInvoice(id, opts);
}
/**
* Submit the CAC Bank OTP for one of the customer's own invoices
* (ownership-checked). Settlement of the invoice happens inside the payment
* service when the OTP succeeds.
*/
async confirmInvoiceOtpForUser(
id: string,
userId: string,
otp: string,
): Promise<IntentStatusDto> {
await this.findByIdForUser(id, userId);
return this.confirmInvoiceOtp(id, otp);
}
/** OTP confirmation by invoice id — the intent is the one stamped at initiate. */
async confirmInvoiceOtp(
invoiceId: string,
otp: string,
): Promise<IntentStatusDto> {
const invoice = await this.dataSource
.getRepository(Invoice)
.findOne({ where: { id: invoiceId } });
if (!invoice?.paymentId) {
throw new NotFoundException("No payment to confirm for this invoice");
}
return this.payment.confirmOtp(invoice.paymentId, otp);
}
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
async documentForUser(
id: string,
@@ -983,6 +1043,17 @@ export class BillingService {
* never fire before the link exists. Throws when the invoice is not found or
* not in an open/payable status.
*/
/**
* Settlement check before expiring a payable order (reconcile-before-expire):
* live-queries the gateway for any settled intent on the source order. Kept
* on billing so the domain never talks to the payment service directly.
*/
reconcilePayable(
sourceId: string,
): Promise<{ paid: boolean; unverifiable: boolean }> {
return this.payment.reconcileShipment(sourceId);
}
async payInvoice(
invoiceId: string,
opts: {
@@ -995,6 +1066,7 @@ export class BillingService {
): Promise<InitiateResponseDto> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { id: invoiceId, status: In(OPEN_STATUSES) },
relations: { company: true },
});
if (!invoice) {
throw new NotFoundException(
@@ -1002,11 +1074,39 @@ export class BillingService {
);
}
// A booking's PREPAID invoice is only payable inside its pay window —
// `dueAt` mirrors booking.paymentDeadline (issuePayable at reserve time).
// Blocking INITIATION here is what makes the deadline real: a payment
// STARTED before this gate but settling late is still honored by the
// expire-time gateway reconcile. Other invoice types keep dueAt display-only.
if (
invoice.source === Freight.InvoiceSource.Booking &&
invoice.type === "PREPAID" &&
invoice.dueAt &&
invoice.dueAt.getTime() <= Date.now()
) {
throw new BadRequestException(
"The payment window for this booking has closed — the reserved wagons " +
"were released. Please book again.",
);
}
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
if (!(amountDue > 0)) {
throw new BadRequestException("Invoice has no outstanding balance.");
}
// CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is
// required up front (the payment service rejects it otherwise, as a 502 here).
if (
(opts.method ?? "").toUpperCase() === "CAC_BANK" &&
!opts.payerAccount?.trim()
) {
throw new BadRequestException(
"payerAccount (mobile number) is required for CAC Bank",
);
}
const result = await this.payment.initiate({
referenceId: invoice.sourceId,
source: invoice.source,
@@ -1023,6 +1123,10 @@ export class BillingService {
method: opts.method ?? "TELEBIRR",
platform: opts.platform,
payerAccount: opts.payerAccount,
// CBE_BILL: payer identity + the invoice's own due date as the bill expiry
// (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §6.4).
payerName: invoice.company?.name,
expiresAt: invoice.dueAt?.toISOString(),
returnUrl: opts.returnUrl,
failureUrl: opts.failureUrl,
});
@@ -1033,17 +1137,24 @@ export class BillingService {
.update({ id: invoice.id }, { paymentId: result.intentId });
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
// billing must not simulate it. Kept commented for local demos only.
if (!result.immediateSuccess) {
await this.payment.handlePaymentEvent({
eventType: "payment.succeeded",
eventId: `demo-${result.intentId}`,
referenceId: invoice.sourceId,
intentId: result.intentId,
providerTxnId: result.providerTxnId,
paidAt: (result.paidAt ?? new Date()).toISOString(),
});
}
// billing must not simulate it. Kept for local demos only.
// An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the
// code — so the demo shortcut must never fire for it. Same for CBE_BILL: its
// bill must stay open until CBE actually settles it via /cbe/payment.
// if (
// !result.immediateSuccess &&
// result.response.clientAction?.type !== "COLLECT_OTP" &&
// opts.method !== "CBE_BILL"
// ) {
// await this.payment.handlePaymentEvent({
// eventType: "payment.succeeded",
// eventId: `demo-${result.intentId}`,
// referenceId: invoice.sourceId,
// intentId: result.intentId,
// providerTxnId: result.providerTxnId,
// paidAt: (result.paidAt ?? new Date()).toISOString(),
// });
// }
if (result.immediateSuccess) {
await this.settleByPaymentId(
@@ -1079,4 +1190,67 @@ export class BillingService {
paidAt,
});
}
/**
* CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check for
* the invoice behind a payment reference. `referenceId` is the gateway intent's referenceId,
* i.e. the invoice `sourceId`. Read-only; called while a CBE teller/app is waiting.
*/
async billQuery(referenceId: string): Promise<{
stillPayable: boolean;
payerName?: string | null;
currentAmountMinor?: number | null;
currency?: string | null;
reason?: string | null;
paymentReason?: string | null;
}> {
const repo = this.dataSource.getRepository(Invoice);
const open = await repo.findOne({
where: { sourceId: referenceId, status: In(OPEN_STATUSES) },
relations: { company: true },
order: { issuedAt: "DESC" },
});
if (open) {
const balance = Math.round(Number(open.balanceAmount ?? open.totalAmount));
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
return {
stillPayable: balance > 0 && !expired,
payerName: open.company?.name ?? null,
currentAmountMinor: balance,
currency: open.currency,
// CBE shows this beside the amount on the confirmation screen — the invoice number
// the payer is holding, not our internal reference.
paymentReason: `Freight invoice ${open.invoiceNumber}`,
// Settled-in-full wins over past-due: an invoice with nothing left to pay is paid, not
// expired, and that is what the payer at the CBE counter must be told.
reason: balance > 0 ? (expired ? "EXPIRED" : null) : "ALREADY_PAID",
};
}
const latest = await repo.findOne({
where: { sourceId: referenceId },
relations: { company: true },
order: { createdAt: "DESC" },
});
// A bill reference whose invoice no longer exists at all — a data problem, not a
// cancellation the payer did anything to cause.
if (!latest) {
return {
stillPayable: false,
payerName: null,
currentAmountMinor: null,
currency: null,
reason: "NOT_FOUND",
};
}
return {
stillPayable: false,
payerName: latest.company?.name ?? null,
currentAmountMinor: Math.round(Number(latest.totalAmount)),
currency: latest.currency,
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
reason: closedInvoiceReason(latest.status),
};
}
}

View File

@@ -1,5 +1,13 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsNotEmpty, IsOptional, IsString } from "class-validator";
/** OTP submitted for a COLLECT_OTP provider (CAC Bank). */
export class ConfirmOtpDto {
@ApiProperty({ description: "One-time password SMSed by the bank." })
@IsString()
@IsNotEmpty()
otp!: string;
}
/** Gateway options for paying an invoice from the customer portal. */
export class PayInvoiceDto {

View File

@@ -18,7 +18,7 @@ import {
} from "../../common/resolve-auth-user-id";
import { sendPdf } from "./billing.controller";
import { BillingService } from "./billing.service";
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
import { ConfirmOtpDto, PayInvoiceDto } from "./dto/pay-invoice.dto";
/**
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
@@ -96,4 +96,20 @@ export class PortalBillingController {
failureUrl: dto.failureUrl,
});
}
@Post("my-invoices/:id/confirm")
@ApiOperation({
summary: "Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices",
})
confirmOtp(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto: ConfirmOtpDto,
) {
return this.billingService.confirmInvoiceOtpForUser(
id,
resolveAuthUserId(user),
dto.otp,
);
}
}

View File

@@ -112,15 +112,9 @@ export class BookingContractService {
const templateKey = this.templateResolver.resolve(booking);
const summary = this.buildContractSummary(booking);
// PDF rendering (Puppeteer/Chromium) is best-effort and must NOT block the contract
// from becoming ready — the document is (re)rendered lazily on view/download.
try {
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
} catch (err) {
this.logger.warn(
`Contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download once Chromium is available.`,
);
}
// No eager PDF render here: streamContract re-renders the document on every
// view/download, so rendering now only adds a Chromium launch (seconds, or a
// 60s asset-load hang) inside the staff-accept request.
const now = new Date();
const updated = await this.bookingsRepository.update(bookingId, {
@@ -132,6 +126,31 @@ export class BookingContractService {
return updated!;
}
/**
* Government bookings skip the whole customer contract flow (approve →
* CONTRACT_READY → sign chain): their contract is stamped server-side at
* creation/expedite WITHOUT touching booking status — the booking is already
* PAID/allocatable and the contract can be signed at any time. Idempotent.
*/
async generateContractForGovernment(bookingId: string): Promise<void> {
const booking = await this.requireBooking(bookingId);
if (!booking.isGovernment || booking.contractGeneratedAt) return;
const templateKey = this.templateResolver.resolve(booking);
await this.bookingsRepository.update(bookingId, {
contractSummary: this.buildContractSummary(booking),
contractTemplateKey: templateKey,
contractGeneratedAt: new Date(),
} as never);
// Render the PDF eagerly but NEVER block creation on it — Chromium can take
// seconds (or hang on assets); the document re-renders on view/download.
void this.upsertContractPdf(bookingId, booking.reference, templateKey).catch(
(err) =>
this.logger.warn(
`Government contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`,
),
);
}
async streamContract(bookingId: string) {
const booking = await this.requireBooking(bookingId);
const templateKey =
@@ -152,8 +171,13 @@ export class BookingContractService {
const booking = await this.requireBooking(bookingId);
const role = dto.role as ContractSignerRole;
// Government contracts are order-free and status-free: either party may
// sign at any time (each once) — the booking is already expedited past the
// customer contract flow, so no status gate applies.
if (role === 'CUSTOMER') {
if (!booking.isGovernment) {
assertBookingStatus(booking, ['CONTRACT_READY']);
}
const existing = await this.bookingsRepository.findContractSignature(
bookingId,
'CUSTOMER',
@@ -162,7 +186,9 @@ export class BookingContractService {
throw new BadRequestException('Customer has already signed this contract');
}
} else {
if (!booking.isGovernment) {
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
}
const existing = await this.bookingsRepository.findContractSignature(
bookingId,
'STAFF',
@@ -235,20 +261,30 @@ export class BookingContractService {
);
if (role === 'CUSTOMER') {
updates.status = 'SIGNED_CUSTOMER';
updates.customerSignedAt = now;
// Government bookings keep their operational status (PAID) — a signature
// must never pull them back into the customer workflow.
if (!booking.isGovernment) updates.status = 'SIGNED_CUSTOMER';
} else {
updates.fullyExecutedAt = now;
updates.marketingApprovedAt = now;
updates.marketingApprovedById = options.signerUserId ?? null;
if (!booking.isGovernment) {
updates.lockedAt = now;
updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED';
}
}
const updated = await this.bookingsRepository.update(bookingId, updates as never);
// Only the non-clearance (legacy/domestic) path enters the batch pipeline now;
// clearance bookings enter operations after the GL document gate.
if (role === 'STAFF' && !clearanceCode && updated?.trainScheduleId) {
// clearance bookings enter operations after the GL document gate. Government
// bookings are already in the pool from expedite — signing changes nothing.
if (
role === 'STAFF' &&
!booking.isGovernment &&
!clearanceCode &&
updated?.trainScheduleId
) {
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
}
try {

View File

@@ -0,0 +1,76 @@
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import type { Booking } from './entities/booking.entity';
/**
* Who hears "Operations wants changes" depends on who owns the booking. A
* customs (Path B) booking is created BY GL Ethiopia on the customer's behalf —
* the customer can neither edit nor resubmit it, so the note has to reach the GL
* who made it, not the portal.
*/
describe('BookingLifecycleNotifierService — operation changes requested', () => {
const booking = (over: Partial<Booking> = {}): Booking =>
({
id: 'b-1',
reference: 'BKG-0001',
companyId: 'co-1',
contractId: 'ctr-1',
createdByRole: 'CUSTOMER',
company: { email: 'customer@example.com' },
...over,
}) as Booking;
let notifications: { directSend: jest.Mock };
let inbox: { notify: jest.Mock };
let service: BookingLifecycleNotifierService;
const flush = () => new Promise((resolve) => setImmediate(resolve));
beforeEach(() => {
notifications = { directSend: jest.fn().mockResolvedValue(undefined) };
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
service = new BookingLifecycleNotifierService(
notifications as never,
inbox as never,
{ query: jest.fn().mockResolvedValue([{ phone: '+251900000000' }]) } as never,
);
});
it('sends a GL-created booking back to the GL who created it, not the customer', async () => {
service.operationChangesRequested(
booking({ createdByRole: 'GL_ET', createdByUserId: 'gl-user-1' }),
'Cargo weight does not match the declaration',
);
await flush();
expect(inbox.notify).toHaveBeenCalledTimes(1);
const sent = inbox.notify.mock.calls[0][0];
expect(sent.recipients).toEqual({ userIds: ['gl-user-1'] });
expect(sent.audience).toBe('BACKOFFICE');
expect(sent.body).toContain('Cargo weight does not match the declaration');
// Deep-links the clearance page GL works from, not the portal booking.
expect(sent.link).toBe('/dashboard/contracts/clearance/ctr-1');
// The customer is not told to fix something they cannot touch.
expect(notifications.directSend).not.toHaveBeenCalled();
});
it('still tells the customer when the booking is their own', async () => {
service.operationChangesRequested(booking(), 'Please attach the packing list');
await flush();
const sent = inbox.notify.mock.calls[0][0];
expect(sent.recipients).toEqual({ companyId: 'co-1' });
expect(sent.audience).toBe('PORTAL');
expect(sent.link).toBe('/bookings/b-1');
expect(notifications.directSend).toHaveBeenCalled();
});
it('falls back to the customer when the GL creator is unknown (legacy rows)', async () => {
service.operationChangesRequested(
booking({ createdByRole: 'GL_ET', createdByUserId: null }),
'Fix the declaration',
);
await flush();
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' });
});
});

View File

@@ -179,8 +179,35 @@ 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
* behalf — the customer cannot edit or resubmit it, so telling them to "update
* from the portal" is a dead end. Those go to the GL who created it, linking
* the contract clearance page they work from. Everything else (customer-made
* bookings) keeps the portal message.
*/
operationChangesRequested(b: Booking, note: string): void {
if (b.createdByRole === 'GL_ET' && b.createdByUserId) {
const msg =
`Operations returned booking ${b.reference} for changes: ${note}. ` +
`Address it on the contract clearance page and resubmit to Operations.`;
this.logger.log(`OPERATION CHANGES REQUESTED (to GL) — ${this.ref(b)}`);
void this.inbox.notify({
recipients: { userIds: [b.createdByUserId] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.BOOKING_STATUS,
title: `Booking ${b.reference} needs changes`,
body: msg,
link: b.contractId
? `/dashboard/contracts/clearance/${b.contractId}`
: `/dashboard/bookings/${b.id}/clearance`,
data: { bookingId: b.id, reference: b.reference, note },
});
return;
}
const msg =
`Your operation request for booking ${b.reference} needs changes: ${note}. ` +
`Please update and resubmit from the portal.`;
@@ -197,6 +224,24 @@ export class BookingLifecycleNotifierService {
this.inApp(b, 'Operation request accepted', msg);
}
/**
* GL Ethiopia created this booking on the customer's behalf. On a customs
* (Path B) contract the customer never books themselves, so without this they
* would have no signal that their shipment now exists and is priced.
*/
createdByGlForCustomer(b: Booking): void {
const total = Number(b.totalAmount ?? 0);
const priced =
total > 0
? ` The total is ${total.toLocaleString()} ${b.paymentCurrency}.`
: '';
const msg =
`Global Logistics has created shipment ${b.reference} under your contract.${priced} ` +
`You can review it in the portal.`;
void this.notifyContact(b, msg, 'CREATED BY GL');
this.inApp(b, 'Shipment created for you', msg);
}
/** Shipment started → in transit. */
inTransit(b: Booking): void {
const msg = `Your shipment for booking ${b.reference} is now in transit.`;
@@ -218,6 +263,36 @@ export class BookingLifecycleNotifierService {
this.inApp(b, 'Booking cancelled', msg);
}
/**
* GL Ethiopia asked Djibouti to name the transit officer. Staff-only, and
* deep-linked to the Djibouti clearance page where the name is entered — the
* customs declaration is blocked until they answer.
*/
transitAssigneeRequested(b: Booking, note: string | null): void {
const msg =
`GL Ethiopia needs a transit assignee for shipment ${b.reference} before ` +
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/gl-djibouti/clearance/${b.id}`,
});
}
/** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */
transitAssigneeAssigned(b: Booking, assignee: string, previous: string | null): void {
const msg = previous
? `GL Djibouti changed the transit assignee for shipment ${b.reference} from ` +
`"${previous}" to "${assignee}".`
: `GL Djibouti assigned ${assignee} to handle shipment ${b.reference} in transit. ` +
`The customs declaration can now be filed.`;
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`,
});
}
// ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax — the customer must pay and upload the slip. */
@@ -242,11 +317,11 @@ export class BookingLifecycleNotifierService {
});
}
/** GL raised the final (post-offload) invoice — customer pays + uploads slip. */
/** GL raised the final (post-offload) invoice — customer approves, pays, uploads slip. */
finalInvoiceCreated(b: Booking, amount: number, currency: string): void {
const msg =
`A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` +
`Please pay and upload the payment slip from the portal.`;
`A final invoice of ${amount} ${currency} has been raised for booking ${b.reference}. ` +
`Please review and approve it in the portal, then pay and upload the payment slip.`;
void this.notifyContact(b, msg, 'FINAL INVOICE');
this.inApp(b, 'Final invoice issued', msg, {
type: NotificationType.INVOICE_ISSUED,
@@ -286,6 +361,15 @@ export class BookingLifecycleNotifierService {
);
}
/** Customer approved the GL Djibouti final invoice — payment slip can follow. */
finalInvoiceApprovedToStaff(b: Booking): void {
this.inAppStaff(
b,
'Final invoice approved',
`The customer approved the final invoice for booking ${this.ref(b)} — awaiting payment slip.`,
);
}
/** Customer signed the booking contract. */
customerSignedToStaff(b: Booking): void {
this.inAppStaff(
@@ -317,6 +401,32 @@ export class BookingLifecycleNotifierService {
);
}
/** GL Ethiopia sent a draft customs declaration — the customer must accept or request a change. */
draftDeclarationReady(b: Booking, price: number, currency: string): void {
const msg =
`A draft customs declaration for booking ${b.reference} is ready for your review — ` +
`estimated price ${price} ${currency}. Please accept it or request a change from the portal.`;
void this.notifyContact(b, msg, 'DRAFT DECLARATION READY');
this.inApp(b, 'Draft declaration ready for review', msg, {
type: NotificationType.DOCUMENT_ACTION,
});
}
/**
* The customer asked for a change on the draft declaration. This goes to
* STAFF, not the customer: GL Ethiopia is the one who has to send a
* corrected draft, and the clearance page is where they do it.
*/
draftDeclarationChangeRequested(b: Booking, note: string): void {
const msg =
`The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` +
`"${note}". Send a corrected draft from the clearance page.`;
this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, {
type: NotificationType.CLEARANCE_REVIEW,
link: `/dashboard/bookings/${b.id}/clearance`,
});
}
/** Customer uploaded a duty/tax payment slip — GL verifies it. */
dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void {
const label =

View File

@@ -473,3 +473,247 @@ describe('BookingPricingService — customs clearance fee billed on the booking
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
});
});
/**
* Bulk freight bills in the commodity's own unit: tonnage for a weighed
* commodity (PER_TON), item count for a counted one (PER_ITEM). Both read the
* booking's cargo amount; PER_WAGON bills the wagons the cargo occupies.
*/
describe('BookingPricingService — bulk base freight units', () => {
const DJ = 'yard-dj-bulk';
const DIRE_B = 'yard-dire-bulk';
const bulkRate = (overrides: Partial<Rate> = {}): Rate =>
({
id: 'rate-bulk',
rateType: 'BULK_IMPORT',
appliesTo: 'BULK',
trigger: 'ALWAYS',
currency: 'USD',
rateValue: 200,
rateUnit: 'PER_ITEM',
status: 'LIVE',
containerTypeId: null,
cargoTypeId: null,
tradeDirection: 'IMPORT',
originYardId: DJ,
destinationYardId: DIRE_B,
...overrides,
}) as Rate;
const makeService = (liveRates: Rate[], wagonCapacity?: number) =>
new BookingPricingService(
{
calculateWagonCount: jest.fn().mockResolvedValue(0),
findContractRateSnapshots: jest.fn().mockResolvedValue([]),
} as never,
{
evaluate: jest.fn().mockResolvedValue({
priorityScore: 0,
appliedModifiers: [],
containerWeightResults: [],
warnings: [],
hardBlocked: [],
requiresDirectorApproval: false,
}),
} as never,
{ findById: jest.fn() } as never,
{ findLiveRates: jest.fn().mockResolvedValue(liveRates) } as never,
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{
findById: jest.fn().mockResolvedValue({
wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [],
}),
} as never,
);
// 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here.
const booking = (overrides: Record<string, unknown> = {}) =>
({
id: 'b-bulk',
freightType: 'BULK',
tradeDirection: 'IMPORT',
paymentCurrency: 'USD',
cargoTypeId: 'cargo-machinery',
cargoTotalWeightVgm: 12,
originYardId: DJ,
destinationYardId: DIRE_B,
bookingContainers: [],
...overrides,
}) as unknown as Booking;
it('bills a PER_ITEM rate on the item count', async () => {
const result = await makeService([bulkRate()]).computePriceForBooking(booking());
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
expect(line!.unit).toBe('PER_ITEM');
expect(line!.quantity).toBe(12);
expect(line!.amount).toBe(2400);
});
it('bills a PER_TON rate on the tonnage', async () => {
const result = await makeService([
bulkRate({ rateUnit: 'PER_TON', rateValue: 35 }),
]).computePriceForBooking(booking({ cargoTotalWeightVgm: 120 }));
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
expect(line!.unit).toBe('PER_TON');
expect(line!.amount).toBe(35 * 120);
});
it('bills a PER_WAGON rate on the wagons the cargo occupies, not zero', async () => {
const result = await makeService(
[bulkRate({ rateUnit: 'PER_WAGON', rateValue: 500 })],
60,
).computePriceForBooking(booking({ cargoTotalWeightVgm: 120 }));
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
expect(line!.unit).toBe('PER_WAGON');
expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon
expect(line!.amount).toBe(1000);
});
it('prices off the rate scoped to the booking commodity, not another one', async () => {
const result = await makeService([
bulkRate({ id: 'rate-wheat', cargoTypeId: 'cargo-wheat', rateUnit: 'PER_TON', rateValue: 35 }),
bulkRate({ id: 'rate-machinery', cargoTypeId: 'cargo-machinery', rateValue: 200 }),
]).computePriceForBooking(booking());
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
expect(line!.unit).toBe('PER_ITEM');
expect(line!.amount).toBe(2400);
});
it('hard-blocks when the leg only carries another commoditys rate', async () => {
const result = await makeService([
bulkRate({ id: 'rate-wheat', cargoTypeId: 'cargo-wheat' }),
]).computePriceForBooking(booking());
expect(result.lineItems.some((l) => l.code === 'BULK_IMPORT')).toBe(false);
expect(result.hardBlocked.some((m) => m.includes('rate is configured'))).toBe(true);
});
});
/**
* A PER_WAGON container rate bills the wagons the LINE occupies — two 20ft share
* one wagon, a 40ft takes a whole one. Regression cases taken from real
* bookings on Doraleh → Gelan, where the 20ft line was being charged for the
* 40ft line's wagons as well.
*/
describe('BookingPricingService — PER_WAGON container freight', () => {
const DJ = 'yard-dj-w';
const ET = 'yard-et-w';
const perWagon20: Rate = {
id: 'rate-20-wagon',
rateType: 'CONTAINER_IMPORT',
currency: 'USD',
rateValue: 1690,
rateUnit: 'PER_WAGON',
status: 'LIVE',
containerTypeId: 'ct-20',
originYardId: DJ,
destinationYardId: ET,
} as Rate;
const perContainer40: Rate = {
...perWagon20,
id: 'rate-40-container',
rateValue: 1676,
rateUnit: 'PER_CONTAINER',
containerTypeId: 'ct-40',
} as Rate;
const makeService = () =>
new BookingPricingService(
{
// Booking-wide aggregate — deliberately larger than any single line, so
// a regression that reads it instead of the line's own wagons shows up.
calculateWagonCount: jest.fn().mockResolvedValue(5),
findContractRateSnapshots: jest.fn().mockResolvedValue([]),
} as never,
{
evaluate: jest.fn().mockResolvedValue({
priorityScore: 0,
appliedModifiers: [],
containerWeightResults: [],
warnings: [],
hardBlocked: [],
requiresDirectorApproval: false,
}),
} as never,
{
findById: jest.fn(async (id: string) => ({
id,
sizeFt: id === 'ct-40' ? 40 : 20,
isReefer: false,
code: id === 'ct-40' ? 'C40' : 'C20',
label: id === 'ct-40' ? 'C40' : 'C20',
})),
} as never,
{ findLiveRates: jest.fn().mockResolvedValue([perWagon20, perContainer40]) } as never,
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{ findById: jest.fn() } as never,
);
const booking = (
lines: Array<{ containerTypeId: string; quantity: number }>,
) =>
({
id: 'b-wagon',
freightType: 'CONTAINER',
tradeDirection: 'IMPORT',
paymentCurrency: 'USD',
originYardId: DJ,
destinationYardId: ET,
bookingContainers: lines.map((l) => ({
containerTypeId: l.containerTypeId,
quantity: l.quantity,
vgmPerUnitTons: 10,
})),
}) as unknown as Booking;
const price = async (
lines: Array<{ containerTypeId: string; quantity: number }>,
) => {
const service = makeService();
const result = await service.computePriceForBooking(booking(lines));
return result.lineItems.filter((l) => l.code === 'CONTAINER_IMPORT');
};
it('bills 2× 20ft as one wagon', async () => {
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 2 }]);
expect(line.unit).toBe('PER_WAGON');
expect(line.quantity).toBe(1);
expect(line.amount).toBe(1690);
});
it('bills 10× 20ft as five wagons', async () => {
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 10 }]);
expect(line.quantity).toBe(5);
expect(line.amount).toBe(5 * 1690);
});
it('does not charge the 20ft line for the 40ft lines wagons', async () => {
const lines = await price([
{ containerTypeId: 'ct-20', quantity: 4 },
{ containerTypeId: 'ct-40', quantity: 1 },
]);
const twenty = lines.find((l) => l.description.startsWith('C20'))!;
const forty = lines.find((l) => l.description.startsWith('C40'))!;
// 4× 20ft = 2 wagons, NOT the booking-wide 3.
expect(twenty.quantity).toBe(2);
expect(twenty.amount).toBe(2 * 1690);
// The 40ft line keeps billing per container.
expect(forty.quantity).toBe(1);
expect(forty.amount).toBe(1676);
});
it('rounds an odd 20ft count up to a whole wagon', async () => {
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 5 }]);
expect(line.quantity).toBe(3);
expect(line.amount).toBe(3 * 1690);
});
});

View File

@@ -4,6 +4,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RatesService } from '../rule-engine/services/rates.service';
import { Rate } from '../rule-engine/entities/rate.entity';
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ExchangeService } from '@edr/api-common';
import {
@@ -15,6 +16,7 @@ import {
containersPerWagonForSize,
wagonsPerUnitForSize,
} from '../rule-engine/container-type.util';
import { bulkItemWagonsForAllowedTypes } from '../train-scheduling/train-capacity.util';
import { BookingsRepository } from './bookings.repository';
import { wagonRemainder } from './consolidation.service';
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
@@ -199,7 +201,7 @@ export class BookingPricingService {
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
const frozen = isDerived
? null
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency);
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, usdToEtb);
const unitAmount = frozen
? Number(frozen.unitPrice)
: isEtbBooking
@@ -529,8 +531,6 @@ export class BookingPricingService {
const usedRatesMap = new Map<string, Rate>();
const warnings: string[] = [];
const blocked: string[] = [];
const wagonCount = await this.resolveWagonCount(booking);
for (const container of evalInput.containers) {
const rate = this.pickRate(
liveRates,
@@ -540,14 +540,15 @@ export class BookingPricingService {
booking.originYardId,
booking.destinationYardId,
);
// H15: frozen contract rate for this container size, when present — its
// unitPrice is already in the booking currency (no USD→currency convert).
// It also stands on its own: a contract line prices off the agreed rate
// even when nobody configured a live rate for this leg + type yet.
// H15: frozen contract rate for this container size, when present —
// converted into the booking currency by frozenRateForContainer. It also
// stands on its own: a contract line prices off the agreed rate even when
// nobody configured a live rate for this leg + type yet.
const frozen = await this.frozenRateForContainer(
frozenRates,
container.containerTypeId,
paymentCurrency,
usdToEtb,
);
const label = await this.containerTypeLabel(container.containerTypeId);
if (!rate && !frozen) {
@@ -565,6 +566,10 @@ export class BookingPricingService {
}
const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER';
// A PER_WAGON line bills the wagons THIS line occupies (two 20ft share
// one), never the booking-wide count — otherwise a booking with a 20ft
// and a 40ft line charges each line for the other's wagons too.
const lineWagons = await this.lineWagonCount(container);
let amount: number;
let unitAmount: number;
if (frozen) {
@@ -573,11 +578,11 @@ export class BookingPricingService {
rateUnit,
unitAmount,
container.quantity,
wagonCount,
lineWagons,
);
} else {
const unitUsd = Number(rate!.rateValue);
const usdAmount = this.amountForRate(rate!, container.quantity, wagonCount);
const usdAmount = this.amountForRate(rate!, container.quantity, lineWagons);
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
}
@@ -588,7 +593,7 @@ export class BookingPricingService {
amount,
unitAmount,
unit: rateUnit,
quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount),
quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, lineWagons),
currency: paymentCurrency,
});
}
@@ -600,7 +605,10 @@ export class BookingPricingService {
// container type above or stay unpriced with a warning — falling back to
// a corridor rate of a DIFFERENT container type billed once (qty 1) is
// how a 38-container booking was invoiced 40 USD instead of 1900.
const fallback = liveRates.find(
// Within the leg, the rate scoped to the booking's own commodity wins over
// the commodity-wide catch-all — a per-item machinery rate must never
// price a per-ton wheat booking (or the reverse).
const onLeg = liveRates.filter(
(r) =>
r.rateType === rateType &&
r.currency === 'USD' &&
@@ -608,15 +616,29 @@ export class BookingPricingService {
r.originYardId === booking.originYardId &&
r.destinationYardId === booking.destinationYardId,
);
const fallback =
(booking.cargoTypeId
? onLeg.find((r) => r.cargoTypeId === booking.cargoTypeId)
: undefined) ?? onLeg.find((r) => !r.cargoTypeId);
if (fallback) {
usedRatesMap.set(fallback.id, fallback);
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
// Bulk has no container lines to count wagons from, so a PER_WAGON bulk
// rate bills the tonnage-derived estimate for the WHOLE booking (there
// is only ever this one line).
const wagonCount = isBulk
? Number(evalInput.bulkWagons ?? 0) ||
(await this.bulkWagonCount(booking)) ||
0
: await this.resolveWagonCount(booking);
// Bulk quantity is stored in the commodity's own unit — tonnes for a
// PER_TON commodity, item count for a PER_ITEM one.
const bulkQuantity = Number(booking.cargoTotalWeightVgm ?? 0);
const quantity =
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
isBulk && isBulkQuantityUnit(fallback.rateUnit) ? Math.max(bulkQuantity, 0) : 1;
const unitUsd = Number(fallback.rateValue);
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
const frozen = isBulk
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency)
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, usdToEtb)
: null;
let amount: number;
let unitAmount: number;
@@ -719,6 +741,7 @@ export class BookingPricingService {
quantity = containerCount;
break;
case 'PER_TON':
case 'PER_ITEM':
quantity = bulkTons;
break;
case 'FLAT':
@@ -727,12 +750,13 @@ export class BookingPricingService {
break;
}
// H15: frozen mile rate (already in booking currency) when the contract
// has one; else the live USD rate converted as before.
// H15: frozen mile rate (converted into the booking currency) when the
// contract has one; else the live USD rate converted as before.
const frozen = this.frozenRateByCode(
frozenRates,
leg.rateType,
paymentCurrency,
usdToEtb,
);
let amount: number;
let unitAmount: number;
@@ -764,6 +788,34 @@ export class BookingPricingService {
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
}
/**
* Wagons ONE container line occupies: two 20ft share a wagon, a 40ft takes a
* whole one. This — not the booking-wide total — is what a PER_WAGON base
* freight line bills, so a booking of 4×20ft + 1×40ft charges the 20ft line
* for 2 wagons and the 40ft line for its own 1, instead of billing each line
* for all 3.
*/
private async lineWagonCount(container: {
containerTypeId: string;
quantity: number;
wagonsPerUnit?: number;
}): Promise<number> {
let perUnit = container.wagonsPerUnit;
if (perUnit == null) {
// Preview bookings build their eval input without the fraction — read it
// off the container type instead of assuming one wagon per box.
try {
const ct = await this.containerTypesService.findById(
container.containerTypeId,
);
perUnit = wagonsPerUnitForSize(Number(ct.sizeFt));
} catch {
perUnit = 1; // unknown type: never under-bill
}
}
return Math.max(1, Math.ceil(container.quantity * perUnit));
}
/**
* Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate;
* an unsaved preview booking (no id) sums the wagonsRequired already computed
@@ -804,6 +856,7 @@ export class BookingPricingService {
return 1;
case 'PER_CONTAINER':
case 'PER_TON':
case 'PER_ITEM':
default:
return quantity;
}
@@ -860,6 +913,7 @@ export class BookingPricingService {
case 'PER_WAGON':
return unitValue * wagonCount;
case 'PER_TON':
case 'PER_ITEM':
return unitValue * quantity;
case 'FLAT':
return unitValue;
@@ -889,20 +943,45 @@ export class BookingPricingService {
}
/**
* The frozen snapshot for a rate code, or null when there is none, its price
* is negative, or it is in a different currency than the booking (in which
* case the live-rate path is safer than a mis-converted frozen price).
* The frozen snapshot for a rate code, expressed in the BOOKING's currency.
*
* A contract quotes in USD and freezes USD unit prices; the customer chooses
* the billing currency per booking. So a currency mismatch is the normal case
* now, not an error — the snapshot is converted rather than discarded. (It
* previously returned null on mismatch, which silently dropped the agreed
* contract price and re-priced the booking at whatever the live rate had
* drifted to.) Grandfathered ETB contracts convert the other way for the same
* reason.
*
* Returns null only when there is no snapshot or its price is unusable.
*/
private frozenRateByCode(
frozenRates: Map<string, ContractRateSnapshot> | null,
code: string,
bookingCurrency: string,
usdToEtb: number,
): ContractRateSnapshot | null {
const snap = frozenRates?.get(code);
if (!snap) return null;
if (snap.currency !== bookingCurrency) return null;
if (!(Number(snap.unitPrice) >= 0)) return null;
return snap;
const unitPrice = Number(snap.unitPrice);
if (!(unitPrice >= 0)) return null;
if (snap.currency === bookingCurrency) return snap;
// Only USD <-> ETB exist; a rate of 0/NaN would silently zero the price.
if (!(usdToEtb > 0)) return null;
const converted =
snap.currency === 'USD' && bookingCurrency === 'ETB'
? Math.round(unitPrice * usdToEtb)
: snap.currency === 'ETB' && bookingCurrency === 'USD'
? unitPrice / usdToEtb
: null;
if (converted == null) return null;
// A copy — the snapshot rows are shared across the pricing pass.
return Object.assign(Object.create(Object.getPrototypeOf(snap)), snap, {
unitPrice: converted,
currency: bookingCurrency,
}) as ContractRateSnapshot;
}
/**
@@ -914,6 +993,7 @@ export class BookingPricingService {
frozenRates: Map<string, ContractRateSnapshot> | null,
containerTypeId: string,
bookingCurrency: string,
usdToEtb: number,
): Promise<ContractRateSnapshot | null> {
if (!frozenRates) return null;
let sizeFt: number | null = null;
@@ -923,7 +1003,7 @@ export class BookingPricingService {
return null;
}
if (!sizeFt) return null;
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, usdToEtb);
}
/**
@@ -966,7 +1046,7 @@ export class BookingPricingService {
const hasPerSizeSnapshot =
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
if (legacyFlat && !hasPerSizeSnapshot) {
const amount = Number(legacyFlat.unitPrice);
if (amount > 0) {
@@ -995,7 +1075,7 @@ export class BookingPricingService {
// unknown type — falls through to the live per-type lookup below
}
const frozen = sizeFt
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency)
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency, usdToEtb)
: null;
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
if (!frozen && !live) {
@@ -1030,7 +1110,7 @@ export class BookingPricingService {
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
// Live lookup: the rate scoped to the booking's commodity wins; a
// commodity-less rate (legacy) is the catch-all fallback.
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
const live =
(booking.cargoTypeId
? onLeg.find(
@@ -1044,7 +1124,7 @@ export class BookingPricingService {
const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit;
const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue));
let billedQty = 1;
if (unit === 'PER_TON') {
if (isBulkQuantityUnit(unit)) {
billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0));
} else if (unit === 'PER_WAGON') {
const wagons = await this.bulkWagonCount(booking);
@@ -1081,6 +1161,8 @@ export class BookingPricingService {
return 'PER_WAGON';
case 'per_ton':
return 'PER_TON';
case 'per_item':
return 'PER_ITEM';
case 'per_container':
return 'PER_CONTAINER';
default:
@@ -1105,6 +1187,11 @@ export class BookingPricingService {
...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0),
);
if (!(capacity > 0)) return null;
// Break-bulk (PER_ITEM): `tons` above is the item count; size by
// indivisible items instead of pretending the count is tonnage. Best
// count across allowed wagon types, each capped by its items-fit.
const byItems = bulkItemWagonsForAllowedTypes(booking, cargo, capacity);
if (byItems > 0) return byItems;
return Math.max(1, Math.ceil(tons / capacity));
} catch {
return null;

View File

@@ -22,6 +22,7 @@ import {
IYardsRepository,
YARDS_REPOSITORY,
} from "../rule-engine/interfaces/yards.repository.interface";
import { YardFacilitiesService } from "../rule-engine/services/yard-facilities.service";
import {
BookingReferenceCargoTypeChildDto,
BookingReferenceCargoTypeGroupDto,
@@ -33,41 +34,86 @@ import {
BookingReferenceYardDto,
} from "./dto/booking-reference-data.dto";
/**
* Reference cargo tree: top-level groups, each carrying its selectable
* commodities.
*
* `cargo_types` is an arbitrary-depth tree (Bulk → Steel Billet → S1 → …), but
* only a LEAF is a real commodity — an intermediate node is a container for
* finer types, and booking against it would be ambiguous. So each group's
* `children` are all of its leaf descendants, flattened, whatever the depth.
* Deep leaves carry their path below the group ("Steel Billet → S1") so a
* generically-named leaf still reads unambiguously in a dropdown.
*
* A group with no active descendants is its own leaf and is emitted as its
* single child — otherwise it is selectable as a group but offers no commodity,
* which dead-ends every form that requires one.
*/
export function buildCargoTypeTree(
rows: CargoType[],
): BookingReferenceCargoTypeGroupDto[] {
const active = rows.filter((r) => r.isActive);
const parents = active
.filter((r) => !r.parentGroupId)
.sort(
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
);
const byOrder = (a: CargoType, b: CargoType) =>
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code);
const childrenOf = new Map<string, CargoType[]>();
for (const row of active) {
if (!row.parentGroupId) continue;
const siblings = childrenOf.get(row.parentGroupId) ?? [];
siblings.push(row);
childrenOf.set(row.parentGroupId, siblings);
}
for (const siblings of childrenOf.values()) siblings.sort(byOrder);
const parents = active.filter((r) => !r.parentGroupId).sort(byOrder);
/** Depth-first leaf walk; `trail` is the path below the group. */
const collectLeaves = (
node: CargoType,
trail: string[],
seen: Set<string>,
): BookingReferenceCargoTypeChildDto[] => {
// Admin-entered parent pointers could in principle cycle — never loop.
if (seen.has(node.id)) return [];
seen.add(node.id);
const kids = childrenOf.get(node.id) ?? [];
if (kids.length === 0) {
return [
{
id: node.id,
name: [...trail, node.cargoTypeName].join(" → "),
code: node.code,
unit_of_measure: node.unitOfMeasure ?? null,
},
];
}
const nextTrail = [...trail, node.cargoTypeName];
return kids.flatMap((kid) => collectLeaves(kid, nextTrail, seen));
};
return parents.map((parent) => {
const children = active
.filter((r) => r.parentGroupId === parent.id)
.sort(
(a, b) =>
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
)
.map(
(child): BookingReferenceCargoTypeChildDto => ({
id: child.id,
name: child.cargoTypeName,
code: child.code,
unit_of_measure: child.unitOfMeasure ?? null,
}),
);
const group: BookingReferenceCargoTypeGroupDto = {
const kids = childrenOf.get(parent.id) ?? [];
const children =
kids.length === 0
? // The group itself is the commodity.
[
{
id: parent.id,
name: parent.cargoTypeName,
code: parent.code,
unit_of_measure: parent.unitOfMeasure ?? null,
},
]
: kids.flatMap((kid) => collectLeaves(kid, [], new Set<string>()));
return {
id: parent.id,
name: parent.cargoTypeName,
code: parent.code,
children,
};
if (children.length > 0) {
group.children = children;
}
return group;
});
}
@@ -125,11 +171,18 @@ export class BookingReferenceDataService {
private readonly shippingLinesRepository: IShippingLinesRepository,
@Inject(CARGO_TYPES_REPOSITORY)
private readonly cargoTypesRepository: ICargoTypesRepository,
private readonly yardFacilitiesService: YardFacilitiesService,
) { }
async getReferenceData(): Promise<BookingReferenceDataDto> {
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
await Promise.all([
const [
yards,
containerTypes,
serviceTypes,
shippingLines,
cargoTypes,
facilityYards,
] = await Promise.all([
this.yardsRepository.findAll({
where: { isActive: true },
order: { displayOrder: "ASC", code: "ASC" },
@@ -150,17 +203,30 @@ export class BookingReferenceDataService {
where: { isActive: true },
order: { displayOrder: "ASC", code: "ASC" },
}),
this.yardFacilitiesService.listFacilityYards(),
]);
// Every active yard is still listed; a yard with no facility record simply
// reports no capability, so the forms drop it from the pickers themselves.
const facilityByYardId = new Map(facilityYards.map((f) => [f.yardId, f]));
return {
yard: yards.map((y): BookingReferenceYardDto => {
const facility = facilityByYardId.get(y.id);
return {
yard: yards.map(
(y): BookingReferenceYardDto => ({
id: y.id,
name: y.label,
code: y.code,
country: y.country,
hasContainerFacilityOrigin:
facility?.hasContainerFacilityOrigin ?? false,
hasBulkFacilityOrigin: facility?.hasBulkFacilityOrigin ?? false,
hasContainerFacilityDestination:
facility?.hasContainerFacilityDestination ?? false,
hasBulkFacilityDestination:
facility?.hasBulkFacilityDestination ?? false,
};
}),
),
containers: groupContainersBySize(containerTypes),
service: serviceTypes.map(
(s): BookingReferenceServiceDto => ({

View File

@@ -37,7 +37,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
{} as never, // fileUploadSettingsService
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -59,6 +59,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, ruleEngineService, contractService };
}

View File

@@ -14,10 +14,10 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
serviceType: { includesCustoms: false }, // no output set → only the input gate
};
// Input set has two required docs. Non-customs bookings resolve to the
// ONE_TIME self-clearance document set.
// Input set has two required docs. Non-customs bookings resolve to their
// own without-customs document set.
const inputSetting = {
code: 'contract_clearance_selfclear_import_container',
code: 'clearance_import_container_without_customs',
fields: [
{ fileKey: 'commercial_invoice', isRequired: true },
{ fileKey: 'packing_list', isRequired: true },
@@ -46,7 +46,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
fileUploadSettingsService as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -68,6 +68,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository };
}
@@ -149,7 +150,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
fileUploadSettingsService as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -171,6 +172,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository };
}
@@ -199,7 +201,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
*/
describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => {
const inputSetting = {
code: 'contract_clearance_selfclear_import_container',
code: 'clearance_import_container_without_customs',
fields: [
{ fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true },
{ fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true },
@@ -238,7 +240,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
fileUploadSettingsService as never,
{} as never, // bookingBatchService
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -260,6 +262,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, filesService };
}

View File

@@ -26,6 +26,7 @@ describe('BookingTransitionService — operation review', () => {
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
assertNoUnpaidHold: jest.fn().mockResolvedValue(undefined),
};
const bookingBatchService = {
enqueueRouteDayProcessing: jest.fn(),
@@ -48,7 +49,7 @@ describe('BookingTransitionService — operation review', () => {
{} as never, // fileUploadSettingsService
bookingBatchService as never,
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
invoiceService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
@@ -70,6 +71,7 @@ describe('BookingTransitionService — operation review', () => {
clearanceDocsUploadedToStaff: jest.fn(),
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, bookingBatchService, invoiceService };
}
@@ -143,6 +145,7 @@ describe('BookingTransitionService — requestOperation export space gate', () =
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
assertNoUnpaidHold: jest.fn().mockResolvedValue(undefined),
checkDayCompatibilityForBooking: jest
.fn()
.mockResolvedValue({ hasDeparture: true, hasCompatible: true }),
@@ -164,11 +167,12 @@ describe('BookingTransitionService — requestOperation export space gate', () =
{} as never, // fileUploadSettingsService
bookingBatchService as never,
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
notifier as never,
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, bookingBatchService };
}

View File

@@ -7,9 +7,12 @@ import {
Logger,
Optional,
} from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";
import { EventEmitter2, OnEvent } from "@nestjs/event-emitter";
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import {
BookingBatchService,
type ExportTrainOption,
} from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { isRoadService } from './road.util';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
@@ -56,11 +59,12 @@ export class BookingTransitionService {
private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService,
private readonly notifier: BookingLifecycleNotifierService,
private readonly events: EventEmitter2,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
) {}
private isPhasedGeneralCustoms(booking: Booking): boolean {
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
private isPhasedCustoms(booking: Booking): boolean {
return this.bookingClearanceService.isPhasedCustomsBooking(booking);
}
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
@@ -376,6 +380,8 @@ export class BookingTransitionService {
} as never);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.completed(fresh);
// A ONE_TIME contract closes on its single shipment being delivered.
this.events.emit('booking.completed', { bookingId });
// Customer tracking: close out the tail milestones so a finished shipment
// never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are
// implied by delivery; a storage invoice that was never raised is skipped
@@ -398,6 +404,31 @@ export class BookingTransitionService {
return fresh;
}
/**
* Customer cancels their own unpaid hold (SELECTED_FOR_BATCH): the wagons
* release immediately instead of tying up the train until the pay window
* lapses. Ends CANCELLED; the freed capacity tops up from the waiting list.
*/
async cancelHold(bookingId: string, reason?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]);
if (booking.consolidationPartnerId) {
throw new BadRequestException(
"This booking shares a consolidated wagon with another booking — " +
"contact support to cancel it.",
);
}
await this.bookingsRepository.createReviewNote(
bookingId,
reason ?? "Customer cancelled before payment",
"REJECTION",
);
await this.bookingBatchService.cancelReservation(bookingId);
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.cancelled(fresh, reason ?? "Cancelled before payment");
return fresh;
}
async cancel(bookingId: string, reason: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
@@ -491,7 +522,7 @@ export class BookingTransitionService {
operationReady?: boolean;
}> {
const booking = await this.bookingsService.findById(bookingId);
if (this.isPhasedGeneralCustoms(booking)) {
if (this.isPhasedCustoms(booking)) {
return this.bookingClearanceService.getClearanceView(bookingId);
}
const { inputCode, outputCode, includesCustoms } =
@@ -650,7 +681,7 @@ export class BookingTransitionService {
status: "DOCUMENTS_UNDER_REVIEW",
} as never);
if (this.isPhasedGeneralCustoms(booking)) {
if (this.isPhasedCustoms(booking)) {
await this.workflowService.onCustomerDocsUploadedForBooking(
bookingId,
booking.tradeDirection ?? 'IMPORT',
@@ -732,7 +763,7 @@ export class BookingTransitionService {
}
if (
status === 'QUERIED' &&
this.isPhasedGeneralCustoms(booking) &&
this.isPhasedCustoms(booking) &&
booking.preClearanceFinalizedAt
) {
throw new BadRequestException(
@@ -755,7 +786,7 @@ export class BookingTransitionService {
"CHANGES_REQUESTED",
staffId,
);
if (this.isPhasedGeneralCustoms(booking)) {
if (this.isPhasedCustoms(booking)) {
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
@@ -767,7 +798,7 @@ export class BookingTransitionService {
if (status === "QUERIED") {
this.notifier.documentQueried(updated, fileKey, note ?? '');
}
if (this.isPhasedGeneralCustoms(updated)) {
if (this.isPhasedCustoms(updated)) {
const allApproved = await this.isClearanceFullyApproved(updated);
if (allApproved) {
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
@@ -817,7 +848,7 @@ export class BookingTransitionService {
*/
async finalizeClearance(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
if (this.isPhasedGeneralCustoms(booking)) {
if (this.isPhasedCustoms(booking)) {
throw new BadRequestException(
'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.',
);
@@ -888,6 +919,7 @@ export class BookingTransitionService {
async requestOperation(
bookingId: string,
scheduledDate: string,
requestedTrainScheduleId?: string | null,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
@@ -895,6 +927,13 @@ export class BookingTransitionService {
"OPERATION_CHANGES_REQUESTED",
]);
// A company sitting on another unpaid hold commits nothing new — this is
// the moment export capacity locks, so the lock applies here too.
// Government bookings allocate without paying and are exempt.
if (!booking.isGovernment) {
await this.bookingsService.assertNoUnpaidHold(booking.companyId);
}
// A bare initiated instance (clearance-first flow) carries no cargo or
// price — it must go through the contract completion endpoint, which
// persists cargo, prices, invoices and only then lands here itself.
@@ -939,10 +978,18 @@ export class BookingTransitionService {
// largest bookable leftover ("reduce to N wagons or pick another day").
// Import/domestic bookings are batched + splittable, so they are NOT gated
// here — they get an advisory count below and the batch engine sizes them.
const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
const isExportTrain =
booking.tradeDirection === "EXPORT" &&
!isRoadService(booking.serviceType);
// The customer's train pick only exists for export rail; it rides the
// booking through the space checks below AND is persisted so the accept /
// reserve path locks onto that train (pickExportSchedule honors it).
const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null;
const scheduledBooking = {
...booking,
scheduledDate: date,
requestedTrainScheduleId: requestedId,
} as Booking;
if (isExportTrain) {
// With export split ON the booking no longer has to ride ONE train whole:
// the largest fitting part is offered and the leftover rebooks on the next
@@ -955,9 +1002,14 @@ export class BookingTransitionService {
eatDay(date),
"EXPORT",
);
if (!fitting.length) {
const fitsRequest = requestedId
? fitting.some((f) => f.scheduleId === requestedId)
: fitting.length > 0;
if (!fitsRequest) {
throw new ConflictException(
"No export train on this day has space left — pick another shipment day.",
requestedId
? "The selected train has no space left — pick another train or day."
: "No export train on this day has space left — pick another shipment day.",
);
}
} else {
@@ -968,6 +1020,7 @@ export class BookingTransitionService {
await this.bookingsRepository.update(bookingId, {
status: "OPERATION_REQUEST_PENDING",
scheduledDate: date,
requestedTrainScheduleId: requestedId,
} as never);
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.operationRequestedToStaff(fresh);
@@ -985,6 +1038,43 @@ export class BookingTransitionService {
* total covers the booking. `trainsForDay` is false when no departure carries
* the leg — the day is unbookable regardless of space.
*/
/**
* Export train picker data for a shipment day the customer is choosing:
* each export train on the booking's corridor with per-wagon-type free
* space. Export rail bookings only — nothing else picks a train.
*/
async exportTrainsForBooking(
bookingId: string,
scheduledDate: string,
overrides?: {
containerTypeIds?: string[];
containerSizes?: string[];
cargoTypeId?: string;
cargoTypeCode?: string;
wagons?: number;
},
): Promise<ExportTrainOption[]> {
const booking = await this.bookingsService.findById(bookingId);
const date = new Date(scheduledDate);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException("A valid schedule date is required");
}
if (
booking.tradeDirection !== "EXPORT" ||
isRoadService(booking.serviceType)
) {
throw new BadRequestException(
"Train selection is only available for export rail bookings",
);
}
const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
return this.bookingBatchService.exportTrainOptionsForDay(
scheduledBooking,
eatDay(date),
overrides,
);
}
async dayAvailabilityForBooking(
bookingId: string,
scheduledDate: string,

View File

@@ -43,6 +43,8 @@ import {
RoAmendmentDto,
} from '../contracts/dto/phased-clearance.dto';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { scopedDirections } from '../user-trade-access/trade-scope.util';
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
import { BookingsService } from './bookings.service';
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
import { CreateBookingDto } from './dto/create-booking.dto';
@@ -150,6 +152,7 @@ export class BookingsController {
private readonly containerReceiptService: ContainerReceiptService,
private readonly firstMileService: FirstMileService,
private readonly lastMileService: LastMileService,
private readonly userTradeAccessService: UserTradeAccessService,
) {}
@Post()
@@ -217,7 +220,16 @@ export class BookingsController {
// Staff (backoffice) see every booking. Customers (portal) are always
// force-scoped to their own company, regardless of any companyId they pass.
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
return this.bookingsService.findAll(filter);
// Per-user trade-direction scope (import/export/intercity checkboxes).
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
const dirs = scopedDirections(allowed, filter.tradeDirection);
return this.bookingsService.findAll(
filter,
undefined,
undefined,
dirs ?? undefined,
);
}
// Global Logistics has clearance:view but NOT bookings:view — it is scoped
// to the customs document-clearance queue only and never sees the general
@@ -464,6 +476,26 @@ export class BookingsController {
res.send(buffer);
}
@Get(':id/carriage-acceptance-sheet')
@ApiOperation({
summary:
'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)',
})
async carriageAcceptanceSheet(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const { filename, buffer } = await this.bookingsService.carriageAcceptanceSheet(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(buffer);
}
@Get(':id/customer-trucks')
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
async listCustomerTrucks(
@@ -751,10 +783,42 @@ export class BookingsController {
const booking = await this.transitionService.requestOperation(
id,
dto.scheduledDate,
dto.trainScheduleId ?? null,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Get(":id/export-trains")
@ApiOperation({
summary:
"Export train picker: the day's export trains on the booking's corridor " +
"with per-wagon-type free space (export rail bookings only)",
})
async exportTrainsForBooking(
@Param("id", ParseUUIDPipe) id: string,
@Query("date") date: string,
// Bare contract instances carry no cargo yet — the completion form sends
// what the customer is entering so per-type space reflects THEIR cargo.
@Query("containerTypeIds") containerTypeIds?: string,
@Query("containerSizes") containerSizes?: string,
@Query("cargoTypeId") cargoTypeId?: string,
@Query("cargoTypeCode") cargoTypeCode?: string,
@Query("wagons") wagons?: string,
) {
const parsedWagons = Number(wagons);
return this.transitionService.exportTrainsForBooking(id, date, {
containerTypeIds: containerTypeIds
? containerTypeIds.split(",").filter(Boolean)
: undefined,
containerSizes: containerSizes
? containerSizes.split(",").filter(Boolean)
: undefined,
cargoTypeId: cargoTypeId || undefined,
cargoTypeCode: cargoTypeCode || undefined,
wagons: Number.isFinite(parsedWagons) && parsedWagons > 0 ? parsedWagons : undefined,
});
}
@Post(":id/operation/review")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
@@ -823,6 +887,34 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/transit-assignee/request')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
'GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration',
})
async requestBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('note') note: string | undefined,
) {
const booking = await this.bookingClearanceService.requestTransitAssignee(id, note);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/transit-assignee/assign')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({
summary:
'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns',
})
async assignBookingTransitAssignee(
@Param('id', ParseUUIDPipe) id: string,
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
) {
const booking = await this.bookingClearanceService.assignTransitAssignee(id, transitAgentId);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/declaration')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@@ -872,6 +964,59 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/draft-declaration')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary:
'GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review',
})
async uploadBookingDraftDeclaration(
@Param('id', ParseUUIDPipe) id: string,
@Body('price') priceRaw: string,
@Body('currency') currency: string | undefined,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.uploadDraftDeclaration(
id,
files ?? [],
Number(priceRaw),
currency ?? 'ETB',
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/draft-declaration/accept')
@ApiOperation({
summary:
'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia',
})
async acceptBookingDraftDeclaration(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.bookingClearanceService.acceptDraftDeclaration(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/draft-declaration/change')
@ApiOperation({
summary:
'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)',
})
async requestBookingDraftDeclarationChange(
@Param('id', ParseUUIDPipe) id: string,
@Body('note') note: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.requestDraftDeclarationChange(
id,
note,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/finalize-pre-clearance')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
@@ -916,14 +1061,15 @@ export class BookingsController {
async uploadBookingDeliveryOrder(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
@Body('vesselArrivalDate') vesselArrivalDate: string | undefined,
@Body('doCollectedDate') doCollectedDate: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
id,
file,
resolveAuthUserId(user),
vesselDepartureDate,
{ vesselArrivalDate, doCollectedDate },
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -1189,6 +1335,20 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/cancel-hold")
@ApiOperation({
summary:
"Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); " +
"reserved wagons release immediately",
})
async cancelHold(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CancelBookingDto,
) {
const booking = await this.transitionService.cancelHold(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/consolidation")
@ApiOperation({ summary: "Request freight consolidation" })
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {

View File

@@ -1,4 +1,5 @@
import { Module, forwardRef } from "@nestjs/common";
import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module";
import { ConfigService } from "@nestjs/config";
import { TypeOrmModule } from "@nestjs/typeorm";
import { ExchangeModule, ExchangeOptions } from "@edr/api-common";
@@ -80,6 +81,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
MinioModule,
VehiclesModule,
CompaniesModule,
UserTradeAccessModule,
// CustomersModule,
RuleEngineModule,
FileUploadSettingsModule,

View File

@@ -1,20 +1,30 @@
import { BaseRepository } from '@edr/api-common';
import { SchedulingStatus } from '@edr/types';
import { Injectable } from '@nestjs/common';
import { ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
import {
DataSource,
DeepPartial,
EntityManager,
FindOptionsWhere,
In,
Repository,
SelectQueryBuilder,
} from 'typeorm';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import {
BookingDocumentReview,
DocumentReviewStatus,
} from './entities/booking-document-review.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingContainerUnit } from './entities/booking-container-unit.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
@@ -26,6 +36,22 @@ import {
import { FileRecord } from '../files/entities/file.entity';
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
/** A booking is ready for a batch: commercial = signed, government = approved/paid. */
const BATCH_POOL_READY = `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`;
/**
* Suspending a contract freezes its bookings, so they drop out of every
* scheduling pool. Filtering here (rather than letting the write guard throw)
* keeps the batch crons quiet — a frozen contract simply stops being a
* candidate until the suspension is lifted.
*/
const NOT_ON_SUSPENDED_CONTRACT = `(booking.contract_id IS NULL
OR NOT EXISTS (
SELECT 1 FROM freight.contracts c
WHERE c.id = booking.contract_id AND c.status = 'SUSPENDED'
))`;
export interface BookingListFilterOptions {
statuses?: string[];
status?: string;
@@ -39,6 +65,8 @@ export interface BookingListFilterOptions {
freightType?: string;
bookingType?: string;
tradeDirection?: string;
/** Per-user trade-direction scope — `[]` matches nothing. */
tradeDirections?: string[];
paymentCurrency?: string;
paymentStatus?: string;
excludePaymentStatus?: string;
@@ -68,6 +96,42 @@ export class BookingsRepository extends BaseRepository<Booking> {
return this.repository.findOne({ where: { reference } });
}
/**
* Suspending a contract freezes its bookings too, so the single write path
* every booking mutation funnels through is the place to enforce it — one
* guard instead of one per transition method.
*
* The batch/scheduling pools filter suspended contracts out up front
* (see {@link excludeSuspendedContract}), so the engine and its crons never
* reach a frozen booking and this only ever fires on a user-initiated action.
*
* ponytail: the seven `manager.getRepository(Booking)` writes inside
* train-scheduling transactions bypass this — they only run on bookings the
* pool already handed out, which the filter above has excluded. Move them onto
* this repository if that ever stops holding.
*/
private async assertContractNotSuspended(id: string): Promise<void> {
const row = await this.repository
.createQueryBuilder('booking')
.select('contract.status', 'status')
.innerJoin(Contract, 'contract', 'contract.id = booking.contract_id')
.where('booking.id = :id', { id })
.getRawOne<{ status: string }>();
if (row?.status === 'SUSPENDED') {
throw new ConflictException(
'This shipment belongs to a suspended contract. EDR must lift the suspension before it can move.',
);
}
}
override async update(
id: string,
data: DeepPartial<Booking>,
): Promise<Booking | null> {
await this.assertContractNotSuspended(id);
return super.update(id, data);
}
/**
* Highest NNNNNN sequence already issued for `BK-<year>-…` references.
* Includes soft-deleted bookings so the next number clears references that
@@ -123,7 +187,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
'booking.files',
FileRecord,
'file',
"file.resource_id = booking.id AND file.resource = 'bookings'",
// Superseded versions are soft-deleted, not dropped — keep them out of
// the live file list (a manual join condition is not filtered for us).
"file.resource_id = booking.id AND file.resource = 'bookings' AND file.deleted_at IS NULL",
)
.getOne();
@@ -139,10 +205,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
vgmPerUnitTons: number;
hazardousQuantity?: number;
reeferQuantity?: number;
containerNumbers?: string[];
weightResult: ContainerWeightResult;
}>,
): Promise<BookingContainer[]> {
const containerRepo = this.dataSource.getRepository(BookingContainer);
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
const typeRepo = this.dataSource.getRepository(ContainerType);
const saved: BookingContainer[] = [];
@@ -168,7 +236,26 @@ export class BookingsRepository extends BaseRepository<Booking> {
isOverweight: item.weightResult.isOverweight,
overweightExcessTons: item.weightResult.overweightExcessTons,
});
saved.push(await containerRepo.save(row));
const savedRow = await containerRepo.save(row);
saved.push(savedRow);
// Physical container numbers, one unit row each (capped to the line
// quantity; blanks skipped). Optional — units can also be entered later.
const numbers = (item.containerNumbers ?? [])
.map((n) => n.trim())
.filter(Boolean)
.slice(0, item.quantity);
let sortOrder = 0;
for (const containerNumber of numbers) {
await unitRepo.save(
unitRepo.create({
bookingContainerId: savedRow.id,
containerNumber,
vgmTons: item.vgmPerUnitTons,
sortOrder: sortOrder++,
}),
);
}
}
return saved;
@@ -549,6 +636,17 @@ export class BookingsRepository extends BaseRepository<Booking> {
);
}
/** Review notes of one type, newest first — the duty advice/dispute rounds. */
async findReviewNotes(
bookingId: string,
type: ReviewNoteType,
): Promise<BookingReviewNote[]> {
return this.dataSource.getRepository(BookingReviewNote).find({
where: { bookingId, type },
order: { createdAt: 'DESC' },
});
}
async findLatestReviewNote(
bookingId: string,
type?: ReviewNoteType,
@@ -905,6 +1003,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
tradeDirection: options.tradeDirection,
});
}
if (options.tradeDirections) {
applyDirectionScope(qb, 'booking.trade_direction', options.tradeDirections);
}
if (options.paymentCurrency) {
qb.andWhere('booking.payment_currency = :paymentCurrency', {
paymentCurrency: options.paymentCurrency,
@@ -1015,6 +1116,15 @@ export class BookingsRepository extends BaseRepository<Booking> {
* the whole (route, day) pool rather than bookings pre-targeted to one train.
*/
day?: string;
/**
* The schedule's ordered route stops. When given, the corridor filter
* replaces the exact origin/destination match: any booking whose BOTH yards
* lie on the route qualifies (sub-corridor bookings like Dire→DCT on a
* GMT→Dire→DCT train — the caller still checks stop ORDER). Dateless
* DOMESTIC (intercity) bookings also join the pool: they ride any train on
* their corridor.
*/
corridorYardIds?: string[];
}): Promise<Booking[]> {
const qb = this.repository
.createQueryBuilder('booking')
@@ -1030,15 +1140,19 @@ export class BookingsRepository extends BaseRepository<Booking> {
'scheduleBooking.booking_id = booking.id',
)
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
.andWhere('scheduleBooking.id IS NULL');
.andWhere('scheduleBooking.id IS NULL')
.andWhere(NOT_ON_SUSPENDED_CONTRACT);
// Day-level pooling: customers no longer set train_schedule_id, so the wizard
// surfaces the whole (route, EAT day) pool. Fall back to the legacy
// single-schedule filter only when no day is supplied (e.g. a staff-pinned
// booking that still carries train_schedule_id).
if (options.day) {
// Dateless DOMESTIC (intercity) bookings ride any train on their corridor
// — no scheduled_date to match, so the day filter must not hide them.
qb.andWhere(
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
`(DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day
OR (booking.trade_direction = 'DOMESTIC' AND booking.scheduled_date IS NULL))`,
{ day: options.day },
);
} else if (options.trainScheduleId) {
@@ -1051,6 +1165,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType });
}
if (options.corridorYardIds?.length) {
qb.andWhere('booking.originYardId IN (:...corridorYardIds)', {
corridorYardIds: options.corridorYardIds,
}).andWhere('booking.destinationYardId IN (:...corridorYardIds)', {
corridorYardIds: options.corridorYardIds,
});
} else {
if (options.originStationId) {
qb.andWhere('booking.originYardId = :originStationId', {
originStationId: options.originStationId,
@@ -1061,6 +1182,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
destinationStationId: options.destinationStationId,
});
}
}
if (options.schedulingStatus) {
qb.andWhere('booking.scheduling_status = :schedulingStatus', {
schedulingStatus: options.schedulingStatus,
@@ -1089,10 +1211,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.andWhere('sb.id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
)
.andWhere(BATCH_POOL_READY)
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.fully_executed_at', 'ASC')
@@ -1128,10 +1248,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
{ day },
)
.andWhere('sb.id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
)
.andWhere(BATCH_POOL_READY)
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.fully_executed_at', 'ASC')
@@ -1168,10 +1286,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
{ day },
)
.andWhere('sb.id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
)
.andWhere(BATCH_POOL_READY)
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.fully_executed_at', 'ASC')
@@ -1179,6 +1295,38 @@ export class BookingsRepository extends BaseRepository<Booking> {
.getMany();
}
/**
* EXPIRED bookings on the day's corridor — the batch board's expired lane.
* Expiry nulls train_schedule_id, so neither findAllBySchedule nor the
* ready-pool query can ever see them.
*/
findExpiredByCorridorDay(
corridorYardIds: string[],
day: string,
): Promise<Booking[]> {
if (corridorYardIds.length === 0) return Promise.resolve([]);
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
.andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
corridorYardIds,
})
.andWhere(
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
{ day },
)
.andWhere('sb.id IS NULL')
.andWhere(`booking.status = 'EXPIRED'`)
.orderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
}
/**
* Commercial bookings on the day's corridor whose operation request was NOT
* accepted by staff (still pending / changes / price-confirm) and are not yet
@@ -1248,6 +1396,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
.getMany();
}
/** Open unpaid holds (wagons reserved, pay window running) for a company. */
countUnpaidHoldsForCompany(companyId: string): Promise<number> {
return this.repository.count({
where: {
companyId,
status: In(['SELECTED_FOR_BATCH', 'AWAITING_PAYMENT']),
},
});
}
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
return this.repository

View File

@@ -32,6 +32,8 @@ import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { Contract } from '../contracts/entities/contract.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { BookingContractService } from './booking-contract.service';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { VehiclesService } from '../vehicles/vehicles.service';
@@ -68,6 +70,29 @@ export interface PaginatedBookings {
};
}
/** One wagon line on the carriage acceptance sheet (raw SQL projection). */
interface CarriageAcceptanceWagonRow {
sequenceNo: number;
wagonType: string | null;
wagonNumber: string | null;
tareWeightTons: string | null;
equatedLength: string | null;
loadCapacityTons: string | null;
allocatedWeightTons: string | null;
trainNumber: string | null;
departureAt: Date | null;
marshalledAt: string | null;
arrivalAt: string | null;
containerNumbers: string | null;
sealNumbers: string | null;
}
/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */
interface CarriageAcceptanceReceivedRow {
allocatedWeightTons: string | null;
containerNumbers: string | null;
}
const URGENT_PRIORITY_THRESHOLD = 1000;
const NEEDS_ACTION_STATUSES = [
'SUBMITTED',
@@ -103,6 +128,10 @@ export class BookingsService {
private readonly vehiclesService: VehiclesService,
private readonly pdfRender: PdfRenderService,
private readonly events: EventEmitter2,
@Inject(forwardRef(() => BookingContractService))
private readonly bookingContractService: BookingContractService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
) {}
async assignCustomerTruck(
@@ -202,6 +231,284 @@ export class BookingsService {
};
}
/**
* Carriage acceptance sheet — one per booking, listing every wagon the booking
* occupies. Handed to the customer when EDR accepts the cargo (export) and when
* the wagons are allocated before marshalling (import), so it is only available
* once the booking has wagon allocations.
*/
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
let wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo",
COALESCE(wt.code, wt.name) AS "wagonType",
w.wagon_number AS "wagonNumber",
wt.tare_weight_tons AS "tareWeightTons",
tsw.length_meters AS "equatedLength",
tsw.capacity_tons AS "loadCapacityTons",
a.allocated_weight_tons AS "allocatedWeightTons",
s.train_number AS "trainNumber",
s.scheduled_departure_date AS "departureAt",
so.label AS "marshalledAt",
sd.label AS "arrivalAt",
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
FROM freight.wagon_booking_allocations a
JOIN freight.train_set_wagons tsw
ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
LEFT JOIN freight.train_schedules s
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label
ORDER BY tsw.sequence_no`,
[bookingId],
);
// Export acceptance happens at the warehouse gate, not at marshalling: EDR
// takes custody of the cargo when it receives it, and the customer is handed
// this sheet then — before the booking is put on a train. So a received
// export booking gets its sheet off the received cargo, wagon columns blank
// until the consist exists. Import keeps the allocation gate: nothing is
// accepted from the customer before the wagons carry it.
//
// Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork
// and never appears on this sheet — it is only the signal that EDR has taken
// the cargo, which is what the customer's sheet attests to.
const pendingWagons = wagons.length === 0;
if (pendingWagons) {
const receivedLines: CarriageAcceptanceReceivedRow[] =
booking.tradeDirection === 'EXPORT'
? await this.dataSource.query(
`SELECT inv.weight AS "allocatedWeightTons",
c.container_number AS "containerNumbers"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.containers c
ON c.id = inv.container_id AND c.deleted_at IS NULL
WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL
AND COALESCE(NULLIF(TRIM(inv.grn_number), ''), '') <> ''
ORDER BY inv.created_at`,
[bookingId],
)
: [];
if (receivedLines.length === 0) {
throw new BadRequestException(
booking.tradeDirection === 'EXPORT'
? 'This export booking has no GRN yet — receive the cargo at the warehouse before issuing the carriage acceptance sheet'
: 'No wagons are allocated to this booking yet — the carriage acceptance sheet is issued after wagon allocation',
);
}
wagons = receivedLines.map((row, index) => ({
sequenceNo: index + 1,
wagonType: null,
wagonNumber: null,
tareWeightTons: null,
equatedLength: null,
loadCapacityTons: null,
allocatedWeightTons: row.allocatedWeightTons,
trainNumber: null,
departureAt: null,
marshalledAt: null,
arrivalAt: null,
containerNumbers: row.containerNumbers,
sealNumbers: null,
}));
}
const html = this.buildCarriageAcceptanceSheetHtml(booking, wagons, { pendingWagons });
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
label: 'carriage acceptance sheet',
fallback: (prepared) => buildTabularFallbackPdf(prepared),
});
return {
filename: `carriage-acceptance-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer,
};
}
/**
* Split the booking amount across its wagons, proportional to allocated weight
* (equal shares when no weights are recorded). The last row absorbs the rounding
* remainder so the Price column always sums to the Total Amount on the sheet.
*/
private splitAmountAcrossWagons(total: number, weights: number[]): number[] {
const sum = weights.reduce((acc, w) => acc + w, 0);
const shares = weights.map((w) =>
Math.round((sum > 0 ? (total * w) / sum : total / weights.length) * 100) / 100,
);
const drift = Math.round((total - shares.reduce((a, b) => a + b, 0)) * 100) / 100;
shares[shares.length - 1] = Math.round((shares[shares.length - 1] + drift) * 100) / 100;
return shares;
}
private buildCarriageAcceptanceSheetHtml(
booking: Booking,
wagons: CarriageAcceptanceWagonRow[],
{ pendingWagons }: { pendingWagons: boolean },
): string {
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
const num = (v: unknown, digits = 3) => (Number(v) || 0).toFixed(digits);
const money = (v: number) =>
v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-';
const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-';
const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-';
const currency = booking.paymentCurrency ?? 'ETB';
const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0;
const prices = this.splitAmountAcrossWagons(
totalAmount,
wagons.map((w) => Number(w.allocatedWeightTons) || 0),
);
const header = wagons[0];
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
const totals = wagons.reduce(
(acc, w) => ({
tare: acc.tare + (Number(w.tareWeightTons) || 0),
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
load: acc.load + (Number(w.allocatedWeightTons) || 0),
length: acc.length + (Number(w.equatedLength) || 0),
}),
{ tare: 0, capacity: 0, load: 0, length: 0 },
);
// A wagon carrying no weight and no container is running empty under this booking.
const fullWagons = wagons.filter(
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
).length;
const rows = wagons
.map(
(w, i) => `<tr>
<td class="num">${i + 1}</td>
<td>${esc(w.wagonType)}</td>
<td>${esc(w.wagonNumber)}</td>
<td class="num">${num(w.tareWeightTons, 2)}</td>
<td class="num">${num(w.equatedLength)}</td>
<td class="num">${num(w.loadCapacityTons)}</td>
<td>${esc(arrivalStation)}</td>
<td>${esc(cargoName)}</td>
<td>${esc(departureStation)}</td>
<td>${esc(w.containerNumbers)}</td>
<td>${esc(w.sealNumbers)}</td>
<td class="num">${money(prices[i])}</td>
</tr>`,
)
.join('');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Carriage Acceptance Sheet</title>
<style>
@page { size: A4 landscape; margin: 10mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
.subtitle { font-size: 11px; color: #475569; margin-top: 4px; }
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; }
tfoot td { background: #f8fafc; font-weight: 700; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
</style>
</head>
<body>
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Carriage Acceptance Sheet</h1>
<div class="subtitle">Booking ${esc(booking.reference)}${esc(booking.tradeDirection)}</div>
</div>
<div class="meta">
Sheet No.
<strong>CAS-${esc(booking.reference)}</strong>
Generated: ${esc(new Date().toLocaleString('en-GB'))}
</div>
</div>
<div class="summary">
<div class="tile"><span>Marshalled at</span><strong>${esc(header.marshalledAt ?? departureStation)}</strong></div>
<div class="tile"><span>Arrival at</span><strong>${esc(header.arrivalAt ?? arrivalStation)}</strong></div>
<div class="tile"><span>Date and time</span><strong>${esc(sheetDate.toLocaleString('en-GB'))}</strong></div>
<div class="tile"><span>Train No.</span><strong>${esc(header.trainNumber)}</strong></div>
<div class="tile"><span>Customer</span><strong>${esc(booking.company?.name)}</strong></div>
<div class="tile"><span>Cargo</span><strong>${esc(cargoName)}</strong></div>
</div>
<table>
<thead>
<tr>
<th class="num">SN</th>
<th>Type of Wagon</th>
<th>Wagon No.</th>
<th class="num">Tare Weight</th>
<th class="num">Equated Length</th>
<th class="num">Load Capacity</th>
<th>Arrival Station</th>
<th>Cargo Name</th>
<th>Departure Station</th>
<th>Container No.</th>
<th>Seal No.</th>
<th class="num">Price (${esc(currency)})</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
<tfoot>
<tr>
<td colspan="3">${
pendingWagons
? `Received lines: ${wagons.length} — wagons pending marshalling`
: `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})`
}</td>
<td class="num">${num(totals.tare, 2)}</td>
<td class="num">${num(totals.length)}</td>
<td class="num">${num(totals.capacity)}</td>
<td colspan="5">Gross weight (tare + load): ${num(totals.tare + totals.load)} T</td>
<td class="num">${money(totalAmount)}</td>
</tr>
</tfoot>
</table>
<div class="notice">
${
pendingWagons
? `The cargo listed above is accepted for carriage under booking ${esc(booking.reference)}.
Wagon identity and seal numbers are filled in when the booking is marshalled onto a train.`
: `The wagons listed above are accepted for carriage under booking ${esc(booking.reference)}.
Wagon identity, container and seal numbers must be verified against the physical consist
before the sheet is signed.`
}
</div>
<div class="signatures">
<div class="line">Signed by — EDR operations / date</div>
<div class="line">Signed by — customer or agent / date</div>
<div class="line">Signed by — marshalling yard / date</div>
</div>
</body>
</html>`;
}
/** Resolve trade direction from yard countries; reject client mismatch. */
/**
* An intercity corridor is valid when both yards are Ethiopian and at least
@@ -602,6 +909,24 @@ export class BookingsService {
return result.booking;
}
/**
* A company with an open unpaid hold (SELECTED_FOR_BATCH — wagons reserved,
* pay window running) may not take more capacity until it pays or the hold
* dies: otherwise one customer can lock a train's wagons over and over
* without ever paying. EXPIRED / CANCELLED holds free the lock.
*/
async assertNoUnpaidHold(companyId?: string | null): Promise<void> {
if (!companyId) return;
const holds =
await this.bookingsRepository.countUnpaidHoldsForCompany(companyId);
if (holds > 0) {
throw new ConflictException(
'You already have a booking waiting for payment. Pay it or cancel it ' +
'before making a new booking.',
);
}
}
/** Create a new freight booking. */
async create(
dto: CreateBookingDto,
@@ -664,6 +989,10 @@ export class BookingsService {
companyId = company.id;
}
// Government bookings allocate without paying, so the unpaid-hold lock
// only applies to commercial companies.
if (!isGovernment) await this.assertNoUnpaidHold(companyId);
if (dto.trainScheduleId) {
// Staff manual pin: the schedule must be OPEN and on the same route.
const schedule = await this.dataSource
@@ -857,6 +1186,9 @@ export class BookingsService {
cargoFreeText: dto.cargoFreeText,
shippingLineId: dto.shippingLineId,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
// Break-bulk actual tonnage (PER_ITEM cargo); meaningless outside BULK.
bulkTotalWeightTons:
dto.freightType === 'BULK' ? (dto.bulkTotalWeightTons ?? null) : null,
isHazardous: dto.isHazardous ?? false,
// Bulk reefer is the customer's toggle; container reefer is derived from
// the container type at pricing time, so the booking-level flag stays off
@@ -903,6 +1235,7 @@ export class BookingsService {
vgmPerUnitTons: c.vgmPerUnitTons,
hazardousQuantity: c.hazardousQuantity,
reeferQuantity: c.reeferQuantity,
containerNumbers: c.containerNumbers,
weightResult: ruleResult.containerWeightResults[i],
})),
);
@@ -957,6 +1290,21 @@ export class BookingsService {
warnings.push(...consolidation.messages);
}
// Government bookings pass every customer step at creation: the server
// expedites them to PAID/Eligible, generates the contract (signable at any
// time) and queues priority placement. Best-effort — the booking row is
// already inserted, so a late failure must not 500 the whole create; the
// idempotent expedite endpoint remains the retry path.
if (isGovernment) {
try {
full = await this.governmentExpedite(booking.id, userId ?? 'system');
} catch (err) {
warnings.push(
`Government expedite incomplete — retry via the expedite action: ${(err as Error).message}`,
);
}
}
return { booking: full, warnings };
}
@@ -1048,6 +1396,11 @@ export class BookingsService {
...dto,
freightType,
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
// Break-bulk actual tonnage; cleared when the booking leaves BULK.
bulkTotalWeightTons:
freightType === 'BULK'
? (dto.bulkTotalWeightTons ?? existing.bulkTotalWeightTons ?? null)
: null,
// Booking-level reefer is only meaningful for bulk; container reefer is
// derived from the container type at pricing time.
isReefer:
@@ -1266,6 +1619,7 @@ export class BookingsService {
filter: FilterBookingDto,
forceCompanyId?: string,
forceCompanyProfileId?: string,
tradeDirections?: string[],
): Promise<PaginatedBookings> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
@@ -1285,6 +1639,7 @@ export class BookingsService {
// ANDs both, so cross-company access is impossible.
companyId: forceCompanyId ?? filter.companyId,
companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
tradeDirections,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
@@ -1800,13 +2155,22 @@ export class BookingsService {
return false;
}
/** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */
/**
* Expedite a government booking past every customer step: PAID + Eligible
* (no commercial hold, no payment), contract generated server-side (signable
* at any time), and the (route, day) fill kicked immediately so it grabs a
* seat on any open train — government-first, preempting commercial cargo if
* the day is full. Runs automatically at creation; the endpoint remains as a
* no-op-safe retry for older bookings.
*/
async governmentExpedite(id: string, staffUserId: string): Promise<Booking> {
const booking = await this.findById(id);
if (!booking.isGovernment) {
throw new BadRequestException('Only government bookings can be expedited');
}
const blocked = ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED'];
// Idempotent: create() already expedites — a repeat call changes nothing.
if (booking.status === 'PAID') return booking;
const blocked = ['IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED'];
if (blocked.includes(booking.status)) {
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
}
@@ -1818,12 +2182,22 @@ export class BookingsService {
holdStartedAt: null,
holdExpiresAt: null,
});
await this.bookingContractService.generateContractForGovernment(id);
await this.bookingsRepository.createReviewNote(
id,
`Government booking expedited to PAID by staff (${staffUserId})`,
'STAFF_NOTE',
staffUserId,
);
// Priority placement: run the day-level fill now instead of waiting for a
// batch tick — the pool sorts government first and preempts if needed.
if (booking.scheduledDate) {
this.bookingBatchService.enqueueRouteDayProcessing(
booking.originYardId,
booking.destinationYardId,
eatDay(booking.scheduledDate),
);
}
return this.findById(id);
}

View File

@@ -0,0 +1,72 @@
import { buildCargoTypeTree } from './booking-reference-data.service';
import type { CargoType } from '../rule-engine/entities/cargo-type.entity';
const node = (
id: string,
name: string,
parentGroupId: string | null,
isActive = true,
): CargoType =>
({
id,
cargoTypeName: name,
code: name.toUpperCase().replace(/\s+/g, '_'),
parentGroupId,
displayOrder: 0,
isActive,
unitOfMeasure: 'PER_TON',
}) as unknown as CargoType;
describe('buildCargoTypeTree', () => {
// Bulk ──┬─ Wheat (leaf, depth 2)
// └─ Steel Billet ──┬─ S1 (leaf, depth 3)
// └─ S2 ─ S2a (leaf, depth 4)
const rows = [
node('bulk', 'Bulk', null),
node('wheat', 'Wheat', 'bulk'),
node('steel', 'Steel Billet', 'bulk'),
node('s1', 'S1', 'steel'),
node('s2', 'S2', 'steel'),
node('s2a', 'S2a', 's2'),
node('general', 'General Cargo', null),
];
it('offers only leaves as commodities, at any depth', () => {
const [bulk] = buildCargoTypeTree(rows);
// Leaves stay grouped under their branch (siblings ordered by
// displayOrder then code — STEEL_BILLET before WHEAT here).
expect(bulk.children?.map((c) => c.id)).toEqual(['s1', 's2a', 'wheat']);
// "Steel Billet" is a container for finer types, never bookable itself.
expect(bulk.children?.some((c) => c.id === 'steel')).toBe(false);
});
it('labels deep leaves with their path below the group', () => {
const [bulk] = buildCargoTypeTree(rows);
const byId = new Map(bulk.children?.map((c) => [c.id, c.name]));
expect(byId.get('wheat')).toBe('Wheat');
expect(byId.get('s1')).toBe('Steel Billet → S1');
expect(byId.get('s2a')).toBe('Steel Billet → S2 → S2a');
});
it('emits a childless group as its own commodity', () => {
const general = buildCargoTypeTree(rows).find((g) => g.id === 'general');
expect(general?.children).toEqual([
expect.objectContaining({ id: 'general', name: 'General Cargo' }),
]);
});
it('skips inactive nodes and their descendants', () => {
const withRetired = [
...rows,
node('retired', 'Retired', 'bulk', false),
node('retiredKid', 'Retired Kid', 'retired', false),
];
const [bulk] = buildCargoTypeTree(withRetired);
expect(bulk.children?.map((c) => c.id)).not.toContain('retired');
expect(bulk.children?.map((c) => c.id)).not.toContain('retiredKid');
});
});

View File

@@ -0,0 +1,26 @@
import { BookingsService } from './bookings.service';
// The split is a pure helper on the prototype (never touches `this`), so it can be
// exercised without constructing the service and its dependency graph.
const split = (total: number, weights: number[]): number[] =>
(
BookingsService.prototype as unknown as {
splitAmountAcrossWagons(total: number, weights: number[]): number[];
}
).splitAmountAcrossWagons(total, weights);
describe('carriage acceptance sheet — price split', () => {
it('splits proportionally to allocated weight', () => {
expect(split(100, [30, 10])).toEqual([75, 25]);
});
it('splits equally when no weights are recorded', () => {
expect(split(90, [0, 0, 0])).toEqual([30, 30, 30]);
});
it('always sums back to the booking total despite rounding', () => {
const shares = split(100, [1, 1, 1]);
expect(shares.reduce((a, b) => a + b, 0)).toBe(100);
expect(shares).toEqual([33.33, 33.33, 33.34]);
});
});

View File

@@ -11,10 +11,8 @@ describe('clearance.util — clearanceSettingCode', () => {
expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe(
'clearance_import_container_with_customs',
);
// Non-customs bookings self-clear with the same document set a ONE_TIME
// self-clear contract uses.
expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe(
'contract_clearance_selfclear_import_container',
'clearance_import_container_without_customs',
);
});
@@ -23,7 +21,7 @@ describe('clearance.util — clearanceSettingCode', () => {
'clearance_export_bulk_with_customs',
);
expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe(
'contract_clearance_selfclear_export_bulk',
'clearance_export_bulk_without_customs',
);
});
@@ -62,13 +60,15 @@ describe('clearance.util — clearanceCodesForBooking (intercity)', () => {
expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
});
it('ONE_TIME contract drawdowns skip the per-booking set (contract collected it)', () => {
it('ONE_TIME contract shipments carry the same per-booking set', () => {
// Contracts no longer collect clearance documents — every shipment does,
// whatever kind of contract it draws on.
const drawdown = clearanceCodesForBooking({
...base,
contractId: 'c1',
contractKind: 'ONE_TIME',
} as unknown as Booking);
expect(drawdown.inputCode).toBeNull();
expect(drawdown.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
expect(drawdown.outputCode).toBeNull();
});
});

View File

@@ -11,9 +11,8 @@ type Freight = 'container' | 'bulk';
/**
* The single (admin-configured) document set intercity shipments upload.
* DOMESTIC has no customs, so one shared set serves contracts and bookings:
* ONE_TIME collects it at contract level, GENERAL per booking — Operations
* reviews either way.
* DOMESTIC has no customs, so one shared set serves every intercity booking
* ONE_TIME and GENERAL alike, collected per booking and reviewed by Operations.
*/
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
@@ -40,12 +39,11 @@ export function clearanceSettingCode(
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);
// Non-customs (Path A) bookings self-clear: the customer proves his own
// clearance with the SAME smaller document set a ONE_TIME self-clear
// contract uses (customs declaration, release permit, …) — not the
// GL-oriented booking sets.
// 4 import + 4 export cases (bulk/container × with/without customs) — each
// booking resolves to its own clearance_{op}_{freight}_{with|without}_customs
// set, independent of any contract-level clearance codes.
if (!includesCustoms) {
return `contract_clearance_selfclear_${op}_${freight}`;
return `clearance_${op}_${freight}_without_customs`;
}
return `clearance_${op}_${freight}_with_customs`;
}
@@ -77,16 +75,6 @@ export function clearanceCodesForBooking(booking: Booking): {
const includesCustoms =
Boolean(booking.serviceType?.includesCustoms) ||
Boolean(booking.customsClearingEnabled);
// Intercity drawdowns under a ONE_TIME contract already cleared the intercity
// document set on the CONTRACT (post-signature); only GENERAL drawdowns and
// direct (contract-less) bookings carry the per-booking set.
if (
booking.tradeDirection === 'DOMESTIC' &&
booking.contractId &&
booking.contractKind === 'ONE_TIME'
) {
return { inputCode: null, outputCode: null, includesCustoms: false };
}
return {
inputCode: clearanceSettingCode(
booking.tradeDirection,

View File

@@ -85,6 +85,24 @@ export class CustomerTruckService {
if (isBulk) {
const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId);
assertBulkTonnageRemains(totalTons, remainingTons);
// Assignment-time drawdown: planned tonnage across live trucks (weighed
// net once departed, planned before) may not exceed the declared total.
if (totalTons > 0) {
const [p]: Array<{ planned: string | null }> = await this.dataSource.query(
`SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned
FROM freight.customer_truck_assignments a
WHERE a.booking_id = $1 AND a.deleted_at IS NULL`,
[bookingId],
);
const alreadyPlanned = Number(p?.planned ?? 0);
const requestedTons = Number(dto.plannedTons ?? 0);
if (requestedTons > 0 && alreadyPlanned + requestedTons > totalTons + 0.001) {
throw new BadRequestException(
`Planned tonnage exceeds the booking: ${alreadyPlanned} t already assigned of ${totalTons} t — at most ${Math.max(0, totalTons - alreadyPlanned)} t left for this truck`,
);
}
}
}
if (requested.length) {
@@ -108,6 +126,8 @@ export class CustomerTruckService {
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
driverName: dto.driverName.trim(),
truckType: dto.truckType.trim(),
plannedTons: isBulk ? (dto.plannedTons ?? null) : null,
plannedQuantity: isBulk ? (dto.plannedQuantity ?? null) : null,
}),
);
await manager.getRepository(CustomerTruckContainer).save(
@@ -186,10 +206,16 @@ export class CustomerTruckService {
throw new ConflictException('Cannot edit a truck that has already arrived');
}
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (requested.length < 1) {
// Bulk trucks carry loose tonnage, not containers — planned tonnage is
// editable instead, capped by what the other trucks haven't claimed.
const isBulk = booking.freightType === 'BULK';
const requested = isBulk
? []
: (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (!isBulk && requested.length < 1) {
throw new BadRequestException('Select at least one container for this truck');
}
if (!isBulk) {
assertTruckLoad({
containers: requested,
bookingContainers: await this.bookingContainerNumbers(bookingId),
@@ -197,12 +223,35 @@ export class CustomerTruckService {
// Exclude THIS truck's own containers so re-saving the same set is allowed.
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
});
} else if (dto.plannedTons != null) {
const { totalTons } = await remainingBulkTons(this.dataSource, bookingId);
if (totalTons > 0) {
const [p]: Array<{ planned: string | null }> = await this.dataSource.query(
`SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned
FROM freight.customer_truck_assignments a
WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.id <> $2`,
[bookingId, assignmentId],
);
const others = Number(p?.planned ?? 0);
if (others + Number(dto.plannedTons) > totalTons + 0.001) {
throw new BadRequestException(
`Planned tonnage exceeds the booking: ${others} t on other trucks of ${totalTons} t — at most ${Math.max(0, totalTons - others)} t left for this truck`,
);
}
}
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
driverName: dto.driverName.trim(),
truckType: dto.truckType.trim(),
...(isBulk
? {
plannedTons: dto.plannedTons ?? null,
plannedQuantity: dto.plannedQuantity ?? null,
}
: {}),
});
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
await manager.getRepository(CustomerTruckContainer).save(

View File

@@ -4,10 +4,12 @@ import {
IsArray,
IsIn,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
} from 'class-validator';
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
@@ -44,4 +46,16 @@ export class AddCustomerTruckDto {
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})
containerNumbers?: string[];
/** Bulk: planned tonnage this truck hauls — draws down the booking total at assignment. */
@IsOptional()
@IsNumber()
@Min(0)
plannedTons?: number;
/** Bulk: optional item/piece count on this truck. */
@IsOptional()
@IsNumber()
@Min(0)
plannedQuantity?: number;
}

View File

@@ -13,6 +13,18 @@ export class BookingReferenceYardDto {
@ApiProperty({ example: 'Ethiopia' })
country!: string;
@ApiProperty({ description: 'Can load containers onto a train here.' })
hasContainerFacilityOrigin!: boolean;
@ApiProperty({ description: 'Can load bulk cargo onto a train here.' })
hasBulkFacilityOrigin!: boolean;
@ApiProperty({ description: 'Can receive containers off a train here.' })
hasContainerFacilityDestination!: boolean;
@ApiProperty({ description: 'Can receive bulk cargo off a train here.' })
hasBulkFacilityDestination!: boolean;
}
export class BookingReferenceContainerTypeDto {

View File

@@ -20,6 +20,9 @@ export class SavedSignatureViewDto {
@ApiPropertyOptional()
signatureImageUrl?: string | null;
@ApiPropertyOptional()
stampImageUrl?: string | null;
}
export class ContractViewDto {

View File

@@ -74,6 +74,17 @@ export class CreateBookingContainerDto {
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
reeferQuantity?: number;
@ApiPropertyOptional({
description:
'Physical container numbers for this line (each becomes a booking_container_unit; extras beyond `quantity` are ignored)',
type: [String],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
@MaxLength(64, { each: true })
containerNumbers?: string[];
}
/**
@@ -325,6 +336,21 @@ export class CreateBookingDto {
@Transform(({ value }) => Number(value))
cargoTotalWeightVgm!: number;
/**
* Break-bulk only: actual total cargo weight in tons when the bulk cargo
* type is PER_ITEM — `cargoTotalWeightVgm` then carries the item count.
* Omit for PER_TON bulk and container freight.
*/
@ApiPropertyOptional({
minimum: 0,
description: 'Break-bulk (PER_ITEM) total weight in tons; cargoTotalWeightVgm holds the item count',
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => (value == null ? undefined : Number(value)))
bulkTotalWeightTons?: number;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()

View File

@@ -5,6 +5,7 @@ import {
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
Min,
MinLength,
@@ -93,6 +94,17 @@ export class RequestOperationDto {
})
@IsDateString()
scheduledDate!: string;
@ApiPropertyOptional({
description:
'EXPORT rail only: the specific train (schedule id) the customer picked ' +
'from GET /bookings/:id/export-trains. The reserve path locks onto this ' +
'train instead of earliest-first; 409 if it no longer fits. Ignored for ' +
'import/domestic/road bookings.',
})
@IsOptional()
@IsUUID()
trainScheduleId?: string;
}
export class OperationReviewDto {

View File

@@ -2,7 +2,16 @@ import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION', 'STAFF_NOTE'] as const;
export const REVIEW_NOTE_TYPES = [
'CHANGES_REQUESTED',
'REJECTION',
'STAFF_NOTE',
/**
* The customer asked GL Ethiopia to correct the draft customs declaration
* (price/files). One row per round — the draft/change-request loop can repeat.
*/
'DRAFT_DECL_CHANGE_REQUEST',
] as const;
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
@Entity({ schema: 'freight', name: 'booking_review_note' })

View File

@@ -302,6 +302,20 @@ export class Booking extends BaseEntity {
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
customerTruckArrivedAt?: Date | null;
/**
* Did the goods need re-handling in the warehouse? Recorded by warehouse
* staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule;
* null = not yet decided (no charge).
*/
@Column({ name: 'double_handling', type: 'boolean', nullable: true })
doubleHandling?: boolean | null;
@Column({ name: 'double_handling_set_at', type: 'timestamptz', nullable: true })
doubleHandlingSetAt?: Date | null;
@Column({ name: 'double_handling_set_by', type: 'varchar', length: 160, nullable: true })
doubleHandlingSetBy?: string | null;
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
customsClearingEnabled!: boolean;
@@ -351,6 +365,15 @@ export class Booking extends BaseEntity {
@Column({ name: 'cargo_total_weight_vgm', type: 'numeric', precision: 12, scale: 3 })
cargoTotalWeightVgm!: number;
/**
* Break-bulk only: actual total cargo weight in tons when the bulk cargo
* type is PER_ITEM (`cargoTotalWeightVgm` then carries the item COUNT).
* Null for PER_TON bulk and all CONTAINER bookings. Wagon allocation uses
* weight ÷ count to size indivisible items per wagon.
*/
@Column({ name: 'bulk_total_weight_tons', type: 'numeric', precision: 12, scale: 3, nullable: true })
bulkTotalWeightTons?: number | null;
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
isHazardous!: boolean;
@@ -485,6 +508,18 @@ export class Booking extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;
/**
* EXPORT only: the specific train the customer picked at day-commit.
* pickExportSchedule reserves on this train (409 if it no longer fits)
* instead of falling back to earliest-departure-first. NULL = no preference.
*/
@Column({ name: 'requested_train_schedule_id', type: 'uuid', nullable: true })
requestedTrainScheduleId?: string | null;
/** Stamped when the one pre-deadline pay reminder went out (tick dedup). */
@Column({ name: 'payment_reminder_sent_at', type: 'timestamptz', nullable: true })
paymentReminderSentAt?: Date | null;
// ── Per-booking journey (segment corridor bookings) ────────────────────────
// A booking rides only its own origin→destination leg of the train's route,
// so dispatch/arrival are per-booking facts, not train facts. Clearance gates
@@ -526,6 +561,14 @@ export class Booking extends BaseEntity {
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
vesselDepartureDate?: string | null;
/** Import DO: when the vessel arrived in Djibouti. Required on DO upload. */
@Column({ name: 'vessel_arrival_date', type: 'date', nullable: true })
vesselArrivalDate?: string | null;
/** Import DO: when GL Djibouti collected the DO. Required on DO upload. */
@Column({ name: 'do_collected_date', type: 'date', nullable: true })
doCollectedDate?: string | null;
@Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true })
roAmendmentRequestedAt?: Date | null;
@@ -535,6 +578,23 @@ export class Booking extends BaseEntity {
@Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true })
preClearanceFinalizedAt?: Date | null;
/**
* Pre-declaration handshake: GL Ethiopia asks GL Djibouti who will handle this
* shipment in transit, Djibouti answers with a name (free text — the officer is
* not a platform user). The import declaration is blocked until `name` is set.
*/
@Column({ name: 'transit_assignee_requested_at', type: 'timestamptz', nullable: true })
transitAssigneeRequestedAt?: Date | null;
@Column({ name: 'transit_assignee_request_note', type: 'text', nullable: true })
transitAssigneeRequestNote?: string | null;
@Column({ name: 'transit_assignee_name', type: 'text', nullable: true })
transitAssigneeName?: string | null;
@Column({ name: 'transit_assignee_assigned_at', type: 'timestamptz', nullable: true })
transitAssigneeAssignedAt?: Date | null;
/** GL staff user bound to this shipment by the station manager. */
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
glAssignedStaffId?: string | null;

View File

@@ -51,6 +51,14 @@ export class CustomerTruckAssignment extends BaseEntity {
@Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
netWeightTons?: number | null;
/** Bulk: planned tonnage at assignment — draws down the booking before weigh-out. */
@Column({ name: 'planned_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
plannedTons?: number | null;
/** Bulk: optional item/piece count planned on this truck. */
@Column({ name: 'planned_quantity', type: 'integer', nullable: true })
plannedQuantity?: number | null;
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
departedAt?: Date | null;

View File

@@ -35,6 +35,10 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
import {
CompanyIdentityStateDto,
CompleteIdentityVerificationDto,
} from "./dto/complete-identity-verification.dto";
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
@@ -58,6 +62,7 @@ import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-stat
import { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
import { RequestDocumentChangeDto } from "./dto/request-document-change.dto";
import { ChangeRequestResponseDto } from "./dto/change-request-response.dto";
import { CompanyRevisionResponseDto } from "./dto/company-revision-response.dto";
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
import { ETradeResponseDto } from "./dto/etrade-response.dto";
@@ -188,9 +193,20 @@ export class CompaniesController {
@Post("fetch-etrade-info")
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
async fetchETradeInfo(
@CurrentUser() user: CurrentIamUser,
@Body() dto: FetchETradeDto,
): Promise<ETradeResponseDto> {
const data = await this.companiesService.fetchETradeData(dto.tin);
// Best-effort: a first-run onboarding draft may not exist yet, in which
// case there is no company to exclude and `tinTaken` checks every row —
// the correct behaviour for a brand-new lookup.
const companyId = await this.companiesService
.getCompanyInfoByUserId(user.id)
.then(({ company }) => company.id)
.catch(() => undefined);
const data = await this.companiesService.fetchETradeData(
dto.tin,
companyId,
);
return new ETradeResponseDto(data);
}
@@ -378,6 +394,32 @@ export class CompaniesController {
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
}
@Post("identity/fayda/complete")
@ApiOperation({
summary:
"Bind a completed Fayda verification to the company's owner or Power of Attorney. " +
"Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " +
"The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.",
})
async completeIdentityVerification(
@CurrentUser() user: CurrentIamUser,
@Body() dto: CompleteIdentityVerificationDto,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.completeIdentityVerification(user.id, dto);
}
@Delete("identity/fayda/poa")
@ApiOperation({
summary:
"Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " +
"Refused while the company holds a freight forwarder role, which cannot operate without a representative.",
})
async removePoaIdentity(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.removePoaIdentity(user.id);
}
@Patch("onboarding-step")
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
@HttpCode(HttpStatus.NO_CONTENT)
@@ -644,6 +686,17 @@ export class CompaniesController {
return requests.map((r) => new ChangeRequestResponseDto(r));
}
@Get(":companyId/revisions")
@BookingStaff(FREIGHT_PERMS.customers.view)
@ApiOperation({ summary: "Onboarding-phase edit history (version history)" })
async listCompanyRevisions(
@Param("companyId", ParseUUIDPipe) companyId: string,
): Promise<CompanyRevisionResponseDto[]> {
const revisions =
await this.companiesService.listCompanyRevisions(companyId);
return revisions.map((r) => new CompanyRevisionResponseDto(r));
}
@Post("change-requests/:id/approve")
@BookingStaff(FREIGHT_PERMS.customers.verify)
@ApiOperation({
@@ -678,6 +731,25 @@ export class CompaniesController {
return new ChangeRequestResponseDto(request);
}
@Post("change-requests/:id/request-changes")
@BookingStaff(FREIGHT_PERMS.customers.verify)
@ApiOperation({
summary:
"Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)",
})
async requestChangeRequestChanges(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RejectChangeRequestDto,
): Promise<ChangeRequestResponseDto> {
const request = await this.companiesService.requestChangeRequestChanges(
id,
dto.note,
user.id,
);
return new ChangeRequestResponseDto(request);
}
@Post(":companyId/profiles")
@BookingStaff(FREIGHT_PERMS.customers.update)
@ApiOperation({ summary: "Add a profile (employee) to a company" })

View File

@@ -0,0 +1,466 @@
import { BadRequestException } from "@nestjs/common";
import { CompaniesService } from "./companies.service";
import { CompanyNationality, CompanyStatus } from "./entities/company.entity";
import { ProfileType } from "./entities/company-profile.entity";
import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants";
/**
* A person's identity is proved through Fayda: name, email, phone and address
* come from the verified payload, not typed. Fayda's userinfo carries no
* national ID number, so none is collected or derived here.
*
* Only the OWNER's credential varies by nationality:
* - Ethiopian company: the owner is verified through Fayda.
* - Foreign company: Fayda is an Ethiopian national ID, so the owner instead
* supplies a typed passport number — required on its own, whether or not the
* owner also completes a (purely optional) Fayda verification.
*
* The PoA does not vary. A representative acts for the company inside Ethiopia
* whoever owns it, so a PoA is always an Ethiopian holding a Fayda ID: once one
* is named, both nationalities must verify them, and their details come from
* the verified payload rather than the form.
*
* The owner is NOT the general manager — GM is a separate, plain typed role
* the portal offers a "same as owner" copy for, but it is never itself
* Fayda-verified or gated on.
*/
interface Ctx {
attributes: Record<string, unknown>;
files: { id: string; code: string; reviewStatus?: string | null }[];
profileTypes: ProfileType[];
status: CompanyStatus;
nationality: CompanyNationality;
verification: Record<string, unknown>;
}
const OWNER_VERIFIED = {
ownerFaydaSub: "owner-sub",
ownerFaydaVerifiedAt: "2026-07-01T00:00:00.000Z",
ownerName: "Abebe Bikila",
};
const POA_VERIFIED = {
poaFaydaSub: "poa-sub",
poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z",
poaName: "Tirunesh Dibaba",
poaEmail: "tirunesh@example.com",
poaPhone: "+251911000000",
};
const paper = () => ({
id: "file-1",
code: POA_DELEGATION_FILE_KEY,
reviewStatus: null,
});
function makeService(overrides: Partial<Ctx> = {}) {
const ctx: Ctx = {
attributes: {},
files: [],
profileTypes: [ProfileType.importer],
status: CompanyStatus.Pending,
nationality: CompanyNationality.Ethiopian,
verification: {
purpose: "VERIFY",
verified: true,
sub: "new-sub",
fullName: "Haile Gebrselassie",
email: "haile@example.com",
phoneNumber: "+251922000000",
address: "Addis Ababa",
birthdate: "1973-04-18",
gender: "Male",
},
...overrides,
};
const company = () => ({
id: "company-1",
status: ctx.status,
nationality: ctx.nationality,
attributes: ctx.attributes,
companyProfiles: ctx.profileTypes.map((type, i) => ({
id: `profile-${i}`,
type,
})),
type: "customer",
});
const deps = {
companiesRepo: {
findById: jest.fn(async () => company()),
update: jest.fn(async (_id: string, patch: Record<string, unknown>) => {
if (patch.attributes)
ctx.attributes = patch.attributes as Record<string, unknown>;
return company();
}),
findByTin: jest.fn(async () => null),
},
companyProfilesRepo: {
findByCompanyId: jest.fn(async () =>
ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })),
),
findByType: jest.fn(async (_id: string, type: ProfileType) =>
ctx.profileTypes.includes(type) ? { id: "existing", type } : null,
),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "new",
...row,
})),
},
changeRequestRepo: {
findPendingByCompanyId: jest.fn(async () => null),
findLatestOpenByCompanyId: jest.fn(async () => null),
findByCompanyId: jest.fn(async () => []),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "cr-1",
...row,
})),
update: jest.fn(async () => ({ id: "cr-1" })),
},
revisionRepo: {
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "rev-1",
...row,
})),
findByCompanyId: jest.fn(async () => []),
},
profilesRepo: {
findByCompanyId: jest.fn(async () => []),
findByUserId: jest.fn(async () => ({
id: "external-1",
companyId: "company-1",
company: company(),
onboardingCompleted: false,
})),
},
filesService: {
findByResource: jest.fn(async () => ctx.files),
findById: jest.fn(async () => null),
remove: jest.fn(async () => undefined),
},
companyNotifier: { changeRequestSubmitted: jest.fn() },
verifayda: {
completeVerification: jest.fn(async () => ctx.verification),
},
};
const service = new CompaniesService(
deps.companiesRepo as never,
deps.companyProfilesRepo as never,
deps.changeRequestRepo as never,
deps.revisionRepo as never,
deps.profilesRepo as never,
{} as never,
deps.filesService as never,
{} as never,
{} as never,
deps.companyNotifier as never,
{} as never,
deps.verifayda as never,
);
jest
.spyOn(service, "getCompanyInfoByUserId")
.mockImplementation(
async () =>
({ profile: { id: "external-1" }, company: company() }) as never,
);
return { service, ctx, deps, company };
}
describe("Fayda identity verification binds a person to the company", () => {
it("writes the verified identity", async () => {
const { service, ctx } = makeService();
const state = await service.completeIdentityVerification("user-1", {
subject: "owner",
code: "c",
state: "s",
});
expect(ctx.attributes.ownerFaydaSub).toBe("new-sub");
expect(ctx.attributes.ownerName).toBe("Haile Gebrselassie");
expect(state.owner.verified).toBe(true);
});
it("fills every PoA detail from the payload, address included", async () => {
const { service, ctx } = makeService();
await service.completeIdentityVerification("user-1", {
subject: "poa",
code: "c",
state: "s",
});
expect(ctx.attributes.poaName).toBe("Haile Gebrselassie");
expect(ctx.attributes.poaEmail).toBe("haile@example.com");
expect(ctx.attributes.poaPhone).toBe("+251922000000");
expect(ctx.attributes.poaAddress).toBe("Addis Ababa");
});
it("verifies successfully even though Fayda returns no national ID number", async () => {
// Fayda's userinfo carries no FAN/FIN claim at all — this must be the
// normal, successful path, not an error.
const { service } = makeService({
verification: {
purpose: "VERIFY",
verified: true,
sub: "x",
fullName: "No Fan Here",
},
});
const state = await service.completeIdentityVerification("user-1", {
subject: "owner",
code: "c",
state: "s",
});
expect(state.owner.verified).toBe(true);
});
it("refuses to make one identity both owner and PoA", async () => {
const { service } = makeService({
attributes: { ownerFaydaSub: "same-person" },
verification: {
purpose: "VERIFY",
verified: true,
sub: "same-person",
fullName: "Abebe Bikila",
},
});
await expect(
service.completeIdentityVerification("user-1", {
subject: "poa",
code: "c",
state: "s",
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it("stages an owner re-verification for review on an approved company", async () => {
// The owner is the live company's identity proof, so re-verifying one is
// exactly what the backoffice review exists for: it must not rewrite the
// row directly.
const { service, ctx, deps } = makeService({
status: CompanyStatus.Active,
});
await service.completeIdentityVerification("user-1", {
subject: "owner",
code: "c",
state: "s",
});
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
expect(ctx.attributes.ownerFaydaSub).toBeUndefined();
});
it("applies a PoA verification live on an approved company", async () => {
// The PoA is personnel the company names for itself — the delegation paper
// is what a reviewer actually judges — so it does not go to review.
const { service, ctx, deps } = makeService({
status: CompanyStatus.Active,
});
await service.completeIdentityVerification("user-1", {
subject: "poa",
code: "c",
state: "s",
});
expect(deps.changeRequestRepo.create).not.toHaveBeenCalled();
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
});
it("refuses to rename a verified person by hand", async () => {
const { service } = makeService({
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
files: [paper()],
});
await expect(
service.updateProfile("user-1", { poaName: "Someone Else" } as never),
).rejects.toBeInstanceOf(BadRequestException);
});
it("never locks or gates the general manager — it is not the verified subject", async () => {
// GM is a plain typed role; the portal offers a "same as owner" copy, but
// the backend must not treat it as identity-owned or require it verified.
const { service } = makeService({
attributes: { ...OWNER_VERIFIED },
});
await expect(
service.updateProfile("user-1", {
generalManagerName: "Someone Else",
generalManagerEmail: "someone@example.com",
generalManagerPhone: "+251911223344",
} as never),
).resolves.toBeDefined();
});
});
describe("Ethiopian companies verify with Fayda; foreign companies verify identity by passport", () => {
// The company is applying for the forwarder role, so it must not already
// hold it — createCompanyProfileForUser short-circuits on an existing profile
// and would never reach the gate.
const applyingForFf = {
profileTypes: [ProfileType.importer],
attributes: { ...POA_VERIFIED },
files: [paper()],
};
it("blocks the forwarder role while the owner is unverified", async () => {
const { service } = makeService(applyingForFf);
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it("blocks the forwarder role while the PoA is unverified", async () => {
const { service } = makeService({
profileTypes: [ProfileType.importer],
attributes: {
...OWNER_VERIFIED,
poaName: "Tirunesh Dibaba",
poaEmail: "t@example.com",
poaPhone: "+251911000000",
},
files: [paper()],
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it("grants the forwarder role once owner and PoA are both verified", async () => {
const { service } = makeService({
profileTypes: [ProfileType.importer],
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
files: [paper()],
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
});
it("never asks a foreign company for Fayda, verified or not", async () => {
const { service } = makeService({
nationality: CompanyNationality.Foreign,
});
const state = await service.completeIdentityVerification("user-1", {
subject: "owner",
code: "c",
state: "s",
});
// Still lets the owner verify — a foreign owner verifying is allowed, just
// never required — but the passport is the thing that actually gates it.
expect(state.owner.verified).toBe(true);
expect(state.faydaRequired).toBe(false);
expect(state.passportRequired).toBe(true);
});
it("blocks the forwarder role for a foreign company with no owner passport", async () => {
const { service } = makeService({
profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign,
attributes: {
poaName: "Jean Dupont",
poaEmail: "jean@example.com",
poaPhone: "+33100000000",
},
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it("grants the forwarder role to a foreign company whose owner has a passport and whose PoA is Fayda-verified", async () => {
const { service } = makeService({
profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign,
attributes: {
ownerPassportNumber: "P1234567",
...POA_VERIFIED,
},
files: [paper()],
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
});
it("still requires a Fayda-verified PoA from a foreign company", async () => {
// The owner's credential is nationality-specific; the representative's is
// not. A PoA acts for the company inside Ethiopia whoever owns it, so a
// typed foreign name is not a representative the platform can accept.
const { service } = makeService({
profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign,
attributes: {
ownerPassportNumber: "P1234567",
poaName: "Jean Dupont",
poaEmail: "jean@example.com",
poaPhone: "+33100000000",
},
files: [paper()],
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {
// Verifying is optional for a foreign owner, but it does not waive the
// passport requirement — the two are independent credentials.
const { service } = makeService({
profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign,
attributes: {
...OWNER_VERIFIED,
poaName: "Jean Dupont",
poaEmail: "jean@example.com",
poaPhone: "+33100000000",
},
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).rejects.toBeInstanceOf(BadRequestException);
});
});

View File

@@ -15,11 +15,14 @@ import { Company } from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import { CompanyProfile } from "./entities/company-profile.entity";
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
import { CompanyRevision } from "./entities/company-revision.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { CompanyRevisionRepository } from "./company-revision.repository";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
import { VerifaydaModule } from "../verifayda/verifayda.module";
@Module({
imports: [
@@ -28,6 +31,7 @@ import { CompanyNotifierService } from "./company-notifier.service";
ExternalProfile,
CompanyProfile,
CompanyChangeRequest,
CompanyRevision,
Booking,
]),
HttpModule,
@@ -38,6 +42,8 @@ import { CompanyNotifierService } from "./company-notifier.service";
// imports this module back for portal recipient targeting, hence forwardRef.
NotificationsModule,
forwardRef(() => NotificationInboxModule),
// Fayda identity verification for the company's owner and PoA.
VerifaydaModule,
],
controllers: [CompaniesController],
providers: [
@@ -46,6 +52,7 @@ import { CompanyNotifierService } from "./company-notifier.service";
ExternalProfileRepository,
CompanyProfileRepository,
CompanyChangeRequestRepository,
CompanyRevisionRepository,
CompanyDashboardRepository,
ETradeService,
CompanyNotifierService,

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