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
|
# cypress e2e artifacts
|
||||||
e2e/**/cypress/videos/
|
e2e/**/cypress/videos/
|
||||||
e2e/**/cypress/screenshots/
|
e2e/**/cypress/screenshots/
|
||||||
|
e2e/**/cypress/reports/
|
||||||
e2e/**/cypress/downloads/
|
e2e/**/cypress/downloads/
|
||||||
|
|
||||||
# e2e launcher state (ports of the running stack)
|
# 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/types` | Shared TypeScript interfaces and enums |
|
||||||
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
|
| `@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/ui-common` | Shared React components and theme |
|
||||||
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
|
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
|
||||||
| `@edr/tsconfig` | Shared TypeScript configurations |
|
| `@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)
|
# IAM seed defaults (used by @tria-plc/iamapi-common on first boot)
|
||||||
SUPER_ADMIN_EMAIL=superadmin@tria.com
|
SUPER_ADMIN_EMAIL=superadmin@tria.com
|
||||||
SUPER_ADMIN_PHONE=
|
SUPER_ADMIN_PHONE=
|
||||||
|
# Super-admin password. Falls back to DEFAULT_PASSWORD when empty.
|
||||||
|
SUPER_ADMIN_DEFAULT_PASSWORD=
|
||||||
DEFAULT_PASSWORD=password@tria
|
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)
|
# Freight org + staff (bookings / rule-engine IAM)
|
||||||
SEED_EDR_ORG=true
|
SEED_EDR_ORG=true
|
||||||
SEED_FREIGHT_STAFF=true
|
SEED_FREIGHT_STAFF=true
|
||||||
@@ -84,6 +93,9 @@ FAYDA_PRIVATE_KEY_BASE64=
|
|||||||
FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete
|
FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete
|
||||||
# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
|
# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
|
||||||
FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback
|
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
|
CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
|
||||||
FAYDA_SCOPE=openid profile email phone address
|
FAYDA_SCOPE=openid profile email phone address
|
||||||
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
|
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: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",
|
"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",
|
"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: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: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",
|
"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:run": "pnpm run iam:typeorm:cli migration:run",
|
||||||
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
"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",
|
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
|
||||||
"migration:run": "node dist/scripts/migrate.js",
|
"migration:run": "node dist/scripts/migrate.js",
|
||||||
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
|
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edr/api-common": "workspace:*",
|
"@edr/api-common": "workspace:*",
|
||||||
|
"@edr/iam-seed": "workspace:*",
|
||||||
"@edr/payment-providers": "workspace:*",
|
"@edr/payment-providers": "workspace:*",
|
||||||
"@edr/types": "workspace:*",
|
"@edr/types": "workspace:*",
|
||||||
"@golevelup/nestjs-rabbitmq": "^5.5.0",
|
"@golevelup/nestjs-rabbitmq": "^5.5.0",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
MiddlewareConsumer,
|
MiddlewareConsumer,
|
||||||
Module,
|
Module,
|
||||||
OnApplicationBootstrap,
|
OnApplicationBootstrap,
|
||||||
|
RequestMethod,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
ensurePostgresSchemas,
|
ensurePostgresSchemas,
|
||||||
APPLICATION_SEARCH_PATH,
|
APPLICATION_SEARCH_PATH,
|
||||||
} from "./config/ensure-postgres-schemas";
|
} from "./config/ensure-postgres-schemas";
|
||||||
|
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
||||||
import { IamModule } from "@tria-plc/iamapi-common";
|
import { IamModule } from "@tria-plc/iamapi-common";
|
||||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
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 { TrainsModule } from "./modules/trains/trains.module";
|
||||||
import { LocomotivesModule } from "./modules/locomotives/locomotives.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 { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
|
||||||
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
||||||
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.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 { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||||
import { AiModule } from "./modules/ai/ai.module";
|
import { AiModule } from "./modules/ai/ai.module";
|
||||||
import { LoggerMiddleware } from "./logger.middleware";
|
import { LoggerMiddleware } from "./logger.middleware";
|
||||||
|
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -152,12 +157,26 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
applications: [EDR_FREIGHT_APPLICATION],
|
applications: [EDR_FREIGHT_APPLICATION],
|
||||||
permissions: EDR_FREIGHT_PERMISSIONS,
|
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,
|
BookingsModule,
|
||||||
ContractsModule,
|
ContractsModule,
|
||||||
SignaturesModule,
|
SignaturesModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
ConsignmentsModule,
|
ConsignmentsModule,
|
||||||
LocomotivesModule,
|
LocomotivesModule,
|
||||||
|
TruckTypesModule,
|
||||||
|
TransitAgentsModule,
|
||||||
WagonTypesModule,
|
WagonTypesModule,
|
||||||
TrainSetsModule,
|
TrainSetsModule,
|
||||||
TrainSchedulesModule,
|
TrainSchedulesModule,
|
||||||
@@ -225,11 +244,12 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
// MarshallingDemoTrainsSeeder,
|
// MarshallingDemoTrainsSeeder,
|
||||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||||
PaidImportExportMileDemoSeeder,
|
PaidImportExportMileDemoSeeder,
|
||||||
|
LoginAudienceMiddleware,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
constructor(
|
constructor(
|
||||||
// private readonly seeder: DataSeeder,
|
private readonly iamBaselineSeeder: IamBaselineSeeder,
|
||||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||||
@@ -259,13 +279,22 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
|
|
||||||
// Permissions foundation — keep enabled:
|
// Permissions foundation — keep enabled:
|
||||||
// freightPermissionKeyMigration → renames legacy permission keys
|
// freightPermissionKeyMigration → renames legacy permission keys
|
||||||
// seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions
|
|
||||||
// edrOrgSeeder → seeds org/unit + the Permission catalog
|
// 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
|
// freightPositionsSeeder → seeds Position + PositionPermission rows
|
||||||
// (depends on edrOrgSeeder, must run after)
|
// (depends on edrOrgSeeder, must run after)
|
||||||
await this.freightPermissionKeyMigrationSeeder.run();
|
await this.freightPermissionKeyMigrationSeeder.run();
|
||||||
// await this.seeder.run();
|
|
||||||
await this.edrOrgSeeder.run();
|
await this.edrOrgSeeder.run();
|
||||||
|
await this.iamBaselineSeeder.run();
|
||||||
await this.freightPositionsSeeder.run();
|
await this.freightPositionsSeeder.run();
|
||||||
|
|
||||||
// File upload settings — keep enabled.
|
// File upload settings — keep enabled.
|
||||||
@@ -304,5 +333,9 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
|
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
consumer.apply(LoggerMiddleware).forRoutes("*");
|
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);
|
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 = () =>
|
export const TrainSchedulingView = () =>
|
||||||
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
|
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
|
||||||
|
|
||||||
export const TrainSchedulingManage = () =>
|
// Granular train-scheduling actions replace the retired coarse manage:
|
||||||
BookingStaff(FREIGHT_PERMS.trainScheduling.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,
|
* Fleet guards take an optional granular per-resource key (locomotives:create,
|
||||||
@@ -58,6 +81,32 @@ export const WagonTransferFulfill = () =>
|
|||||||
export const WagonTransferHistoryAll = () =>
|
export const WagonTransferHistoryAll = () =>
|
||||||
BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll);
|
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). */
|
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
|
||||||
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
|
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,
|
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> = {
|
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
||||||
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
|
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
|
||||||
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
||||||
@@ -183,6 +201,16 @@ export function assertCanApproveContractStep(
|
|||||||
): void {
|
): void {
|
||||||
if (isFreightApprovalAdmin(user)) return;
|
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);
|
const positionTypes = collectPositionTypeKeys(user);
|
||||||
if (positionTypes.includes(requiredRole)) return;
|
if (positionTypes.includes(requiredRole)) return;
|
||||||
|
|
||||||
@@ -219,6 +247,11 @@ export function canEditContractStep(
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (isFreightApprovalAdmin(user)) return true;
|
if (isFreightApprovalAdmin(user)) return true;
|
||||||
|
|
||||||
|
const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole];
|
||||||
|
if (hazardousPermission) {
|
||||||
|
return hasFreightPermission(user, hazardousPermission);
|
||||||
|
}
|
||||||
|
|
||||||
const positionTypes = collectPositionTypeKeys(user);
|
const positionTypes = collectPositionTypeKeys(user);
|
||||||
if (positionTypes.includes(requiredRole)) return true;
|
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
|
* Shared so a GRN raised at a load/unload facility is indistinguishable from one
|
||||||
* raised in a warehouse — the two live in different tables
|
* raised in a warehouse — the two live in different tables
|
||||||
* (facility_handling_events vs warehouse_inventory), and a second generator would
|
* (facility_handling_events vs warehouse_inventory), and a second generator would
|
||||||
* eventually let their formats drift apart.
|
* 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 stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
|
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<{
|
bookingContainers?: Array<{
|
||||||
units?: Array<{ vgmTons?: number | string | null }> | null;
|
units?: Array<{ vgmTons?: number | string | null }> | null;
|
||||||
}> | null;
|
}> | null;
|
||||||
|
/** Attached here: the train schedule the booking rides, for mile alignment. */
|
||||||
|
trainSchedule?: { trainNumber: string | null; departureDate: string | null } | null;
|
||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,6 +38,38 @@ export async function attachMileFinancials(
|
|||||||
if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3));
|
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(
|
const needAdvance = records.filter(
|
||||||
(r) => r.bookingId && !(Number(r.advancedPayment) > 0),
|
(r) => r.bookingId && !(Number(r.advancedPayment) > 0),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,9 +13,22 @@ export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
|
|||||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
|
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(
|
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;
|
qi?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FaydaPlatform = 'WEB' | 'MOBILE';
|
export type FaydaPlatform = 'WEB' | 'MOBILE' | 'PORTAL';
|
||||||
|
|
||||||
export interface FaydaConfig {
|
export interface FaydaConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -25,8 +25,10 @@ export interface FaydaConfig {
|
|||||||
userInfoEndpoint: string;
|
userInfoEndpoint: string;
|
||||||
/** OAuth redirect_uri sent to eSignet for MOBILE clients. */
|
/** OAuth redirect_uri sent to eSignet for MOBILE clients. */
|
||||||
redirectUri: string;
|
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;
|
webRedirectUri: string;
|
||||||
|
/** OAuth redirect_uri sent to eSignet for the customer portal. Falls back to `webRedirectUri`. */
|
||||||
|
portalRedirectUri: string;
|
||||||
privateJwk: FaydaJwk;
|
privateJwk: FaydaJwk;
|
||||||
scope: string;
|
scope: string;
|
||||||
acrValues: 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 sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
|
||||||
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
|
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
|
||||||
const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri;
|
const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri;
|
||||||
|
const portalRedirectUri = process.env.FAYDA_PORTAL_REDIRECT_URI || webRedirectUri;
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -86,6 +89,7 @@ export default registerAs('fayda', (): FaydaConfig => {
|
|||||||
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
|
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
|
||||||
redirectUri,
|
redirectUri,
|
||||||
webRedirectUri,
|
webRedirectUri,
|
||||||
|
portalRedirectUri,
|
||||||
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
|
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
|
||||||
scope,
|
scope,
|
||||||
acrValues,
|
acrValues,
|
||||||
@@ -117,6 +121,7 @@ export default registerAs('fayda', (): FaydaConfig => {
|
|||||||
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
|
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
|
||||||
redirectUri,
|
redirectUri,
|
||||||
webRedirectUri,
|
webRedirectUri,
|
||||||
|
portalRedirectUri,
|
||||||
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
|
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
|
||||||
scope,
|
scope,
|
||||||
acrValues,
|
acrValues,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { hazardClassLabel } from '@edr/types';
|
||||||
|
|
||||||
import { ContractsRepository } from '../modules/contracts/contracts.repository';
|
import { ContractsRepository } from '../modules/contracts/contracts.repository';
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +31,8 @@ export interface ContractDocumentSignatureView {
|
|||||||
signerDisplayName: string;
|
signerDisplayName: string;
|
||||||
signedAt: string;
|
signedAt: string;
|
||||||
signatureImageUrl?: string | null;
|
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. */
|
/** 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 hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
|
||||||
const hasStaff = signatures.some((s) => s.role === 'STAFF');
|
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(
|
const hasContractFile = Boolean(
|
||||||
contract.files?.some((f) => f.code === 'contract'),
|
contract.files?.some((f) => f.code === 'contract'),
|
||||||
);
|
);
|
||||||
@@ -183,7 +191,9 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
// Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
|
// Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
|
||||||
// view-model's narrower CUSTOMER|STAFF role union.
|
// view-model's narrower CUSTOMER|STAFF role union.
|
||||||
signatures: signatures as unknown as ContractViewModel['signatures'],
|
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:
|
canSignStaff:
|
||||||
contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
|
contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
|
||||||
hasContractDocument: hasContractFile,
|
hasContractDocument: hasContractFile,
|
||||||
@@ -208,6 +218,7 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
signerDisplayName: row.signerDisplayName,
|
signerDisplayName: row.signerDisplayName,
|
||||||
signedAt: this.formatDate(row.signedAt),
|
signedAt: this.formatDate(row.signedAt),
|
||||||
signatureImageUrl: row.signatureFile?.url ?? null,
|
signatureImageUrl: row.signatureFile?.url ?? null,
|
||||||
|
stampImageUrl: row.stampFile?.url ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +303,16 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
cargoDescription: this.valueOrDash(cargoName),
|
cargoDescription: this.valueOrDash(cargoName),
|
||||||
totalWeightVgm: '—',
|
totalWeightVgm: '—',
|
||||||
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
|
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),
|
firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress),
|
||||||
lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress),
|
lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -157,10 +157,14 @@ export class ContractViewModelBuilder {
|
|||||||
pricing,
|
pricing,
|
||||||
rateSchedule,
|
rateSchedule,
|
||||||
signatures,
|
signatures,
|
||||||
canSignCustomer:
|
// Government contracts are generated at creation and signable at any
|
||||||
booking.status === 'CONTRACT_READY' && !hasCustomer,
|
// time, in any order — no status gate, no customer-first sequencing.
|
||||||
canSignStaff:
|
canSignCustomer: booking.isGovernment
|
||||||
booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
|
? !hasCustomer
|
||||||
|
: booking.status === 'CONTRACT_READY' && !hasCustomer,
|
||||||
|
canSignStaff: booking.isGovernment
|
||||||
|
? !hasStaff
|
||||||
|
: booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
|
||||||
hasContractDocument: hasContractFile,
|
hasContractDocument: hasContractFile,
|
||||||
hasCustomerSignature: hasCustomer,
|
hasCustomerSignature: hasCustomer,
|
||||||
hasStaffSignature: hasStaff,
|
hasStaffSignature: hasStaff,
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
|
<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>Role:</strong> Authorized EDR representative</p>
|
||||||
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</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}}
|
{{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{else}}
|
{{else}}
|
||||||
@@ -32,6 +38,12 @@
|
|||||||
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
|
<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>Role:</strong> Authorized client representative</p>
|
||||||
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</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}}
|
{{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -372,25 +372,30 @@
|
|||||||
font-size: 9pt;
|
font-size: 9pt;
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
}
|
}
|
||||||
|
.sig-stamp {
|
||||||
/* ── Witnesses ────────────────────────────────────────────────────────── */
|
margin-top: 12px;
|
||||||
.witnesses { margin-top: 20px; }
|
|
||||||
.witness-table {
|
|
||||||
font-size: 9.5pt;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
}
|
||||||
.witness-table th,
|
.sig-stamp-label {
|
||||||
.witness-table td {
|
|
||||||
border-bottom: 1px solid #c9e4d9;
|
|
||||||
padding: 9px 8px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.witness-table th {
|
|
||||||
color: #0e5b45;
|
color: #0e5b45;
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
font-size: 8.5pt;
|
font-size: 7.5pt;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.4pt;
|
||||||
text-transform: uppercase;
|
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 {
|
@media print {
|
||||||
body { background: #fff; }
|
body { background: #fff; }
|
||||||
|
|||||||
@@ -147,19 +147,6 @@
|
|||||||
authorized to sign and execute this Contract Agreement.
|
authorized to sign and execute this Contract Agreement.
|
||||||
</p>
|
</p>
|
||||||
{{> signatures_block}}
|
{{> 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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "reflect-metadata";
|
|||||||
import * as dotenv from "dotenv";
|
import * as dotenv from "dotenv";
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
|
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||||
import {
|
import {
|
||||||
HttpExceptionFilter,
|
HttpExceptionFilter,
|
||||||
@@ -11,8 +12,25 @@ import {
|
|||||||
|
|
||||||
import { AppModule } from "./app.module";
|
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() {
|
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
|
// Dev CORS: reflect any localhost origin and allow credentials so the
|
||||||
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
|
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
|
||||||
@@ -28,6 +46,9 @@ async function bootstrap() {
|
|||||||
"Accept",
|
"Accept",
|
||||||
"Authorization",
|
"Authorization",
|
||||||
"X-Requested-With",
|
"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
|
// IAM context headers required by @tria-plc/api-common's JwtGuard
|
||||||
"organization-unit-id",
|
"organization-unit-id",
|
||||||
"delegator-position-id",
|
"delegator-position-id",
|
||||||
@@ -40,6 +61,13 @@ async function bootstrap() {
|
|||||||
"x-delegator-position-id",
|
"x-delegator-position-id",
|
||||||
"x-current-project-id",
|
"x-current-project-id",
|
||||||
"x-current-position-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"],
|
exposedHeaders: ["Content-Disposition"],
|
||||||
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
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();
|
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 { CompaniesService } from "../companies/companies.service";
|
||||||
import { PaymentService } from "../payment/payment.service";
|
import { PaymentService } from "../payment/payment.service";
|
||||||
import { InitiateResponseDto } from "../payment/payments.dto";
|
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||||
import {
|
import {
|
||||||
InvoiceDocumentModel,
|
InvoiceDocumentModel,
|
||||||
InvoiceDocumentService,
|
InvoiceDocumentService,
|
||||||
@@ -352,6 +352,34 @@ export class BillingService {
|
|||||||
return this.payInvoice(id, opts);
|
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). */
|
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
|
||||||
async documentForUser(
|
async documentForUser(
|
||||||
id: string,
|
id: string,
|
||||||
@@ -983,6 +1011,17 @@ export class BillingService {
|
|||||||
* never fire before the link exists. Throws when the invoice is not found or
|
* never fire before the link exists. Throws when the invoice is not found or
|
||||||
* not in an open/payable status.
|
* 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(
|
async payInvoice(
|
||||||
invoiceId: string,
|
invoiceId: string,
|
||||||
opts: {
|
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);
|
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
|
||||||
if (!(amountDue > 0)) {
|
if (!(amountDue > 0)) {
|
||||||
throw new BadRequestException("Invoice has no outstanding balance.");
|
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({
|
const result = await this.payment.initiate({
|
||||||
referenceId: invoice.sourceId,
|
referenceId: invoice.sourceId,
|
||||||
source: invoice.source,
|
source: invoice.source,
|
||||||
@@ -1034,7 +1101,12 @@ export class BillingService {
|
|||||||
|
|
||||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||||
// billing must not simulate it. Kept commented for local demos only.
|
// 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({
|
await this.payment.handlePaymentEvent({
|
||||||
eventType: "payment.succeeded",
|
eventType: "payment.succeeded",
|
||||||
eventId: `demo-${result.intentId}`,
|
eventId: `demo-${result.intentId}`,
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { IsIn, IsOptional, IsString } from "class-validator";
|
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. */
|
/** Gateway options for paying an invoice from the customer portal. */
|
||||||
export class PayInvoiceDto {
|
export class PayInvoiceDto {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from "../../common/resolve-auth-user-id";
|
} from "../../common/resolve-auth-user-id";
|
||||||
import { sendPdf } from "./billing.controller";
|
import { sendPdf } from "./billing.controller";
|
||||||
import { BillingService } from "./billing.service";
|
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,
|
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
|
||||||
@@ -96,4 +96,20 @@ export class PortalBillingController {
|
|||||||
failureUrl: dto.failureUrl,
|
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 templateKey = this.templateResolver.resolve(booking);
|
||||||
const summary = this.buildContractSummary(booking);
|
const summary = this.buildContractSummary(booking);
|
||||||
|
|
||||||
// PDF rendering (Puppeteer/Chromium) is best-effort and must NOT block the contract
|
// No eager PDF render here: streamContract re-renders the document on every
|
||||||
// from becoming ready — the document is (re)rendered lazily on view/download.
|
// view/download, so rendering now only adds a Chromium launch (seconds, or a
|
||||||
try {
|
// 60s asset-load hang) inside the staff-accept request.
|
||||||
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.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
@@ -132,6 +126,31 @@ export class BookingContractService {
|
|||||||
return updated!;
|
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) {
|
async streamContract(bookingId: string) {
|
||||||
const booking = await this.requireBooking(bookingId);
|
const booking = await this.requireBooking(bookingId);
|
||||||
const templateKey =
|
const templateKey =
|
||||||
@@ -152,8 +171,13 @@ export class BookingContractService {
|
|||||||
const booking = await this.requireBooking(bookingId);
|
const booking = await this.requireBooking(bookingId);
|
||||||
const role = dto.role as ContractSignerRole;
|
const role = dto.role as ContractSignerRole;
|
||||||
|
|
||||||
|
// Government contracts are order-free and status-free: either party may
|
||||||
|
// sign at any time (each once) — the booking is already expedited past the
|
||||||
|
// customer contract flow, so no status gate applies.
|
||||||
if (role === 'CUSTOMER') {
|
if (role === 'CUSTOMER') {
|
||||||
assertBookingStatus(booking, ['CONTRACT_READY']);
|
if (!booking.isGovernment) {
|
||||||
|
assertBookingStatus(booking, ['CONTRACT_READY']);
|
||||||
|
}
|
||||||
const existing = await this.bookingsRepository.findContractSignature(
|
const existing = await this.bookingsRepository.findContractSignature(
|
||||||
bookingId,
|
bookingId,
|
||||||
'CUSTOMER',
|
'CUSTOMER',
|
||||||
@@ -162,7 +186,9 @@ export class BookingContractService {
|
|||||||
throw new BadRequestException('Customer has already signed this contract');
|
throw new BadRequestException('Customer has already signed this contract');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
|
if (!booking.isGovernment) {
|
||||||
|
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
|
||||||
|
}
|
||||||
const existing = await this.bookingsRepository.findContractSignature(
|
const existing = await this.bookingsRepository.findContractSignature(
|
||||||
bookingId,
|
bookingId,
|
||||||
'STAFF',
|
'STAFF',
|
||||||
@@ -235,20 +261,30 @@ export class BookingContractService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (role === 'CUSTOMER') {
|
if (role === 'CUSTOMER') {
|
||||||
updates.status = 'SIGNED_CUSTOMER';
|
|
||||||
updates.customerSignedAt = now;
|
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 {
|
} else {
|
||||||
updates.fullyExecutedAt = now;
|
updates.fullyExecutedAt = now;
|
||||||
updates.marketingApprovedAt = now;
|
updates.marketingApprovedAt = now;
|
||||||
updates.marketingApprovedById = options.signerUserId ?? null;
|
updates.marketingApprovedById = options.signerUserId ?? null;
|
||||||
updates.lockedAt = now;
|
if (!booking.isGovernment) {
|
||||||
updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED';
|
updates.lockedAt = now;
|
||||||
|
updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, updates as never);
|
const updated = await this.bookingsRepository.update(bookingId, updates as never);
|
||||||
// Only the non-clearance (legacy/domestic) path enters the batch pipeline now;
|
// Only the non-clearance (legacy/domestic) path enters the batch pipeline now;
|
||||||
// clearance bookings enter operations after the GL document gate.
|
// clearance bookings enter operations after the GL document gate. Government
|
||||||
if (role === 'STAFF' && !clearanceCode && updated?.trainScheduleId) {
|
// bookings are already in the pool from expedite — signing changes nothing.
|
||||||
|
if (
|
||||||
|
role === 'STAFF' &&
|
||||||
|
!booking.isGovernment &&
|
||||||
|
!clearanceCode &&
|
||||||
|
updated?.trainScheduleId
|
||||||
|
) {
|
||||||
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
|
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
|
||||||
}
|
}
|
||||||
try {
|
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 {
|
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 =
|
const msg =
|
||||||
`Your operation request for booking ${b.reference} needs changes: ${note}. ` +
|
`Your operation request for booking ${b.reference} needs changes: ${note}. ` +
|
||||||
`Please update and resubmit from the portal.`;
|
`Please update and resubmit from the portal.`;
|
||||||
@@ -197,6 +224,24 @@ export class BookingLifecycleNotifierService {
|
|||||||
this.inApp(b, 'Operation request accepted', msg);
|
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. */
|
/** Shipment started → in transit. */
|
||||||
inTransit(b: Booking): void {
|
inTransit(b: Booking): void {
|
||||||
const msg = `Your shipment for booking ${b.reference} is now in transit.`;
|
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);
|
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 ──────────────────────────
|
// ── Clearance milestones needing customer action ──────────────────────────
|
||||||
|
|
||||||
/** GL advised duty & tax — the customer must pay and upload the slip. */
|
/** 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 {
|
finalInvoiceCreated(b: Booking, amount: number, currency: string): void {
|
||||||
const msg =
|
const msg =
|
||||||
`A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` +
|
`A final invoice of ${amount} ${currency} has been raised for booking ${b.reference}. ` +
|
||||||
`Please pay and upload the payment slip from the portal.`;
|
`Please review and approve it in the portal, then pay and upload the payment slip.`;
|
||||||
void this.notifyContact(b, msg, 'FINAL INVOICE');
|
void this.notifyContact(b, msg, 'FINAL INVOICE');
|
||||||
this.inApp(b, 'Final invoice issued', msg, {
|
this.inApp(b, 'Final invoice issued', msg, {
|
||||||
type: NotificationType.INVOICE_ISSUED,
|
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. */
|
/** Customer signed the booking contract. */
|
||||||
customerSignedToStaff(b: Booking): void {
|
customerSignedToStaff(b: Booking): void {
|
||||||
this.inAppStaff(
|
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. */
|
/** Customer uploaded a duty/tax payment slip — GL verifies it. */
|
||||||
dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void {
|
dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void {
|
||||||
const label =
|
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);
|
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 { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
import { RatesService } from '../rule-engine/services/rates.service';
|
import { RatesService } from '../rule-engine/services/rates.service';
|
||||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
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 { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||||
import { ExchangeService } from '@edr/api-common';
|
import { ExchangeService } from '@edr/api-common';
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
containersPerWagonForSize,
|
containersPerWagonForSize,
|
||||||
wagonsPerUnitForSize,
|
wagonsPerUnitForSize,
|
||||||
} from '../rule-engine/container-type.util';
|
} from '../rule-engine/container-type.util';
|
||||||
|
import { bulkItemWagonsForAllowedTypes } from '../train-scheduling/train-capacity.util';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { wagonRemainder } from './consolidation.service';
|
import { wagonRemainder } from './consolidation.service';
|
||||||
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
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.
|
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
|
||||||
const frozen = isDerived
|
const frozen = isDerived
|
||||||
? null
|
? null
|
||||||
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency);
|
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, usdToEtb);
|
||||||
const unitAmount = frozen
|
const unitAmount = frozen
|
||||||
? Number(frozen.unitPrice)
|
? Number(frozen.unitPrice)
|
||||||
: isEtbBooking
|
: isEtbBooking
|
||||||
@@ -529,8 +531,6 @@ export class BookingPricingService {
|
|||||||
const usedRatesMap = new Map<string, Rate>();
|
const usedRatesMap = new Map<string, Rate>();
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const blocked: string[] = [];
|
const blocked: string[] = [];
|
||||||
const wagonCount = await this.resolveWagonCount(booking);
|
|
||||||
|
|
||||||
for (const container of evalInput.containers) {
|
for (const container of evalInput.containers) {
|
||||||
const rate = this.pickRate(
|
const rate = this.pickRate(
|
||||||
liveRates,
|
liveRates,
|
||||||
@@ -540,14 +540,15 @@ export class BookingPricingService {
|
|||||||
booking.originYardId,
|
booking.originYardId,
|
||||||
booking.destinationYardId,
|
booking.destinationYardId,
|
||||||
);
|
);
|
||||||
// H15: frozen contract rate for this container size, when present — its
|
// H15: frozen contract rate for this container size, when present —
|
||||||
// unitPrice is already in the booking currency (no USD→currency convert).
|
// converted into the booking currency by frozenRateForContainer. It also
|
||||||
// It also stands on its own: a contract line prices off the agreed rate
|
// stands on its own: a contract line prices off the agreed rate even when
|
||||||
// even when nobody configured a live rate for this leg + type yet.
|
// nobody configured a live rate for this leg + type yet.
|
||||||
const frozen = await this.frozenRateForContainer(
|
const frozen = await this.frozenRateForContainer(
|
||||||
frozenRates,
|
frozenRates,
|
||||||
container.containerTypeId,
|
container.containerTypeId,
|
||||||
paymentCurrency,
|
paymentCurrency,
|
||||||
|
usdToEtb,
|
||||||
);
|
);
|
||||||
const label = await this.containerTypeLabel(container.containerTypeId);
|
const label = await this.containerTypeLabel(container.containerTypeId);
|
||||||
if (!rate && !frozen) {
|
if (!rate && !frozen) {
|
||||||
@@ -565,6 +566,10 @@ export class BookingPricingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER';
|
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 amount: number;
|
||||||
let unitAmount: number;
|
let unitAmount: number;
|
||||||
if (frozen) {
|
if (frozen) {
|
||||||
@@ -573,11 +578,11 @@ export class BookingPricingService {
|
|||||||
rateUnit,
|
rateUnit,
|
||||||
unitAmount,
|
unitAmount,
|
||||||
container.quantity,
|
container.quantity,
|
||||||
wagonCount,
|
lineWagons,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const unitUsd = Number(rate!.rateValue);
|
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;
|
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||||
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||||
}
|
}
|
||||||
@@ -588,7 +593,7 @@ export class BookingPricingService {
|
|||||||
amount,
|
amount,
|
||||||
unitAmount,
|
unitAmount,
|
||||||
unit: rateUnit,
|
unit: rateUnit,
|
||||||
quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount),
|
quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, lineWagons),
|
||||||
currency: paymentCurrency,
|
currency: paymentCurrency,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -600,7 +605,10 @@ export class BookingPricingService {
|
|||||||
// container type above or stay unpriced with a warning — falling back to
|
// 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
|
// 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.
|
// 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) =>
|
||||||
r.rateType === rateType &&
|
r.rateType === rateType &&
|
||||||
r.currency === 'USD' &&
|
r.currency === 'USD' &&
|
||||||
@@ -608,15 +616,29 @@ export class BookingPricingService {
|
|||||||
r.originYardId === booking.originYardId &&
|
r.originYardId === booking.originYardId &&
|
||||||
r.destinationYardId === booking.destinationYardId,
|
r.destinationYardId === booking.destinationYardId,
|
||||||
);
|
);
|
||||||
|
const fallback =
|
||||||
|
(booking.cargoTypeId
|
||||||
|
? onLeg.find((r) => r.cargoTypeId === booking.cargoTypeId)
|
||||||
|
: undefined) ?? onLeg.find((r) => !r.cargoTypeId);
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
usedRatesMap.set(fallback.id, 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 =
|
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);
|
const unitUsd = Number(fallback.rateValue);
|
||||||
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
|
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
|
||||||
const frozen = isBulk
|
const frozen = isBulk
|
||||||
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency)
|
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, usdToEtb)
|
||||||
: null;
|
: null;
|
||||||
let amount: number;
|
let amount: number;
|
||||||
let unitAmount: number;
|
let unitAmount: number;
|
||||||
@@ -719,6 +741,7 @@ export class BookingPricingService {
|
|||||||
quantity = containerCount;
|
quantity = containerCount;
|
||||||
break;
|
break;
|
||||||
case 'PER_TON':
|
case 'PER_TON':
|
||||||
|
case 'PER_ITEM':
|
||||||
quantity = bulkTons;
|
quantity = bulkTons;
|
||||||
break;
|
break;
|
||||||
case 'FLAT':
|
case 'FLAT':
|
||||||
@@ -727,12 +750,13 @@ export class BookingPricingService {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// H15: frozen mile rate (already in booking currency) when the contract
|
// H15: frozen mile rate (converted into the booking currency) when the
|
||||||
// has one; else the live USD rate converted as before.
|
// contract has one; else the live USD rate converted as before.
|
||||||
const frozen = this.frozenRateByCode(
|
const frozen = this.frozenRateByCode(
|
||||||
frozenRates,
|
frozenRates,
|
||||||
leg.rateType,
|
leg.rateType,
|
||||||
paymentCurrency,
|
paymentCurrency,
|
||||||
|
usdToEtb,
|
||||||
);
|
);
|
||||||
let amount: number;
|
let amount: number;
|
||||||
let unitAmount: number;
|
let unitAmount: number;
|
||||||
@@ -764,6 +788,34 @@ export class BookingPricingService {
|
|||||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
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;
|
* Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate;
|
||||||
* an unsaved preview booking (no id) sums the wagonsRequired already computed
|
* an unsaved preview booking (no id) sums the wagonsRequired already computed
|
||||||
@@ -804,6 +856,7 @@ export class BookingPricingService {
|
|||||||
return 1;
|
return 1;
|
||||||
case 'PER_CONTAINER':
|
case 'PER_CONTAINER':
|
||||||
case 'PER_TON':
|
case 'PER_TON':
|
||||||
|
case 'PER_ITEM':
|
||||||
default:
|
default:
|
||||||
return quantity;
|
return quantity;
|
||||||
}
|
}
|
||||||
@@ -860,6 +913,7 @@ export class BookingPricingService {
|
|||||||
case 'PER_WAGON':
|
case 'PER_WAGON':
|
||||||
return unitValue * wagonCount;
|
return unitValue * wagonCount;
|
||||||
case 'PER_TON':
|
case 'PER_TON':
|
||||||
|
case 'PER_ITEM':
|
||||||
return unitValue * quantity;
|
return unitValue * quantity;
|
||||||
case 'FLAT':
|
case 'FLAT':
|
||||||
return unitValue;
|
return unitValue;
|
||||||
@@ -889,20 +943,45 @@ export class BookingPricingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The frozen snapshot for a rate code, or null when there is none, its price
|
* The frozen snapshot for a rate code, expressed in the BOOKING's currency.
|
||||||
* 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).
|
* 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(
|
private frozenRateByCode(
|
||||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||||
code: string,
|
code: string,
|
||||||
bookingCurrency: string,
|
bookingCurrency: string,
|
||||||
|
usdToEtb: number,
|
||||||
): ContractRateSnapshot | null {
|
): ContractRateSnapshot | null {
|
||||||
const snap = frozenRates?.get(code);
|
const snap = frozenRates?.get(code);
|
||||||
if (!snap) return null;
|
if (!snap) return null;
|
||||||
if (snap.currency !== bookingCurrency) return null;
|
const unitPrice = Number(snap.unitPrice);
|
||||||
if (!(Number(snap.unitPrice) >= 0)) return null;
|
if (!(unitPrice >= 0)) return null;
|
||||||
return snap;
|
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,
|
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||||
containerTypeId: string,
|
containerTypeId: string,
|
||||||
bookingCurrency: string,
|
bookingCurrency: string,
|
||||||
|
usdToEtb: number,
|
||||||
): Promise<ContractRateSnapshot | null> {
|
): Promise<ContractRateSnapshot | null> {
|
||||||
if (!frozenRates) return null;
|
if (!frozenRates) return null;
|
||||||
let sizeFt: number | null = null;
|
let sizeFt: number | null = null;
|
||||||
@@ -923,7 +1003,7 @@ export class BookingPricingService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!sizeFt) 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 =
|
const hasPerSizeSnapshot =
|
||||||
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
|
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
|
||||||
frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
|
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) {
|
if (legacyFlat && !hasPerSizeSnapshot) {
|
||||||
const amount = Number(legacyFlat.unitPrice);
|
const amount = Number(legacyFlat.unitPrice);
|
||||||
if (amount > 0) {
|
if (amount > 0) {
|
||||||
@@ -995,7 +1075,7 @@ export class BookingPricingService {
|
|||||||
// unknown type — falls through to the live per-type lookup below
|
// unknown type — falls through to the live per-type lookup below
|
||||||
}
|
}
|
||||||
const frozen = sizeFt
|
const frozen = sizeFt
|
||||||
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency)
|
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency, usdToEtb)
|
||||||
: null;
|
: null;
|
||||||
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
|
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
|
||||||
if (!frozen && !live) {
|
if (!frozen && !live) {
|
||||||
@@ -1030,7 +1110,7 @@ export class BookingPricingService {
|
|||||||
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
||||||
// Live lookup: the rate scoped to the booking's commodity wins; a
|
// Live lookup: the rate scoped to the booking's commodity wins; a
|
||||||
// commodity-less rate (legacy) is the catch-all fallback.
|
// 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 =
|
const live =
|
||||||
(booking.cargoTypeId
|
(booking.cargoTypeId
|
||||||
? onLeg.find(
|
? onLeg.find(
|
||||||
@@ -1044,7 +1124,7 @@ export class BookingPricingService {
|
|||||||
const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit;
|
const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit;
|
||||||
const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue));
|
const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue));
|
||||||
let billedQty = 1;
|
let billedQty = 1;
|
||||||
if (unit === 'PER_TON') {
|
if (isBulkQuantityUnit(unit)) {
|
||||||
billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0));
|
billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0));
|
||||||
} else if (unit === 'PER_WAGON') {
|
} else if (unit === 'PER_WAGON') {
|
||||||
const wagons = await this.bulkWagonCount(booking);
|
const wagons = await this.bulkWagonCount(booking);
|
||||||
@@ -1081,6 +1161,8 @@ export class BookingPricingService {
|
|||||||
return 'PER_WAGON';
|
return 'PER_WAGON';
|
||||||
case 'per_ton':
|
case 'per_ton':
|
||||||
return 'PER_TON';
|
return 'PER_TON';
|
||||||
|
case 'per_item':
|
||||||
|
return 'PER_ITEM';
|
||||||
case 'per_container':
|
case 'per_container':
|
||||||
return 'PER_CONTAINER';
|
return 'PER_CONTAINER';
|
||||||
default:
|
default:
|
||||||
@@ -1105,6 +1187,11 @@ export class BookingPricingService {
|
|||||||
...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0),
|
...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0),
|
||||||
);
|
);
|
||||||
if (!(capacity > 0)) return null;
|
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));
|
return Math.max(1, Math.ceil(tons / capacity));
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -33,41 +33,86 @@ import {
|
|||||||
BookingReferenceYardDto,
|
BookingReferenceYardDto,
|
||||||
} from "./dto/booking-reference-data.dto";
|
} 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(
|
export function buildCargoTypeTree(
|
||||||
rows: CargoType[],
|
rows: CargoType[],
|
||||||
): BookingReferenceCargoTypeGroupDto[] {
|
): BookingReferenceCargoTypeGroupDto[] {
|
||||||
const active = rows.filter((r) => r.isActive);
|
const active = rows.filter((r) => r.isActive);
|
||||||
const parents = active
|
|
||||||
.filter((r) => !r.parentGroupId)
|
const byOrder = (a: CargoType, b: CargoType) =>
|
||||||
.sort(
|
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code);
|
||||||
(a, b) => 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) => {
|
return parents.map((parent) => {
|
||||||
const children = active
|
const kids = childrenOf.get(parent.id) ?? [];
|
||||||
.filter((r) => r.parentGroupId === parent.id)
|
const children =
|
||||||
.sort(
|
kids.length === 0
|
||||||
(a, b) =>
|
? // The group itself is the commodity.
|
||||||
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
|
[
|
||||||
)
|
{
|
||||||
.map(
|
id: parent.id,
|
||||||
(child): BookingReferenceCargoTypeChildDto => ({
|
name: parent.cargoTypeName,
|
||||||
id: child.id,
|
code: parent.code,
|
||||||
name: child.cargoTypeName,
|
unit_of_measure: parent.unitOfMeasure ?? null,
|
||||||
code: child.code,
|
},
|
||||||
unit_of_measure: child.unitOfMeasure ?? null,
|
]
|
||||||
}),
|
: kids.flatMap((kid) => collectLeaves(kid, [], new Set<string>()));
|
||||||
);
|
|
||||||
|
|
||||||
const group: BookingReferenceCargoTypeGroupDto = {
|
return {
|
||||||
id: parent.id,
|
id: parent.id,
|
||||||
name: parent.cargoTypeName,
|
name: parent.cargoTypeName,
|
||||||
code: parent.code,
|
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, // fileUploadSettingsService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedCustomsBooking: () => false } as never,
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
@@ -59,6 +59,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, ruleEngineService, contractService };
|
return { service, bookingsRepository, ruleEngineService, contractService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
|||||||
serviceType: { includesCustoms: false }, // no output set → only the input gate
|
serviceType: { includesCustoms: false }, // no output set → only the input gate
|
||||||
};
|
};
|
||||||
|
|
||||||
// Input set has two required docs. Non-customs bookings resolve to the
|
// Input set has two required docs. Non-customs bookings resolve to their
|
||||||
// ONE_TIME self-clearance document set.
|
// own without-customs document set.
|
||||||
const inputSetting = {
|
const inputSetting = {
|
||||||
code: 'contract_clearance_selfclear_import_container',
|
code: 'clearance_import_container_without_customs',
|
||||||
fields: [
|
fields: [
|
||||||
{ fileKey: 'commercial_invoice', isRequired: true },
|
{ fileKey: 'commercial_invoice', isRequired: true },
|
||||||
{ fileKey: 'packing_list', isRequired: true },
|
{ fileKey: 'packing_list', isRequired: true },
|
||||||
@@ -46,7 +46,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
|||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedCustomsBooking: () => false } as never,
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
@@ -68,6 +68,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository };
|
return { service, bookingsRepository };
|
||||||
}
|
}
|
||||||
@@ -149,7 +150,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
|||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedCustomsBooking: () => false } as never,
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
@@ -171,6 +172,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository };
|
return { service, bookingsRepository };
|
||||||
}
|
}
|
||||||
@@ -199,7 +201,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
|||||||
*/
|
*/
|
||||||
describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => {
|
describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => {
|
||||||
const inputSetting = {
|
const inputSetting = {
|
||||||
code: 'contract_clearance_selfclear_import_container',
|
code: 'clearance_import_container_without_customs',
|
||||||
fields: [
|
fields: [
|
||||||
{ fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true },
|
{ fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true },
|
||||||
{ fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true },
|
{ fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true },
|
||||||
@@ -238,7 +240,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
|||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedCustomsBooking: () => false } as never,
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
@@ -260,6 +262,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, filesService };
|
return { service, bookingsRepository, filesService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ describe('BookingTransitionService — operation review', () => {
|
|||||||
};
|
};
|
||||||
const bookingsService = {
|
const bookingsService = {
|
||||||
findById: jest.fn().mockResolvedValue(booking),
|
findById: jest.fn().mockResolvedValue(booking),
|
||||||
|
assertNoUnpaidHold: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const bookingBatchService = {
|
const bookingBatchService = {
|
||||||
enqueueRouteDayProcessing: jest.fn(),
|
enqueueRouteDayProcessing: jest.fn(),
|
||||||
@@ -48,7 +49,7 @@ describe('BookingTransitionService — operation review', () => {
|
|||||||
{} as never, // fileUploadSettingsService
|
{} as never, // fileUploadSettingsService
|
||||||
bookingBatchService as never,
|
bookingBatchService as never,
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedCustomsBooking: () => false } as never,
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
invoiceService as never,
|
invoiceService as never,
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
@@ -70,6 +71,7 @@ describe('BookingTransitionService — operation review', () => {
|
|||||||
clearanceDocsUploadedToStaff: jest.fn(),
|
clearanceDocsUploadedToStaff: jest.fn(),
|
||||||
dutySlipUploadedToStaff: jest.fn(),
|
dutySlipUploadedToStaff: jest.fn(),
|
||||||
} as never, // notifier
|
} as never, // notifier
|
||||||
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, bookingBatchService, invoiceService };
|
return { service, bookingsRepository, bookingBatchService, invoiceService };
|
||||||
}
|
}
|
||||||
@@ -143,6 +145,7 @@ describe('BookingTransitionService — requestOperation export space gate', () =
|
|||||||
};
|
};
|
||||||
const bookingsService = {
|
const bookingsService = {
|
||||||
findById: jest.fn().mockResolvedValue(booking),
|
findById: jest.fn().mockResolvedValue(booking),
|
||||||
|
assertNoUnpaidHold: jest.fn().mockResolvedValue(undefined),
|
||||||
checkDayCompatibilityForBooking: jest
|
checkDayCompatibilityForBooking: jest
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue({ hasDeparture: true, hasCompatible: true }),
|
.mockResolvedValue({ hasDeparture: true, hasCompatible: true }),
|
||||||
@@ -164,11 +167,12 @@ describe('BookingTransitionService — requestOperation export space gate', () =
|
|||||||
{} as never, // fileUploadSettingsService
|
{} as never, // fileUploadSettingsService
|
||||||
bookingBatchService as never,
|
bookingBatchService as never,
|
||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedCustomsBooking: () => false } as never,
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
notifier as never,
|
notifier as never,
|
||||||
|
{ emit: jest.fn() } as never, // events
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, bookingBatchService };
|
return { service, bookingsRepository, bookingBatchService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
Optional,
|
Optional,
|
||||||
} from "@nestjs/common";
|
} 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 { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
import { isRoadService } from './road.util';
|
import { isRoadService } from './road.util';
|
||||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||||
@@ -56,11 +59,12 @@ export class BookingTransitionService {
|
|||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
private readonly containerValidationService: ContainerValidationService,
|
private readonly containerValidationService: ContainerValidationService,
|
||||||
private readonly notifier: BookingLifecycleNotifierService,
|
private readonly notifier: BookingLifecycleNotifierService,
|
||||||
|
private readonly events: EventEmitter2,
|
||||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private isPhasedGeneralCustoms(booking: Booking): boolean {
|
private isPhasedCustoms(booking: Booking): boolean {
|
||||||
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
|
return this.bookingClearanceService.isPhasedCustomsBooking(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
|
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
|
||||||
@@ -376,6 +380,8 @@ export class BookingTransitionService {
|
|||||||
} as never);
|
} as never);
|
||||||
const fresh = await this.bookingsService.findById(updated!.id);
|
const fresh = await this.bookingsService.findById(updated!.id);
|
||||||
this.notifier.completed(fresh);
|
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
|
// Customer tracking: close out the tail milestones so a finished shipment
|
||||||
// never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are
|
// never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are
|
||||||
// implied by delivery; a storage invoice that was never raised is skipped
|
// implied by delivery; a storage invoice that was never raised is skipped
|
||||||
@@ -398,6 +404,31 @@ export class BookingTransitionService {
|
|||||||
return fresh;
|
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> {
|
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, [
|
assertBookingStatus(booking, [
|
||||||
@@ -491,7 +522,7 @@ export class BookingTransitionService {
|
|||||||
operationReady?: boolean;
|
operationReady?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
if (this.isPhasedGeneralCustoms(booking)) {
|
if (this.isPhasedCustoms(booking)) {
|
||||||
return this.bookingClearanceService.getClearanceView(bookingId);
|
return this.bookingClearanceService.getClearanceView(bookingId);
|
||||||
}
|
}
|
||||||
const { inputCode, outputCode, includesCustoms } =
|
const { inputCode, outputCode, includesCustoms } =
|
||||||
@@ -650,7 +681,7 @@ export class BookingTransitionService {
|
|||||||
status: "DOCUMENTS_UNDER_REVIEW",
|
status: "DOCUMENTS_UNDER_REVIEW",
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
if (this.isPhasedGeneralCustoms(booking)) {
|
if (this.isPhasedCustoms(booking)) {
|
||||||
await this.workflowService.onCustomerDocsUploadedForBooking(
|
await this.workflowService.onCustomerDocsUploadedForBooking(
|
||||||
bookingId,
|
bookingId,
|
||||||
booking.tradeDirection ?? 'IMPORT',
|
booking.tradeDirection ?? 'IMPORT',
|
||||||
@@ -732,7 +763,7 @@ export class BookingTransitionService {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
status === 'QUERIED' &&
|
status === 'QUERIED' &&
|
||||||
this.isPhasedGeneralCustoms(booking) &&
|
this.isPhasedCustoms(booking) &&
|
||||||
booking.preClearanceFinalizedAt
|
booking.preClearanceFinalizedAt
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -755,7 +786,7 @@ export class BookingTransitionService {
|
|||||||
"CHANGES_REQUESTED",
|
"CHANGES_REQUESTED",
|
||||||
staffId,
|
staffId,
|
||||||
);
|
);
|
||||||
if (this.isPhasedGeneralCustoms(booking)) {
|
if (this.isPhasedCustoms(booking)) {
|
||||||
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||||
@@ -767,7 +798,7 @@ export class BookingTransitionService {
|
|||||||
if (status === "QUERIED") {
|
if (status === "QUERIED") {
|
||||||
this.notifier.documentQueried(updated, fileKey, note ?? '');
|
this.notifier.documentQueried(updated, fileKey, note ?? '');
|
||||||
}
|
}
|
||||||
if (this.isPhasedGeneralCustoms(updated)) {
|
if (this.isPhasedCustoms(updated)) {
|
||||||
const allApproved = await this.isClearanceFullyApproved(updated);
|
const allApproved = await this.isClearanceFullyApproved(updated);
|
||||||
if (allApproved) {
|
if (allApproved) {
|
||||||
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
|
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
|
||||||
@@ -817,7 +848,7 @@ export class BookingTransitionService {
|
|||||||
*/
|
*/
|
||||||
async finalizeClearance(bookingId: string): Promise<Booking> {
|
async finalizeClearance(bookingId: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
if (this.isPhasedGeneralCustoms(booking)) {
|
if (this.isPhasedCustoms(booking)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.',
|
'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.',
|
||||||
);
|
);
|
||||||
@@ -888,6 +919,7 @@ export class BookingTransitionService {
|
|||||||
async requestOperation(
|
async requestOperation(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
scheduledDate: string,
|
scheduledDate: string,
|
||||||
|
requestedTrainScheduleId?: string | null,
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, [
|
assertBookingStatus(booking, [
|
||||||
@@ -895,6 +927,13 @@ export class BookingTransitionService {
|
|||||||
"OPERATION_CHANGES_REQUESTED",
|
"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
|
// A bare initiated instance (clearance-first flow) carries no cargo or
|
||||||
// price — it must go through the contract completion endpoint, which
|
// price — it must go through the contract completion endpoint, which
|
||||||
// persists cargo, prices, invoices and only then lands here itself.
|
// 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").
|
// largest bookable leftover ("reduce to N wagons or pick another day").
|
||||||
// Import/domestic bookings are batched + splittable, so they are NOT gated
|
// Import/domestic bookings are batched + splittable, so they are NOT gated
|
||||||
// here — they get an advisory count below and the batch engine sizes them.
|
// here — they get an advisory count below and the batch engine sizes them.
|
||||||
const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
|
|
||||||
const isExportTrain =
|
const isExportTrain =
|
||||||
booking.tradeDirection === "EXPORT" &&
|
booking.tradeDirection === "EXPORT" &&
|
||||||
!isRoadService(booking.serviceType);
|
!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) {
|
if (isExportTrain) {
|
||||||
// With export split ON the booking no longer has to ride ONE train whole:
|
// 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
|
// the largest fitting part is offered and the leftover rebooks on the next
|
||||||
@@ -955,9 +1002,14 @@ export class BookingTransitionService {
|
|||||||
eatDay(date),
|
eatDay(date),
|
||||||
"EXPORT",
|
"EXPORT",
|
||||||
);
|
);
|
||||||
if (!fitting.length) {
|
const fitsRequest = requestedId
|
||||||
|
? fitting.some((f) => f.scheduleId === requestedId)
|
||||||
|
: fitting.length > 0;
|
||||||
|
if (!fitsRequest) {
|
||||||
throw new ConflictException(
|
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 {
|
} else {
|
||||||
@@ -968,6 +1020,7 @@ export class BookingTransitionService {
|
|||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: "OPERATION_REQUEST_PENDING",
|
status: "OPERATION_REQUEST_PENDING",
|
||||||
scheduledDate: date,
|
scheduledDate: date,
|
||||||
|
requestedTrainScheduleId: requestedId,
|
||||||
} as never);
|
} as never);
|
||||||
const fresh = await this.bookingsService.findById(bookingId);
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
this.notifier.operationRequestedToStaff(fresh);
|
this.notifier.operationRequestedToStaff(fresh);
|
||||||
@@ -985,6 +1038,43 @@ export class BookingTransitionService {
|
|||||||
* total covers the booking. `trainsForDay` is false when no departure carries
|
* total covers the booking. `trainsForDay` is false when no departure carries
|
||||||
* the leg — the day is unbookable regardless of space.
|
* 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(
|
async dayAvailabilityForBooking(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
scheduledDate: string,
|
scheduledDate: string,
|
||||||
|
|||||||
@@ -464,6 +464,26 @@ export class BookingsController {
|
|||||||
res.send(buffer);
|
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')
|
@Get(':id/customer-trucks')
|
||||||
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
|
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
|
||||||
async listCustomerTrucks(
|
async listCustomerTrucks(
|
||||||
@@ -751,10 +771,42 @@ export class BookingsController {
|
|||||||
const booking = await this.transitionService.requestOperation(
|
const booking = await this.transitionService.requestOperation(
|
||||||
id,
|
id,
|
||||||
dto.scheduledDate,
|
dto.scheduledDate,
|
||||||
|
dto.trainScheduleId ?? null,
|
||||||
);
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
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")
|
@Post(":id/operation/review")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@@ -823,6 +875,34 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
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')
|
@Post(':id/clearance/declaration')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@@ -872,6 +952,59 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
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')
|
@Post(':id/clearance/finalize-pre-clearance')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||||
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
|
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
|
||||||
@@ -916,14 +1049,15 @@ export class BookingsController {
|
|||||||
async uploadBookingDeliveryOrder(
|
async uploadBookingDeliveryOrder(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
|
@Body('vesselArrivalDate') vesselArrivalDate: string | undefined,
|
||||||
|
@Body('doCollectedDate') doCollectedDate: string | undefined,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||||||
id,
|
id,
|
||||||
file,
|
file,
|
||||||
resolveAuthUserId(user),
|
resolveAuthUserId(user),
|
||||||
vesselDepartureDate,
|
{ vesselArrivalDate, doCollectedDate },
|
||||||
);
|
);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
@@ -1189,6 +1323,20 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
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")
|
@Post(":id/consolidation")
|
||||||
@ApiOperation({ summary: "Request freight consolidation" })
|
@ApiOperation({ summary: "Request freight consolidation" })
|
||||||
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { BaseRepository } from '@edr/api-common';
|
import { BaseRepository } from '@edr/api-common';
|
||||||
import { SchedulingStatus } from '@edr/types';
|
import { SchedulingStatus } from '@edr/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { ConflictException, Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
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 { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
@@ -15,6 +23,7 @@ import {
|
|||||||
DocumentReviewStatus,
|
DocumentReviewStatus,
|
||||||
} from './entities/booking-document-review.entity';
|
} from './entities/booking-document-review.entity';
|
||||||
import { BookingContainer } from './entities/booking-container.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 { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||||
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
||||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||||
@@ -26,6 +35,22 @@ import {
|
|||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
|
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 {
|
export interface BookingListFilterOptions {
|
||||||
statuses?: string[];
|
statuses?: string[];
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -68,6 +93,42 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
return this.repository.findOne({ where: { reference } });
|
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.
|
* Highest NNNNNN sequence already issued for `BK-<year>-…` references.
|
||||||
* Includes soft-deleted bookings so the next number clears references that
|
* Includes soft-deleted bookings so the next number clears references that
|
||||||
@@ -123,7 +184,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
'booking.files',
|
'booking.files',
|
||||||
FileRecord,
|
FileRecord,
|
||||||
'file',
|
'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();
|
.getOne();
|
||||||
|
|
||||||
@@ -139,10 +202,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
vgmPerUnitTons: number;
|
vgmPerUnitTons: number;
|
||||||
hazardousQuantity?: number;
|
hazardousQuantity?: number;
|
||||||
reeferQuantity?: number;
|
reeferQuantity?: number;
|
||||||
|
containerNumbers?: string[];
|
||||||
weightResult: ContainerWeightResult;
|
weightResult: ContainerWeightResult;
|
||||||
}>,
|
}>,
|
||||||
): Promise<BookingContainer[]> {
|
): Promise<BookingContainer[]> {
|
||||||
const containerRepo = this.dataSource.getRepository(BookingContainer);
|
const containerRepo = this.dataSource.getRepository(BookingContainer);
|
||||||
|
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
|
||||||
const typeRepo = this.dataSource.getRepository(ContainerType);
|
const typeRepo = this.dataSource.getRepository(ContainerType);
|
||||||
const saved: BookingContainer[] = [];
|
const saved: BookingContainer[] = [];
|
||||||
|
|
||||||
@@ -168,7 +233,26 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
isOverweight: item.weightResult.isOverweight,
|
isOverweight: item.weightResult.isOverweight,
|
||||||
overweightExcessTons: item.weightResult.overweightExcessTons,
|
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;
|
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(
|
async findLatestReviewNote(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
type?: ReviewNoteType,
|
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.
|
* the whole (route, day) pool rather than bookings pre-targeted to one train.
|
||||||
*/
|
*/
|
||||||
day?: string;
|
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[]> {
|
}): Promise<Booking[]> {
|
||||||
const qb = this.repository
|
const qb = this.repository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
@@ -1030,15 +1134,19 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
'scheduleBooking.booking_id = booking.id',
|
'scheduleBooking.booking_id = booking.id',
|
||||||
)
|
)
|
||||||
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
|
.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
|
// 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
|
// 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
|
// single-schedule filter only when no day is supplied (e.g. a staff-pinned
|
||||||
// booking that still carries train_schedule_id).
|
// booking that still carries train_schedule_id).
|
||||||
if (options.day) {
|
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(
|
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 },
|
{ day: options.day },
|
||||||
);
|
);
|
||||||
} else if (options.trainScheduleId) {
|
} else if (options.trainScheduleId) {
|
||||||
@@ -1051,15 +1159,23 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType });
|
qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.originStationId) {
|
if (options.corridorYardIds?.length) {
|
||||||
qb.andWhere('booking.originYardId = :originStationId', {
|
qb.andWhere('booking.originYardId IN (:...corridorYardIds)', {
|
||||||
originStationId: options.originStationId,
|
corridorYardIds: options.corridorYardIds,
|
||||||
});
|
}).andWhere('booking.destinationYardId IN (:...corridorYardIds)', {
|
||||||
}
|
corridorYardIds: options.corridorYardIds,
|
||||||
if (options.destinationStationId) {
|
|
||||||
qb.andWhere('booking.destinationYardId = :destinationStationId', {
|
|
||||||
destinationStationId: options.destinationStationId,
|
|
||||||
});
|
});
|
||||||
|
} 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) {
|
if (options.schedulingStatus) {
|
||||||
qb.andWhere('booking.scheduling_status = :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')
|
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||||
.andWhere('sb.id IS NULL')
|
.andWhere('sb.id IS NULL')
|
||||||
.andWhere(
|
.andWhere(BATCH_POOL_READY)
|
||||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
|
||||||
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
|
||||||
)
|
|
||||||
.orderBy('booking.is_government', 'DESC')
|
.orderBy('booking.is_government', 'DESC')
|
||||||
.addOrderBy('booking.priority_score', 'DESC')
|
.addOrderBy('booking.priority_score', 'DESC')
|
||||||
.addOrderBy('booking.fully_executed_at', 'ASC')
|
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||||
@@ -1128,10 +1242,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
{ day },
|
{ day },
|
||||||
)
|
)
|
||||||
.andWhere('sb.id IS NULL')
|
.andWhere('sb.id IS NULL')
|
||||||
.andWhere(
|
.andWhere(BATCH_POOL_READY)
|
||||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
|
||||||
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
|
||||||
)
|
|
||||||
.orderBy('booking.is_government', 'DESC')
|
.orderBy('booking.is_government', 'DESC')
|
||||||
.addOrderBy('booking.priority_score', 'DESC')
|
.addOrderBy('booking.priority_score', 'DESC')
|
||||||
.addOrderBy('booking.fully_executed_at', 'ASC')
|
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||||
@@ -1168,10 +1280,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
{ day },
|
{ day },
|
||||||
)
|
)
|
||||||
.andWhere('sb.id IS NULL')
|
.andWhere('sb.id IS NULL')
|
||||||
.andWhere(
|
.andWhere(BATCH_POOL_READY)
|
||||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
|
||||||
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
|
||||||
)
|
|
||||||
.orderBy('booking.is_government', 'DESC')
|
.orderBy('booking.is_government', 'DESC')
|
||||||
.addOrderBy('booking.priority_score', 'DESC')
|
.addOrderBy('booking.priority_score', 'DESC')
|
||||||
.addOrderBy('booking.fully_executed_at', 'ASC')
|
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||||
@@ -1248,6 +1358,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.getMany();
|
.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. */
|
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
|
||||||
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
|
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||||
return this.repository
|
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 { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
import { Contract } from '../contracts/entities/contract.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 { BookingsRepository } from './bookings.repository';
|
||||||
import { ConsolidationService } from './consolidation.service';
|
import { ConsolidationService } from './consolidation.service';
|
||||||
import { VehiclesService } from '../vehicles/vehicles.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 URGENT_PRIORITY_THRESHOLD = 1000;
|
||||||
const NEEDS_ACTION_STATUSES = [
|
const NEEDS_ACTION_STATUSES = [
|
||||||
'SUBMITTED',
|
'SUBMITTED',
|
||||||
@@ -103,6 +128,10 @@ export class BookingsService {
|
|||||||
private readonly vehiclesService: VehiclesService,
|
private readonly vehiclesService: VehiclesService,
|
||||||
private readonly pdfRender: PdfRenderService,
|
private readonly pdfRender: PdfRenderService,
|
||||||
private readonly events: EventEmitter2,
|
private readonly events: EventEmitter2,
|
||||||
|
@Inject(forwardRef(() => BookingContractService))
|
||||||
|
private readonly bookingContractService: BookingContractService,
|
||||||
|
@Inject(forwardRef(() => BookingBatchService))
|
||||||
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async assignCustomerTruck(
|
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. */
|
/** Resolve trade direction from yard countries; reject client mismatch. */
|
||||||
/**
|
/**
|
||||||
* An intercity corridor is valid when both yards are Ethiopian and at least
|
* An intercity corridor is valid when both yards are Ethiopian and at least
|
||||||
@@ -602,6 +909,24 @@ export class BookingsService {
|
|||||||
return result.booking;
|
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. */
|
/** Create a new freight booking. */
|
||||||
async create(
|
async create(
|
||||||
dto: CreateBookingDto,
|
dto: CreateBookingDto,
|
||||||
@@ -664,6 +989,10 @@ export class BookingsService {
|
|||||||
companyId = company.id;
|
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) {
|
if (dto.trainScheduleId) {
|
||||||
// Staff manual pin: the schedule must be OPEN and on the same route.
|
// Staff manual pin: the schedule must be OPEN and on the same route.
|
||||||
const schedule = await this.dataSource
|
const schedule = await this.dataSource
|
||||||
@@ -857,6 +1186,9 @@ export class BookingsService {
|
|||||||
cargoFreeText: dto.cargoFreeText,
|
cargoFreeText: dto.cargoFreeText,
|
||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
|
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,
|
isHazardous: dto.isHazardous ?? false,
|
||||||
// Bulk reefer is the customer's toggle; container reefer is derived from
|
// 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
|
// the container type at pricing time, so the booking-level flag stays off
|
||||||
@@ -903,6 +1235,7 @@ export class BookingsService {
|
|||||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||||
hazardousQuantity: c.hazardousQuantity,
|
hazardousQuantity: c.hazardousQuantity,
|
||||||
reeferQuantity: c.reeferQuantity,
|
reeferQuantity: c.reeferQuantity,
|
||||||
|
containerNumbers: c.containerNumbers,
|
||||||
weightResult: ruleResult.containerWeightResults[i],
|
weightResult: ruleResult.containerWeightResults[i],
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -957,6 +1290,21 @@ export class BookingsService {
|
|||||||
warnings.push(...consolidation.messages);
|
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 };
|
return { booking: full, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1048,6 +1396,11 @@ export class BookingsService {
|
|||||||
...dto,
|
...dto,
|
||||||
freightType,
|
freightType,
|
||||||
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
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
|
// Booking-level reefer is only meaningful for bulk; container reefer is
|
||||||
// derived from the container type at pricing time.
|
// derived from the container type at pricing time.
|
||||||
isReefer:
|
isReefer:
|
||||||
@@ -1800,13 +2153,22 @@ export class BookingsService {
|
|||||||
return false;
|
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> {
|
async governmentExpedite(id: string, staffUserId: string): Promise<Booking> {
|
||||||
const booking = await this.findById(id);
|
const booking = await this.findById(id);
|
||||||
if (!booking.isGovernment) {
|
if (!booking.isGovernment) {
|
||||||
throw new BadRequestException('Only government bookings can be expedited');
|
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)) {
|
if (blocked.includes(booking.status)) {
|
||||||
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
|
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
|
||||||
}
|
}
|
||||||
@@ -1818,12 +2180,22 @@ export class BookingsService {
|
|||||||
holdStartedAt: null,
|
holdStartedAt: null,
|
||||||
holdExpiresAt: null,
|
holdExpiresAt: null,
|
||||||
});
|
});
|
||||||
|
await this.bookingContractService.generateContractForGovernment(id);
|
||||||
await this.bookingsRepository.createReviewNote(
|
await this.bookingsRepository.createReviewNote(
|
||||||
id,
|
id,
|
||||||
`Government booking expedited to PAID by staff (${staffUserId})`,
|
`Government booking expedited to PAID by staff (${staffUserId})`,
|
||||||
'STAFF_NOTE',
|
'STAFF_NOTE',
|
||||||
staffUserId,
|
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);
|
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(
|
expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe(
|
||||||
'clearance_import_container_with_customs',
|
'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(
|
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',
|
'clearance_export_bulk_with_customs',
|
||||||
);
|
);
|
||||||
expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe(
|
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);
|
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({
|
const drawdown = clearanceCodesForBooking({
|
||||||
...base,
|
...base,
|
||||||
contractId: 'c1',
|
contractId: 'c1',
|
||||||
contractKind: 'ONE_TIME',
|
contractKind: 'ONE_TIME',
|
||||||
} as unknown as Booking);
|
} as unknown as Booking);
|
||||||
expect(drawdown.inputCode).toBeNull();
|
expect(drawdown.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
|
||||||
expect(drawdown.outputCode).toBeNull();
|
expect(drawdown.outputCode).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ type Freight = 'container' | 'bulk';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The single (admin-configured) document set intercity shipments upload.
|
* The single (admin-configured) document set intercity shipments upload.
|
||||||
* DOMESTIC has no customs, so one shared set serves contracts and bookings:
|
* DOMESTIC has no customs, so one shared set serves every intercity booking —
|
||||||
* ONE_TIME collects it at contract level, GENERAL per booking — Operations
|
* ONE_TIME and GENERAL alike, collected per booking and reviewed by Operations.
|
||||||
* reviews either way.
|
|
||||||
*/
|
*/
|
||||||
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
|
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
|
||||||
|
|
||||||
@@ -40,12 +39,11 @@ export function clearanceSettingCode(
|
|||||||
const op = operationFor(tradeDirection);
|
const op = operationFor(tradeDirection);
|
||||||
if (!op) return null;
|
if (!op) return null;
|
||||||
const freight = freightFor(freightType);
|
const freight = freightFor(freightType);
|
||||||
// Non-customs (Path A) bookings self-clear: the customer proves his own
|
// 4 import + 4 export cases (bulk/container × with/without customs) — each
|
||||||
// clearance with the SAME smaller document set a ONE_TIME self-clear
|
// booking resolves to its own clearance_{op}_{freight}_{with|without}_customs
|
||||||
// contract uses (customs declaration, release permit, …) — not the
|
// set, independent of any contract-level clearance codes.
|
||||||
// GL-oriented booking sets.
|
|
||||||
if (!includesCustoms) {
|
if (!includesCustoms) {
|
||||||
return `contract_clearance_selfclear_${op}_${freight}`;
|
return `clearance_${op}_${freight}_without_customs`;
|
||||||
}
|
}
|
||||||
return `clearance_${op}_${freight}_with_customs`;
|
return `clearance_${op}_${freight}_with_customs`;
|
||||||
}
|
}
|
||||||
@@ -77,16 +75,6 @@ export function clearanceCodesForBooking(booking: Booking): {
|
|||||||
const includesCustoms =
|
const includesCustoms =
|
||||||
Boolean(booking.serviceType?.includesCustoms) ||
|
Boolean(booking.serviceType?.includesCustoms) ||
|
||||||
Boolean(booking.customsClearingEnabled);
|
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 {
|
return {
|
||||||
inputCode: clearanceSettingCode(
|
inputCode: clearanceSettingCode(
|
||||||
booking.tradeDirection,
|
booking.tradeDirection,
|
||||||
|
|||||||
@@ -85,6 +85,24 @@ export class CustomerTruckService {
|
|||||||
if (isBulk) {
|
if (isBulk) {
|
||||||
const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId);
|
const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId);
|
||||||
assertBulkTonnageRemains(totalTons, remainingTons);
|
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) {
|
if (requested.length) {
|
||||||
@@ -108,6 +126,8 @@ export class CustomerTruckService {
|
|||||||
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
||||||
driverName: dto.driverName.trim(),
|
driverName: dto.driverName.trim(),
|
||||||
truckType: dto.truckType.trim(),
|
truckType: dto.truckType.trim(),
|
||||||
|
plannedTons: isBulk ? (dto.plannedTons ?? null) : null,
|
||||||
|
plannedQuantity: isBulk ? (dto.plannedQuantity ?? null) : null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await manager.getRepository(CustomerTruckContainer).save(
|
await manager.getRepository(CustomerTruckContainer).save(
|
||||||
@@ -186,23 +206,52 @@ export class CustomerTruckService {
|
|||||||
throw new ConflictException('Cannot edit a truck that has already arrived');
|
throw new ConflictException('Cannot edit a truck that has already arrived');
|
||||||
}
|
}
|
||||||
|
|
||||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
// Bulk trucks carry loose tonnage, not containers — planned tonnage is
|
||||||
if (requested.length < 1) {
|
// 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');
|
throw new BadRequestException('Select at least one container for this truck');
|
||||||
}
|
}
|
||||||
assertTruckLoad({
|
if (!isBulk) {
|
||||||
containers: requested,
|
assertTruckLoad({
|
||||||
bookingContainers: await this.bookingContainerNumbers(bookingId),
|
containers: requested,
|
||||||
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
bookingContainers: await this.bookingContainerNumbers(bookingId),
|
||||||
// Exclude THIS truck's own containers so re-saving the same set is allowed.
|
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
||||||
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
|
// 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 this.dataSource.transaction(async (manager) => {
|
||||||
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||||
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
||||||
driverName: dto.driverName.trim(),
|
driverName: dto.driverName.trim(),
|
||||||
truckType: dto.truckType.trim(),
|
truckType: dto.truckType.trim(),
|
||||||
|
...(isBulk
|
||||||
|
? {
|
||||||
|
plannedTons: dto.plannedTons ?? null,
|
||||||
|
plannedQuantity: dto.plannedQuantity ?? null,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||||
await manager.getRepository(CustomerTruckContainer).save(
|
await manager.getRepository(CustomerTruckContainer).save(
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Matches,
|
Matches,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
|
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',
|
message: 'each container number must match ISO container format, e.g. ABCD1234567',
|
||||||
})
|
})
|
||||||
containerNumbers?: string[];
|
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()
|
@ApiPropertyOptional()
|
||||||
signatureImageUrl?: string | null;
|
signatureImageUrl?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
stampImageUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ContractViewDto {
|
export class ContractViewDto {
|
||||||
|
|||||||
@@ -74,6 +74,17 @@ export class CreateBookingContainerDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
@Transform(({ value }) => Number(value ?? 0))
|
@Transform(({ value }) => Number(value ?? 0))
|
||||||
reeferQuantity?: number;
|
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))
|
@Transform(({ value }) => Number(value))
|
||||||
cargoTotalWeightVgm!: number;
|
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 })
|
@ApiPropertyOptional({ default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
IsUUID,
|
||||||
Max,
|
Max,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
@@ -93,6 +94,17 @@ export class RequestOperationDto {
|
|||||||
})
|
})
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
scheduledDate!: string;
|
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 {
|
export class OperationReviewDto {
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ import { BaseEntity } from '@edr/api-common';
|
|||||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
import { Booking } from './booking.entity';
|
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];
|
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'booking_review_note' })
|
@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 })
|
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
|
||||||
customerTruckArrivedAt?: Date | null;
|
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 })
|
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
|
||||||
customsClearingEnabled!: boolean;
|
customsClearingEnabled!: boolean;
|
||||||
|
|
||||||
@@ -351,6 +365,15 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'cargo_total_weight_vgm', type: 'numeric', precision: 12, scale: 3 })
|
@Column({ name: 'cargo_total_weight_vgm', type: 'numeric', precision: 12, scale: 3 })
|
||||||
cargoTotalWeightVgm!: number;
|
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 })
|
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||||
isHazardous!: boolean;
|
isHazardous!: boolean;
|
||||||
|
|
||||||
@@ -485,6 +508,18 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||||
trainScheduleId?: string | null;
|
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) ────────────────────────
|
// ── Per-booking journey (segment corridor bookings) ────────────────────────
|
||||||
// A booking rides only its own origin→destination leg of the train's route,
|
// 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
|
// 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 })
|
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
|
||||||
vesselDepartureDate?: string | null;
|
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 })
|
@Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true })
|
||||||
roAmendmentRequestedAt?: Date | null;
|
roAmendmentRequestedAt?: Date | null;
|
||||||
|
|
||||||
@@ -535,6 +578,23 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true })
|
||||||
preClearanceFinalizedAt?: Date | null;
|
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. */
|
/** GL staff user bound to this shipment by the station manager. */
|
||||||
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
|
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
|
||||||
glAssignedStaffId?: string | null;
|
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 })
|
@Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||||
netWeightTons?: number | null;
|
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 })
|
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||||
departedAt?: Date | null;
|
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 { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||||
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
||||||
import { CreateCompanyProfileDto } from "./dto/create-company-profile.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 { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
||||||
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
||||||
import { DashboardQueryDto } from "./dto/dashboard-query.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 { RejectChangeRequestDto } from "./dto/reject-change-request.dto";
|
||||||
import { RequestDocumentChangeDto } from "./dto/request-document-change.dto";
|
import { RequestDocumentChangeDto } from "./dto/request-document-change.dto";
|
||||||
import { ChangeRequestResponseDto } from "./dto/change-request-response.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 { FetchETradeDto } from "./dto/fetch-etrade.dto";
|
||||||
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
import { ETradeResponseDto } from "./dto/etrade-response.dto";
|
||||||
|
|
||||||
@@ -188,9 +193,20 @@ export class CompaniesController {
|
|||||||
@Post("fetch-etrade-info")
|
@Post("fetch-etrade-info")
|
||||||
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
|
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
|
||||||
async fetchETradeInfo(
|
async fetchETradeInfo(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
@Body() dto: FetchETradeDto,
|
@Body() dto: FetchETradeDto,
|
||||||
): Promise<ETradeResponseDto> {
|
): 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);
|
return new ETradeResponseDto(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,6 +394,32 @@ export class CompaniesController {
|
|||||||
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
|
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")
|
@Patch("onboarding-step")
|
||||||
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@@ -644,6 +686,17 @@ export class CompaniesController {
|
|||||||
return requests.map((r) => new ChangeRequestResponseDto(r));
|
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")
|
@Post("change-requests/:id/approve")
|
||||||
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
@BookingStaff(FREIGHT_PERMS.customers.verify)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
@@ -678,6 +731,25 @@ export class CompaniesController {
|
|||||||
return new ChangeRequestResponseDto(request);
|
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")
|
@Post(":companyId/profiles")
|
||||||
@BookingStaff(FREIGHT_PERMS.customers.update)
|
@BookingStaff(FREIGHT_PERMS.customers.update)
|
||||||
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
@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 { ExternalProfile } from "./entities/external-profile.entity";
|
||||||
import { CompanyProfile } from "./entities/company-profile.entity";
|
import { CompanyProfile } from "./entities/company-profile.entity";
|
||||||
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
|
import { CompanyChangeRequest } from "./entities/company-change-request.entity";
|
||||||
|
import { CompanyRevision } from "./entities/company-revision.entity";
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||||
|
import { CompanyRevisionRepository } from "./company-revision.repository";
|
||||||
import { ETradeService } from "./services/etrade.service";
|
import { ETradeService } from "./services/etrade.service";
|
||||||
import { CompanyNotifierService } from "./company-notifier.service";
|
import { CompanyNotifierService } from "./company-notifier.service";
|
||||||
|
import { VerifaydaModule } from "../verifayda/verifayda.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -28,6 +31,7 @@ import { CompanyNotifierService } from "./company-notifier.service";
|
|||||||
ExternalProfile,
|
ExternalProfile,
|
||||||
CompanyProfile,
|
CompanyProfile,
|
||||||
CompanyChangeRequest,
|
CompanyChangeRequest,
|
||||||
|
CompanyRevision,
|
||||||
Booking,
|
Booking,
|
||||||
]),
|
]),
|
||||||
HttpModule,
|
HttpModule,
|
||||||
@@ -38,6 +42,8 @@ import { CompanyNotifierService } from "./company-notifier.service";
|
|||||||
// imports this module back for portal recipient targeting, hence forwardRef.
|
// imports this module back for portal recipient targeting, hence forwardRef.
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
forwardRef(() => NotificationInboxModule),
|
forwardRef(() => NotificationInboxModule),
|
||||||
|
// Fayda identity verification for the company's owner and PoA.
|
||||||
|
VerifaydaModule,
|
||||||
],
|
],
|
||||||
controllers: [CompaniesController],
|
controllers: [CompaniesController],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -46,6 +52,7 @@ import { CompanyNotifierService } from "./company-notifier.service";
|
|||||||
ExternalProfileRepository,
|
ExternalProfileRepository,
|
||||||
CompanyProfileRepository,
|
CompanyProfileRepository,
|
||||||
CompanyChangeRequestRepository,
|
CompanyChangeRequestRepository,
|
||||||
|
CompanyRevisionRepository,
|
||||||
CompanyDashboardRepository,
|
CompanyDashboardRepository,
|
||||||
ETradeService,
|
ETradeService,
|
||||||
CompanyNotifierService,
|
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();
|
.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
async existsByTin(tin: string): Promise<boolean> {
|
async existsByTin(tin: string, excludeCompanyId?: string): Promise<boolean> {
|
||||||
const count = await this.repository.count({ where: { tin } as any });
|
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;
|
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 { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,16 @@ type Row = Pick<CompanyChangeRequest, "id" | "status"> & { createdAt: Date };
|
|||||||
|
|
||||||
const COMPANY_ID = "company-1";
|
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
|
* 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.
|
* `where.status` filter and the `createdAt DESC` ordering findOne relies on.
|
||||||
@@ -17,13 +27,17 @@ const COMPANY_ID = "company-1";
|
|||||||
function mockRepositoryOver(rows: Row[]) {
|
function mockRepositoryOver(rows: Row[]) {
|
||||||
return {
|
return {
|
||||||
findOne: jest.fn(
|
findOne: jest.fn(
|
||||||
({ where }: { where: Partial<Row> & { companyId: string } }) =>
|
({
|
||||||
|
where,
|
||||||
|
}: {
|
||||||
|
where: { companyId: string; status?: Row["status"] | FindOperator<Row["status"]> };
|
||||||
|
}) =>
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
rows
|
rows
|
||||||
.filter(
|
.filter(
|
||||||
(row) =>
|
(row) =>
|
||||||
where.companyId === COMPANY_ID &&
|
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] ??
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ??
|
||||||
null,
|
null,
|
||||||
@@ -57,6 +71,21 @@ describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => {
|
|||||||
expect(result?.id).toBe("pending");
|
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 () => {
|
it("returns the latest rejected request when nothing is pending", async () => {
|
||||||
const result = await subject([rejected]).findLatestOpenByCompanyId(
|
const result = await subject([rejected]).findLatestOpenByCompanyId(
|
||||||
COMPANY_ID,
|
COMPANY_ID,
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository } from "typeorm";
|
import { In, Repository } from "typeorm";
|
||||||
import { BaseRepository } from "@edr/api-common";
|
import { BaseRepository } from "@edr/api-common";
|
||||||
import {
|
import {
|
||||||
ChangeRequestStatus,
|
ChangeRequestStatus,
|
||||||
CompanyChangeRequest,
|
CompanyChangeRequest,
|
||||||
} from "./entities/company-change-request.entity";
|
} 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()
|
@Injectable()
|
||||||
export class CompanyChangeRequestRepository extends BaseRepository<CompanyChangeRequest> {
|
export class CompanyChangeRequestRepository extends BaseRepository<CompanyChangeRequest> {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -16,12 +22,12 @@ export class CompanyChangeRequestRepository extends BaseRepository<CompanyChange
|
|||||||
super(repo);
|
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(
|
async findPendingByCompanyId(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
): Promise<CompanyChangeRequest | null> {
|
): Promise<CompanyChangeRequest | null> {
|
||||||
return this.repository.findOne({
|
return this.repository.findOne({
|
||||||
where: { companyId, status: ChangeRequestStatus.Pending },
|
where: { companyId, status: In(OPEN_FOR_EDIT_STATUSES) },
|
||||||
order: { createdAt: "DESC" },
|
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 →
|
* Open profile-edit review, if any. Drives the portal-wide lock (pending →
|
||||||
* settings + new-contract/booking creation disabled) and the reapply banner.
|
* 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: {
|
review: {
|
||||||
status: 'pending' | 'rejected';
|
status: 'pending' | 'rejected' | 'changes_requested';
|
||||||
note: string | null;
|
note: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
@@ -30,12 +33,13 @@ export class CompanyInfoResponseDto {
|
|||||||
const open =
|
const open =
|
||||||
changeRequest &&
|
changeRequest &&
|
||||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||||
|
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||||
? changeRequest
|
? changeRequest
|
||||||
: null;
|
: null;
|
||||||
this.review = open
|
this.review = open
|
||||||
? {
|
? {
|
||||||
status: open.status as 'pending' | 'rejected',
|
status: open.status as 'pending' | 'rejected' | 'changes_requested',
|
||||||
note: open.note ?? null,
|
note: open.note ?? null,
|
||||||
}
|
}
|
||||||
: 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.
|
* truth the wizard uses to auto-finish.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { CompanyIdentityStateDto } from "./complete-identity-verification.dto";
|
||||||
|
|
||||||
export interface OnboardingInfoField {
|
export interface OnboardingInfoField {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -40,11 +42,13 @@ export interface OnboardingPoaState {
|
|||||||
required: boolean;
|
required: boolean;
|
||||||
/** True once any PoA detail has been entered. */
|
/** True once any PoA detail has been entered. */
|
||||||
provided: boolean;
|
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;
|
delegationLetterUploaded: boolean;
|
||||||
|
/** True when a reviewer sent the paper back for correction. */
|
||||||
|
delegationLetterFlagged: boolean;
|
||||||
/** PoA details still missing (only populated when `required`). */
|
/** PoA details still missing (only populated when `required`). */
|
||||||
missingFields: OnboardingInfoField[];
|
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;
|
complete: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +72,13 @@ export class OnboardingRequirementsResponseDto {
|
|||||||
/** Power of Attorney state, so the wizard needn't re-derive the rule. */
|
/** Power of Attorney state, so the wizard needn't re-derive the rule. */
|
||||||
poa: OnboardingPoaState;
|
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. */
|
/** Overall setup progress across fields + documents + licenses. */
|
||||||
progress: { completed: number; total: number };
|
progress: { completed: number; total: number };
|
||||||
|
|
||||||
@@ -87,6 +98,7 @@ export class OnboardingRequirementsResponseDto {
|
|||||||
this.documents = init.documents;
|
this.documents = init.documents;
|
||||||
this.licenseProfiles = init.licenseProfiles;
|
this.licenseProfiles = init.licenseProfiles;
|
||||||
this.poa = init.poa;
|
this.poa = init.poa;
|
||||||
|
this.identity = init.identity;
|
||||||
this.progress = init.progress;
|
this.progress = init.progress;
|
||||||
this.isComplete = init.isComplete;
|
this.isComplete = init.isComplete;
|
||||||
this.onboardingCompleted = init.onboardingCompleted;
|
this.onboardingCompleted = init.onboardingCompleted;
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
buildCompanyIdentityState,
|
||||||
|
CompanyIdentityStateDto,
|
||||||
|
} from "./complete-identity-verification.dto";
|
||||||
import { Company } from '../entities/company.entity';
|
import { Company } from '../entities/company.entity';
|
||||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||||
import {
|
import {
|
||||||
@@ -53,11 +57,23 @@ export class ProfileResponseDto {
|
|||||||
profileId: string;
|
profileId: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
|
* Fayda verification state for the company's owner and PoA — not the general
|
||||||
* settings page; `"rejected"` surfaces the note and prefills the (declined)
|
* manager, which is a separate typed role. The settings tabs and the
|
||||||
* proposed values from `pendingChanges` so the customer can amend & resubmit.
|
* 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;
|
reviewNote: string | null;
|
||||||
pendingChanges: Record<string, any> | null;
|
pendingChanges: Record<string, any> | null;
|
||||||
|
|
||||||
@@ -113,7 +129,8 @@ export class ProfileResponseDto {
|
|||||||
const openReview =
|
const openReview =
|
||||||
changeRequest &&
|
changeRequest &&
|
||||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||||
changeRequest.status === ChangeRequestStatus.Rejected)
|
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||||
|
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||||
? changeRequest
|
? changeRequest
|
||||||
: null;
|
: null;
|
||||||
this.reviewStatus =
|
this.reviewStatus =
|
||||||
@@ -121,8 +138,11 @@ export class ProfileResponseDto {
|
|||||||
? "pending"
|
? "pending"
|
||||||
: openReview?.status === ChangeRequestStatus.Rejected
|
: openReview?.status === ChangeRequestStatus.Rejected
|
||||||
? "rejected"
|
? "rejected"
|
||||||
: null;
|
: openReview?.status === ChangeRequestStatus.ChangesRequested
|
||||||
|
? "changes_requested"
|
||||||
|
: null;
|
||||||
this.reviewNote = openReview?.note ?? null;
|
this.reviewNote = openReview?.note ?? null;
|
||||||
this.pendingChanges = openReview?.snapshot ?? null;
|
this.pendingChanges = openReview?.snapshot ?? null;
|
||||||
|
this.identity = buildCompanyIdentityState(company);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
ProfileLicenseFileView,
|
ProfileLicenseFileView,
|
||||||
} from '../entities/company-profile.entity';
|
} from '../entities/company-profile.entity';
|
||||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||||
|
import {
|
||||||
|
buildCompanyIdentityState,
|
||||||
|
CompanyIdentityStateDto,
|
||||||
|
} from './complete-identity-verification.dto';
|
||||||
|
|
||||||
export class ResponseCompanyProfileDto {
|
export class ResponseCompanyProfileDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -69,8 +73,31 @@ export class ResponseCompanyDto {
|
|||||||
* external profiles weren't loaded.
|
* external profiles weren't loaded.
|
||||||
*/
|
*/
|
||||||
onboardingCompleted?: boolean;
|
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;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
approvedAt: Date | null;
|
||||||
|
|
||||||
constructor(company: Company) {
|
constructor(company: Company) {
|
||||||
this.id = company.id;
|
this.id = company.id;
|
||||||
@@ -95,7 +122,20 @@ export class ResponseCompanyDto {
|
|||||||
? company.profiles.length === 0 ||
|
? company.profiles.length === 0 ||
|
||||||
company.profiles.some((p) => p.onboardingCompleted)
|
company.profiles.some((p) => p.onboardingCompleted)
|
||||||
: undefined;
|
: 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.createdAt = company.createdAt;
|
||||||
this.updatedAt = company.updatedAt;
|
this.updatedAt = company.updatedAt;
|
||||||
|
this.approvedAt = company.approvedAt ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,10 +44,11 @@ export class UpdateProfileDto {
|
|||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
vatNumber?: string;
|
vatNumber?: string;
|
||||||
|
|
||||||
@IsOptional()
|
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
|
||||||
@IsString()
|
// company's PoA (or its general manager), so it is derived from a completed
|
||||||
@MaxLength(16)
|
// Fayda verification rather than typed. The global validation pipe runs with
|
||||||
fanNumber?: string;
|
// forbidNonWhitelisted, so a client that still sends it gets a 400 telling it
|
||||||
|
// so — see CompaniesService.completeIdentityVerification.
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -110,6 +111,16 @@ export class UpdateProfileDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
poaAddress?: string;
|
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()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ import { Company } from "./company.entity";
|
|||||||
/**
|
/**
|
||||||
* Lifecycle of a customer's proposed profile change. Edits made on the portal
|
* 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
|
* 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 live Company row) until a backoffice reviewer resolves it:
|
||||||
* the snapshot is applied — or rejects with a note, after which the customer can
|
* - Approved — the snapshot is applied to the live Company row.
|
||||||
* amend and resubmit.
|
* - 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 {
|
export enum ChangeRequestStatus {
|
||||||
Pending = "pending",
|
Pending = "pending",
|
||||||
Approved = "approved",
|
Approved = "approved",
|
||||||
Rejected = "rejected",
|
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