mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into update-freight-migrations
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
440
E2E_TEST_REPORT.md
Normal file
440
E2E_TEST_REPORT.md
Normal 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 & 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 2–5 then approve and sign *that* contract. Because test 1 could not finish, there was no fresh contract, so tests 2–5 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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,13 +36,13 @@
|
||||
"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": "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",
|
||||
|
||||
@@ -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";
|
||||
@@ -99,6 +103,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 +157,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,
|
||||
@@ -225,11 +244,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 +279,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 +333,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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
40
apps/edr-freight-api/src/common/grn.util.spec.ts
Normal file
40
apps/edr-freight-api/src/common/grn.util.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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)])),
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,6 +2,7 @@ import "reflect-metadata";
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import {
|
||||
HttpExceptionFilter,
|
||||
@@ -11,8 +12,25 @@ 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';
|
||||
|
||||
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 +46,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 +61,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
|
||||
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { PaymentService } from "../payment/payment.service";
|
||||
import { InitiateResponseDto } from "../payment/payments.dto";
|
||||
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
InvoiceDocumentService,
|
||||
@@ -352,6 +352,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 +1011,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: {
|
||||
@@ -1002,11 +1041,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,
|
||||
@@ -1034,7 +1101,12 @@ export class BillingService {
|
||||
|
||||
// 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) {
|
||||
// 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.
|
||||
if (
|
||||
!result.immediateSuccess &&
|
||||
result.response.clientAction?.type !== "COLLECT_OTP"
|
||||
) {
|
||||
await this.payment.handlePaymentEvent({
|
||||
eventType: "payment.succeeded",
|
||||
eventId: `demo-${result.intentId}`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
assertBookingStatus(booking, ['CONTRACT_READY']);
|
||||
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 {
|
||||
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
|
||||
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;
|
||||
updates.lockedAt = now;
|
||||
updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED';
|
||||
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 {
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 commodity’s 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 line’s 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -33,41 +33,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 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>()));
|
||||
|
||||
const group: BookingReferenceCargoTypeGroupDto = {
|
||||
return {
|
||||
id: parent.id,
|
||||
name: parent.cargoTypeName,
|
||||
code: parent.code,
|
||||
children,
|
||||
};
|
||||
if (children.length > 0) {
|
||||
group.children = children;
|
||||
}
|
||||
return group;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -464,6 +464,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 +771,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 +875,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 +952,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 +1049,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 +1323,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) {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
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';
|
||||
@@ -15,6 +23,7 @@ import {
|
||||
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 +35,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;
|
||||
@@ -68,6 +93,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 +184,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 +202,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 +233,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 +633,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,
|
||||
@@ -1015,6 +1110,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 +1134,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,15 +1159,23 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType });
|
||||
}
|
||||
|
||||
if (options.originStationId) {
|
||||
qb.andWhere('booking.originYardId = :originStationId', {
|
||||
originStationId: options.originStationId,
|
||||
});
|
||||
}
|
||||
if (options.destinationStationId) {
|
||||
qb.andWhere('booking.destinationYardId = :destinationStationId', {
|
||||
destinationStationId: options.destinationStationId,
|
||||
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,
|
||||
});
|
||||
}
|
||||
if (options.destinationStationId) {
|
||||
qb.andWhere('booking.destinationYardId = :destinationStationId', {
|
||||
destinationStationId: options.destinationStationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (options.schedulingStatus) {
|
||||
qb.andWhere('booking.scheduling_status = :schedulingStatus', {
|
||||
@@ -1089,10 +1205,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 +1242,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 +1280,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')
|
||||
@@ -1248,6 +1358,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
|
||||
|
||||
@@ -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:
|
||||
@@ -1800,13 +2153,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 +2180,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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,23 +206,52 @@ 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');
|
||||
}
|
||||
assertTruckLoad({
|
||||
containers: requested,
|
||||
bookingContainers: await this.bookingContainerNumbers(bookingId),
|
||||
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
||||
// Exclude THIS truck's own containers so re-saving the same set is allowed.
|
||||
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
|
||||
});
|
||||
if (!isBulk) {
|
||||
assertTruckLoad({
|
||||
containers: requested,
|
||||
bookingContainers: await this.bookingContainerNumbers(bookingId),
|
||||
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
||||
// 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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ export class SavedSignatureViewDto {
|
||||
|
||||
@ApiPropertyOptional()
|
||||
signatureImageUrl?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
stampImageUrl?: string | null;
|
||||
}
|
||||
|
||||
export class ContractViewDto {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { CompanyStatus } from "./entities/company.entity";
|
||||
import { ProfileType } from "./entities/company-profile.entity";
|
||||
import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants";
|
||||
|
||||
/**
|
||||
* EDRFREIGHT-358: a company that names a Power of Attorney must have the DARS
|
||||
* delegation paper on file. The rule used to live only in the onboarding
|
||||
* wizard's completion check, so every other write that could break the pairing
|
||||
* — saving PoA details, deleting the paper, picking up the forwarder role —
|
||||
* went unguarded. These cover those writes.
|
||||
*/
|
||||
|
||||
interface Ctx {
|
||||
attributes: Record<string, unknown>;
|
||||
files: { id: string; code: string; reviewStatus?: string | null }[];
|
||||
profileTypes: ProfileType[];
|
||||
status: CompanyStatus;
|
||||
pendingSnapshot: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const POA = { poaName: "Abebe", poaEmail: "a@b.com", poaPhone: "+251911000000" };
|
||||
|
||||
/**
|
||||
* The forwarder role is gated on Fayda-verified identities as well as on the
|
||||
* delegation paper. These tests are about the paper, so they run against a
|
||||
* company whose identities are already verified — the identity rule itself is
|
||||
* covered in companies.fayda-identity.spec.ts.
|
||||
*/
|
||||
const VERIFIED_IDENTITIES = {
|
||||
ownerFaydaSub: "owner-sub",
|
||||
poaFaydaSub: "poa-sub",
|
||||
};
|
||||
|
||||
function makeService(overrides: Partial<Ctx> = {}) {
|
||||
const ctx: Ctx = {
|
||||
attributes: {},
|
||||
files: [],
|
||||
profileTypes: [ProfileType.importer],
|
||||
status: CompanyStatus.Pending,
|
||||
pendingSnapshot: null,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const company = () => ({
|
||||
id: "company-1",
|
||||
status: ctx.status,
|
||||
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>) => {
|
||||
ctx.attributes = (patch.attributes ??
|
||||
ctx.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 () =>
|
||||
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
|
||||
),
|
||||
findLatestOpenByCompanyId: jest.fn(async () =>
|
||||
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : 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 (id: string) =>
|
||||
ctx.files.find((f) => f.id === id)
|
||||
? {
|
||||
...ctx.files.find((f) => f.id === id),
|
||||
resource: "companies",
|
||||
resourceId: "company-1",
|
||||
name: "dars.pdf",
|
||||
}
|
||||
: null,
|
||||
),
|
||||
remove: jest.fn(async () => undefined),
|
||||
},
|
||||
companyNotifier: { changeRequestSubmitted: jest.fn() },
|
||||
};
|
||||
|
||||
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,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
// getCompanyInfoByUserId does its own lookups; the stubs above are enough for
|
||||
// the PoA paths, so short-circuit it rather than mock the whole graph.
|
||||
jest
|
||||
.spyOn(service, "getCompanyInfoByUserId")
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
({ profile: { id: "external-1" }, company: company() }) as never,
|
||||
);
|
||||
|
||||
return { service, ctx, deps };
|
||||
}
|
||||
|
||||
const paper = (reviewStatus: string | null = null) => ({
|
||||
id: "file-1",
|
||||
code: POA_DELEGATION_FILE_KEY,
|
||||
reviewStatus,
|
||||
});
|
||||
|
||||
describe("PoA delegation paper is enforced wherever PoA state changes", () => {
|
||||
it("rejects PoA details saved with no paper on file", async () => {
|
||||
const { service } = makeService();
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", POA as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("accepts PoA details once the paper is on file", async () => {
|
||||
const { service } = makeService({ files: [paper()] });
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", POA as never),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects a paper the reviewer sent back for correction", async () => {
|
||||
const { service } = makeService({ files: [paper("change_requested")] });
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", POA as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("leaves edits that don't touch the PoA alone", async () => {
|
||||
// A company carrying legacy details must not be locked out of every other
|
||||
// field until it produces a paper.
|
||||
const { service } = makeService({ attributes: { ...POA }, files: [] });
|
||||
|
||||
await expect(
|
||||
service.updateProfile("user-1", { companyEmail: "x@y.com" } as never),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("refuses to remove the paper while the PoA is still named", async () => {
|
||||
const { service } = makeService({
|
||||
attributes: { ...POA },
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.removePoaDelegationLetter("user-1", "file-1"),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("allows removing the paper once the PoA has been cleared", async () => {
|
||||
const { service } = makeService({ attributes: {}, files: [paper()] });
|
||||
|
||||
await expect(
|
||||
service.removePoaDelegationLetter("user-1", "file-1"),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("judges the removal against a staged clear, not the live row", async () => {
|
||||
// An Active company's edits are staged for review rather than written, so
|
||||
// the live attributes still carry the PoA the customer just cleared.
|
||||
const { service } = makeService({
|
||||
status: CompanyStatus.Active,
|
||||
attributes: { ...POA },
|
||||
pendingSnapshot: { poaName: "", poaEmail: "", poaPhone: "" },
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.removePoaDelegationLetter("user-1", "file-1"),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("refuses the forwarder role to a company with no PoA", async () => {
|
||||
const { service } = makeService({ attributes: { ...VERIFIED_IDENTITIES } });
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("grants the forwarder role once PoA details and paper are both in place", async () => {
|
||||
const { service } = makeService({
|
||||
attributes: { ...POA, ...VERIFIED_IDENTITIES },
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -75,8 +75,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async existsByTin(tin: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { tin } as any });
|
||||
async existsByTin(tin: string, excludeCompanyId?: string): Promise<boolean> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('company')
|
||||
.where('company.tin = :tin', { tin });
|
||||
if (excludeCompanyId) {
|
||||
qb.andWhere('company.id != :excludeCompanyId', { excludeCompanyId });
|
||||
}
|
||||
const count = await qb.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { CompanyType } from "./entities/company.entity";
|
||||
import { ProfileStatus, ProfileType } from "./entities/company-profile.entity";
|
||||
|
||||
/**
|
||||
* EDRFREIGHT-416: onboarding asked for a deselected role's documents.
|
||||
*
|
||||
* Re-running role selection used to only ADD operational profiles, so a role
|
||||
* the user unticked on the way back left its company_profile row behind — and
|
||||
* every role-driven requirement (business license, forwarder PoA) is derived
|
||||
* from those rows. startOnboarding now reconciles both directions.
|
||||
*/
|
||||
|
||||
interface ExistingProfile {
|
||||
id: string;
|
||||
type: ProfileType;
|
||||
status: ProfileStatus;
|
||||
}
|
||||
|
||||
function makeService(existing: ExistingProfile[]) {
|
||||
const companyProfilesRepo = {
|
||||
findByCompanyId: jest.fn(async () => existing),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "new",
|
||||
...row,
|
||||
})),
|
||||
softDelete: jest.fn(async () => undefined),
|
||||
};
|
||||
const companiesRepo = { update: jest.fn(async () => null) };
|
||||
const profilesRepo = {
|
||||
findByUserId: jest.fn(async () => ({
|
||||
id: "external-1",
|
||||
companyId: "company-1",
|
||||
company: { id: "company-1" },
|
||||
})),
|
||||
};
|
||||
|
||||
const service = new CompaniesService(
|
||||
companiesRepo as never,
|
||||
companyProfilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
profilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(service, "getCompanyInfoByUserId")
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never,
|
||||
);
|
||||
|
||||
return { service, companyProfilesRepo };
|
||||
}
|
||||
|
||||
const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" };
|
||||
|
||||
const start = (service: CompaniesService, roles: ProfileType[]) =>
|
||||
service.startOnboarding(identity as never, CompanyType.Customer, roles);
|
||||
|
||||
describe("re-running role selection reconciles the operational profiles", () => {
|
||||
it("drops the profile for a role the user deselected", async () => {
|
||||
const { service, companyProfilesRepo } = makeService([
|
||||
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
|
||||
{
|
||||
id: "p-ff",
|
||||
type: ProfileType.freightForwarder,
|
||||
status: ProfileStatus.Pending,
|
||||
},
|
||||
]);
|
||||
|
||||
await start(service, [ProfileType.importer]);
|
||||
|
||||
expect(companyProfilesRepo.softDelete).toHaveBeenCalledWith("p-ff");
|
||||
expect(companyProfilesRepo.softDelete).toHaveBeenCalledTimes(1);
|
||||
expect(companyProfilesRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an already-approved profile even when it is unticked", async () => {
|
||||
const { service, companyProfilesRepo } = makeService([
|
||||
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
|
||||
{
|
||||
id: "p-exp",
|
||||
type: ProfileType.exporter,
|
||||
status: ProfileStatus.Active,
|
||||
},
|
||||
]);
|
||||
|
||||
await start(service, [ProfileType.importer]);
|
||||
|
||||
expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still adds a newly-picked role", async () => {
|
||||
const { service, companyProfilesRepo } = makeService([
|
||||
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
|
||||
]);
|
||||
|
||||
await start(service, [ProfileType.importer, ProfileType.exporter]);
|
||||
|
||||
expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled();
|
||||
expect(companyProfilesRepo.create).toHaveBeenCalledTimes(1);
|
||||
expect(companyProfilesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: ProfileType.exporter }),
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { Repository } from "typeorm";
|
||||
import { FindOperator, Repository } from "typeorm";
|
||||
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
import {
|
||||
@@ -10,6 +10,16 @@ type Row = Pick<CompanyChangeRequest, "id" | "status"> & { createdAt: Date };
|
||||
|
||||
const COMPANY_ID = "company-1";
|
||||
|
||||
/** Matches a row's status against either a plain value or an `In([...])` operator. */
|
||||
function statusMatches(
|
||||
rowStatus: ChangeRequestStatus,
|
||||
where: ChangeRequestStatus | FindOperator<ChangeRequestStatus> | undefined,
|
||||
): boolean {
|
||||
if (where === undefined) return true;
|
||||
if (where instanceof FindOperator) return where.value.includes(rowStatus);
|
||||
return rowStatus === where;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for the TypeORM repository over a fixed set of rows, honouring the
|
||||
* `where.status` filter and the `createdAt DESC` ordering findOne relies on.
|
||||
@@ -17,13 +27,17 @@ const COMPANY_ID = "company-1";
|
||||
function mockRepositoryOver(rows: Row[]) {
|
||||
return {
|
||||
findOne: jest.fn(
|
||||
({ where }: { where: Partial<Row> & { companyId: string } }) =>
|
||||
({
|
||||
where,
|
||||
}: {
|
||||
where: { companyId: string; status?: Row["status"] | FindOperator<Row["status"]> };
|
||||
}) =>
|
||||
Promise.resolve(
|
||||
rows
|
||||
.filter(
|
||||
(row) =>
|
||||
where.companyId === COMPANY_ID &&
|
||||
(where.status === undefined || row.status === where.status),
|
||||
statusMatches(row.status, where.status),
|
||||
)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ??
|
||||
null,
|
||||
@@ -57,6 +71,21 @@ describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => {
|
||||
expect(result?.id).toBe("pending");
|
||||
});
|
||||
|
||||
it("treats a changes-requested request as open, same as pending", async () => {
|
||||
const changesRequested: Row = {
|
||||
id: "changes-requested",
|
||||
status: ChangeRequestStatus.ChangesRequested,
|
||||
createdAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
const result = await subject([
|
||||
rejected,
|
||||
changesRequested,
|
||||
]).findLatestOpenByCompanyId(COMPANY_ID);
|
||||
|
||||
expect(result?.id).toBe("changes-requested");
|
||||
});
|
||||
|
||||
it("returns the latest rejected request when nothing is pending", async () => {
|
||||
const result = await subject([rejected]).findLatestOpenByCompanyId(
|
||||
COMPANY_ID,
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { In, Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import {
|
||||
ChangeRequestStatus,
|
||||
CompanyChangeRequest,
|
||||
} from "./entities/company-change-request.entity";
|
||||
|
||||
/** Statuses that mean "still open, awaiting the customer's next edit" — Pending and ChangesRequested behave identically here, they just carry a note or not. */
|
||||
const OPEN_FOR_EDIT_STATUSES = [
|
||||
ChangeRequestStatus.Pending,
|
||||
ChangeRequestStatus.ChangesRequested,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class CompanyChangeRequestRepository extends BaseRepository<CompanyChangeRequest> {
|
||||
constructor(
|
||||
@@ -16,12 +22,12 @@ export class CompanyChangeRequestRepository extends BaseRepository<CompanyChange
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** The company's current pending request, if any. */
|
||||
/** The company's current open request (Pending or ChangesRequested), if any — the row the next edit appends to. */
|
||||
async findPendingByCompanyId(
|
||||
companyId: string,
|
||||
): Promise<CompanyChangeRequest | null> {
|
||||
return this.repository.findOne({
|
||||
where: { companyId, status: ChangeRequestStatus.Pending },
|
||||
where: { companyId, status: In(OPEN_FOR_EDIT_STATUSES) },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Company } from "./entities/company.entity";
|
||||
import type { CompanyRevisionChange } from "./entities/company-revision.entity";
|
||||
|
||||
/** Human label per audited company field — anything not listed here is skipped (internal/lock fields like `*FaydaSub`). */
|
||||
export const COMPANY_FIELD_LABELS: Record<string, string> = {
|
||||
name: "Company name",
|
||||
phone: "Phone",
|
||||
email: "Email",
|
||||
address: "Address",
|
||||
country: "Country",
|
||||
tin: "TIN",
|
||||
vatNumber: "VAT number",
|
||||
fanNumber: "FAN number",
|
||||
nationality: "Nationality",
|
||||
website: "Website",
|
||||
licenceNumber: "Licence number",
|
||||
region: "Region",
|
||||
zone: "Zone",
|
||||
woreda: "Woreda",
|
||||
kebele: "Kebele",
|
||||
houseNo: "House No",
|
||||
contactPersonName: "Contact person name",
|
||||
contactPersonPhone: "Contact person phone",
|
||||
contactPersonEmail: "Contact person email",
|
||||
contactPersonPosition: "Contact person position",
|
||||
generalManagerName: "General manager name",
|
||||
generalManagerPhone: "General manager phone",
|
||||
generalManagerEmail: "General manager email",
|
||||
poaName: "PoA name",
|
||||
poaPhone: "PoA phone",
|
||||
poaEmail: "PoA email",
|
||||
poaLocation: "PoA location",
|
||||
poaAddress: "PoA address",
|
||||
documents: "Document",
|
||||
};
|
||||
|
||||
function displayValue(value: unknown): string | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the company row before a write against the patch about to be
|
||||
* applied (the same shape `mapProfileDtoToCompanyUpdates` returns: scalar
|
||||
* columns plus a merged `attributes` blob). Only fields with a known label
|
||||
* are reported, so identity-lock bookkeeping (`ownerFaydaSub`, etc.) never
|
||||
* shows up as noise.
|
||||
*/
|
||||
export function diffCompanyUpdate(
|
||||
before: Company,
|
||||
patch: Record<string, any>,
|
||||
): CompanyRevisionChange[] {
|
||||
const changes: CompanyRevisionChange[] = [];
|
||||
const { attributes: attrPatch, ...columnPatch } = patch;
|
||||
|
||||
for (const [field, nextRaw] of Object.entries(columnPatch)) {
|
||||
const label = COMPANY_FIELD_LABELS[field];
|
||||
if (!label) continue;
|
||||
const next = displayValue(nextRaw);
|
||||
const previous = displayValue((before as unknown as Record<string, unknown>)[field]);
|
||||
if (next === previous) continue;
|
||||
changes.push({ field, label, from: previous, to: next });
|
||||
}
|
||||
|
||||
if (attrPatch) {
|
||||
const beforeAttrs = before.attributes ?? {};
|
||||
for (const [field, nextRaw] of Object.entries(attrPatch)) {
|
||||
const label = COMPANY_FIELD_LABELS[field];
|
||||
if (!label) continue;
|
||||
const next = displayValue(nextRaw);
|
||||
const previous = displayValue(beforeAttrs[field]);
|
||||
if (next === previous) continue;
|
||||
changes.push({ field, label, from: previous, to: next });
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** Short human summary of a change set, e.g. "phone, address changed". */
|
||||
export function summarizeCompanyChanges(
|
||||
changes: CompanyRevisionChange[],
|
||||
): string {
|
||||
if (changes.length === 0) return "No changes";
|
||||
const labels = changes.map((c) => c.label.toLowerCase());
|
||||
return labels.length <= 3
|
||||
? `${labels.join(", ")} changed`
|
||||
: `${labels.length} fields changed`;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { CompanyRevision } from "./entities/company-revision.entity";
|
||||
|
||||
@Injectable()
|
||||
export class CompanyRevisionRepository extends BaseRepository<CompanyRevision> {
|
||||
constructor(
|
||||
@InjectRepository(CompanyRevision)
|
||||
repo: Repository<CompanyRevision>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** Revision history for a company, newest first. */
|
||||
async findByCompanyId(companyId: string): Promise<CompanyRevision[]> {
|
||||
return this.repository.find({
|
||||
where: { companyId },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,12 @@ export class CompanyInfoResponseDto {
|
||||
/**
|
||||
* Open profile-edit review, if any. Drives the portal-wide lock (pending →
|
||||
* settings + new-contract/booking creation disabled) and the reapply banner.
|
||||
* `changes_requested` is the soft variant of `rejected`: same edit-and-resubmit
|
||||
* call to action, but the customer's edit appends to this SAME request
|
||||
* instead of starting a fresh one.
|
||||
*/
|
||||
review: {
|
||||
status: 'pending' | 'rejected';
|
||||
status: 'pending' | 'rejected' | 'changes_requested';
|
||||
note: string | null;
|
||||
} | null;
|
||||
|
||||
@@ -30,12 +33,13 @@ export class CompanyInfoResponseDto {
|
||||
const open =
|
||||
changeRequest &&
|
||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
||||
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||
? changeRequest
|
||||
: null;
|
||||
this.review = open
|
||||
? {
|
||||
status: open.status as 'pending' | 'rejected',
|
||||
status: open.status as 'pending' | 'rejected' | 'changes_requested',
|
||||
note: open.note ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
CompanyRevision,
|
||||
CompanyRevisionChange,
|
||||
} from "../entities/company-revision.entity";
|
||||
|
||||
/** One version-history entry, shown on the backoffice customer detail page. */
|
||||
export class CompanyRevisionResponseDto {
|
||||
id: string;
|
||||
companyId: string;
|
||||
actorId: string | null;
|
||||
summary: string;
|
||||
changes: CompanyRevisionChange[];
|
||||
createdAt: Date;
|
||||
|
||||
constructor(revision: CompanyRevision) {
|
||||
this.id = revision.id;
|
||||
this.companyId = revision.companyId;
|
||||
this.actorId = revision.actorId ?? null;
|
||||
this.summary = revision.summary;
|
||||
this.changes = revision.changes ?? [];
|
||||
this.createdAt = revision.createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsIn, IsString, IsNotEmpty } from "class-validator";
|
||||
|
||||
import { Company, CompanyNationality } from "../entities/company.entity";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
|
||||
/**
|
||||
* The two people a company is verified through — its owner and its Power of
|
||||
* Attorney. "Owner" is not the same as the General Manager: a company's GM is
|
||||
* a plain typed role (with a "same as owner" copy the portal offers), while
|
||||
* the owner is the person this verification proves. They're very often the
|
||||
* same human, which is exactly what the copy is for.
|
||||
*/
|
||||
export const IDENTITY_SUBJECTS = ["owner", "poa"] as const;
|
||||
export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
|
||||
|
||||
export class CompleteIdentityVerificationDto {
|
||||
@ApiProperty({
|
||||
enum: IDENTITY_SUBJECTS,
|
||||
description: "Which of the company's people this verification is for.",
|
||||
})
|
||||
@IsIn(IDENTITY_SUBJECTS)
|
||||
subject!: IdentitySubject;
|
||||
|
||||
@ApiProperty({ description: "Authorization code from the Fayda redirect." })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ description: "CSRF state from the Fayda redirect." })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
state!: string;
|
||||
}
|
||||
|
||||
/** One person's verification state, as reported back to the portal. */
|
||||
export class IdentityVerificationStateDto {
|
||||
@ApiProperty() verified!: boolean;
|
||||
@ApiProperty({ nullable: true }) name!: string | null;
|
||||
@ApiProperty({ nullable: true }) phone!: string | null;
|
||||
@ApiProperty({ nullable: true }) email!: string | null;
|
||||
@ApiProperty({ nullable: true }) address!: string | null;
|
||||
@ApiProperty({ nullable: true }) verifiedAt!: string | null;
|
||||
@ApiProperty({ nullable: true }) birthdate!: string | null;
|
||||
@ApiProperty({ nullable: true }) gender!: string | null;
|
||||
}
|
||||
|
||||
export class OwnerIdentityStateDto extends IdentityVerificationStateDto {
|
||||
@ApiProperty({
|
||||
nullable: true,
|
||||
description:
|
||||
"Typed passport number — the foreign-company identity credential. Independent of Fayda: never written by a verification, and still required even if the owner also verifies.",
|
||||
})
|
||||
passportNumber!: string | null;
|
||||
}
|
||||
|
||||
export class CompanyIdentityStateDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.",
|
||||
})
|
||||
faydaRequired!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"True when the owner's passport number is mandatory — foreign companies only. Independent of faydaRequired: a foreign owner may verify with Fayda too, but the passport is still required.",
|
||||
})
|
||||
passportRequired!: boolean;
|
||||
|
||||
@ApiProperty({ type: OwnerIdentityStateDto })
|
||||
owner!: OwnerIdentityStateDto;
|
||||
|
||||
@ApiProperty({ type: IdentityVerificationStateDto })
|
||||
poa!: IdentityVerificationStateDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.",
|
||||
})
|
||||
complete!: boolean;
|
||||
}
|
||||
|
||||
/** `attributes` key prefix per person. */
|
||||
const PREFIX: Record<IdentitySubject, "owner" | "poa"> = {
|
||||
owner: "owner",
|
||||
poa: "poa",
|
||||
};
|
||||
|
||||
/** company.attributes keys that together mean "a PoA was entered". */
|
||||
const POA_KEYS = [
|
||||
"poaName",
|
||||
"poaPhone",
|
||||
"poaEmail",
|
||||
"poaLocation",
|
||||
"poaAddress",
|
||||
] as const;
|
||||
|
||||
function stateFor(
|
||||
attrs: Record<string, unknown>,
|
||||
subject: IdentitySubject,
|
||||
): IdentityVerificationStateDto {
|
||||
const p = PREFIX[subject];
|
||||
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
||||
return {
|
||||
verified: Boolean(read(`${p}FaydaSub`)),
|
||||
name: read(`${p}Name`),
|
||||
phone: read(`${p}Phone`),
|
||||
email: read(`${p}Email`),
|
||||
address: read(`${p}Address`),
|
||||
verifiedAt: read(`${p}FaydaVerifiedAt`),
|
||||
birthdate: read(`${p}Birthdate`),
|
||||
gender: read(`${p}Gender`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive both people's verification state from the company row.
|
||||
*
|
||||
* Pure and shared: `CompaniesService` gates on it and `ProfileResponseDto`
|
||||
* renders from it, so the settings page and the onboarding wizard can never
|
||||
* disagree with the rule the API actually enforces.
|
||||
*/
|
||||
export function buildCompanyIdentityState(
|
||||
company: Company,
|
||||
): CompanyIdentityStateDto {
|
||||
const attrs = company.attributes ?? {};
|
||||
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
||||
|
||||
// Fayda is an Ethiopian national ID — a foreign company's owner may not hold
|
||||
// one, so a typed passport number is the mandatory credential there instead.
|
||||
// The two are mutually exclusive by nationality but independently tracked,
|
||||
// since a foreign owner verifying with Fayda doesn't waive the passport.
|
||||
const foreign = company.nationality === CompanyNationality.Foreign;
|
||||
const faydaRequired = !foreign;
|
||||
const passportRequired = foreign;
|
||||
|
||||
const owner: OwnerIdentityStateDto = {
|
||||
...stateFor(attrs, "owner"),
|
||||
passportNumber: read("ownerPassportNumber"),
|
||||
};
|
||||
const poa = stateFor(attrs, "poa");
|
||||
const poaDue =
|
||||
(company.companyProfiles ?? []).some(
|
||||
(p) => p.type === ProfileType.freightForwarder,
|
||||
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
||||
|
||||
// Only the *owner's* credential is nationality-specific. A Power of Attorney
|
||||
// acts for the company inside Ethiopia whoever owns it, so the PoA is always
|
||||
// proven with Fayda — a foreign company nominates a representative who holds
|
||||
// one rather than typing a name nothing backs.
|
||||
const ownerProven = faydaRequired
|
||||
? owner.verified
|
||||
: !passportRequired || Boolean(owner.passportNumber);
|
||||
const complete = ownerProven && (!poaDue || poa.verified);
|
||||
|
||||
return { faydaRequired, passportRequired, owner, poa, complete };
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
* truth the wizard uses to auto-finish.
|
||||
*/
|
||||
|
||||
import { CompanyIdentityStateDto } from "./complete-identity-verification.dto";
|
||||
|
||||
export interface OnboardingInfoField {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -40,11 +42,13 @@ export interface OnboardingPoaState {
|
||||
required: boolean;
|
||||
/** True once any PoA detail has been entered. */
|
||||
provided: boolean;
|
||||
/** True when the delegation letter is stored for the company. */
|
||||
/** True when the DARS delegation paper is stored for the company. */
|
||||
delegationLetterUploaded: boolean;
|
||||
/** True when a reviewer sent the paper back for correction. */
|
||||
delegationLetterFlagged: boolean;
|
||||
/** PoA details still missing (only populated when `required`). */
|
||||
missingFields: OnboardingInfoField[];
|
||||
/** False while the PoA step still owes details or a delegation letter. */
|
||||
/** False while the PoA step still owes details or an uncorrected paper. */
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
@@ -68,6 +72,13 @@ export class OnboardingRequirementsResponseDto {
|
||||
/** Power of Attorney state, so the wizard needn't re-derive the rule. */
|
||||
poa: OnboardingPoaState;
|
||||
|
||||
/**
|
||||
* Fayda verification state for the company's people. `required` is false for
|
||||
* a foreign company, which is never gated on it — the portal renders the
|
||||
* typed personnel forms in that case and the verify panels otherwise.
|
||||
*/
|
||||
identity: CompanyIdentityStateDto;
|
||||
|
||||
/** Overall setup progress across fields + documents + licenses. */
|
||||
progress: { completed: number; total: number };
|
||||
|
||||
@@ -87,6 +98,7 @@ export class OnboardingRequirementsResponseDto {
|
||||
this.documents = init.documents;
|
||||
this.licenseProfiles = init.licenseProfiles;
|
||||
this.poa = init.poa;
|
||||
this.identity = init.identity;
|
||||
this.progress = init.progress;
|
||||
this.isComplete = init.isComplete;
|
||||
this.onboardingCompleted = init.onboardingCompleted;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
buildCompanyIdentityState,
|
||||
CompanyIdentityStateDto,
|
||||
} from "./complete-identity-verification.dto";
|
||||
import { Company } from '../entities/company.entity';
|
||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||
import {
|
||||
@@ -53,11 +57,23 @@ export class ProfileResponseDto {
|
||||
profileId: string;
|
||||
|
||||
/**
|
||||
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
|
||||
* settings page; `"rejected"` surfaces the note and prefills the (declined)
|
||||
* proposed values from `pendingChanges` so the customer can amend & resubmit.
|
||||
* Fayda verification state for the company's owner and PoA — not the general
|
||||
* manager, which is a separate typed role. The settings tabs and the
|
||||
* onboarding wizard render from `identity.faydaRequired` /
|
||||
* `identity.passportRequired`: an Ethiopian company verifies the owner (and
|
||||
* PoA) instead of typing their details; a foreign one requires a typed
|
||||
* passport number instead.
|
||||
*/
|
||||
reviewStatus: "pending" | "rejected" | null;
|
||||
identity: CompanyIdentityStateDto;
|
||||
|
||||
/**
|
||||
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
|
||||
* settings page; `"rejected"`/`"changes_requested"` both surface the note and
|
||||
* prefill the proposed values from `pendingChanges` so the customer can amend
|
||||
* & resubmit — `"changes_requested"` just appends the edit to this same
|
||||
* request instead of starting a fresh one.
|
||||
*/
|
||||
reviewStatus: "pending" | "rejected" | "changes_requested" | null;
|
||||
reviewNote: string | null;
|
||||
pendingChanges: Record<string, any> | null;
|
||||
|
||||
@@ -113,7 +129,8 @@ export class ProfileResponseDto {
|
||||
const openReview =
|
||||
changeRequest &&
|
||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
||||
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||
? changeRequest
|
||||
: null;
|
||||
this.reviewStatus =
|
||||
@@ -121,8 +138,11 @@ export class ProfileResponseDto {
|
||||
? "pending"
|
||||
: openReview?.status === ChangeRequestStatus.Rejected
|
||||
? "rejected"
|
||||
: null;
|
||||
: openReview?.status === ChangeRequestStatus.ChangesRequested
|
||||
? "changes_requested"
|
||||
: null;
|
||||
this.reviewNote = openReview?.note ?? null;
|
||||
this.pendingChanges = openReview?.snapshot ?? null;
|
||||
this.identity = buildCompanyIdentityState(company);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
ProfileLicenseFileView,
|
||||
} from '../entities/company-profile.entity';
|
||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||
import {
|
||||
buildCompanyIdentityState,
|
||||
CompanyIdentityStateDto,
|
||||
} from './complete-identity-verification.dto';
|
||||
|
||||
export class ResponseCompanyProfileDto {
|
||||
id: string;
|
||||
@@ -69,8 +73,31 @@ export class ResponseCompanyDto {
|
||||
* external profiles weren't loaded.
|
||||
*/
|
||||
onboardingCompleted?: boolean;
|
||||
|
||||
// eTrade-sourced registration record — populated by the onboarding TIN
|
||||
// lookup, locked/read-only on the portal from the moment it's fetched.
|
||||
licenceNumber?: string | null;
|
||||
statusDescription?: string | null;
|
||||
dateRegistered?: string | null;
|
||||
renewedFrom?: string | null;
|
||||
renewalDate?: string | null;
|
||||
renewedTo?: string | null;
|
||||
region?: string | null;
|
||||
zone?: string | null;
|
||||
woreda?: string | null;
|
||||
kebele?: string | null;
|
||||
houseNo?: string | null;
|
||||
|
||||
/**
|
||||
* Owner/PoA Fayda verification state, shared with the portal
|
||||
* (`buildCompanyIdentityState`) so backoffice never re-derives — or
|
||||
* disagrees with — the rule the API actually enforces.
|
||||
*/
|
||||
identity: CompanyIdentityStateDto;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
approvedAt: Date | null;
|
||||
|
||||
constructor(company: Company) {
|
||||
this.id = company.id;
|
||||
@@ -95,7 +122,20 @@ export class ResponseCompanyDto {
|
||||
? company.profiles.length === 0 ||
|
||||
company.profiles.some((p) => p.onboardingCompleted)
|
||||
: undefined;
|
||||
this.licenceNumber = company.licenceNumber;
|
||||
this.statusDescription = company.statusDescription;
|
||||
this.dateRegistered = company.dateRegistered;
|
||||
this.renewedFrom = company.renewedFrom;
|
||||
this.renewalDate = company.renewalDate;
|
||||
this.renewedTo = company.renewedTo;
|
||||
this.region = company.region;
|
||||
this.zone = company.zone;
|
||||
this.woreda = company.woreda;
|
||||
this.kebele = company.kebele;
|
||||
this.houseNo = company.houseNo;
|
||||
this.identity = buildCompanyIdentityState(company);
|
||||
this.createdAt = company.createdAt;
|
||||
this.updatedAt = company.updatedAt;
|
||||
this.approvedAt = company.approvedAt ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,10 +44,11 @@ export class UpdateProfileDto {
|
||||
@MaxLength(50)
|
||||
vatNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(16)
|
||||
fanNumber?: string;
|
||||
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
|
||||
// company's PoA (or its general manager), so it is derived from a completed
|
||||
// Fayda verification rather than typed. The global validation pipe runs with
|
||||
// forbidNonWhitelisted, so a client that still sends it gets a 400 telling it
|
||||
// so — see CompaniesService.completeIdentityVerification.
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -110,6 +111,16 @@ export class UpdateProfileDto {
|
||||
@IsString()
|
||||
poaAddress?: string;
|
||||
|
||||
/**
|
||||
* The owner's passport number — the identity credential for a foreign
|
||||
* company, since Fayda is an Ethiopian national ID. Plain typed field, never
|
||||
* written or locked by a Fayda verification: still required even if the
|
||||
* owner also verifies.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ownerPassportNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
|
||||
@@ -5,14 +5,18 @@ import { Company } from "./company.entity";
|
||||
/**
|
||||
* Lifecycle of a customer's proposed profile change. Edits made on the portal
|
||||
* settings page by an already-approved company are staged here (not written to
|
||||
* the live Company row) until a backoffice reviewer approves — at which point
|
||||
* the snapshot is applied — or rejects with a note, after which the customer can
|
||||
* amend and resubmit.
|
||||
* the live Company row) until a backoffice reviewer resolves it:
|
||||
* - Approved — the snapshot is applied to the live Company row.
|
||||
* - Rejected — terminal for this row; the customer's next edit starts a fresh one.
|
||||
* - ChangesRequested — soft: the row stays open with the reviewer's note attached,
|
||||
* so the customer's next edit is appended (merged) into this SAME row instead
|
||||
* of starting a new cycle.
|
||||
*/
|
||||
export enum ChangeRequestStatus {
|
||||
Pending = "pending",
|
||||
Approved = "approved",
|
||||
Rejected = "rejected",
|
||||
ChangesRequested = "changes_requested",
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user