mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
Merge branch 'dev' into tests
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
|
||||||
|
|||||||
@@ -35,12 +35,12 @@
|
|||||||
"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",
|
||||||
"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",
|
||||||
|
|||||||
@@ -12,6 +12,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 +30,7 @@ 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 { 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";
|
||||||
@@ -152,12 +154,25 @@ 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,
|
||||||
WagonTypesModule,
|
WagonTypesModule,
|
||||||
TrainSetsModule,
|
TrainSetsModule,
|
||||||
TrainSchedulesModule,
|
TrainSchedulesModule,
|
||||||
@@ -229,7 +244,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
})
|
})
|
||||||
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 +274,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.
|
||||||
|
|||||||
@@ -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)])),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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.`;
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -199,7 +200,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 +530,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 +539,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 +565,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 +577,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 +592,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 +604,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 +615,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 +740,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 +749,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 +787,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 +855,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 +912,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 +942,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 +992,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 +1002,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 +1045,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 +1074,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 +1109,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 +1123,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 +1160,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:
|
||||||
|
|||||||
@@ -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;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -916,14 +916,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,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();
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -526,6 +540,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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { DataSource } from "typeorm";
|
import { DataSource, EntityManager } from "typeorm";
|
||||||
import { CompaniesRepository } from "./companies.repository";
|
import { CompaniesRepository } from "./companies.repository";
|
||||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||||
@@ -1111,9 +1111,11 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Anything other than approval has no document gate and no concurrency
|
// Anything other than approval has no document gate and no concurrency
|
||||||
// hazard — apply it directly.
|
// hazard — no row lock, just the write.
|
||||||
if (status !== ProfileStatus.Active) {
|
if (status !== ProfileStatus.Active) {
|
||||||
return this.applyProfileStatus(existing, status, note, reviewerId);
|
return this.dataSource.transaction((manager) =>
|
||||||
|
this.applyProfileStatus(manager, existing, status, note, reviewerId),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Approving over an outstanding document correction would silently accept the
|
// Approving over an outstanding document correction would silently accept the
|
||||||
@@ -1149,7 +1151,7 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.applyProfileStatus(existing, status, note, reviewerId);
|
return this.applyProfileStatus(manager, existing, status, note, reviewerId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1160,11 +1162,21 @@ export class CompaniesService {
|
|||||||
* transaction while every other status skips that overhead.
|
* transaction while every other status skips that overhead.
|
||||||
*/
|
*/
|
||||||
private async applyProfileStatus(
|
private async applyProfileStatus(
|
||||||
|
manager: EntityManager,
|
||||||
existing: CompanyProfile,
|
existing: CompanyProfile,
|
||||||
status: ProfileStatus,
|
status: ProfileStatus,
|
||||||
note?: string,
|
note?: string,
|
||||||
reviewerId?: string,
|
reviewerId?: string,
|
||||||
): Promise<CompanyProfile> {
|
): Promise<CompanyProfile> {
|
||||||
|
// Every write below goes through `manager`. The approval path holds a
|
||||||
|
// pessimistic_write lock on the company row, and the injected repositories
|
||||||
|
// are bound to the DataSource's default pool — writing the same row through
|
||||||
|
// one of them would block on a lock this very transaction holds, hanging the
|
||||||
|
// request until the statement timed out. That deadlocked the first approval
|
||||||
|
// of any customer: the profile went Active on its own connection while the
|
||||||
|
// company stayed Pending and the caller never got a response.
|
||||||
|
const profileRepo = manager.getRepository(CompanyProfile);
|
||||||
|
const companyRepo = manager.getRepository(Company);
|
||||||
// A reference number is only minted the first time a profile is approved
|
// A reference number is only minted the first time a profile is approved
|
||||||
// (status → Active). Pending/unapproved profiles carry no reference.
|
// (status → Active). Pending/unapproved profiles carry no reference.
|
||||||
const patch: Partial<CompanyProfile> = { status };
|
const patch: Partial<CompanyProfile> = { status };
|
||||||
@@ -1190,7 +1202,8 @@ export class CompaniesService {
|
|||||||
patch.reviewedAt = new Date();
|
patch.reviewedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.companyProfilesRepo.update(existing.id, patch);
|
await profileRepo.update(existing.id, patch);
|
||||||
|
const updated = await profileRepo.findOne({ where: { id: existing.id } });
|
||||||
if (!updated)
|
if (!updated)
|
||||||
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
||||||
|
|
||||||
@@ -1208,7 +1221,9 @@ export class CompaniesService {
|
|||||||
: "approved"
|
: "approved"
|
||||||
: null;
|
: null;
|
||||||
if (change) {
|
if (change) {
|
||||||
const company = await this.companiesRepo.findById(updated.companyId);
|
const company = await companyRepo.findOne({
|
||||||
|
where: { id: updated.companyId },
|
||||||
|
});
|
||||||
if (company) {
|
if (company) {
|
||||||
this.companyNotifier.profileStatusChanged(
|
this.companyNotifier.profileStatusChanged(
|
||||||
company,
|
company,
|
||||||
@@ -1222,7 +1237,7 @@ export class CompaniesService {
|
|||||||
status === ProfileStatus.Active &&
|
status === ProfileStatus.Active &&
|
||||||
company.status === CompanyStatus.Pending
|
company.status === CompanyStatus.Pending
|
||||||
) {
|
) {
|
||||||
await this.companiesRepo.update(updated.companyId, {
|
await companyRepo.update(updated.companyId, {
|
||||||
status: CompanyStatus.Active,
|
status: CompanyStatus.Active,
|
||||||
});
|
});
|
||||||
this.companyNotifier.companyApproved(company);
|
this.companyNotifier.companyApproved(company);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from './entities/clearance-milestone.entity';
|
} from './entities/clearance-milestone.entity';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||||
|
import { assertDoCollectionDates } from './contract-clearance.util';
|
||||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { GlOperationsService } from './gl-operations.service';
|
import { GlOperationsService } from './gl-operations.service';
|
||||||
@@ -64,6 +65,9 @@ export interface BookingClearanceView {
|
|||||||
roHold?: boolean;
|
roHold?: boolean;
|
||||||
roHoldReason?: string | null;
|
roHoldReason?: string | null;
|
||||||
vesselDepartureDate?: string | null;
|
vesselDepartureDate?: string | null;
|
||||||
|
/** Import DO dates recorded by GL Djibouti on upload. */
|
||||||
|
vesselArrivalDate?: string | null;
|
||||||
|
doCollectedDate?: string | null;
|
||||||
roAmendmentRequestedAt?: string | null;
|
roAmendmentRequestedAt?: string | null;
|
||||||
operationReady?: boolean;
|
operationReady?: boolean;
|
||||||
preClearanceFinalized?: boolean;
|
preClearanceFinalized?: boolean;
|
||||||
@@ -261,6 +265,8 @@ export class BookingClearanceService {
|
|||||||
roHold: Boolean(booking.roHoldReason),
|
roHold: Boolean(booking.roHoldReason),
|
||||||
roHoldReason: booking.roHoldReason ?? null,
|
roHoldReason: booking.roHoldReason ?? null,
|
||||||
vesselDepartureDate: booking.vesselDepartureDate ?? null,
|
vesselDepartureDate: booking.vesselDepartureDate ?? null,
|
||||||
|
vesselArrivalDate: booking.vesselArrivalDate ?? null,
|
||||||
|
doCollectedDate: booking.doCollectedDate ?? null,
|
||||||
roAmendmentRequestedAt: booking.roAmendmentRequestedAt
|
roAmendmentRequestedAt: booking.roAmendmentRequestedAt
|
||||||
? booking.roAmendmentRequestedAt.toISOString()
|
? booking.roAmendmentRequestedAt.toISOString()
|
||||||
: null,
|
: null,
|
||||||
@@ -547,7 +553,7 @@ export class BookingClearanceService {
|
|||||||
bookingId: string,
|
bookingId: string,
|
||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
vesselDepartureDate?: string,
|
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.loadBooking(bookingId);
|
const booking = await this.loadBooking(bookingId);
|
||||||
if (booking.tradeDirection !== 'IMPORT') {
|
if (booking.tradeDirection !== 'IMPORT') {
|
||||||
@@ -556,6 +562,8 @@ export class BookingClearanceService {
|
|||||||
|
|
||||||
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
||||||
|
|
||||||
|
const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates);
|
||||||
|
|
||||||
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
|
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
|
||||||
// any file type. The DO_COLLECTED milestone (and operation readiness) still
|
// any file type. The DO_COLLECTED milestone (and operation readiness) still
|
||||||
// waits for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
// waits for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
||||||
@@ -566,11 +574,10 @@ export class BookingClearanceService {
|
|||||||
file,
|
file,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (vesselDepartureDate?.trim()) {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
await this.bookingsRepository.update(bookingId, {
|
vesselArrivalDate,
|
||||||
vesselDepartureDate: vesselDepartureDate.trim(),
|
doCollectedDate,
|
||||||
} as never);
|
} as never);
|
||||||
}
|
|
||||||
|
|
||||||
if (booking.preClearanceFinalizedAt) {
|
if (booking.preClearanceFinalizedAt) {
|
||||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
|
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ export class BookingRequestService {
|
|||||||
private readonly notifier: ContractNotifierService,
|
private readonly notifier: ContractNotifierService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */
|
/**
|
||||||
|
* Only GENERAL contracts that bundle customs use the request → GL → clearance
|
||||||
|
* flow. A ONE_TIME customs contract runs its clearance at the contract level
|
||||||
|
* and GL books it directly, with no customer-facing request step.
|
||||||
|
*/
|
||||||
private assertGeneralCustoms(contract: Contract): void {
|
private assertGeneralCustoms(contract: Contract): void {
|
||||||
if (
|
if (
|
||||||
contract.contractKind !== 'GENERAL' ||
|
contract.contractKind !== 'GENERAL' ||
|
||||||
@@ -121,7 +125,11 @@ export class BookingRequestService {
|
|||||||
// instance is created first so a failure leaves no half-linked request.
|
// instance is created first so a failure leaves no half-linked request.
|
||||||
const booking = await this.contractBookingService.initiateForShipmentRequest(
|
const booking = await this.contractBookingService.initiateForShipmentRequest(
|
||||||
contract,
|
contract,
|
||||||
{ contractRouteId: dto.contractRouteId, userId },
|
{
|
||||||
|
contractRouteId: dto.contractRouteId,
|
||||||
|
userId,
|
||||||
|
paymentCurrency: dto.paymentCurrency,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const reference = await this.generateReference();
|
const reference = await this.generateReference();
|
||||||
@@ -134,6 +142,11 @@ export class BookingRequestService {
|
|||||||
status: 'ACCEPTED',
|
status: 'ACCEPTED',
|
||||||
createdBookingId: booking.id,
|
createdBookingId: booking.id,
|
||||||
requestedLines,
|
requestedLines,
|
||||||
|
// Intercity is invoiced in birr whatever the customer picked.
|
||||||
|
paymentCurrency:
|
||||||
|
contract.tradeDirection === 'DOMESTIC'
|
||||||
|
? 'ETB'
|
||||||
|
: (dto.paymentCurrency ?? contract.paymentCurrency ?? 'USD'),
|
||||||
notes: dto.notes ?? null,
|
notes: dto.notes ?? null,
|
||||||
} as never);
|
} as never);
|
||||||
this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference);
|
this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference);
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
|||||||
milestoneService as never,
|
milestoneService as never,
|
||||||
{} as never, // workflowService
|
{} as never, // workflowService
|
||||||
invoiceService as never,
|
invoiceService as never,
|
||||||
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
{
|
||||||
|
createdToStaff: jest.fn(),
|
||||||
|
createdByGlForCustomer: jest.fn(),
|
||||||
|
} as never, // bookingNotifier
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ export class ContractBookingService {
|
|||||||
createdByUserId: user?.id ?? null,
|
createdByUserId: user?.id ?? null,
|
||||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||||
serviceTypeId: contract.serviceTypeId,
|
serviceTypeId: contract.serviceTypeId,
|
||||||
paymentCurrency: contract.paymentCurrency,
|
paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency),
|
||||||
contractType: 'NEW',
|
contractType: 'NEW',
|
||||||
customsClearingEnabled: contract.customsClearingEnabled,
|
customsClearingEnabled: contract.customsClearingEnabled,
|
||||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||||
@@ -481,7 +481,7 @@ export class ContractBookingService {
|
|||||||
createdByUserId: user?.id ?? null,
|
createdByUserId: user?.id ?? null,
|
||||||
scheduledDate: null,
|
scheduledDate: null,
|
||||||
serviceTypeId: contract.serviceTypeId,
|
serviceTypeId: contract.serviceTypeId,
|
||||||
paymentCurrency: contract.paymentCurrency,
|
paymentCurrency: this.resolveShipmentCurrency(contract, null),
|
||||||
contractType: 'NEW',
|
contractType: 'NEW',
|
||||||
customsClearingEnabled: contract.customsClearingEnabled,
|
customsClearingEnabled: contract.customsClearingEnabled,
|
||||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||||
@@ -520,7 +520,12 @@ export class ContractBookingService {
|
|||||||
*/
|
*/
|
||||||
async initiateForShipmentRequest(
|
async initiateForShipmentRequest(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
opts: { contractRouteId?: string; userId?: string | null },
|
opts: {
|
||||||
|
contractRouteId?: string;
|
||||||
|
userId?: string | null;
|
||||||
|
/** Billing currency the customer chose on the shipment request. */
|
||||||
|
paymentCurrency?: string | null;
|
||||||
|
},
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const generalCustoms =
|
const generalCustoms =
|
||||||
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
||||||
@@ -555,7 +560,7 @@ export class ContractBookingService {
|
|||||||
createdByUserId: opts.userId ?? null,
|
createdByUserId: opts.userId ?? null,
|
||||||
scheduledDate: null,
|
scheduledDate: null,
|
||||||
serviceTypeId: contract.serviceTypeId,
|
serviceTypeId: contract.serviceTypeId,
|
||||||
paymentCurrency: contract.paymentCurrency,
|
paymentCurrency: this.resolveShipmentCurrency(contract, opts?.paymentCurrency),
|
||||||
contractType: 'NEW',
|
contractType: 'NEW',
|
||||||
customsClearingEnabled: contract.customsClearingEnabled,
|
customsClearingEnabled: contract.customsClearingEnabled,
|
||||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||||
@@ -733,6 +738,13 @@ export class ContractBookingService {
|
|||||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||||
|
// Completion is where the cargo — and therefore the price — is fixed, so
|
||||||
|
// it is also where the billing currency is chosen. A bare instance was
|
||||||
|
// created before the customer had any figure to look at.
|
||||||
|
paymentCurrency: this.resolveShipmentCurrency(
|
||||||
|
contract,
|
||||||
|
dto.paymentCurrency ?? booking.paymentCurrency,
|
||||||
|
),
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||||
@@ -921,6 +933,33 @@ export class ContractBookingService {
|
|||||||
}`,
|
}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// On a customs contract the customer never books — GL Ethiopia does it for
|
||||||
|
// them (assertGate enforces that) — so tell them their shipment now exists.
|
||||||
|
//
|
||||||
|
// Gated on the contract, NOT on booking.createdByRole: a GENERAL customs
|
||||||
|
// instance is stamped CUSTOMER when the customer's shipment request opens
|
||||||
|
// it, yet it is GL who later completes it with cargo and a price. Keying on
|
||||||
|
// the role would silently skip exactly that case.
|
||||||
|
//
|
||||||
|
// Sent from here because this is the single funnel every contract booking
|
||||||
|
// passes through exactly once (create, complete, and the deferred
|
||||||
|
// consolidation-pairing replay), and it runs after invoicing so the message
|
||||||
|
// can quote the priced total.
|
||||||
|
if (contract.customsClearingEnabled) {
|
||||||
|
// Never let a notification failure read as a finalize failure — the
|
||||||
|
// booking is already committed by this point.
|
||||||
|
try {
|
||||||
|
const priced = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||||||
|
this.bookingNotifier.createdByGlForCustomer(priced ?? booking);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Could not notify the customer that GL created booking ${booking.reference}: ${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1565,6 +1604,23 @@ export class ContractBookingService {
|
|||||||
* booking-level override (dto.equipmentReturn ?? contract default) applies.
|
* booking-level override (dto.equipmentReturn ?? contract default) applies.
|
||||||
* Bulk freight keeps the legacy behaviour untouched.
|
* Bulk freight keeps the legacy behaviour untouched.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The billing currency for a shipment under this contract.
|
||||||
|
*
|
||||||
|
* A contract quotes in USD only — the currency is a per-shipment choice now.
|
||||||
|
* Precedence: intercity is always ETB (domestic transport is invoiced in
|
||||||
|
* birr), then the customer's explicit choice, then the contract's own
|
||||||
|
* currency, which is USD for contracts created under the current rule and the
|
||||||
|
* grandfathered value for older ones.
|
||||||
|
*/
|
||||||
|
private resolveShipmentCurrency(
|
||||||
|
contract: Contract,
|
||||||
|
requested?: string | null,
|
||||||
|
): string {
|
||||||
|
if (contract.tradeDirection === 'DOMESTIC') return 'ETB';
|
||||||
|
return requested?.trim() || contract.paymentCurrency || 'USD';
|
||||||
|
}
|
||||||
|
|
||||||
private resolveShipmentEquipmentReturn(
|
private resolveShipmentEquipmentReturn(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
dto: CreateBookingUnderContractDto,
|
dto: CreateBookingUnderContractDto,
|
||||||
@@ -1795,7 +1851,7 @@ export class ContractBookingService {
|
|||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
freightType: contract.freightType,
|
freightType: contract.freightType,
|
||||||
tradeDirection: contract.tradeDirection,
|
tradeDirection: contract.tradeDirection,
|
||||||
paymentCurrency: contract.paymentCurrency,
|
paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency),
|
||||||
serviceTypeId: contract.serviceTypeId,
|
serviceTypeId: contract.serviceTypeId,
|
||||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ContractDocPhase,
|
ContractDocPhase,
|
||||||
type ClearanceFinalInvoiceSummary,
|
type ClearanceFinalInvoiceSummary,
|
||||||
@@ -13,7 +18,10 @@ import { FilesService } from '../files/files.service';
|
|||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { ContractsService, PaginatedContracts } from './contracts.service';
|
import { ContractsService, PaginatedContracts } from './contracts.service';
|
||||||
import { BookingsService } from '../bookings/bookings.service';
|
import { BookingsService } from '../bookings/bookings.service';
|
||||||
import { contractClearanceCodes } from './contract-clearance.util';
|
import {
|
||||||
|
assertDoCollectionDates,
|
||||||
|
contractClearanceCodes,
|
||||||
|
} from './contract-clearance.util';
|
||||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import { ContractNotifierService } from './contract-notifier.service';
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
@@ -72,9 +80,23 @@ export interface ContractClearanceView {
|
|||||||
blockedReason?: string | null;
|
blockedReason?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
dutyRequired?: boolean | null;
|
dutyRequired?: boolean | null;
|
||||||
|
/**
|
||||||
|
* Pre-declaration handshake with GL Djibouti: who will handle the shipment in
|
||||||
|
* transit. `name` is null until Djibouti answers, and the declaration step is
|
||||||
|
* shut until it is set.
|
||||||
|
*/
|
||||||
|
transitAssignee?: {
|
||||||
|
requestedAt: string | null;
|
||||||
|
requestNote: string | null;
|
||||||
|
name: string | null;
|
||||||
|
assignedAt: string | null;
|
||||||
|
} | null;
|
||||||
roHold?: boolean;
|
roHold?: boolean;
|
||||||
roHoldReason?: string | null;
|
roHoldReason?: string | null;
|
||||||
vesselDepartureDate?: string | null;
|
vesselDepartureDate?: string | null;
|
||||||
|
/** Import DO dates recorded by GL Djibouti on upload. */
|
||||||
|
vesselArrivalDate?: string | null;
|
||||||
|
doCollectedDate?: string | null;
|
||||||
roAmendmentRequestedAt?: string | null;
|
roAmendmentRequestedAt?: string | null;
|
||||||
bookingReady?: boolean;
|
bookingReady?: boolean;
|
||||||
preClearanceFinalized?: boolean;
|
preClearanceFinalized?: boolean;
|
||||||
@@ -84,12 +106,30 @@ export interface ContractClearanceView {
|
|||||||
/** Reference + status of the GL-created shipment booking, once it exists. */
|
/** Reference + status of the GL-created shipment booking, once it exists. */
|
||||||
linkedBookingReference?: string | null;
|
linkedBookingReference?: string | null;
|
||||||
linkedBookingStatus?: string | null;
|
linkedBookingStatus?: string | null;
|
||||||
|
/**
|
||||||
|
* Operations' latest "needs changes" note on that booking. GL created the
|
||||||
|
* booking, so GL is the one who has to act on it — surfaced here because the
|
||||||
|
* clearance page is where GL works, not the portal.
|
||||||
|
*/
|
||||||
|
linkedBookingReviewNote?: string | null;
|
||||||
|
/** Shipment day the booking currently holds — the default when GL resubmits. */
|
||||||
|
linkedBookingScheduledDate?: string | null;
|
||||||
dutyAdvice?: {
|
dutyAdvice?: {
|
||||||
amount: number;
|
amount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
declarationSerial?: string | null;
|
declarationSerial?: string | null;
|
||||||
noticeFile?: { id: string; name: string; url: string } | null;
|
noticeFile?: { id: string; name: string; url: string } | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
/**
|
||||||
|
* The customer's open objection to the advised duty — present only while GL
|
||||||
|
* has not re-advised (the advice milestone is back to PENDING). `rounds` is
|
||||||
|
* how many times it has been sent back, so both sides can see the loop.
|
||||||
|
*/
|
||||||
|
dutyDispute?: {
|
||||||
|
note: string;
|
||||||
|
raisedAt: string;
|
||||||
|
rounds: number;
|
||||||
|
} | null;
|
||||||
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
|
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
|
||||||
/** Import post-allocation T1 transit document state (null until a booking is linked). */
|
/** Import post-allocation T1 transit document state (null until a booking is linked). */
|
||||||
t1?: ClearanceT1State | null;
|
t1?: ClearanceT1State | null;
|
||||||
@@ -239,6 +279,19 @@ export class ContractClearanceService {
|
|||||||
contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary);
|
contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary);
|
||||||
const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
|
const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
|
||||||
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
||||||
|
const dutyDispute = await this.buildDutyDispute(contractId, milestones);
|
||||||
|
const transitAssignee = cycle
|
||||||
|
? {
|
||||||
|
requestedAt: cycle.transitAssigneeRequestedAt
|
||||||
|
? cycle.transitAssigneeRequestedAt.toISOString()
|
||||||
|
: null,
|
||||||
|
requestNote: cycle.transitAssigneeRequestNote ?? null,
|
||||||
|
name: cycle.transitAssigneeName ?? null,
|
||||||
|
assignedAt: cycle.transitAssigneeAssignedAt
|
||||||
|
? cycle.transitAssigneeAssignedAt.toISOString()
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
let workflowFiles = buildWorkflowFiles(
|
let workflowFiles = buildWorkflowFiles(
|
||||||
files,
|
files,
|
||||||
contract.tradeDirection ?? 'IMPORT',
|
contract.tradeDirection ?? 'IMPORT',
|
||||||
@@ -301,11 +354,24 @@ export class ContractClearanceService {
|
|||||||
// shortly" message. Reuse the export booking load; fetch for import too.
|
// shortly" message. Reuse the export booking load; fetch for import too.
|
||||||
let linkedBookingReference: string | null = null;
|
let linkedBookingReference: string | null = null;
|
||||||
let linkedBookingStatus: string | null = null;
|
let linkedBookingStatus: string | null = null;
|
||||||
|
let linkedBookingReviewNote: string | null = null;
|
||||||
|
let linkedBookingScheduledDate: string | null = null;
|
||||||
if (cycle?.bookingId) {
|
if (cycle?.bookingId) {
|
||||||
const booking = await this.bookingsService.findById(cycle.bookingId);
|
const booking = await this.bookingsService.findById(cycle.bookingId);
|
||||||
if (booking) {
|
if (booking) {
|
||||||
linkedBookingReference = booking.reference ?? null;
|
linkedBookingReference = booking.reference ?? null;
|
||||||
linkedBookingStatus = booking.status ?? null;
|
linkedBookingStatus = booking.status ?? null;
|
||||||
|
linkedBookingScheduledDate = booking.scheduledDate
|
||||||
|
? new Date(booking.scheduledDate).toISOString()
|
||||||
|
: null;
|
||||||
|
// Newest changes-requested note (reviewNotes ride along on findById).
|
||||||
|
linkedBookingReviewNote =
|
||||||
|
[...(booking.reviewNotes ?? [])]
|
||||||
|
.filter((n) => n.type === 'CHANGES_REQUESTED')
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||||
|
)[0]?.note ?? null;
|
||||||
if (contract.tradeDirection === 'EXPORT') {
|
if (contract.tradeDirection === 'EXPORT') {
|
||||||
nextAction = this.workflowService.computeNextActionForBooking(
|
nextAction = this.workflowService.computeNextActionForBooking(
|
||||||
booking,
|
booking,
|
||||||
@@ -340,6 +406,8 @@ export class ContractClearanceService {
|
|||||||
roHold: Boolean(cycle?.roHoldReason),
|
roHold: Boolean(cycle?.roHoldReason),
|
||||||
roHoldReason: cycle?.roHoldReason ?? null,
|
roHoldReason: cycle?.roHoldReason ?? null,
|
||||||
vesselDepartureDate: cycle?.vesselDepartureDate ?? null,
|
vesselDepartureDate: cycle?.vesselDepartureDate ?? null,
|
||||||
|
vesselArrivalDate: cycle?.vesselArrivalDate ?? null,
|
||||||
|
doCollectedDate: cycle?.doCollectedDate ?? null,
|
||||||
roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt
|
roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt
|
||||||
? cycle.roAmendmentRequestedAt.toISOString()
|
? cycle.roAmendmentRequestedAt.toISOString()
|
||||||
: null,
|
: null,
|
||||||
@@ -349,7 +417,11 @@ export class ContractClearanceService {
|
|||||||
linkedBookingId: cycle?.bookingId ?? null,
|
linkedBookingId: cycle?.bookingId ?? null,
|
||||||
linkedBookingReference,
|
linkedBookingReference,
|
||||||
linkedBookingStatus,
|
linkedBookingStatus,
|
||||||
|
linkedBookingReviewNote,
|
||||||
|
linkedBookingScheduledDate,
|
||||||
dutyAdvice,
|
dutyAdvice,
|
||||||
|
dutyDispute,
|
||||||
|
transitAssignee,
|
||||||
workflowFiles,
|
workflowFiles,
|
||||||
t1,
|
t1,
|
||||||
train,
|
train,
|
||||||
@@ -406,6 +478,32 @@ export class ContractClearanceService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer's duty objection, but only while it is still OPEN — i.e. the
|
||||||
|
* advice milestone sits back at PENDING because nobody has re-advised yet.
|
||||||
|
* Re-advising completes that milestone again, which closes the dispute here
|
||||||
|
* without any extra state to keep in sync; the notes stay as the audit trail
|
||||||
|
* and their count is the round number.
|
||||||
|
*/
|
||||||
|
private async buildDutyDispute(
|
||||||
|
contractId: string,
|
||||||
|
milestones: ClearanceMilestone[],
|
||||||
|
): Promise<ContractClearanceView['dutyDispute']> {
|
||||||
|
const advised = milestones.find((m) => m.milestoneCode === 'DUTY_TAXES_ADVISED');
|
||||||
|
if (!advised || advised.status === 'COMPLETED') return null;
|
||||||
|
const notes = await this.contractsRepository.findReviewNotes(
|
||||||
|
contractId,
|
||||||
|
'DUTY_DISPUTE',
|
||||||
|
);
|
||||||
|
const latest = notes[0];
|
||||||
|
if (!latest) return null;
|
||||||
|
return {
|
||||||
|
note: latest.body,
|
||||||
|
raisedAt: latest.createdAt.toISOString(),
|
||||||
|
rounds: notes.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when every REQUIRED customer-input field has an APPROVED review row in
|
* True when every REQUIRED customer-input field has an APPROVED review row in
|
||||||
* the current cycle. The 100% gate before clearance can be finalized.
|
* the current cycle. The 100% gate before clearance can be finalized.
|
||||||
@@ -631,6 +729,92 @@ export class ContractClearanceService {
|
|||||||
return this.applyReview(contractId, fileKey, status, staffId, 'OPERATIONS', note);
|
return this.applyReview(contractId, fileKey, status, staffId, 'OPERATIONS', note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL corrects a clearance document in place instead of bouncing it back to
|
||||||
|
* the customer. The customer's upload is NOT lost — it is retired into the
|
||||||
|
* document's version history, stamped with who replaced it and why — and the
|
||||||
|
* new version starts unreviewed, so GL still has to approve it (or query it)
|
||||||
|
* before clearance can be finalized.
|
||||||
|
*
|
||||||
|
* Use this for the small fixes staff can make faster than the customer can
|
||||||
|
* (a wrong page order, a missing stamp scan); a query is still the right tool
|
||||||
|
* when only the customer can produce the correct document.
|
||||||
|
*/
|
||||||
|
async replaceDocument(
|
||||||
|
contractId: string,
|
||||||
|
fileKey: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
staffId: string,
|
||||||
|
reason?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
this.assertClearanceReviewableStatus(contract);
|
||||||
|
if (!file) throw new BadRequestException('No replacement file uploaded');
|
||||||
|
if (!reason?.trim()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Say why the document is being replaced — it is kept on the file history.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (this.isPhasedCustoms(contract) && cycle?.preClearanceFinalizedAt) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Documents cannot be changed after pre-clearance is finalized.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.filesService.findByCode(
|
||||||
|
contractId,
|
||||||
|
'contracts',
|
||||||
|
fileKey,
|
||||||
|
);
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`No document is stored under "${fileKey}" on this contract.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.filesService.upsertByCode(
|
||||||
|
{ resourceId: contractId, resource: 'contracts', code: fileKey, file },
|
||||||
|
{ userId: staffId, reason: reason.trim() },
|
||||||
|
);
|
||||||
|
|
||||||
|
// A fresh version is unreviewed by definition: clear any earlier verdict so
|
||||||
|
// the corrected file is signed off explicitly rather than inheriting a tick.
|
||||||
|
const { inputCode, outputCode } = contractClearanceCodes(contract);
|
||||||
|
const reviews = await this.contractsRepository.findDocumentReviews(
|
||||||
|
contractId,
|
||||||
|
cycle?.id ?? null,
|
||||||
|
);
|
||||||
|
const settingCode =
|
||||||
|
reviews.find((r) => r.fileKey === fileKey)?.settingCode ??
|
||||||
|
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
|
||||||
|
await this.contractsRepository.setDocumentReviewStatus({
|
||||||
|
contractId,
|
||||||
|
clearanceCycleId: cycle?.id ?? null,
|
||||||
|
settingCode,
|
||||||
|
fileKey,
|
||||||
|
status: 'PENDING',
|
||||||
|
staffId,
|
||||||
|
note: `Replaced by staff: ${reason.trim()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.contractsRepository.createReviewNote(
|
||||||
|
contractId,
|
||||||
|
`Document "${fileKey}" replaced by staff: ${reason.trim()}`,
|
||||||
|
'STAFF_NOTE',
|
||||||
|
staffId,
|
||||||
|
'GL_ET',
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.contractsService.findById(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every stored version of one clearance document, newest first. */
|
||||||
|
async documentVersions(contractId: string, fileKey: string) {
|
||||||
|
return this.filesService.versionHistory(contractId, 'contracts', fileKey);
|
||||||
|
}
|
||||||
|
|
||||||
private async applyReview(
|
private async applyReview(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
fileKey: string,
|
fileKey: string,
|
||||||
@@ -945,6 +1129,68 @@ export class ContractClearanceService {
|
|||||||
// ── Phased clearance actions (ONE_TIME customs, Phase 1) ───────────────────
|
// ── Phased clearance actions (ONE_TIME customs, Phase 1) ───────────────────
|
||||||
|
|
||||||
/** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */
|
/** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */
|
||||||
|
/**
|
||||||
|
* GL Ethiopia asks Djibouti to name the officer who will handle the shipment
|
||||||
|
* in transit. Nothing else moves until Djibouti answers — the declaration is
|
||||||
|
* gated on it — so this is the first thing ET does once the documents are
|
||||||
|
* approved. Re-requesting is allowed (a nudge) and simply restamps the ask.
|
||||||
|
*/
|
||||||
|
async requestTransitAssignee(
|
||||||
|
contractId: string,
|
||||||
|
note: string | undefined,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
this.assertPhasedCustoms(contract);
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||||
|
|
||||||
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
|
transitAssigneeRequestedAt: new Date(),
|
||||||
|
transitAssigneeRequestedByUserId: userId ?? null,
|
||||||
|
transitAssigneeRequestNote: note?.trim() || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.transitAssigneeRequested(updated, note?.trim() ?? null);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GL Djibouti names the transit officer — free text, because the person is
|
||||||
|
* not a platform user. Answering unblocks the declaration for Ethiopia. A
|
||||||
|
* later call overwrites the name (reassignment) and re-notifies.
|
||||||
|
*/
|
||||||
|
async assignTransitAssignee(
|
||||||
|
contractId: string,
|
||||||
|
assignee: string,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
this.assertPhasedCustoms(contract);
|
||||||
|
if (!assignee?.trim()) {
|
||||||
|
throw new BadRequestException('Name the officer who will handle the transit.');
|
||||||
|
}
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||||
|
if (!cycle.transitAssigneeRequestedAt) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'GL Ethiopia has not requested a transit assignee for this clearance yet.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = cycle.transitAssigneeName ?? null;
|
||||||
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
|
transitAssigneeName: assignee.trim(),
|
||||||
|
transitAssigneeAssignedAt: new Date(),
|
||||||
|
transitAssigneeAssignedByUserId: userId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.transitAssigneeAssigned(updated, assignee.trim(), previous);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureDeclarationPrerequisites(
|
private async ensureDeclarationPrerequisites(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
@@ -955,6 +1201,16 @@ export class ContractClearanceService {
|
|||||||
'All required customer documents must be approved before uploading a declaration.',
|
'All required customer documents must be approved before uploading a declaration.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// The transit officer must be named by Djibouti first — the declaration is
|
||||||
|
// filed against whoever will physically handle the shipment there.
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (!cycle?.transitAssigneeName) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
cycle?.transitAssigneeRequestedAt
|
||||||
|
? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.'
|
||||||
|
: 'Request a transit assignee from GL Djibouti before filing the customs declaration.',
|
||||||
|
);
|
||||||
|
}
|
||||||
const milestones = await this.workflowService.listMilestones(contractId);
|
const milestones = await this.workflowService.listMilestones(contractId);
|
||||||
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
|
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
|
||||||
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
|
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
|
||||||
@@ -1061,6 +1317,67 @@ export class ContractClearanceService {
|
|||||||
return this.contractsService.findById(contractId);
|
return this.contractsService.findById(contractId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer disagrees with the advised duty & tax and asks GL Ethiopia to
|
||||||
|
* correct it. Nothing is paid; the advice milestone reopens so the Duty & tax
|
||||||
|
* step becomes actionable again on the GL clearance page, with the customer's
|
||||||
|
* message shown beside it. GL re-advises (same endpoint as the first time),
|
||||||
|
* which closes the dispute — the loop may run as many rounds as it takes.
|
||||||
|
*/
|
||||||
|
async disputeDuty(
|
||||||
|
contractId: string,
|
||||||
|
note: string,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
this.assertPhasedCustoms(contract);
|
||||||
|
if (contract.tradeDirection !== 'IMPORT') {
|
||||||
|
throw new BadRequestException('Duty applies only to import contracts.');
|
||||||
|
}
|
||||||
|
if (!note?.trim()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Say what is wrong with the advised amount so GL can correct it.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
|
if (!cycle?.dutyRequired) {
|
||||||
|
throw new BadRequestException('Duty/tax is not required for this clearance.');
|
||||||
|
}
|
||||||
|
const milestones = await this.workflowService.listMilestones(contractId);
|
||||||
|
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||||
|
if (byCode.get('DUTY_TAXES_ADVISED')?.status !== 'COMPLETED') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'There is no advised duty amount to dispute yet.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Once the slip is in, the money is paid — a dispute then is a refund
|
||||||
|
// conversation, not a re-advice.
|
||||||
|
if (byCode.get('DUTY_TAX_PAID')?.status === 'COMPLETED') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'The duty payment slip has already been submitted — contact GL Ethiopia directly.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.contractsRepository.createReviewNote(
|
||||||
|
contractId,
|
||||||
|
note.trim(),
|
||||||
|
'DUTY_DISPUTE',
|
||||||
|
userId,
|
||||||
|
'CUSTOMER',
|
||||||
|
);
|
||||||
|
// Back to GL: reopening the milestone is what re-arms the Duty & tax step
|
||||||
|
// (the stepper picks its active step from milestone completion).
|
||||||
|
await this.milestoneService.reopenForContract(contractId, 'DUTY_TAXES_ADVISED');
|
||||||
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
|
currentPhase: ContractDocPhase.GlEtOutput,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.dutyDisputed(updated, note.trim());
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async uploadDutySlip(
|
async uploadDutySlip(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
@@ -1175,7 +1492,7 @@ export class ContractClearanceService {
|
|||||||
contractId: string,
|
contractId: string,
|
||||||
file: Express.Multer.File,
|
file: Express.Multer.File,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
vesselDepartureDate?: string,
|
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
this.assertPhasedCustoms(contract);
|
this.assertPhasedCustoms(contract);
|
||||||
@@ -1185,6 +1502,8 @@ export class ContractClearanceService {
|
|||||||
|
|
||||||
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
||||||
|
|
||||||
|
const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates);
|
||||||
|
|
||||||
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
|
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
|
||||||
// any file type. The DO_COLLECTED milestone (and booking readiness) still waits
|
// any file type. The DO_COLLECTED milestone (and booking readiness) still waits
|
||||||
// for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
// for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
||||||
@@ -1196,9 +1515,10 @@ export class ContractClearanceService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||||
if (cycle && vesselDepartureDate?.trim()) {
|
if (cycle) {
|
||||||
await this.contractsRepository.updateCycle(cycle.id, {
|
await this.contractsRepository.updateCycle(cycle.id, {
|
||||||
vesselDepartureDate: vesselDepartureDate.trim(),
|
vesselArrivalDate,
|
||||||
|
doCollectedDate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (cycle?.preClearanceFinalizedAt) {
|
if (cycle?.preClearanceFinalizedAt) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util';
|
import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util';
|
||||||
|
|
||||||
@@ -84,3 +86,47 @@ export function contractClearanceCodes(contract: Contract): {
|
|||||||
includesCustoms,
|
includesCustoms,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Djibouti GL cannot record a Delivery Order without saying WHEN the vessel
|
||||||
|
* arrived and WHEN the DO was collected — the file alone leaves the import
|
||||||
|
* timeline unauditable. Shared by the contract and per-booking DO uploads so
|
||||||
|
* one endpoint can never be laxer than the other.
|
||||||
|
*
|
||||||
|
* Returns the normalized `YYYY-MM-DD` pair; throws if either is missing,
|
||||||
|
* unparseable, or the DO predates the vessel's arrival.
|
||||||
|
*/
|
||||||
|
export function assertDoCollectionDates(dates?: {
|
||||||
|
vesselArrivalDate?: string;
|
||||||
|
doCollectedDate?: string;
|
||||||
|
}): { vesselArrivalDate: string; doCollectedDate: string } {
|
||||||
|
const vesselArrivalDate = normalizeDoDate(
|
||||||
|
dates?.vesselArrivalDate,
|
||||||
|
'Vessel arrival date',
|
||||||
|
);
|
||||||
|
const doCollectedDate = normalizeDoDate(
|
||||||
|
dates?.doCollectedDate,
|
||||||
|
'DO collected date',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (doCollectedDate < vesselArrivalDate) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'DO collected date cannot be earlier than the vessel arrival date.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { vesselArrivalDate, doCollectedDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `YYYY-MM-DD` or throw — the column is a DATE, so time zones never enter. */
|
||||||
|
function normalizeDoDate(value: string | undefined, label: string): string {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new BadRequestException(`${label} is required to upload a Delivery Order.`);
|
||||||
|
}
|
||||||
|
const date = trimmed.slice(0, 10);
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date))) {
|
||||||
|
throw new BadRequestException(`${label} is not a valid date.`);
|
||||||
|
}
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import type {
|
|||||||
} from './entities/contract.entity';
|
} from './entities/contract.entity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One recorded change between two document snapshots. Granularity is per
|
* One recorded change between two document snapshots. A body edit carries the
|
||||||
* article: a body edit is reported as "the body changed", not as a text diff.
|
* text on both sides so the audit trail shows WHAT was rewritten, not merely
|
||||||
|
* that something was — the UI diffs the two strings for display.
|
||||||
*/
|
*/
|
||||||
export type ContractDocumentChange =
|
export type ContractDocumentChange =
|
||||||
| { kind: 'ARTICLE_ADDED'; articleId: string; title: string }
|
| { kind: 'ARTICLE_ADDED'; articleId: string; title: string }
|
||||||
@@ -16,7 +17,14 @@ export type ContractDocumentChange =
|
|||||||
title: string;
|
title: string;
|
||||||
fromTitle: string;
|
fromTitle: string;
|
||||||
}
|
}
|
||||||
| { kind: 'ARTICLE_BODY_CHANGED'; articleId: string; title: string }
|
| {
|
||||||
|
kind: 'ARTICLE_BODY_CHANGED';
|
||||||
|
articleId: string;
|
||||||
|
title: string;
|
||||||
|
/** Body before / after the edit. Absent on revisions recorded earlier. */
|
||||||
|
fromBody?: string;
|
||||||
|
toBody?: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
kind: 'ARTICLE_REORDERED';
|
kind: 'ARTICLE_REORDERED';
|
||||||
articleId: string;
|
articleId: string;
|
||||||
@@ -25,7 +33,18 @@ export type ContractDocumentChange =
|
|||||||
toOrder: number;
|
toOrder: number;
|
||||||
}
|
}
|
||||||
| { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null }
|
| { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null }
|
||||||
| { kind: 'WHEREAS_CHANGED'; added: number; removed: number };
|
| { kind: 'WHEREAS_CHANGED'; added: number; removed: number }
|
||||||
|
/**
|
||||||
|
* A contract field (not a document article) changed — the customer editing a
|
||||||
|
* DRAFT/CHANGES_REQUESTED contract, e.g. its route, cargo or service type.
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
kind: 'FIELD_CHANGED';
|
||||||
|
field: string;
|
||||||
|
label: string;
|
||||||
|
from: string | null;
|
||||||
|
to: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type SnapshotLike = Pick<
|
type SnapshotLike = Pick<
|
||||||
ContractDocumentSnapshot,
|
ContractDocumentSnapshot,
|
||||||
@@ -109,6 +128,8 @@ export function diffSnapshots(
|
|||||||
kind: 'ARTICLE_BODY_CHANGED',
|
kind: 'ARTICLE_BODY_CHANGED',
|
||||||
articleId: article.id,
|
articleId: article.id,
|
||||||
title: article.title,
|
title: article.title,
|
||||||
|
fromBody: previous.body,
|
||||||
|
toBody: article.body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (previous.order !== article.order) {
|
if (previous.order !== article.order) {
|
||||||
@@ -134,6 +155,60 @@ export function diffSnapshots(
|
|||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Human label per audited contract field, in the order they read on the form. */
|
||||||
|
export const CONTRACT_FIELD_LABELS: Record<string, string> = {
|
||||||
|
contractKind: 'Contract kind',
|
||||||
|
tradeDirection: 'Trade direction',
|
||||||
|
freightType: 'Freight type',
|
||||||
|
serviceType: 'Service type',
|
||||||
|
paymentCurrency: 'Payment currency',
|
||||||
|
contractType: 'Contract type',
|
||||||
|
isHazardous: 'Hazardous',
|
||||||
|
hazardClass: 'Hazard class',
|
||||||
|
unNumber: 'UN number',
|
||||||
|
isReefer: 'Reefer',
|
||||||
|
equipmentReturn: 'Equipment return',
|
||||||
|
customsClearingAgent: 'Customs clearing agent',
|
||||||
|
firstMilePickupAddress: 'First-mile pickup address',
|
||||||
|
lastMileDeliveryAddress: 'Last-mile delivery address',
|
||||||
|
routes: 'Routes',
|
||||||
|
cargoScope: 'Cargo scope',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Render a field value for the audit trail — never "[object Object]". */
|
||||||
|
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 two flat maps of contract fields and report what changed. Only keys
|
||||||
|
* present in `after` are considered, so a partial update never reports the
|
||||||
|
* fields it did not touch.
|
||||||
|
*/
|
||||||
|
export function diffContractFields(
|
||||||
|
before: Record<string, unknown>,
|
||||||
|
after: Record<string, unknown>,
|
||||||
|
): ContractDocumentChange[] {
|
||||||
|
const changes: ContractDocumentChange[] = [];
|
||||||
|
|
||||||
|
for (const [field, nextRaw] of Object.entries(after)) {
|
||||||
|
const next = displayValue(nextRaw);
|
||||||
|
const previous = displayValue(before[field]);
|
||||||
|
if (next === previous) continue;
|
||||||
|
changes.push({
|
||||||
|
kind: 'FIELD_CHANGED',
|
||||||
|
field,
|
||||||
|
label: CONTRACT_FIELD_LABELS[field] ?? field,
|
||||||
|
from: previous,
|
||||||
|
to: next,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
/** Short human summary of a change set, e.g. "2 articles edited, 1 article added". */
|
/** Short human summary of a change set, e.g. "2 articles edited, 1 article added". */
|
||||||
export function summarizeChanges(changes: ContractDocumentChange[]): string {
|
export function summarizeChanges(changes: ContractDocumentChange[]): string {
|
||||||
if (changes.length === 0) return 'No changes';
|
if (changes.length === 0) return 'No changes';
|
||||||
@@ -148,6 +223,7 @@ export function summarizeChanges(changes: ContractDocumentChange[]): string {
|
|||||||
|
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
const fields: string[] = [];
|
||||||
|
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
const verb = articleVerbs[change.kind];
|
const verb = articleVerbs[change.kind];
|
||||||
@@ -157,9 +233,19 @@ export function summarizeChanges(changes: ContractDocumentChange[]): string {
|
|||||||
parts.push('document title changed');
|
parts.push('document title changed');
|
||||||
} else if (change.kind === 'WHEREAS_CHANGED') {
|
} else if (change.kind === 'WHEREAS_CHANGED') {
|
||||||
parts.push('recitals changed');
|
parts.push('recitals changed');
|
||||||
|
} else if (change.kind === 'FIELD_CHANGED') {
|
||||||
|
fields.push(change.label.toLowerCase());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fields.length > 0) {
|
||||||
|
parts.push(
|
||||||
|
fields.length <= 3
|
||||||
|
? `${fields.join(', ')} changed`
|
||||||
|
: `${fields.length} contract fields changed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const articleParts = [...counts.entries()].map(
|
const articleParts = [...counts.entries()].map(
|
||||||
([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`,
|
([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
|
||||||
import { diffSnapshots, summarizeChanges } from './contract-document-diff.util';
|
import {
|
||||||
|
ContractDocumentChange,
|
||||||
|
diffSnapshots,
|
||||||
|
summarizeChanges,
|
||||||
|
} from './contract-document-diff.util';
|
||||||
import { ContractDocumentRevision } from './entities/contract-document-revision.entity';
|
import { ContractDocumentRevision } from './entities/contract-document-revision.entity';
|
||||||
import type { ContractDocumentSnapshot } from './entities/contract.entity';
|
import type { ContractDocumentSnapshot } from './entities/contract.entity';
|
||||||
|
|
||||||
@@ -12,6 +16,39 @@ export interface RecordRevisionInput {
|
|||||||
after: ContractDocumentSnapshot | null;
|
after: ContractDocumentSnapshot | null;
|
||||||
actorId?: string | null;
|
actorId?: string | null;
|
||||||
actorRole?: string | null;
|
actorRole?: string | null;
|
||||||
|
actorName?: string | null;
|
||||||
|
stepId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `iam.users.name` is a localized object ({ en, am, … }), not a string — a
|
||||||
|
* plain `String(name)` there yields "[object Object]" in the audit trail.
|
||||||
|
*/
|
||||||
|
interface IamUserRow {
|
||||||
|
name?: Record<string, string> | string | null;
|
||||||
|
username?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best display name for a user row: English label → any locale → login → email. */
|
||||||
|
function pickUserName(user: IamUserRow): string | null {
|
||||||
|
const { name } = user;
|
||||||
|
if (typeof name === 'string' && name.trim()) return name.trim();
|
||||||
|
if (name && typeof name === 'object') {
|
||||||
|
const localized =
|
||||||
|
name.en ?? Object.values(name).find((v) => typeof v === 'string' && v.trim());
|
||||||
|
if (localized?.trim()) return localized.trim();
|
||||||
|
}
|
||||||
|
return user.username?.trim() || user.email?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pre-computed changes (contract fields), rather than a document diff. */
|
||||||
|
export interface RecordChangesInput {
|
||||||
|
contractId: string;
|
||||||
|
changes: ContractDocumentChange[];
|
||||||
|
actorId?: string | null;
|
||||||
|
actorRole?: string | null;
|
||||||
|
actorName?: string | null;
|
||||||
stepId?: string | null;
|
stepId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +59,7 @@ export class ContractDocumentHistoryService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(ContractDocumentRevision)
|
@InjectRepository(ContractDocumentRevision)
|
||||||
private readonly revisionRepo: Repository<ContractDocumentRevision>,
|
private readonly revisionRepo: Repository<ContractDocumentRevision>,
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,18 +68,32 @@ export class ContractDocumentHistoryService {
|
|||||||
* and swallowed. A no-op edit records nothing.
|
* and swallowed. A no-op edit records nothing.
|
||||||
*/
|
*/
|
||||||
async record(input: RecordRevisionInput): Promise<void> {
|
async record(input: RecordRevisionInput): Promise<void> {
|
||||||
|
return this.recordChanges({
|
||||||
|
...input,
|
||||||
|
changes: diffSnapshots(input.before, input.after),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a revision from an already-computed change set — the contract-field
|
||||||
|
* path, where there is no document snapshot to diff. Same best-effort
|
||||||
|
* contract as {@link record}: a no-op change set records nothing, and a
|
||||||
|
* failure here never breaks the edit that triggered it.
|
||||||
|
*/
|
||||||
|
async recordChanges(input: RecordChangesInput): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const changes = diffSnapshots(input.before, input.after);
|
if (input.changes.length === 0) return;
|
||||||
if (changes.length === 0) return;
|
|
||||||
|
|
||||||
await this.revisionRepo.save(
|
await this.revisionRepo.save(
|
||||||
this.revisionRepo.create({
|
this.revisionRepo.create({
|
||||||
contractId: input.contractId,
|
contractId: input.contractId,
|
||||||
actorId: input.actorId ?? null,
|
actorId: input.actorId ?? null,
|
||||||
actorRole: input.actorRole ?? null,
|
actorRole: input.actorRole ?? null,
|
||||||
|
actorName:
|
||||||
|
input.actorName ?? (await this.resolveActorName(input.actorId)),
|
||||||
stepId: input.stepId ?? null,
|
stepId: input.stepId ?? null,
|
||||||
summary: summarizeChanges(changes),
|
summary: summarizeChanges(input.changes),
|
||||||
changes,
|
changes: input.changes,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -51,11 +103,62 @@ export class ContractDocumentHistoryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name for the acting user. `iam.users` is owned by the auth system and has
|
||||||
|
* no entity here, so it is read directly; a miss is not an error — the trail
|
||||||
|
* still carries the id, role and timestamp.
|
||||||
|
*/
|
||||||
|
private async resolveActorName(
|
||||||
|
actorId?: string | null,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!actorId) return null;
|
||||||
|
const names = await this.resolveActorNames([actorId]);
|
||||||
|
return names.get(actorId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Batched {@link resolveActorName} — one query for a whole revision list. */
|
||||||
|
private async resolveActorNames(
|
||||||
|
actorIds: string[],
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const resolved = new Map<string, string>();
|
||||||
|
const ids = [...new Set(actorIds.filter(Boolean))];
|
||||||
|
if (ids.length === 0) return resolved;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = (await this.dataSource.query(
|
||||||
|
`SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`,
|
||||||
|
[ids],
|
||||||
|
)) as Array<IamUserRow & { id: string }>;
|
||||||
|
for (const row of rows) {
|
||||||
|
const name = pickUserName(row);
|
||||||
|
if (name) resolved.set(row.id, name);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Could not resolve actor names: ${String(err)}`);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
/** Revision history for a contract, newest first. */
|
/** Revision history for a contract, newest first. */
|
||||||
list(contractId: string): Promise<ContractDocumentRevision[]> {
|
async list(contractId: string): Promise<ContractDocumentRevision[]> {
|
||||||
return this.revisionRepo.find({
|
const revisions = await this.revisionRepo.find({
|
||||||
where: { contractId },
|
where: { contractId },
|
||||||
order: { createdAt: 'DESC' },
|
order: { createdAt: 'DESC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Rows written before actor_name existed still carry an actor_id — resolve
|
||||||
|
// those for display (one query for the whole list) rather than backfilling.
|
||||||
|
const missing = revisions
|
||||||
|
.filter((r) => !r.actorName && r.actorId)
|
||||||
|
.map((r) => r.actorId as string);
|
||||||
|
if (missing.length === 0) return revisions;
|
||||||
|
|
||||||
|
const names = await this.resolveActorNames(missing);
|
||||||
|
for (const revision of revisions) {
|
||||||
|
if (!revision.actorName && revision.actorId) {
|
||||||
|
revision.actorName = names.get(revision.actorId) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return revisions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ContractClearanceService } from './contract-clearance.service';
|
||||||
|
import type { Contract } from './entities/contract.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The duty advice → dispute → re-advice loop. GL Ethiopia advises an amount;
|
||||||
|
* the customer either pays it or sends it back with a reason. Sending it back
|
||||||
|
* reopens the advice milestone — that is what puts the Duty & tax step back in
|
||||||
|
* GL's hands — and the round can repeat until the amount is agreed.
|
||||||
|
*/
|
||||||
|
describe('ContractClearanceService — duty dispute', () => {
|
||||||
|
const contract = (over: Partial<Contract> = {}): Contract =>
|
||||||
|
({
|
||||||
|
id: 'ctr-1',
|
||||||
|
reference: 'CTR-2026-00042',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
contractKind: 'ONE_TIME',
|
||||||
|
...over,
|
||||||
|
}) as Contract;
|
||||||
|
|
||||||
|
const milestone = (code: string, status: string) =>
|
||||||
|
({ milestoneCode: code, status }) as never;
|
||||||
|
|
||||||
|
let repo: {
|
||||||
|
currentCycle: jest.Mock;
|
||||||
|
createReviewNote: jest.Mock;
|
||||||
|
updateCycle: jest.Mock;
|
||||||
|
findReviewNotes: jest.Mock;
|
||||||
|
};
|
||||||
|
let contractsService: { findById: jest.Mock };
|
||||||
|
let workflowService: { listMilestones: jest.Mock };
|
||||||
|
let milestoneService: { reopenForContract: jest.Mock };
|
||||||
|
let notifier: { dutyDisputed: jest.Mock };
|
||||||
|
let service: ContractClearanceService;
|
||||||
|
|
||||||
|
const build = (milestones: unknown[]) => {
|
||||||
|
workflowService.listMilestones.mockResolvedValue(milestones);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = {
|
||||||
|
currentCycle: jest.fn().mockResolvedValue({ id: 'cyc-1', dutyRequired: true }),
|
||||||
|
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||||
|
updateCycle: jest.fn().mockResolvedValue(undefined),
|
||||||
|
findReviewNotes: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
contractsService = { findById: jest.fn().mockResolvedValue(contract()) };
|
||||||
|
workflowService = { listMilestones: jest.fn().mockResolvedValue([]) };
|
||||||
|
milestoneService = { reopenForContract: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
notifier = { dutyDisputed: jest.fn() };
|
||||||
|
|
||||||
|
service = new ContractClearanceService(
|
||||||
|
repo as never,
|
||||||
|
contractsService as never,
|
||||||
|
{} as never, // bookingsService
|
||||||
|
{} as never, // filesService
|
||||||
|
{} as never, // fileUploadSettingsService
|
||||||
|
workflowService as never,
|
||||||
|
milestoneService as never,
|
||||||
|
{} as never, // dropdownSettingsService
|
||||||
|
{} as never, // glOperationsService
|
||||||
|
notifier as never,
|
||||||
|
);
|
||||||
|
build([
|
||||||
|
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||||
|
milestone('DUTY_TAX_PAID', 'PENDING'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records the objection and hands the step back to GL', async () => {
|
||||||
|
await service.disputeDuty('ctr-1', ' Declared value is wrong ', 'user-1');
|
||||||
|
|
||||||
|
expect(repo.createReviewNote).toHaveBeenCalledWith(
|
||||||
|
'ctr-1',
|
||||||
|
'Declared value is wrong',
|
||||||
|
'DUTY_DISPUTE',
|
||||||
|
'user-1',
|
||||||
|
'CUSTOMER',
|
||||||
|
);
|
||||||
|
// Reopening the advice milestone is what re-arms the Duty & tax step.
|
||||||
|
expect(milestoneService.reopenForContract).toHaveBeenCalledWith(
|
||||||
|
'ctr-1',
|
||||||
|
'DUTY_TAXES_ADVISED',
|
||||||
|
);
|
||||||
|
expect(repo.updateCycle).toHaveBeenCalledWith('cyc-1', {
|
||||||
|
currentPhase: 'GL_ET_OUTPUT',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells GL Ethiopia, not the customer', async () => {
|
||||||
|
await service.disputeDuty('ctr-1', 'Too high', 'user-1');
|
||||||
|
expect(notifier.dutyDisputed).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: 'ctr-1' }),
|
||||||
|
'Too high',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a reason — GL cannot correct an unexplained objection', async () => {
|
||||||
|
await expect(service.disputeDuty('ctr-1', ' ')).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(milestoneService.reopenForContract).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses when nothing has been advised yet', async () => {
|
||||||
|
build([milestone('DUTY_TAXES_ADVISED', 'PENDING')]);
|
||||||
|
await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow(
|
||||||
|
/no advised duty amount/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses once the payment slip is in — that is a refund, not a re-advice', async () => {
|
||||||
|
build([
|
||||||
|
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||||
|
milestone('DUTY_TAX_PAID', 'COMPLETED'),
|
||||||
|
]);
|
||||||
|
await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow(
|
||||||
|
/already been submitted/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses when duty was never required for this clearance', async () => {
|
||||||
|
repo.currentCycle.mockResolvedValue({ id: 'cyc-1', dutyRequired: false });
|
||||||
|
await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow(
|
||||||
|
/not required/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the view', () => {
|
||||||
|
const buildDispute = (milestones: unknown[]) =>
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
buildDutyDispute: (id: string, m: unknown[]) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
).buildDutyDispute('ctr-1', milestones);
|
||||||
|
|
||||||
|
it('shows the objection while GL still owes a corrected advice', async () => {
|
||||||
|
repo.findReviewNotes.mockResolvedValue([
|
||||||
|
{ body: 'Second look please', createdAt: new Date('2026-07-20T09:00:00Z') },
|
||||||
|
{ body: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const dispute = await buildDispute([
|
||||||
|
milestone('DUTY_TAXES_ADVISED', 'PENDING'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(dispute).toMatchObject({ note: 'Second look please', rounds: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears itself once GL re-advises', async () => {
|
||||||
|
repo.findReviewNotes.mockResolvedValue([
|
||||||
|
{ body: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const dispute = await buildDispute([
|
||||||
|
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(dispute).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { ContractExpiryService } from './contract-expiry.service';
|
||||||
|
import type { Contract } from './entities/contract.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reminder must warn each customer once, ten days out, and must never let a
|
||||||
|
* notification failure escape into the scheduler (that would also take out the
|
||||||
|
* expiry sweep sharing this service).
|
||||||
|
*/
|
||||||
|
describe('ContractExpiryService — expiry reminder', () => {
|
||||||
|
const contract = (over: Partial<Contract> = {}): Contract =>
|
||||||
|
({
|
||||||
|
id: 'c-1',
|
||||||
|
reference: 'CTR-2026-00042',
|
||||||
|
companyId: 'co-1',
|
||||||
|
contractValidUntil: new Date('2026-08-10T00:00:00.000Z'),
|
||||||
|
status: 'CONTRACT_ACTIVE',
|
||||||
|
...over,
|
||||||
|
}) as Contract;
|
||||||
|
|
||||||
|
let repo: { expireLapsedContracts: jest.Mock; findExpiringInDays: jest.Mock };
|
||||||
|
let inbox: { notify: jest.Mock };
|
||||||
|
let service: ContractExpiryService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = {
|
||||||
|
expireLapsedContracts: jest.fn().mockResolvedValue(0),
|
||||||
|
findExpiringInDays: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
service = new ContractExpiryService(repo as never, inbox as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks for the contracts lapsing ten days out', async () => {
|
||||||
|
await service.remindExpiringContracts();
|
||||||
|
expect(repo.findExpiringInDays).toHaveBeenCalledWith(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies the owning company once, deep-linking the contract list', async () => {
|
||||||
|
repo.findExpiringInDays.mockResolvedValue([contract()]);
|
||||||
|
|
||||||
|
await service.remindExpiringContracts();
|
||||||
|
|
||||||
|
expect(inbox.notify).toHaveBeenCalledTimes(1);
|
||||||
|
const sent = inbox.notify.mock.calls[0][0];
|
||||||
|
expect(sent.recipients).toEqual({ companyId: 'co-1' });
|
||||||
|
expect(sent.title).toContain('CTR-2026-00042');
|
||||||
|
expect(sent.title).toContain('10 days');
|
||||||
|
expect(sent.link).toBe('/contracts');
|
||||||
|
expect(sent.data).toMatchObject({ contractId: 'c-1', action: 'CONTRACT_EXPIRING' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a contract with no owning company (nobody to notify)', async () => {
|
||||||
|
repo.findExpiringInDays.mockResolvedValue([contract({ companyId: null })]);
|
||||||
|
await service.remindExpiringContracts();
|
||||||
|
expect(inbox.notify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swallows a notification failure instead of throwing into the scheduler', async () => {
|
||||||
|
repo.findExpiringInDays.mockResolvedValue([contract()]);
|
||||||
|
inbox.notify.mockRejectedValue(new Error('inbox down'));
|
||||||
|
await expect(service.remindExpiringContracts()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||||
|
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
import { ContractsRepository } from './contracts.repository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many days before a contract lapses the customer is reminded. Mirrored by
|
||||||
|
* the portal contract list (EXPIRY_NOTICE_DAYS in contract-ui.tsx), which shows
|
||||||
|
* the same countdown on the row.
|
||||||
|
*/
|
||||||
|
const EXPIRY_NOTICE_DAYS = 10;
|
||||||
|
|
||||||
|
/** Nightly sweep that flips contracts past contractValidUntil to EXPIRED. */
|
||||||
|
@Injectable()
|
||||||
|
export class ContractExpiryService {
|
||||||
|
private readonly logger = new Logger(ContractExpiryService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly contractsRepository: ContractsRepository,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warn every customer whose contract lapses in ~10 days, once. The repository
|
||||||
|
* window is a rolling 24h slice, so a contract is picked up by exactly one
|
||||||
|
* daily run — no reminded-flag column needed.
|
||||||
|
*
|
||||||
|
* ponytail: a missed run (API down over the slice) skips that contract's
|
||||||
|
* reminder; the portal list still shows its countdown for the whole window.
|
||||||
|
*/
|
||||||
|
@Cron(CronExpression.EVERY_DAY_AT_2AM, { name: 'contract-expiry-reminder' })
|
||||||
|
async remindExpiringContracts(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const expiring =
|
||||||
|
await this.contractsRepository.findExpiringInDays(EXPIRY_NOTICE_DAYS);
|
||||||
|
let notified = 0;
|
||||||
|
for (const contract of expiring) {
|
||||||
|
if (!contract.companyId || !contract.contractValidUntil) continue;
|
||||||
|
const endsOn = contract.contractValidUntil.toLocaleDateString('en-GB');
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { companyId: contract.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.CONTRACT_STATUS,
|
||||||
|
title: `Contract ${contract.reference} expires in ${EXPIRY_NOTICE_DAYS} days`,
|
||||||
|
body:
|
||||||
|
`Your contract ${contract.reference} is valid until ${endsOn}. ` +
|
||||||
|
'After that date it stops accepting new bookings — contact EDR if ' +
|
||||||
|
'you need it renewed.',
|
||||||
|
link: '/contracts',
|
||||||
|
data: { contractId: contract.id, action: 'CONTRACT_EXPIRING' },
|
||||||
|
});
|
||||||
|
notified += 1;
|
||||||
|
}
|
||||||
|
this.logger.log(
|
||||||
|
`Contract expiry reminder: ${notified} customer(s) warned of a contract ` +
|
||||||
|
`lapsing in ${EXPIRY_NOTICE_DAYS} days`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Never throws into the scheduler — a failed reminder must not stop the
|
||||||
|
// expiry sweep from running.
|
||||||
|
this.logger.error(
|
||||||
|
`Contract expiry reminder failed: ${(err as Error).message}`,
|
||||||
|
(err as Error).stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_DAY_AT_1AM, { name: 'contract-expiry-sweep' })
|
||||||
|
async expireLapsedContracts(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const affected = await this.contractsRepository.expireLapsedContracts();
|
||||||
|
this.logger.log(`Contract expiry sweep: ${affected} contract(s) marked EXPIRED`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Contract expiry sweep failed: ${(err as Error).message}`,
|
||||||
|
(err as Error).stack,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { allBackoffice: true },
|
||||||
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
|
type: NotificationType.GENERIC,
|
||||||
|
title: 'Contract expiry sweep failed',
|
||||||
|
body: `The nightly job that expires lapsed contracts failed: ${(err as Error).message}. Contracts past their validity date may still show as active until this is fixed.`,
|
||||||
|
data: { action: 'CONTRACT_EXPIRY_SWEEP_FAILED' },
|
||||||
|
});
|
||||||
|
} catch (notifyErr) {
|
||||||
|
this.logger.error(
|
||||||
|
`Contract expiry sweep failure alert also failed: ${(notifyErr as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
diffContractFields,
|
||||||
|
summarizeChanges,
|
||||||
|
} from './contract-document-diff.util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contract-field audit runs on the customer's own edits, so it has to be
|
||||||
|
* exact: never report a field the edit did not touch, and never render a value
|
||||||
|
* as "[object Object]" or "true" in the trail a reviewer reads.
|
||||||
|
*/
|
||||||
|
describe('diffContractFields', () => {
|
||||||
|
it('reports only the fields that actually changed', () => {
|
||||||
|
const changes = diffContractFields(
|
||||||
|
{ freightType: 'BULK', paymentCurrency: 'USD', isReefer: false },
|
||||||
|
{ freightType: 'CONTAINER', paymentCurrency: 'USD', isReefer: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(changes).toEqual([
|
||||||
|
{
|
||||||
|
kind: 'FIELD_CHANGED',
|
||||||
|
field: 'freightType',
|
||||||
|
label: 'Freight type',
|
||||||
|
from: 'BULK',
|
||||||
|
to: 'CONTAINER',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders booleans as Yes/No, not true/false', () => {
|
||||||
|
const [change] = diffContractFields({ isHazardous: false }, { isHazardous: true });
|
||||||
|
|
||||||
|
expect(change).toMatchObject({ label: 'Hazardous', from: 'No', to: 'Yes' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats null, undefined and empty string as "not set"', () => {
|
||||||
|
expect(diffContractFields({ unNumber: null }, { unNumber: '' })).toEqual([]);
|
||||||
|
expect(diffContractFields({ unNumber: undefined }, { unNumber: null })).toEqual([]);
|
||||||
|
|
||||||
|
const [set] = diffContractFields({ unNumber: null }, { unNumber: 'UN1234' });
|
||||||
|
expect(set).toMatchObject({ from: null, to: 'UN1234' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores fields absent from the update', () => {
|
||||||
|
// A partial edit must not report the fields it never sent.
|
||||||
|
expect(diffContractFields({ freightType: 'BULK', isReefer: true }, {})).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a route swap that keeps the same lane count', () => {
|
||||||
|
const [change] = diffContractFields(
|
||||||
|
{ routes: 'Nagad → Mojo' },
|
||||||
|
{ routes: 'Nagad → Adama' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(change).toMatchObject({
|
||||||
|
label: 'Routes',
|
||||||
|
from: 'Nagad → Mojo',
|
||||||
|
to: 'Nagad → Adama',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summarises field changes by name, and by count once there are many', () => {
|
||||||
|
const few = diffContractFields(
|
||||||
|
{ freightType: 'BULK', paymentCurrency: 'USD' },
|
||||||
|
{ freightType: 'CONTAINER', paymentCurrency: 'ETB' },
|
||||||
|
);
|
||||||
|
expect(summarizeChanges(few)).toBe('freight type, payment currency changed');
|
||||||
|
|
||||||
|
const many = diffContractFields(
|
||||||
|
{ a: '1', b: '1', c: '1', d: '1' },
|
||||||
|
{ a: '2', b: '2', c: '2', d: '2' },
|
||||||
|
);
|
||||||
|
expect(summarizeChanges(many)).toBe('4 contract fields changed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('summarises document and field changes together', () => {
|
||||||
|
const summary = summarizeChanges([
|
||||||
|
{ kind: 'ARTICLE_BODY_CHANGED', articleId: 'a-1', title: 'Article 1' },
|
||||||
|
{
|
||||||
|
kind: 'FIELD_CHANGED',
|
||||||
|
field: 'routes',
|
||||||
|
label: 'Routes',
|
||||||
|
from: 'A → B',
|
||||||
|
to: 'A → C',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(summary).toBe('1 article edited, routes changed');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -192,6 +192,56 @@ export class ContractNotifierService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(c: Contract, note: string | null): void {
|
||||||
|
const msg =
|
||||||
|
`GL Ethiopia needs a transit assignee for contract ${c.reference} before ` +
|
||||||
|
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
|
||||||
|
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`);
|
||||||
|
this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, {
|
||||||
|
type: NotificationType.CLEARANCE_REVIEW,
|
||||||
|
link: `/dashboard/gl-djibouti/clearance/${c.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */
|
||||||
|
transitAssigneeAssigned(
|
||||||
|
c: Contract,
|
||||||
|
assignee: string,
|
||||||
|
previous: string | null,
|
||||||
|
): void {
|
||||||
|
const msg = previous
|
||||||
|
? `GL Djibouti changed the transit assignee for contract ${c.reference} from ` +
|
||||||
|
`"${previous}" to "${assignee}".`
|
||||||
|
: `GL Djibouti assigned ${assignee} to handle contract ${c.reference} in transit. ` +
|
||||||
|
`The customs declaration can now be filed.`;
|
||||||
|
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`);
|
||||||
|
this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, {
|
||||||
|
type: NotificationType.CLEARANCE_REVIEW,
|
||||||
|
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer disputed the advised duty & tax. This goes to STAFF, not the
|
||||||
|
* customer: GL Ethiopia is the one who has to re-advise, and the clearance
|
||||||
|
* page is where they do it.
|
||||||
|
*/
|
||||||
|
dutyDisputed(c: Contract, note: string): void {
|
||||||
|
const msg =
|
||||||
|
`The customer disputed the duty & tax advised on contract ${c.reference}: ` +
|
||||||
|
`"${note}". Review and re-advise the amount on the clearance page.`;
|
||||||
|
this.logger.log(`DUTY DISPUTED — ${c.reference}`);
|
||||||
|
this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, {
|
||||||
|
type: NotificationType.CLEARANCE_REVIEW,
|
||||||
|
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** A clearance document was queried — customer must re-upload it. */
|
/** A clearance document was queried — customer must re-upload it. */
|
||||||
clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void {
|
clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void {
|
||||||
const msg =
|
const msg =
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
|
|||||||
switch (rateUnit) {
|
switch (rateUnit) {
|
||||||
case 'PER_TON':
|
case 'PER_TON':
|
||||||
return 'per_ton';
|
return 'per_ton';
|
||||||
|
case 'PER_ITEM':
|
||||||
|
return 'per_item';
|
||||||
case 'PER_KM':
|
case 'PER_KM':
|
||||||
return 'per_km';
|
return 'per_km';
|
||||||
case 'PER_WAGON':
|
case 'PER_WAGON':
|
||||||
@@ -115,9 +117,19 @@ export class ContractPricingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const bulkRate =
|
|
||||||
liveRates.find((r) => r.rateType === baseType && r.currency === 'USD') ?? null;
|
|
||||||
const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||||
|
// Freeze the rate for the contract's own commodity when one is configured
|
||||||
|
// — a per-item machinery rate and a per-ton wheat rate live side by side.
|
||||||
|
const bulkRates = liveRates.filter(
|
||||||
|
(r) => r.rateType === baseType && r.currency === 'USD',
|
||||||
|
);
|
||||||
|
const bulkRate =
|
||||||
|
(cargoScope?.cargoTypeId
|
||||||
|
? bulkRates.find((r) => r.cargoTypeId === cargoScope.cargoTypeId)
|
||||||
|
: undefined) ??
|
||||||
|
bulkRates.find((r) => !r.cargoTypeId) ??
|
||||||
|
bulkRates[0] ??
|
||||||
|
null;
|
||||||
if (bulkRate) {
|
if (bulkRate) {
|
||||||
lineItems.push({
|
lineItems.push({
|
||||||
code: 'BULK_FREIGHT',
|
code: 'BULK_FREIGHT',
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `iam.users.name` is a localized jsonb object, not a string. Reading it
|
||||||
|
* naively puts "[object Object]" in the audit trail — or, worse, throws and
|
||||||
|
* leaves every revision anonymous. These specs pin the resolution rules.
|
||||||
|
*/
|
||||||
|
describe('ContractDocumentHistoryService actor names', () => {
|
||||||
|
const build = (rows: unknown[]) => {
|
||||||
|
const saved: Array<Record<string, unknown>> = [];
|
||||||
|
const service = Object.create(
|
||||||
|
ContractDocumentHistoryService.prototype,
|
||||||
|
) as ContractDocumentHistoryService;
|
||||||
|
Object.assign(service, {
|
||||||
|
logger: { warn: jest.fn(), error: jest.fn() },
|
||||||
|
dataSource: { query: jest.fn().mockResolvedValue(rows) },
|
||||||
|
revisionRepo: {
|
||||||
|
create: (row: Record<string, unknown>) => row,
|
||||||
|
save: jest.fn((row: Record<string, unknown>) => {
|
||||||
|
saved.push(row);
|
||||||
|
return Promise.resolve(row);
|
||||||
|
}),
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { service, saved };
|
||||||
|
};
|
||||||
|
|
||||||
|
const change = {
|
||||||
|
kind: 'FIELD_CHANGED' as const,
|
||||||
|
field: 'routes',
|
||||||
|
label: 'Routes',
|
||||||
|
from: 'A → B',
|
||||||
|
to: 'A → C',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('prefers the English label from the localized name object', async () => {
|
||||||
|
const { service, saved } = build([
|
||||||
|
{ id: 'u-1', name: { am: 'ሱፐር አድሚን', en: 'Super Admin' }, username: 'superadmin' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' });
|
||||||
|
|
||||||
|
expect(saved[0].actorName).toBe('Super Admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to another locale, then username, then email', async () => {
|
||||||
|
const onlyAmharic = build([{ id: 'u-1', name: { am: 'ሱፐር' }, username: 'x' }]);
|
||||||
|
await onlyAmharic.service.recordChanges({
|
||||||
|
contractId: 'c-1',
|
||||||
|
changes: [change],
|
||||||
|
actorId: 'u-1',
|
||||||
|
});
|
||||||
|
expect(onlyAmharic.saved[0].actorName).toBe('ሱፐር');
|
||||||
|
|
||||||
|
const noName = build([{ id: 'u-1', name: null, username: 'operator', email: 'o@edr' }]);
|
||||||
|
await noName.service.recordChanges({
|
||||||
|
contractId: 'c-1',
|
||||||
|
changes: [change],
|
||||||
|
actorId: 'u-1',
|
||||||
|
});
|
||||||
|
expect(noName.saved[0].actorName).toBe('operator');
|
||||||
|
|
||||||
|
const emailOnly = build([{ id: 'u-1', name: {}, username: null, email: 'o@edr.local' }]);
|
||||||
|
await emailOnly.service.recordChanges({
|
||||||
|
contractId: 'c-1',
|
||||||
|
changes: [change],
|
||||||
|
actorId: 'u-1',
|
||||||
|
});
|
||||||
|
expect(emailOnly.saved[0].actorName).toBe('o@edr.local');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never writes "[object Object]" as the actor name', async () => {
|
||||||
|
const { service, saved } = build([{ id: 'u-1', name: { en: 'Real Name' } }]);
|
||||||
|
|
||||||
|
await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' });
|
||||||
|
|
||||||
|
expect(String(saved[0].actorName)).not.toContain('object Object');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records nothing when the change set is empty', async () => {
|
||||||
|
const { service, saved } = build([]);
|
||||||
|
|
||||||
|
await service.recordChanges({ contractId: 'c-1', changes: [], actorId: 'u-1' });
|
||||||
|
|
||||||
|
expect(saved).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still records the revision when the user lookup fails', async () => {
|
||||||
|
const { service, saved } = build([]);
|
||||||
|
Object.assign(service, {
|
||||||
|
dataSource: { query: jest.fn().mockRejectedValue(new Error('iam down')) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' });
|
||||||
|
|
||||||
|
expect(saved).toHaveLength(1);
|
||||||
|
expect(saved[0].actorName).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves names for legacy rows that predate the actor_name column', async () => {
|
||||||
|
const { service } = build([{ id: 'u-1', name: { en: 'Abenezer Haile' } }]);
|
||||||
|
Object.assign(service, {
|
||||||
|
revisionRepo: {
|
||||||
|
find: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ id: 'r-1', actorId: 'u-1', actorName: null }]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [revision] = await service.list('c-1');
|
||||||
|
|
||||||
|
expect(revision.actorName).toBe('Abenezer Haile');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stamp may be uploaded as JPEG/WebP while a drawn signature is always PNG.
|
||||||
|
* The type must survive the round-trip: data URL in → stored object extension
|
||||||
|
* → data URL out. Getting this wrong labels JPEG bytes as image/png in the
|
||||||
|
* contract PDF and leaves the seal to browser content-sniffing.
|
||||||
|
*/
|
||||||
|
describe('ContractTransitionService signature/stamp asset typing', () => {
|
||||||
|
const pngPixel =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==';
|
||||||
|
const jpegPixel = `data:image/jpeg;base64,${Buffer.from('fake-jpeg').toString('base64')}`;
|
||||||
|
|
||||||
|
/** Minimal service instance — only filesService/minioService are exercised. */
|
||||||
|
const build = () => {
|
||||||
|
const uploaded: Array<{ code: string; mimetype: string; name: string }> = [];
|
||||||
|
const filesService = {
|
||||||
|
upsertByCode: jest.fn(({ code, file }) => {
|
||||||
|
uploaded.push({ code, mimetype: file.mimetype, name: file.originalname });
|
||||||
|
return Promise.resolve({ id: `file-${code}`, url: `https://minio/x/${file.originalname}` });
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const minioService = {
|
||||||
|
getObjectNameFromUrl: (url: string) => url.split('/').pop() ?? '',
|
||||||
|
getFileStream: () => Promise.resolve(Readable.from(Buffer.from('bytes'))),
|
||||||
|
};
|
||||||
|
const service = Object.create(
|
||||||
|
ContractTransitionService.prototype,
|
||||||
|
) as ContractTransitionService;
|
||||||
|
Object.assign(service, { filesService, minioService });
|
||||||
|
return { service, uploaded };
|
||||||
|
};
|
||||||
|
|
||||||
|
const contract = { id: 'c-1', reference: 'CTR-2026-00001' };
|
||||||
|
|
||||||
|
it('stores a drawn PNG signature as image/png', async () => {
|
||||||
|
const { service, uploaded } = build();
|
||||||
|
await (service as never as {
|
||||||
|
uploadSignatureAsset: (c: unknown, code: string, b64: string) => Promise<unknown>;
|
||||||
|
}).uploadSignatureAsset(contract, 'signature_customer', pngPixel);
|
||||||
|
|
||||||
|
expect(uploaded[0].mimetype).toBe('image/png');
|
||||||
|
expect(uploaded[0].name).toBe('signature-customer-CTR-2026-00001.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an uploaded JPEG stamp as image/jpeg, not image/png', async () => {
|
||||||
|
const { service, uploaded } = build();
|
||||||
|
await (service as never as {
|
||||||
|
uploadSignatureAsset: (c: unknown, code: string, b64: string) => Promise<unknown>;
|
||||||
|
}).uploadSignatureAsset(contract, 'stamp_customer', jpegPixel);
|
||||||
|
|
||||||
|
expect(uploaded[0].mimetype).toBe('image/jpeg');
|
||||||
|
expect(uploaded[0].name).toBe('stamp-customer-CTR-2026-00001.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inlines a stored .jpg back as a data:image/jpeg URI', async () => {
|
||||||
|
const { service } = build();
|
||||||
|
const inline = (service as never as {
|
||||||
|
inlineImageUrl: (url?: string | null) => Promise<string | null | undefined>;
|
||||||
|
}).inlineImageUrl.bind(service);
|
||||||
|
|
||||||
|
await expect(inline('https://minio/x/stamp-customer-CTR.jpg')).resolves.toMatch(
|
||||||
|
/^data:image\/jpeg;base64,/,
|
||||||
|
);
|
||||||
|
await expect(inline('https://minio/x/signature-customer-CTR.png')).resolves.toMatch(
|
||||||
|
/^data:image\/png;base64,/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through empty and already-inlined values untouched', async () => {
|
||||||
|
const { service } = build();
|
||||||
|
const inline = (service as never as {
|
||||||
|
inlineImageUrl: (url?: string | null) => Promise<string | null | undefined>;
|
||||||
|
}).inlineImageUrl.bind(service);
|
||||||
|
|
||||||
|
await expect(inline(null)).resolves.toBeNull();
|
||||||
|
await expect(inline(pngPixel)).resolves.toBe(pngPixel);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signing is one-shot. The single exception: a contract signed before company
|
||||||
|
* stamps were required must be re-signable so the customer can attach one —
|
||||||
|
* otherwise counterSign's both-stamps gate strands it forever. These specs pin
|
||||||
|
* that exception open and pin everything else shut.
|
||||||
|
*/
|
||||||
|
describe('customer re-sign to attach a missing stamp', () => {
|
||||||
|
const contractReady = { id: 'c-1', reference: 'CTR-1', status: 'CONTRACT_READY' };
|
||||||
|
const signedNoStamp = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
|
||||||
|
|
||||||
|
const build = (contract: unknown, existingSignature: unknown) => {
|
||||||
|
const applied: unknown[] = [];
|
||||||
|
const service = Object.create(
|
||||||
|
ContractTransitionService.prototype,
|
||||||
|
) as ContractTransitionService;
|
||||||
|
Object.assign(service, {
|
||||||
|
contractsService: {
|
||||||
|
findById: jest.fn().mockResolvedValue(contract),
|
||||||
|
assertCustomerCanAccessContract: jest.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
contractsRepository: {
|
||||||
|
findSignature: jest.fn().mockResolvedValue(existingSignature),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
otpService: {
|
||||||
|
verifyOtpForAction: jest.fn().mockResolvedValue(undefined),
|
||||||
|
sendOtp: jest.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
notifier: { customerSignedToStaff: jest.fn() },
|
||||||
|
resolveSignerContacts: jest.fn().mockResolvedValue({ phone: '+251900000000' }),
|
||||||
|
applySignature: jest.fn((...args: unknown[]) => {
|
||||||
|
applied.push(args);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
regenerateContractPdf: jest.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
return { service, applied };
|
||||||
|
};
|
||||||
|
|
||||||
|
const dto = {
|
||||||
|
role: 'CUSTOMER' as const,
|
||||||
|
signerDisplayName: 'C. Customer',
|
||||||
|
signatureImageBase64: 'data:image/png;base64,AAAA',
|
||||||
|
stampImageBase64: 'data:image/png;base64,BBBB',
|
||||||
|
otp: '123456',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('lets a customer sign again when their signature has no stamp', async () => {
|
||||||
|
const { service, applied } = build(signedNoStamp, {
|
||||||
|
id: 's-1',
|
||||||
|
role: 'CUSTOMER',
|
||||||
|
stampFileId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).resolves.toBeDefined();
|
||||||
|
expect(applied).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still refuses a second signature once a stamp is on file', async () => {
|
||||||
|
const { service } = build(signedNoStamp, {
|
||||||
|
id: 's-1',
|
||||||
|
role: 'CUSTOMER',
|
||||||
|
stampFileId: 'file-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stamped already → not the re-sign case, so the status guard rejects
|
||||||
|
// SIGNED_CUSTOMER before the already-signed check is reached.
|
||||||
|
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a second signature on a still-ready contract', async () => {
|
||||||
|
const { service } = build(contractReady, {
|
||||||
|
id: 's-1',
|
||||||
|
role: 'CUSTOMER',
|
||||||
|
stampFileId: 'file-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).rejects.toThrow(
|
||||||
|
/already signed/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs normally when nothing is on file yet', async () => {
|
||||||
|
const { service, applied } = build(contractReady, null);
|
||||||
|
|
||||||
|
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).resolves.toBeDefined();
|
||||||
|
expect(applied).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends a signing OTP for the stamp re-sign', async () => {
|
||||||
|
const { service } = build(signedNoStamp, {
|
||||||
|
id: 's-1',
|
||||||
|
role: 'CUSTOMER',
|
||||||
|
stampFileId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendSigningOtp('c-1', { signerUserId: 'u-1' }),
|
||||||
|
).resolves.toEqual(expect.objectContaining({ sentTo: expect.any(String) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a signing OTP once the contract is signed and stamped', async () => {
|
||||||
|
const { service } = build(signedNoStamp, {
|
||||||
|
id: 's-1',
|
||||||
|
role: 'CUSTOMER',
|
||||||
|
stampFileId: 'file-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendSigningOtp('c-1', { signerUserId: 'u-1' }),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires the OTP on the re-sign path too', async () => {
|
||||||
|
const { service } = build(signedNoStamp, {
|
||||||
|
id: 's-1',
|
||||||
|
role: 'CUSTOMER',
|
||||||
|
stampFileId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sign('c-1', { ...dto, otp: undefined }, { signerUserId: 'u-1' }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
assertCanApproveContractStep,
|
assertCanApproveContractStep,
|
||||||
assertFreightPermission,
|
assertFreightPermission,
|
||||||
canEditContractStep,
|
canEditContractStep,
|
||||||
|
HAZARDOUS_APPROVAL_ROLES,
|
||||||
} from '../../common/freight-permission.util';
|
} from '../../common/freight-permission.util';
|
||||||
import {
|
import {
|
||||||
FREIGHT_PERMS,
|
FREIGHT_PERMS,
|
||||||
@@ -243,6 +244,7 @@ export class ContractTransitionService {
|
|||||||
validityDays: number,
|
validityDays: number,
|
||||||
documentSnapshot?: ContractDocumentSnapshotInput | null,
|
documentSnapshot?: ContractDocumentSnapshotInput | null,
|
||||||
user?: TCurrentUser | null,
|
user?: TCurrentUser | null,
|
||||||
|
window?: { validFrom?: string | null; validUntil?: string | null },
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
// The route guard passes on either arm; the contract's freight type decides
|
// The route guard passes on either arm; the contract's freight type decides
|
||||||
@@ -259,11 +261,20 @@ export class ContractTransitionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.assertValidityDaysConfigured(validityDays);
|
// Staff picked an explicit window in the accept dialog — honour it verbatim
|
||||||
|
// (any start, any end). Only the legacy days-only payload is still held to
|
||||||
|
// the admin-configured period list.
|
||||||
|
const picked = window?.validFrom && window?.validUntil;
|
||||||
|
if (!picked) await this.assertValidityDaysConfigured(validityDays);
|
||||||
|
|
||||||
const validFrom = new Date();
|
const validFrom = picked ? new Date(window!.validFrom!) : new Date();
|
||||||
const validUntil = new Date(validFrom);
|
const validUntil = picked ? new Date(window!.validUntil!) : new Date(validFrom);
|
||||||
validUntil.setDate(validUntil.getDate() + validityDays);
|
if (!picked) validUntil.setDate(validUntil.getDate() + validityDays);
|
||||||
|
if (validUntil.getTime() <= validFrom.getTime()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'The contract validity end date must be after the start date.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.instantiateApprovalSteps(contract);
|
await this.instantiateApprovalSteps(contract);
|
||||||
|
|
||||||
@@ -273,6 +284,20 @@ export class ContractTransitionService {
|
|||||||
// shared six templates are never written here.
|
// shared six templates are never written here.
|
||||||
const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot);
|
const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot);
|
||||||
|
|
||||||
|
// Audit whatever staff changed in the accept dialog. The baseline is the
|
||||||
|
// template this contract would otherwise have frozen as-is, so an untouched
|
||||||
|
// accept diffs to nothing and records no revision.
|
||||||
|
if (documentSnapshot) {
|
||||||
|
const baseline = await this.resolveDocumentSnapshot(contract);
|
||||||
|
await this.documentHistory.record({
|
||||||
|
contractId,
|
||||||
|
before: baseline,
|
||||||
|
after: snapshot,
|
||||||
|
actorId,
|
||||||
|
actorRole: 'Reviewing staff',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await this.contractsRepository.update(contractId, {
|
await this.contractsRepository.update(contractId, {
|
||||||
status: 'PENDING_APPROVAL',
|
status: 'PENDING_APPROVAL',
|
||||||
approvedByStaffId: actorId,
|
approvedByStaffId: actorId,
|
||||||
@@ -530,12 +555,26 @@ export class ContractTransitionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const rule of chain) {
|
// Dangerous goods clear two dedicated hazardous desks BEFORE the commercial
|
||||||
await this.contractsRepository.createApprovalStep({
|
// chain — if either refuses, the contract never reaches the approvers who
|
||||||
contractId: contract.id,
|
// would price and sign it. Steps are renumbered sequentially so the prefix
|
||||||
stepOrder: rule.stepOrder,
|
// and the configured chain form one ordered list.
|
||||||
|
const roles: Array<{ requiredRole: string; blocksRole: string | null }> = [
|
||||||
|
...(contract.isHazardous ? [...HAZARDOUS_APPROVAL_ROLES] : []).map(
|
||||||
|
(requiredRole) => ({ requiredRole, blocksRole: null }),
|
||||||
|
),
|
||||||
|
...chain.map((rule) => ({
|
||||||
requiredRole: rule.requiredRole,
|
requiredRole: rule.requiredRole,
|
||||||
blocksRole: rule.blocksRole ?? null,
|
blocksRole: rule.blocksRole ?? null,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [index, role] of roles.entries()) {
|
||||||
|
await this.contractsRepository.createApprovalStep({
|
||||||
|
contractId: contract.id,
|
||||||
|
stepOrder: index + 1,
|
||||||
|
requiredRole: role.requiredRole,
|
||||||
|
blocksRole: role.blocksRole,
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -928,23 +967,41 @@ export class ContractTransitionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace MinIO signature URLs with inline data URIs so they render in the PDF. */
|
/**
|
||||||
|
* Replace MinIO signature/stamp URLs with inline data URIs so they render in
|
||||||
|
* the PDF — Chromium cannot fetch the private bucket.
|
||||||
|
*/
|
||||||
private async inlineSignatureImages(
|
private async inlineSignatureImages(
|
||||||
signatures: Array<{ signatureImageUrl?: string | null }>,
|
signatures: Array<{
|
||||||
|
signatureImageUrl?: string | null;
|
||||||
|
stampImageUrl?: string | null;
|
||||||
|
}>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const sig of signatures) {
|
for (const sig of signatures) {
|
||||||
if (!sig.signatureImageUrl) continue;
|
sig.signatureImageUrl = await this.inlineImageUrl(sig.signatureImageUrl);
|
||||||
try {
|
sig.stampImageUrl = await this.inlineImageUrl(sig.stampImageUrl);
|
||||||
if (sig.signatureImageUrl.startsWith('data:')) continue;
|
}
|
||||||
const objectName = this.minioService.getObjectNameFromUrl(
|
}
|
||||||
sig.signatureImageUrl,
|
|
||||||
);
|
/** MinIO URL → data URI. Returns the input unchanged if absent or on failure. */
|
||||||
const stream = await this.minioService.getFileStream(objectName);
|
private async inlineImageUrl(
|
||||||
const buffer = await this.streamToBuffer(stream);
|
url?: string | null,
|
||||||
sig.signatureImageUrl = `data:image/png;base64,${buffer.toString('base64')}`;
|
): Promise<string | null | undefined> {
|
||||||
} catch {
|
if (!url || url.startsWith('data:')) return url;
|
||||||
/* keep original url */
|
try {
|
||||||
}
|
const objectName = this.minioService.getObjectNameFromUrl(url);
|
||||||
|
const stream = await this.minioService.getFileStream(objectName);
|
||||||
|
const buffer = await this.streamToBuffer(stream);
|
||||||
|
const extension = objectName.split('.').pop()?.toLowerCase();
|
||||||
|
const mime =
|
||||||
|
extension === 'jpg' || extension === 'jpeg'
|
||||||
|
? 'image/jpeg'
|
||||||
|
: extension === 'webp'
|
||||||
|
? 'image/webp'
|
||||||
|
: 'image/png';
|
||||||
|
return `data:${mime};base64,${buffer.toString('base64')}`;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,6 +1016,46 @@ export class ContractTransitionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* base64 (data URL or raw) → image FileRecord stored on the contract under
|
||||||
|
* `code`. Drawn signatures are always PNG; an uploaded stamp may be JPEG or
|
||||||
|
* WebP, so the type is read off the data-URL prefix rather than assumed —
|
||||||
|
* the stored extension is what {@link inlineImageUrl} reads it back as.
|
||||||
|
*/
|
||||||
|
private async uploadSignatureAsset(
|
||||||
|
contract: Contract,
|
||||||
|
code: string,
|
||||||
|
imageBase64: string,
|
||||||
|
): Promise<FileRecord> {
|
||||||
|
const mimetype =
|
||||||
|
/^data:(image\/[a-z+]+);base64,/i.exec(imageBase64)?.[1]?.toLowerCase() ??
|
||||||
|
'image/png';
|
||||||
|
const extension = mimetype === 'image/jpeg' ? 'jpg' : mimetype.split('/')[1];
|
||||||
|
const raw = imageBase64.includes(',')
|
||||||
|
? imageBase64.split(',')[1]!
|
||||||
|
: imageBase64;
|
||||||
|
const buffer = Buffer.from(raw, 'base64');
|
||||||
|
const file: Express.Multer.File = {
|
||||||
|
fieldname: code,
|
||||||
|
originalname: `${code.replace(/_/g, '-')}-${contract.reference}.${extension}`,
|
||||||
|
encoding: '7bit',
|
||||||
|
mimetype,
|
||||||
|
size: buffer.length,
|
||||||
|
buffer,
|
||||||
|
stream: Readable.from(buffer),
|
||||||
|
destination: '',
|
||||||
|
filename: '',
|
||||||
|
path: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.filesService.upsertByCode({
|
||||||
|
resourceId: contract.id,
|
||||||
|
resource: 'contracts',
|
||||||
|
code,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Apply a digital signature row (mirrors booking-contract.service). */
|
/** Apply a digital signature row (mirrors booking-contract.service). */
|
||||||
private async applySignature(
|
private async applySignature(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
@@ -985,29 +1082,28 @@ export class ContractTransitionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = imageBase64.includes(',')
|
// The company stamp is a separate image from the drawn signature. Both
|
||||||
? imageBase64.split(',')[1]!
|
// parties to the contract (client + EDR) must seal it; DIRECTOR/CEO rows
|
||||||
: imageBase64;
|
// are internal approval signatures, not party seals, so they stay exempt.
|
||||||
const buffer = Buffer.from(raw, 'base64');
|
const stampRequired = role === 'CUSTOMER' || role === 'STAFF';
|
||||||
const sigFile: Express.Multer.File = {
|
if (stampRequired && !dto.stampImageBase64) {
|
||||||
fieldname: `signature_${role.toLowerCase()}`,
|
throw new BadRequestException(
|
||||||
originalname: `signature-${role.toLowerCase()}-${contract.reference}.png`,
|
'A company stamp is required to sign this contract.',
|
||||||
encoding: '7bit',
|
);
|
||||||
mimetype: 'image/png',
|
}
|
||||||
size: buffer.length,
|
|
||||||
buffer,
|
|
||||||
stream: Readable.from(buffer),
|
|
||||||
destination: '',
|
|
||||||
filename: '',
|
|
||||||
path: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const fileRecord = await this.filesService.upsertByCode({
|
const fileRecord = await this.uploadSignatureAsset(
|
||||||
resourceId: contract.id,
|
contract,
|
||||||
resource: 'contracts',
|
`signature_${role.toLowerCase()}`,
|
||||||
code: `signature_${role.toLowerCase()}`,
|
imageBase64,
|
||||||
file: sigFile,
|
);
|
||||||
});
|
const stampRecord = dto.stampImageBase64
|
||||||
|
? await this.uploadSignatureAsset(
|
||||||
|
contract,
|
||||||
|
`stamp_${role.toLowerCase()}`,
|
||||||
|
dto.stampImageBase64,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
await this.contractsRepository.saveSignature({
|
await this.contractsRepository.saveSignature({
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
@@ -1015,6 +1111,7 @@ export class ContractTransitionService {
|
|||||||
signerDisplayName,
|
signerDisplayName,
|
||||||
signedAt: new Date(),
|
signedAt: new Date(),
|
||||||
signatureFileId: fileRecord.id,
|
signatureFileId: fileRecord.id,
|
||||||
|
stampFileId: stampRecord?.id ?? null,
|
||||||
consentText: dto.consentText ?? null,
|
consentText: dto.consentText ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1053,7 +1150,17 @@ export class ContractTransitionService {
|
|||||||
options.signerUserId,
|
options.signerUserId,
|
||||||
contract,
|
contract,
|
||||||
);
|
);
|
||||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
// SIGNED_CUSTOMER is allowed only for the re-sign-to-add-a-stamp case that
|
||||||
|
// {@link sign} permits — otherwise the code would be useless on arrival.
|
||||||
|
const existing = await this.contractsRepository.findSignature(
|
||||||
|
contractId,
|
||||||
|
'CUSTOMER',
|
||||||
|
);
|
||||||
|
const addingMissingStamp = Boolean(existing) && !existing?.stampFileId;
|
||||||
|
assertContractStatus(
|
||||||
|
contract,
|
||||||
|
addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['CONTRACT_READY'],
|
||||||
|
);
|
||||||
|
|
||||||
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
|
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
|
||||||
await this.otpService.sendOtp(signerContacts);
|
await this.otpService.sendOtp(signerContacts);
|
||||||
@@ -1077,9 +1184,16 @@ export class ContractTransitionService {
|
|||||||
options.signerUserId,
|
options.signerUserId,
|
||||||
contract,
|
contract,
|
||||||
);
|
);
|
||||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
|
||||||
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
|
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
|
||||||
if (existing) {
|
// Signing is one-shot, with one exception: a contract signed before the
|
||||||
|
// company stamp was required has to be sealed before EDR can counter-sign
|
||||||
|
// it, so the customer may sign again purely to attach the missing stamp.
|
||||||
|
const addingMissingStamp = Boolean(existing) && !existing?.stampFileId;
|
||||||
|
assertContractStatus(
|
||||||
|
contract,
|
||||||
|
addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['CONTRACT_READY'],
|
||||||
|
);
|
||||||
|
if (existing && !addingMissingStamp) {
|
||||||
throw new BadRequestException('Customer has already signed this contract');
|
throw new BadRequestException('Customer has already signed this contract');
|
||||||
}
|
}
|
||||||
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
||||||
@@ -1127,6 +1241,19 @@ export class ContractTransitionService {
|
|||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
assertContractStatus(contract, ['SIGNED_CUSTOMER']);
|
assertContractStatus(contract, ['SIGNED_CUSTOMER']);
|
||||||
|
|
||||||
|
// Both parties' stamps must be on file before the contract executes. The
|
||||||
|
// EDR stamp is enforced by applySignature below; the customer's is checked
|
||||||
|
// here so a contract signed before stamps existed can't slip through.
|
||||||
|
const customerSignature = await this.contractsRepository.findSignature(
|
||||||
|
contractId,
|
||||||
|
'CUSTOMER',
|
||||||
|
);
|
||||||
|
if (!customerSignature?.stampFileId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'The customer stamp is missing on this contract — it cannot be counter-signed until the customer signs again with their company stamp.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.applySignature(contract, dto, options);
|
await this.applySignature(contract, dto, options);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -303,8 +303,10 @@ export class ContractsController {
|
|||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: UpdateContractDto,
|
@Body() dto: UpdateContractDto,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
// Recorded on the edit's audit revision — who changed the contract.
|
||||||
|
@CurrentUser() user?: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
return this.contractsService.update(id, dto, files ?? []);
|
return this.contractsService.update(id, dto, files ?? [], user?.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@@ -358,6 +360,7 @@ export class ContractsController {
|
|||||||
dto.validityDays,
|
dto.validityDays,
|
||||||
dto.documentSnapshot,
|
dto.documentSnapshot,
|
||||||
user,
|
user,
|
||||||
|
{ validFrom: dto.validFrom, validUntil: dto.validUntil },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,6 +529,8 @@ export class ContractsController {
|
|||||||
contractId: view.bookingId,
|
contractId: view.bookingId,
|
||||||
reference: view.reference,
|
reference: view.reference,
|
||||||
status: view.status,
|
status: view.status,
|
||||||
|
// Drives the per-freight-type sign permission on the client.
|
||||||
|
freightType: contract.freightType,
|
||||||
templateKey: view.templateKey,
|
templateKey: view.templateKey,
|
||||||
title: view.template.title,
|
title: view.template.title,
|
||||||
html,
|
html,
|
||||||
@@ -577,19 +582,21 @@ export class ContractsController {
|
|||||||
@Post(':id/contract/sign')
|
@Post(':id/contract/sign')
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||||||
signContract(
|
async signContract(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: SignContractDto,
|
@Body() dto: SignContractDto,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
// Each staff signing role maps to the permission that step already requires;
|
|
||||||
// customers sign their own contract with no permission key.
|
|
||||||
const signRolePermission: Record<string, string> = {
|
|
||||||
STAFF: FREIGHT_PERMS.contracts.signStaff,
|
|
||||||
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
|
||||||
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
|
||||||
};
|
|
||||||
if (dto.role !== 'CUSTOMER') {
|
if (dto.role !== 'CUSTOMER') {
|
||||||
|
// Each staff signing role maps to the permission that step already
|
||||||
|
// requires; the STAFF counter-signature is split per freight type, so a
|
||||||
|
// bulk signer cannot counter-sign a container contract (and vice versa).
|
||||||
|
const contract = await this.contractsService.findById(id);
|
||||||
|
const signRolePermission: Record<string, string> = {
|
||||||
|
STAFF: forFreightType(FREIGHT_PERMS.contracts.signStaff, contract.freightType),
|
||||||
|
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
||||||
|
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
||||||
|
};
|
||||||
assertFreightPermission(user, signRolePermission[dto.role]);
|
assertFreightPermission(user, signRolePermission[dto.role]);
|
||||||
}
|
}
|
||||||
return this.transitionService.sign(id, dto, {
|
return this.transitionService.sign(id, dto, {
|
||||||
@@ -734,6 +741,92 @@ export class ContractsController {
|
|||||||
return this.clearanceService.finalizePreClearance(id);
|
return this.clearanceService.finalizePreClearance(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 customs declaration',
|
||||||
|
})
|
||||||
|
requestTransitAssignee(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body('note') note: string | undefined,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.requestTransitAssignee(
|
||||||
|
id,
|
||||||
|
note,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/transit-assignee/assign')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns',
|
||||||
|
})
|
||||||
|
assignTransitAssignee(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body('assignee') assignee: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.assignTransitAssignee(
|
||||||
|
id,
|
||||||
|
assignee,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/clearance/documents/:fileKey/versions')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Version history of one clearance document — the customer original plus every staff replacement',
|
||||||
|
})
|
||||||
|
documentVersions(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('fileKey') fileKey: string,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.documentVersions(id, fileKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/documents/:fileKey/replace')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving',
|
||||||
|
})
|
||||||
|
replaceClearanceDocument(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('fileKey') fileKey: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@Body('reason') reason: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.replaceDocument(
|
||||||
|
id,
|
||||||
|
fileKey,
|
||||||
|
file,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
reason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/clearance/duty/dispute')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)',
|
||||||
|
})
|
||||||
|
disputeContractDuty(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body('note') note: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.clearanceService.disputeDuty(id, note, resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/clearance/duty-slip')
|
@Post(':id/clearance/duty-slip')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@@ -762,19 +855,21 @@ export class ContractsController {
|
|||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiOperation({ summary: 'GL DJ uploads Delivery Order (import)' })
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates',
|
||||||
|
})
|
||||||
uploadDeliveryOrder(
|
uploadDeliveryOrder(
|
||||||
@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: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
return this.clearanceService.uploadDeliveryOrder(
|
return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user), {
|
||||||
id,
|
vesselArrivalDate,
|
||||||
file,
|
doCollectedDate,
|
||||||
resolveAuthUserId(user),
|
});
|
||||||
vesselDepartureDate,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/clearance/release-order')
|
@Post(':id/clearance/release-order')
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { ContractTemplatesModule } from '../contract-templates/contract-template
|
|||||||
import { ContractsController } from './contracts.controller';
|
import { ContractsController } from './contracts.controller';
|
||||||
import { ContractsService } from './contracts.service';
|
import { ContractsService } from './contracts.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
|
import { ContractExpiryService } from './contract-expiry.service';
|
||||||
import { ContractPricingService } from './contract-pricing.service';
|
import { ContractPricingService } from './contract-pricing.service';
|
||||||
import { ContractNotifierService } from './contract-notifier.service';
|
import { ContractNotifierService } from './contract-notifier.service';
|
||||||
import { ContractTransitionService } from './contract-transition.service';
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
@@ -105,6 +106,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
providers: [
|
providers: [
|
||||||
ContractsService,
|
ContractsService,
|
||||||
ContractsRepository,
|
ContractsRepository,
|
||||||
|
ContractExpiryService,
|
||||||
ContractPricingService,
|
ContractPricingService,
|
||||||
ContractNotifierService,
|
ContractNotifierService,
|
||||||
ContractTransitionService,
|
ContractTransitionService,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
|
import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
|
||||||
import { ContractReviewNote, ContractReviewNoteType } from './entities/contract-review-note.entity';
|
import { ContractReviewNote, ContractReviewNoteType } from './entities/contract-review-note.entity';
|
||||||
import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity';
|
import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity';
|
||||||
|
import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util';
|
||||||
|
|
||||||
export interface ContractListFilterOptions {
|
export interface ContractListFilterOptions {
|
||||||
statuses?: string[];
|
statuses?: string[];
|
||||||
@@ -66,6 +67,81 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
return Number(row?.max ?? 0);
|
return Number(row?.max ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-terminal contracts for the same company + service type, with routes
|
||||||
|
* loaded — candidates for the duplicate-contract check on create(). Terminal
|
||||||
|
* filtering happens in JS via isEffectivelyExpired (also covers the
|
||||||
|
* date-passed-but-not-yet-cron-flipped case).
|
||||||
|
*/
|
||||||
|
async findDuplicateCandidates(
|
||||||
|
companyId: string,
|
||||||
|
serviceTypeId: string,
|
||||||
|
): Promise<Contract[]> {
|
||||||
|
return this.repository
|
||||||
|
.createQueryBuilder('contract')
|
||||||
|
.leftJoinAndSelect('contract.routes', 'routes')
|
||||||
|
.where('contract.deleted_at IS NULL')
|
||||||
|
.andWhere('contract.company_id = :companyId', { companyId })
|
||||||
|
.andWhere('contract.service_type_id = :serviceTypeId', { serviceTypeId })
|
||||||
|
.andWhere('contract.status NOT IN (:...terminal)', {
|
||||||
|
terminal: TERMINAL_CONTRACT_STATUSES,
|
||||||
|
})
|
||||||
|
// A ONE_TIME contract allows a single booking, so once that booking
|
||||||
|
// exists the contract is spent and can never carry another shipment.
|
||||||
|
// Without this it kept blocking new requests on the same service type +
|
||||||
|
// route until its validity lapsed — locking a customer out of a lane for
|
||||||
|
// the rest of the term after one completed shipment.
|
||||||
|
.andWhere(
|
||||||
|
`(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.bookings b
|
||||||
|
WHERE b.contract_id = contract.id AND b.deleted_at IS NULL
|
||||||
|
))`,
|
||||||
|
)
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nightly expiry sweep: flips lapsed contracts to EXPIRED. Returns the
|
||||||
|
* number of rows updated (for cron logging).
|
||||||
|
*/
|
||||||
|
async expireLapsedContracts(): Promise<number> {
|
||||||
|
const result = await this.repository
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update(Contract)
|
||||||
|
.set({ status: 'EXPIRED' })
|
||||||
|
.where('deleted_at IS NULL')
|
||||||
|
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
||||||
|
.andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', {
|
||||||
|
now: new Date(),
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
return result.affected ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live contracts whose validity ends between `days` and `days + 1` days from
|
||||||
|
* now — the slice the daily expiry-reminder cron warns about. The window is
|
||||||
|
* rolling and exactly 24h wide, so consecutive daily runs tile it without
|
||||||
|
* gaps or overlaps: each contract is picked up by exactly one run and the
|
||||||
|
* customer is notified once, with no "already reminded" flag to store.
|
||||||
|
*/
|
||||||
|
async findExpiringInDays(days: number): Promise<Contract[]> {
|
||||||
|
const now = Date.now();
|
||||||
|
return this.repository
|
||||||
|
.createQueryBuilder('contract')
|
||||||
|
.where('contract.deleted_at IS NULL')
|
||||||
|
.andWhere('contract.status NOT IN (:...terminal)', {
|
||||||
|
terminal: TERMINAL_CONTRACT_STATUSES,
|
||||||
|
})
|
||||||
|
.andWhere('contract.contract_valid_until >= :from', {
|
||||||
|
from: new Date(now + days * 86_400_000),
|
||||||
|
})
|
||||||
|
.andWhere('contract.contract_valid_until < :to', {
|
||||||
|
to: new Date(now + (days + 1) * 86_400_000),
|
||||||
|
})
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
/** Find a contract by ID with all child collections, service type, company and files. */
|
/** Find a contract by ID with all child collections, service type, company and files. */
|
||||||
async findByIdWithRelations(id: string): Promise<Contract | null> {
|
async findByIdWithRelations(id: string): Promise<Contract | null> {
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
@@ -88,7 +164,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
'contract.files',
|
'contract.files',
|
||||||
FileRecord,
|
FileRecord,
|
||||||
'file',
|
'file',
|
||||||
"file.resource_id = contract.id AND file.resource = 'contracts'",
|
// 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 = contract.id AND file.resource = 'contracts' AND file.deleted_at IS NULL",
|
||||||
)
|
)
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
@@ -434,7 +512,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
findSignatures(contractId: string): Promise<ContractSignature[]> {
|
findSignatures(contractId: string): Promise<ContractSignature[]> {
|
||||||
return this.dataSource.getRepository(ContractSignature).find({
|
return this.dataSource.getRepository(ContractSignature).find({
|
||||||
where: { contractId },
|
where: { contractId },
|
||||||
relations: ['signatureFile'],
|
relations: ['signatureFile', 'stampFile'],
|
||||||
order: { signedAt: 'ASC' },
|
order: { signedAt: 'ASC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -445,7 +523,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
): Promise<ContractSignature | null> {
|
): Promise<ContractSignature | null> {
|
||||||
return this.dataSource.getRepository(ContractSignature).findOne({
|
return this.dataSource.getRepository(ContractSignature).findOne({
|
||||||
where: { contractId, role },
|
where: { contractId, role },
|
||||||
relations: ['signatureFile'],
|
relations: ['signatureFile', 'stampFile'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,6 +560,17 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Review notes of one type, newest first — the duty advice/dispute rounds. */
|
||||||
|
async findReviewNotes(
|
||||||
|
contractId: string,
|
||||||
|
noteType: ContractReviewNoteType,
|
||||||
|
): Promise<ContractReviewNote[]> {
|
||||||
|
return this.dataSource.getRepository(ContractReviewNote).find({
|
||||||
|
where: { contractId, noteType },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async findLatestReviewNote(
|
async findLatestReviewNote(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
noteType?: ContractReviewNoteType,
|
noteType?: ContractReviewNoteType,
|
||||||
@@ -649,12 +738,20 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
ContractClearanceCycle,
|
ContractClearanceCycle,
|
||||||
| 'dutyRequired'
|
| 'dutyRequired'
|
||||||
| 'vesselDepartureDate'
|
| 'vesselDepartureDate'
|
||||||
|
| 'vesselArrivalDate'
|
||||||
|
| 'doCollectedDate'
|
||||||
| 'roAmendmentRequestedAt'
|
| 'roAmendmentRequestedAt'
|
||||||
| 'roHoldReason'
|
| 'roHoldReason'
|
||||||
| 'currentPhase'
|
| 'currentPhase'
|
||||||
| 'status'
|
| 'status'
|
||||||
| 'preClearanceFinalizedAt'
|
| 'preClearanceFinalizedAt'
|
||||||
| 'completedAt'
|
| 'completedAt'
|
||||||
|
| 'transitAssigneeRequestedAt'
|
||||||
|
| 'transitAssigneeRequestedByUserId'
|
||||||
|
| 'transitAssigneeRequestNote'
|
||||||
|
| 'transitAssigneeName'
|
||||||
|
| 'transitAssigneeAssignedAt'
|
||||||
|
| 'transitAssigneeAssignedByUserId'
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -24,6 +25,9 @@ import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
|
|||||||
import { Contract, CONTRACT_STATUSES, CONTRACT_CUSTOMER_EDITABLE_STATUSES } from './entities/contract.entity';
|
import { Contract, CONTRACT_STATUSES, CONTRACT_CUSTOMER_EDITABLE_STATUSES } from './entities/contract.entity';
|
||||||
import { ContractRoute } from './entities/contract-route.entity';
|
import { ContractRoute } from './entities/contract-route.entity';
|
||||||
import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
|
import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
|
||||||
|
import { isEffectivelyExpired } from './utils/contract-expiry.util';
|
||||||
|
import { diffContractFields } from './contract-document-diff.util';
|
||||||
|
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
|
|
||||||
/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */
|
/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */
|
||||||
@@ -40,6 +44,35 @@ export interface PaginatedContracts {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Route list as a readable lane string, e.g. "Nagad → Mojo, Mojo → Adama". */
|
||||||
|
function describeRoutes(routes?: ContractRoute[]): string | null {
|
||||||
|
if (!routes?.length) return null;
|
||||||
|
return [...routes]
|
||||||
|
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||||
|
.map(
|
||||||
|
(r) =>
|
||||||
|
`${r.originYard?.label ?? r.originYardId} → ${r.destinationYard?.label ?? r.destinationYardId}`,
|
||||||
|
)
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cargo scope as a readable string, e.g. "20ft ×2, 40ft ×1" or "Wheat ×500". */
|
||||||
|
function describeCargoScope(scope?: ContractCargoScope[]): string | null {
|
||||||
|
if (!scope?.length) return null;
|
||||||
|
return scope
|
||||||
|
.map((row) => {
|
||||||
|
const label =
|
||||||
|
row.containerSize ??
|
||||||
|
row.cargoType?.cargoTypeName ??
|
||||||
|
row.cargoFreeText ??
|
||||||
|
row.cargoTypeId ??
|
||||||
|
'cargo';
|
||||||
|
return row.quantityCap != null ? `${label} ×${row.quantityCap}` : String(label);
|
||||||
|
})
|
||||||
|
.sort()
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
const NEEDS_ACTION_STATUSES = [
|
const NEEDS_ACTION_STATUSES = [
|
||||||
'SUBMITTED',
|
'SUBMITTED',
|
||||||
'PENDING_APPROVAL',
|
'PENDING_APPROVAL',
|
||||||
@@ -55,6 +88,7 @@ export class ContractsService {
|
|||||||
private readonly companiesService: CompaniesService,
|
private readonly companiesService: CompaniesService,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly minioService: MinioService,
|
private readonly minioService: MinioService,
|
||||||
|
private readonly documentHistory: ContractDocumentHistoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
|
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
|
||||||
@@ -155,6 +189,42 @@ export class ContractsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same customer + same service type + an overlapping route already has a
|
||||||
|
* non-expired contract → block. A route "overlaps" if any origin/destination
|
||||||
|
* pair matches — good enough today since ONE_TIME and GENERAL contracts both
|
||||||
|
* carry a single route in practice, and still correct if that changes.
|
||||||
|
*/
|
||||||
|
private async assertNoDuplicateContract(
|
||||||
|
companyId: string,
|
||||||
|
serviceTypeId: string,
|
||||||
|
routes: CreateContractDto['routes'],
|
||||||
|
): Promise<void> {
|
||||||
|
const candidates = await this.contractsRepository.findDuplicateCandidates(
|
||||||
|
companyId,
|
||||||
|
serviceTypeId,
|
||||||
|
);
|
||||||
|
const duplicate = candidates.find(
|
||||||
|
(c) =>
|
||||||
|
!isEffectivelyExpired(c) &&
|
||||||
|
(c.routes ?? []).some((existingRoute) =>
|
||||||
|
routes.some(
|
||||||
|
(r) =>
|
||||||
|
r.originYardId === existingRoute.originYardId &&
|
||||||
|
r.destinationYardId === existingRoute.destinationYardId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (duplicate) {
|
||||||
|
const until = duplicate.contractValidUntil
|
||||||
|
? duplicate.contractValidUntil.toISOString().slice(0, 10)
|
||||||
|
: 'its approval completes';
|
||||||
|
throw new ConflictException(
|
||||||
|
`An active contract already exists for this service type and route (${duplicate.reference}, valid until ${until}). A new request can't be submitted until it expires or is rejected/cancelled.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
|
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
|
||||||
async create(
|
async create(
|
||||||
dto: CreateContractDto,
|
dto: CreateContractDto,
|
||||||
@@ -186,6 +256,9 @@ export class ContractsService {
|
|||||||
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
|
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
|
||||||
this.assertRouteShape(dto.contractKind, dto.routes);
|
this.assertRouteShape(dto.contractKind, dto.routes);
|
||||||
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
|
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
|
||||||
|
if (companyId) {
|
||||||
|
await this.assertNoDuplicateContract(companyId, dto.serviceTypeId, dto.routes);
|
||||||
|
}
|
||||||
|
|
||||||
// Stamp the operational profile for portal scoping. A forwarder contract
|
// Stamp the operational profile for portal scoping. A forwarder contract
|
||||||
// pins its profile explicitly (trade direction can't tell it apart from a
|
// pins its profile explicitly (trade direction can't tell it apart from a
|
||||||
@@ -291,7 +364,11 @@ export class ContractsService {
|
|||||||
tradeDirection: dto.tradeDirection,
|
tradeDirection: dto.tradeDirection,
|
||||||
freightType: dto.freightType,
|
freightType: dto.freightType,
|
||||||
serviceTypeId: dto.serviceTypeId,
|
serviceTypeId: dto.serviceTypeId,
|
||||||
paymentCurrency: dto.paymentCurrency,
|
// A contract is always QUOTED in USD — the billing currency is chosen per
|
||||||
|
// booking (or on the shipment request when GL books for the customer), so
|
||||||
|
// any client-supplied currency here is ignored. Contracts created before
|
||||||
|
// this rule keep whatever they stored; update() never rewrites it.
|
||||||
|
paymentCurrency: 'USD',
|
||||||
customsClearingEnabled: includesCustoms,
|
customsClearingEnabled: includesCustoms,
|
||||||
customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
|
customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
|
||||||
equipmentReturn: dto.equipmentReturn ?? null,
|
equipmentReturn: dto.equipmentReturn ?? null,
|
||||||
@@ -302,6 +379,10 @@ export class ContractsService {
|
|||||||
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
|
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
|
||||||
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
|
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
|
||||||
isHazardous: dto.isHazardous ?? false,
|
isHazardous: dto.isHazardous ?? false,
|
||||||
|
// Hazard class / UN number only exist on a hazardous contract — a stale
|
||||||
|
// pair from an earlier draft must never survive the flag being turned off.
|
||||||
|
hazardClass: dto.isHazardous ? (dto.hazardClass ?? null) : null,
|
||||||
|
unNumber: dto.isHazardous ? (dto.unNumber ?? null) : null,
|
||||||
isReefer: dto.isReefer ?? false,
|
isReefer: dto.isReefer ?? false,
|
||||||
contractType: dto.contractType ?? null,
|
contractType: dto.contractType ?? null,
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
@@ -445,6 +526,7 @@ export class ContractsService {
|
|||||||
id: string,
|
id: string,
|
||||||
dto: UpdateContractDto,
|
dto: UpdateContractDto,
|
||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
|
actorId?: string,
|
||||||
): Promise<{ contract: Contract; warnings: string[] }> {
|
): Promise<{ contract: Contract; warnings: string[] }> {
|
||||||
const existing = await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
if (!CONTRACT_CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
|
if (!CONTRACT_CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
|
||||||
@@ -471,9 +553,18 @@ export class ContractsService {
|
|||||||
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
|
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
|
||||||
freightType,
|
freightType,
|
||||||
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
|
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
|
||||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
// Never rewritten: grandfathered contracts keep the currency (and frozen
|
||||||
|
// snapshots) they were signed with.
|
||||||
|
paymentCurrency: existing.paymentCurrency,
|
||||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||||
isReefer: dto.isReefer ?? existing.isReefer,
|
isReefer: dto.isReefer ?? existing.isReefer,
|
||||||
|
// Same rule as create: clearing the flag clears the declaration with it.
|
||||||
|
hazardClass: (dto.isHazardous ?? existing.isHazardous)
|
||||||
|
? (dto.hazardClass ?? existing.hazardClass ?? null)
|
||||||
|
: null,
|
||||||
|
unNumber: (dto.isHazardous ?? existing.isHazardous)
|
||||||
|
? (dto.unNumber ?? existing.unNumber ?? null)
|
||||||
|
: null,
|
||||||
equipmentReturn: dto.equipmentReturn ?? existing.equipmentReturn,
|
equipmentReturn: dto.equipmentReturn ?? existing.equipmentReturn,
|
||||||
firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress,
|
firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress,
|
||||||
firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat,
|
firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat,
|
||||||
@@ -524,7 +615,52 @@ export class ContractsService {
|
|||||||
existing.companyProfileId ?? null,
|
existing.companyProfileId ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { contract: await this.findById(id), warnings };
|
const updated = await this.findById(id);
|
||||||
|
// Audit what this edit actually changed. Runs after the writes so the
|
||||||
|
// "after" side is read back from the contract rather than from the DTO.
|
||||||
|
await this.recordFieldRevision(existing, updated, actorId);
|
||||||
|
|
||||||
|
return { contract: updated, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields worth auditing on a customer edit, read off a loaded contract. */
|
||||||
|
private auditableFields(contract: Contract): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
contractKind: contract.contractKind,
|
||||||
|
tradeDirection: contract.tradeDirection,
|
||||||
|
freightType: contract.freightType,
|
||||||
|
serviceType: contract.serviceType?.serviceName ?? contract.serviceTypeId,
|
||||||
|
paymentCurrency: contract.paymentCurrency,
|
||||||
|
contractType: contract.contractType,
|
||||||
|
isHazardous: contract.isHazardous,
|
||||||
|
hazardClass: contract.hazardClass,
|
||||||
|
unNumber: contract.unNumber,
|
||||||
|
isReefer: contract.isReefer,
|
||||||
|
equipmentReturn: contract.equipmentReturn,
|
||||||
|
customsClearingAgent: contract.customsClearingAgent,
|
||||||
|
firstMilePickupAddress: contract.firstMilePickupAddress,
|
||||||
|
lastMileDeliveryAddress: contract.lastMileDeliveryAddress,
|
||||||
|
routes: describeRoutes(contract.routes),
|
||||||
|
cargoScope: describeCargoScope(contract.cargoScope),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append a revision describing a customer's edit to the contract itself. */
|
||||||
|
private async recordFieldRevision(
|
||||||
|
before: Contract,
|
||||||
|
after: Contract,
|
||||||
|
actorId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const changes = diffContractFields(
|
||||||
|
this.auditableFields(before),
|
||||||
|
this.auditableFields(after),
|
||||||
|
);
|
||||||
|
await this.documentHistory.recordChanges({
|
||||||
|
contractId: after.id,
|
||||||
|
changes,
|
||||||
|
actorId: actorId ?? null,
|
||||||
|
actorRole: 'Customer',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse comma-separated or repeated status query values. */
|
/** Parse comma-separated or repeated status query values. */
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { assertDoCollectionDates } from './contract-clearance.util';
|
||||||
|
|
||||||
|
describe('assertDoCollectionDates', () => {
|
||||||
|
it('requires both dates', () => {
|
||||||
|
expect(() => assertDoCollectionDates(undefined)).toThrow(BadRequestException);
|
||||||
|
expect(() =>
|
||||||
|
assertDoCollectionDates({ vesselArrivalDate: '2026-07-01' }),
|
||||||
|
).toThrow(/DO collected date is required/);
|
||||||
|
expect(() =>
|
||||||
|
assertDoCollectionDates({ doCollectedDate: '2026-07-01' }),
|
||||||
|
).toThrow(/Vessel arrival date is required/);
|
||||||
|
// Whitespace is not a date.
|
||||||
|
expect(() =>
|
||||||
|
assertDoCollectionDates({ vesselArrivalDate: ' ', doCollectedDate: ' ' }),
|
||||||
|
).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a DO collected before the vessel arrived', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertDoCollectionDates({
|
||||||
|
vesselArrivalDate: '2026-07-10',
|
||||||
|
doCollectedDate: '2026-07-09',
|
||||||
|
}),
|
||||||
|
).toThrow(/cannot be earlier than the vessel arrival date/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes an ISO datetime down to its date part', () => {
|
||||||
|
expect(
|
||||||
|
assertDoCollectionDates({
|
||||||
|
vesselArrivalDate: '2026-07-10T21:00:00.000Z',
|
||||||
|
doCollectedDate: '2026-07-10T05:00:00.000Z',
|
||||||
|
}),
|
||||||
|
).toEqual({ vesselArrivalDate: '2026-07-10', doCollectedDate: '2026-07-10' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed date', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertDoCollectionDates({
|
||||||
|
vesselArrivalDate: '10/07/2026',
|
||||||
|
doCollectedDate: '2026-07-10',
|
||||||
|
}),
|
||||||
|
).toThrow(/not a valid date/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsInt, IsOptional, Max, Min, ValidateNested } from 'class-validator';
|
import {
|
||||||
|
IsDateString,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
import { UpdateContractDocumentDto } from './contract-document.dto';
|
import { UpdateContractDocumentDto } from './contract-document.dto';
|
||||||
|
|
||||||
@@ -18,6 +25,21 @@ export class AcceptContractDto {
|
|||||||
@Max(3650)
|
@Max(3650)
|
||||||
validityDays!: number;
|
validityDays!: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit validity window picked by staff in the accept dialog. When both are
|
||||||
|
* present they win over `validityDays` (which is then only the derived span)
|
||||||
|
* and the configured-period check is skipped — staff may enter any range.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({ description: 'Validity start (ISO date)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
validFrom?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Validity end (ISO date)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
validUntil?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional per-contract document override edited by staff in the accept
|
* Optional per-contract document override edited by staff in the accept
|
||||||
* dialog. When present its articles are frozen onto THIS contract; when
|
* dialog. When present its articles are frozen onto THIS contract; when
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
import { Transform, Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
@@ -91,6 +92,15 @@ export class CreateBookingRequestDto {
|
|||||||
@Type(() => RequestBulkLineDto)
|
@Type(() => RequestBulkLineDto)
|
||||||
bulk?: RequestBulkLineDto;
|
bulk?: RequestBulkLineDto;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ['ETB', 'USD'],
|
||||||
|
description:
|
||||||
|
'Billing currency for the shipment GL will book. Intercity is always ETB.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['ETB', 'USD'])
|
||||||
|
paymentCurrency?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
|
import { PAYMENT_CURRENCIES } from './create-contract.dto';
|
||||||
|
|
||||||
/** Per-shipment equipment return — "NA" stays contract-level only. */
|
/** Per-shipment equipment return — "NA" stays contract-level only. */
|
||||||
const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
|
const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
|
||||||
|
|
||||||
@@ -150,6 +152,20 @@ export class CreateBookingUnderContractDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
contractRouteId?: string;
|
contractRouteId?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The contract quotes in USD; the customer picks the billing currency here.
|
||||||
|
* Omitted → the contract's own currency (USD for contracts created under the
|
||||||
|
* current rule, the grandfathered currency for older ones). Intercity is
|
||||||
|
* forced to ETB by the service regardless of what is sent.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: PAYMENT_CURRENCIES,
|
||||||
|
description: 'Billing currency for this shipment. Intercity is always ETB.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...PAYMENT_CURRENCIES])
|
||||||
|
paymentCurrency?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.',
|
'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.',
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
|
import { HAZARD_CLASS_VALUES } from '@edr/types';
|
||||||
|
|
||||||
import { CONTRACT_KINDS } from '../entities/contract.entity';
|
import { CONTRACT_KINDS } from '../entities/contract.entity';
|
||||||
|
|
||||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||||
@@ -156,9 +158,20 @@ export class CreateContractDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
serviceTypeId!: string;
|
serviceTypeId!: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: PAYMENT_CURRENCIES })
|
/**
|
||||||
|
* Deprecated at the contract level. A contract now always quotes in USD; the
|
||||||
|
* customer picks the billing currency per booking (or on the shipment request
|
||||||
|
* when GL books on their behalf). Accepted but ignored on create so older
|
||||||
|
* clients don't break — the service forces USD.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: PAYMENT_CURRENCIES,
|
||||||
|
deprecated: true,
|
||||||
|
description: 'Ignored — contracts always quote in USD. Choose currency at booking.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
@IsIn([...PAYMENT_CURRENCIES])
|
@IsIn([...PAYMENT_CURRENCIES])
|
||||||
paymentCurrency!: string;
|
paymentCurrency?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Whether EDR/GL handles customs clearance' })
|
@ApiPropertyOptional({ description: 'Whether EDR/GL handles customs clearance' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -228,6 +241,28 @@ export class CreateContractDto {
|
|||||||
@Transform(({ value }) => value === 'true' || value === true)
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
isHazardous?: boolean;
|
isHazardous?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: HAZARD_CLASS_VALUES,
|
||||||
|
description: 'UN/ADR dangerous-goods class. Required when isHazardous.',
|
||||||
|
})
|
||||||
|
@ValidateIf((o: CreateContractDto) => o.isHazardous === true)
|
||||||
|
@IsIn(HAZARD_CLASS_VALUES, {
|
||||||
|
message: `hazardClass must be one of: ${HAZARD_CLASS_VALUES.join(', ')}`,
|
||||||
|
})
|
||||||
|
hazardClass?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'UN number of the dangerous good. Required when isHazardous.',
|
||||||
|
})
|
||||||
|
@ValidateIf((o: CreateContractDto) => o.isHazardous === true)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(16)
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' ? value.trim().toUpperCase() : value,
|
||||||
|
)
|
||||||
|
unNumber?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: false, description: 'Sets contracts.is_reefer' })
|
@ApiPropertyOptional({ default: false, description: 'Sets contracts.is_reefer' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ export class SignContractDto {
|
|||||||
@MinLength(20)
|
@MinLength(20)
|
||||||
signatureImageBase64?: string;
|
signatureImageBase64?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'PNG company stamp/seal image as base64 (with or without data URL prefix). ' +
|
||||||
|
'Required for the CUSTOMER and STAFF roles — both parties must seal the ' +
|
||||||
|
'contract before it is fully executed.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(20)
|
||||||
|
stampImageBase64?: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ export class BookingRequest extends BaseEntity {
|
|||||||
@Column({ name: 'requested_lines', type: 'jsonb', default: () => "'{}'::jsonb" })
|
@Column({ name: 'requested_lines', type: 'jsonb', default: () => "'{}'::jsonb" })
|
||||||
requestedLines!: Freight.RequestedShipmentLines;
|
requestedLines!: Freight.RequestedShipmentLines;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Billing currency the customer chose for this shipment. The contract quotes
|
||||||
|
* in USD; on a customs contract GL creates the booking, so this is where the
|
||||||
|
* customer states which currency to be invoiced in.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'payment_currency', type: 'varchar', length: 5, nullable: true })
|
||||||
|
paymentCurrency?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,41 @@ export class ContractClearanceCycle 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transit-assignee handshake that runs BEFORE the customs declaration: GL
|
||||||
|
* Ethiopia asks Djibouti for the officer who will handle the shipment in
|
||||||
|
* transit, and Djibouti answers with a name. The declaration step stays shut
|
||||||
|
* until `transitAssigneeName` is set; Djibouti may overwrite it later
|
||||||
|
* (reassignment) and the newer name simply wins.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'transit_assignee_requested_at', type: 'timestamptz', nullable: true })
|
||||||
|
transitAssigneeRequestedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'transit_assignee_requested_by_user_id', type: 'uuid', nullable: true })
|
||||||
|
transitAssigneeRequestedByUserId?: string | null;
|
||||||
|
|
||||||
|
/** What GL Ethiopia asked for — shown on the Djibouti queue. */
|
||||||
|
@Column({ name: 'transit_assignee_request_note', type: 'text', nullable: true })
|
||||||
|
transitAssigneeRequestNote?: string | null;
|
||||||
|
|
||||||
|
/** The officer Djibouti named — free text, no user directory to bind to. */
|
||||||
|
@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;
|
||||||
|
|
||||||
|
@Column({ name: 'transit_assignee_assigned_by_user_id', type: 'uuid', nullable: true })
|
||||||
|
transitAssigneeAssignedByUserId?: 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;
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ export class ContractDocumentRevision extends BaseEntity {
|
|||||||
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
|
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
|
||||||
actorId?: string | null;
|
actorId?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who made the edit, captured at the time. Denormalised so the trail still
|
||||||
|
* names them after a rename or a deactivated account.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'actor_name', type: 'varchar', length: 200, nullable: true })
|
||||||
|
actorName?: string | null;
|
||||||
|
|
||||||
/** The approval step's required role at the time of the edit. */
|
/** The approval step's required role at the time of the edit. */
|
||||||
@Column({ name: 'actor_role', type: 'varchar', length: 64, nullable: true })
|
@Column({ name: 'actor_role', type: 'varchar', length: 64, nullable: true })
|
||||||
actorRole?: string | null;
|
actorRole?: string | null;
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [
|
|||||||
'STAFF_NOTE',
|
'STAFF_NOTE',
|
||||||
'CUSTOMER_NOTE',
|
'CUSTOMER_NOTE',
|
||||||
'AMENDMENT',
|
'AMENDMENT',
|
||||||
|
/**
|
||||||
|
* The customer disputed the advised duty & tax and asked GL Ethiopia to
|
||||||
|
* correct it. One row per round — the advice/dispute loop can repeat.
|
||||||
|
*/
|
||||||
|
'DUTY_DISPUTE',
|
||||||
] as const;
|
] as const;
|
||||||
export type ContractReviewNoteType =
|
export type ContractReviewNoteType =
|
||||||
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];
|
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ export class ContractSignature extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'signature_file_id' })
|
@JoinColumn({ name: 'signature_file_id' })
|
||||||
signatureFile?: FileRecord | null;
|
signatureFile?: FileRecord | null;
|
||||||
|
|
||||||
|
/** Company stamp/seal image, uploaded alongside the drawn signature. */
|
||||||
|
@Column({ name: 'stamp_file_id', type: 'uuid', nullable: true })
|
||||||
|
stampFileId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => FileRecord, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'stamp_file_id' })
|
||||||
|
stampFile?: FileRecord | null;
|
||||||
|
|
||||||
@Column({ name: 'consent_text', type: 'text', nullable: true })
|
@Column({ name: 'consent_text', type: 'text', nullable: true })
|
||||||
consentText?: string | null;
|
consentText?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,14 @@ export class Contract extends BaseEntity {
|
|||||||
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||||
isHazardous!: boolean;
|
isHazardous!: boolean;
|
||||||
|
|
||||||
|
/** UN/ADR dangerous-goods class (CLASS_1..CLASS_9); null unless hazardous. */
|
||||||
|
@Column({ name: 'hazard_class', type: 'varchar', length: 16, nullable: true })
|
||||||
|
hazardClass?: string | null;
|
||||||
|
|
||||||
|
/** UN number of the dangerous good; null unless hazardous. */
|
||||||
|
@Column({ name: 'un_number', type: 'varchar', length: 16, nullable: true })
|
||||||
|
unNumber?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||||
isReefer!: boolean;
|
isReefer!: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { ContractBookingService } from './contract-booking.service';
|
||||||
|
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||||
|
import type { Contract } from './entities/contract.entity';
|
||||||
|
import type { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
|
||||||
|
|
||||||
|
const contract = (over: Partial<Contract>): Contract =>
|
||||||
|
({ tradeDirection: 'IMPORT', paymentCurrency: 'USD', ...over }) as Contract;
|
||||||
|
|
||||||
|
/** The private resolver, reached without standing up the whole Nest graph. */
|
||||||
|
const resolveCurrency = (c: Contract, requested?: string | null): string =>
|
||||||
|
(
|
||||||
|
ContractBookingService.prototype as unknown as {
|
||||||
|
resolveShipmentCurrency: (c: Contract, r?: string | null) => string;
|
||||||
|
}
|
||||||
|
).resolveShipmentCurrency(c, requested);
|
||||||
|
|
||||||
|
const snapshot = (currency: string, unitPrice: number): ContractRateSnapshot =>
|
||||||
|
({ rateCode: 'CONTAINER_20FT', currency, unitPrice }) as ContractRateSnapshot;
|
||||||
|
|
||||||
|
const frozenByCode = (
|
||||||
|
snap: ContractRateSnapshot | null,
|
||||||
|
bookingCurrency: string,
|
||||||
|
usdToEtb: number,
|
||||||
|
): ContractRateSnapshot | null =>
|
||||||
|
(
|
||||||
|
BookingPricingService.prototype as unknown as {
|
||||||
|
frozenRateByCode: (
|
||||||
|
m: Map<string, ContractRateSnapshot> | null,
|
||||||
|
code: string,
|
||||||
|
bookingCurrency: string,
|
||||||
|
usdToEtb: number,
|
||||||
|
) => ContractRateSnapshot | null;
|
||||||
|
}
|
||||||
|
).frozenRateByCode(
|
||||||
|
snap ? new Map([['CONTAINER_20FT', snap]]) : null,
|
||||||
|
'CONTAINER_20FT',
|
||||||
|
bookingCurrency,
|
||||||
|
usdToEtb,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('per-shipment billing currency', () => {
|
||||||
|
it('takes the customer choice over the contract', () => {
|
||||||
|
expect(resolveCurrency(contract({}), 'ETB')).toBe('ETB');
|
||||||
|
expect(resolveCurrency(contract({}), 'USD')).toBe('USD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the contract currency when none is chosen', () => {
|
||||||
|
// Grandfathered ETB contract with no explicit choice.
|
||||||
|
expect(resolveCurrency(contract({ paymentCurrency: 'ETB' }))).toBe('ETB');
|
||||||
|
expect(resolveCurrency(contract({}), ' ')).toBe('USD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces ETB on intercity whatever was requested', () => {
|
||||||
|
const domestic = contract({ tradeDirection: 'DOMESTIC' });
|
||||||
|
expect(resolveCurrency(domestic, 'USD')).toBe('ETB');
|
||||||
|
expect(resolveCurrency(domestic)).toBe('ETB');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('frozen contract rate in the booking currency', () => {
|
||||||
|
it('converts a USD snapshot for an ETB booking instead of dropping it', () => {
|
||||||
|
// The old behaviour returned null here, which silently re-priced the
|
||||||
|
// booking at live rates and lost the agreed contract price.
|
||||||
|
expect(frozenByCode(snapshot('USD', 400), 'ETB', 150)?.unitPrice).toBe(60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts a grandfathered ETB snapshot back for a USD booking', () => {
|
||||||
|
expect(frozenByCode(snapshot('ETB', 60_000), 'USD', 150)?.unitPrice).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a matching-currency snapshot through untouched', () => {
|
||||||
|
const snap = snapshot('USD', 400);
|
||||||
|
expect(frozenByCode(snap, 'USD', 1)).toBe(snap);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to price off an unusable exchange rate', () => {
|
||||||
|
// Converting with 0 would zero the whole line.
|
||||||
|
expect(frozenByCode(snapshot('USD', 400), 'ETB', 0)).toBeNull();
|
||||||
|
expect(frozenByCode(snapshot('USD', 400), 'ETB', Number.NaN)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when there is no snapshot', () => {
|
||||||
|
expect(frozenByCode(null, 'ETB', 150)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ContractClearanceService } from './contract-clearance.service';
|
||||||
|
import type { Contract } from './entities/contract.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-declaration transit-assignee handshake. GL Ethiopia asks Djibouti who will
|
||||||
|
* handle the shipment in transit; Djibouti answers with a name. The customs
|
||||||
|
* declaration stays shut until that name exists, and Djibouti may send a
|
||||||
|
* different one later.
|
||||||
|
*/
|
||||||
|
describe('ContractClearanceService — transit assignee', () => {
|
||||||
|
const contract = (over: Partial<Contract> = {}): Contract =>
|
||||||
|
({
|
||||||
|
id: 'ctr-1',
|
||||||
|
reference: 'CTR-2026-00042',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
customsClearingEnabled: true,
|
||||||
|
contractKind: 'ONE_TIME',
|
||||||
|
...over,
|
||||||
|
}) as Contract;
|
||||||
|
|
||||||
|
let repo: { currentCycle: jest.Mock; updateCycle: jest.Mock };
|
||||||
|
let contractsService: { findById: jest.Mock };
|
||||||
|
let notifier: {
|
||||||
|
transitAssigneeRequested: jest.Mock;
|
||||||
|
transitAssigneeAssigned: jest.Mock;
|
||||||
|
};
|
||||||
|
let service: ContractClearanceService;
|
||||||
|
|
||||||
|
const cycle = (over: Record<string, unknown> = {}) => ({
|
||||||
|
id: 'cyc-1',
|
||||||
|
transitAssigneeRequestedAt: null,
|
||||||
|
transitAssigneeName: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = {
|
||||||
|
currentCycle: jest.fn().mockResolvedValue(cycle()),
|
||||||
|
updateCycle: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
contractsService = { findById: jest.fn().mockResolvedValue(contract()) };
|
||||||
|
notifier = {
|
||||||
|
transitAssigneeRequested: jest.fn(),
|
||||||
|
transitAssigneeAssigned: jest.fn(),
|
||||||
|
};
|
||||||
|
service = new ContractClearanceService(
|
||||||
|
repo as never,
|
||||||
|
contractsService as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
notifier as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('request (GL Ethiopia)', () => {
|
||||||
|
it('stamps the ask and pings Djibouti', async () => {
|
||||||
|
await service.requestTransitAssignee('ctr-1', ' Reefer, needs a cold-chain officer ', 'et-1');
|
||||||
|
|
||||||
|
const patch = repo.updateCycle.mock.calls[0][1];
|
||||||
|
expect(patch.transitAssigneeRequestedAt).toBeInstanceOf(Date);
|
||||||
|
expect(patch.transitAssigneeRequestedByUserId).toBe('et-1');
|
||||||
|
expect(patch.transitAssigneeRequestNote).toBe(
|
||||||
|
'Reefer, needs a cold-chain officer',
|
||||||
|
);
|
||||||
|
expect(notifier.transitAssigneeRequested).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assign (GL Djibouti)', () => {
|
||||||
|
it('records the officer and tells Ethiopia they can proceed', async () => {
|
||||||
|
repo.currentCycle.mockResolvedValue(
|
||||||
|
cycle({ transitAssigneeRequestedAt: new Date() }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.assignTransitAssignee('ctr-1', ' Ahmed Bourhan ', 'dj-1');
|
||||||
|
|
||||||
|
const patch = repo.updateCycle.mock.calls[0][1];
|
||||||
|
expect(patch.transitAssigneeName).toBe('Ahmed Bourhan');
|
||||||
|
expect(patch.transitAssigneeAssignedByUserId).toBe('dj-1');
|
||||||
|
expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: 'ctr-1' }),
|
||||||
|
'Ahmed Bourhan',
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reassigns, carrying the previous name into the notice', async () => {
|
||||||
|
repo.currentCycle.mockResolvedValue(
|
||||||
|
cycle({
|
||||||
|
transitAssigneeRequestedAt: new Date(),
|
||||||
|
transitAssigneeName: 'Ahmed Bourhan',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.assignTransitAssignee('ctr-1', 'Fatouma Ali', 'dj-1');
|
||||||
|
|
||||||
|
expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
'Fatouma Ali',
|
||||||
|
'Ahmed Bourhan',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an empty name', async () => {
|
||||||
|
repo.currentCycle.mockResolvedValue(
|
||||||
|
cycle({ transitAssigneeRequestedAt: new Date() }),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.assignTransitAssignee('ctr-1', ' ', 'dj-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses before Ethiopia has asked', async () => {
|
||||||
|
await expect(
|
||||||
|
service.assignTransitAssignee('ctr-1', 'Ahmed Bourhan', 'dj-1'),
|
||||||
|
).rejects.toThrow(/not requested/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('declaration gate', () => {
|
||||||
|
const ensure = (c: Contract) =>
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
ensureDeclarationPrerequisites: (id: string, c: Contract) => Promise<void>;
|
||||||
|
}
|
||||||
|
).ensureDeclarationPrerequisites('ctr-1', c);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Documents are approved; only the assignee decides the outcome here.
|
||||||
|
(
|
||||||
|
service as unknown as { isClearanceFullyApproved: unknown }
|
||||||
|
).isClearanceFullyApproved = jest.fn().mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells GL to raise the request when none exists', async () => {
|
||||||
|
await expect(ensure(contract())).rejects.toThrow(
|
||||||
|
/Request a transit assignee/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells GL to wait when Djibouti has not answered', async () => {
|
||||||
|
repo.currentCycle.mockResolvedValue(
|
||||||
|
cycle({ transitAssigneeRequestedAt: new Date() }),
|
||||||
|
);
|
||||||
|
await expect(ensure(contract())).rejects.toThrow(/has not assigned/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the declaration through once the officer is named', async () => {
|
||||||
|
repo.currentCycle.mockResolvedValue(
|
||||||
|
cycle({
|
||||||
|
transitAssigneeRequestedAt: new Date(),
|
||||||
|
transitAssigneeName: 'Ahmed Bourhan',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
(
|
||||||
|
service as unknown as { workflowService: unknown }
|
||||||
|
).workflowService = {
|
||||||
|
listMilestones: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
{ milestoneCode: 'DOCUMENTS_APPROVED', status: 'COMPLETED' },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(ensure(contract())).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Contract } from '../entities/contract.entity';
|
||||||
|
|
||||||
|
/** Statuses that already mean "done/void" — a contract in one of these never blocks a duplicate. */
|
||||||
|
export const TERMINAL_CONTRACT_STATUSES = [
|
||||||
|
'REJECTED',
|
||||||
|
'CANCELLED',
|
||||||
|
'CONTRACT_CLOSED',
|
||||||
|
'ARCHIVED',
|
||||||
|
'EXPIRED',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True once a contract is done, either explicitly (terminal status) or by date
|
||||||
|
* (past contractValidUntil). Checked by date too because the nightly expiry
|
||||||
|
* cron only flips the status once a day — this keeps same-day checks correct
|
||||||
|
* even a few hours before the cron runs.
|
||||||
|
*/
|
||||||
|
export function isEffectivelyExpired(
|
||||||
|
contract: Pick<Contract, 'status' | 'contractValidUntil'>,
|
||||||
|
): boolean {
|
||||||
|
if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Boolean(contract.contractValidUntil && contract.contractValidUntil < new Date());
|
||||||
|
}
|
||||||
@@ -53,4 +53,16 @@ export class FileRecord extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
|
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
|
||||||
reviewedAt!: Date | null;
|
reviewedAt!: Date | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who replaced this version, when a newer file took its place. Superseded
|
||||||
|
* versions are soft-deleted rather than dropped, so the original a customer
|
||||||
|
* uploaded survives a staff correction and the two can be compared.
|
||||||
|
*/
|
||||||
|
@Column({ name: "replaced_by_user_id", type: "uuid", nullable: true })
|
||||||
|
replacedByUserId!: string | null;
|
||||||
|
|
||||||
|
/** Why the file was replaced — shown on the document's version history. */
|
||||||
|
@Column({ name: "replace_reason", type: "text", nullable: true })
|
||||||
|
replaceReason!: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,12 +41,47 @@ export class FilesRepository extends BaseRepository<FileRecord> {
|
|||||||
return this.repository.findOne({ where: { resourceId, resource, code } });
|
return this.repository.findOne({ where: { resourceId, resource, code } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retire the live version(s) of a document code. SOFT delete on purpose: the
|
||||||
|
* bytes and the row stay so the original upload can still be read back from
|
||||||
|
* the version history after staff replace it. Every normal read already
|
||||||
|
* filters soft-deleted rows, so callers see only the current version.
|
||||||
|
*
|
||||||
|
* `replacedBy` / `reason` are stamped on the retired row when a newer file is
|
||||||
|
* taking its place (as opposed to a plain removal).
|
||||||
|
*/
|
||||||
async deleteByCode(
|
async deleteByCode(
|
||||||
resourceId: string,
|
resourceId: string,
|
||||||
resource: string,
|
resource: string,
|
||||||
code: string,
|
code: string,
|
||||||
|
replacedBy?: { userId?: string | null; reason?: string | null },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.repository.delete({ resourceId, resource, code });
|
if (replacedBy) {
|
||||||
|
await this.repository.update(
|
||||||
|
{ resourceId, resource, code },
|
||||||
|
{
|
||||||
|
replacedByUserId: replacedBy.userId ?? null,
|
||||||
|
replaceReason: replacedBy.reason ?? null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.repository.softDelete({ resourceId, resource, code });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every version of one document code, newest first — superseded versions
|
||||||
|
* included. The only read that deliberately looks past the soft-delete filter.
|
||||||
|
*/
|
||||||
|
findVersionHistory(
|
||||||
|
resourceId: string,
|
||||||
|
resource: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<FileRecord[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
where: { resourceId, resource, code },
|
||||||
|
withDeleted: true,
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
112
apps/edr-freight-api/src/modules/files/files.service.spec.ts
Normal file
112
apps/edr-freight-api/src/modules/files/files.service.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { FilesService } from './files.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replacing a stored document must never destroy the previous one: the customer
|
||||||
|
* uploaded it, and a staff correction has to stay auditable against it. The old
|
||||||
|
* row is soft-deleted (so every normal read still returns exactly the current
|
||||||
|
* version) and stamped with who replaced it and why.
|
||||||
|
*/
|
||||||
|
describe('FilesService — document versions', () => {
|
||||||
|
const file = {
|
||||||
|
originalname: 'bill-of-lading.pdf',
|
||||||
|
size: 1234,
|
||||||
|
mimetype: 'application/pdf',
|
||||||
|
buffer: Buffer.from('x'),
|
||||||
|
} as Express.Multer.File;
|
||||||
|
|
||||||
|
let filesRepository: {
|
||||||
|
deleteByCode: jest.Mock;
|
||||||
|
create: jest.Mock;
|
||||||
|
findVersionHistory: jest.Mock;
|
||||||
|
};
|
||||||
|
let service: FilesService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
filesRepository = {
|
||||||
|
deleteByCode: jest.fn().mockResolvedValue(undefined),
|
||||||
|
create: jest.fn(async (row) => ({ id: 'file-new', ...row })),
|
||||||
|
findVersionHistory: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
service = new FilesService(
|
||||||
|
filesRepository as never,
|
||||||
|
{
|
||||||
|
uploadFile: jest.fn().mockResolvedValue('https://minio/bucket/new.pdf'),
|
||||||
|
getObjectNameFromUrl: (u: string) => u,
|
||||||
|
getSignedUrl: jest.fn(),
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stamps the retired version with who replaced it and why', async () => {
|
||||||
|
await service.upsertByCode(
|
||||||
|
{ resourceId: 'ctr-1', resource: 'contracts', code: 'bill_of_lading', file },
|
||||||
|
{ userId: 'gl-user-1', reason: 'Customer sent page 2 only' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(filesRepository.deleteByCode).toHaveBeenCalledWith(
|
||||||
|
'ctr-1',
|
||||||
|
'contracts',
|
||||||
|
'bill_of_lading',
|
||||||
|
{ userId: 'gl-user-1', reason: 'Customer sent page 2 only' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still replaces silently when no replacer is given (system overwrites)', async () => {
|
||||||
|
await service.upsertByCode({
|
||||||
|
resourceId: 'ctr-1',
|
||||||
|
resource: 'contracts',
|
||||||
|
code: 'contract_pdf',
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(filesRepository.deleteByCode).toHaveBeenCalledWith(
|
||||||
|
'ctr-1',
|
||||||
|
'contracts',
|
||||||
|
'contract_pdf',
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the live row current and the soft-deleted ones superseded', async () => {
|
||||||
|
filesRepository.findVersionHistory.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'v2',
|
||||||
|
name: 'corrected.pdf',
|
||||||
|
url: 'u2',
|
||||||
|
size: 2,
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
createdAt: new Date('2026-07-20T10:00:00Z'),
|
||||||
|
deletedAt: null,
|
||||||
|
replacedByUserId: null,
|
||||||
|
replaceReason: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'v1',
|
||||||
|
name: 'original.pdf',
|
||||||
|
url: 'u1',
|
||||||
|
size: 1,
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
createdAt: new Date('2026-07-18T10:00:00Z'),
|
||||||
|
deletedAt: new Date('2026-07-20T10:00:00Z'),
|
||||||
|
replacedByUserId: 'gl-user-1',
|
||||||
|
replaceReason: 'Wrong page order',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const versions = await service.versionHistory(
|
||||||
|
'ctr-1',
|
||||||
|
'contracts',
|
||||||
|
'bill_of_lading',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(versions[0]).toMatchObject({ id: 'v2', isCurrent: true, replacedAt: null });
|
||||||
|
expect(versions[1]).toMatchObject({
|
||||||
|
id: 'v1',
|
||||||
|
isCurrent: false,
|
||||||
|
replacedByUserId: 'gl-user-1',
|
||||||
|
replaceReason: 'Wrong page order',
|
||||||
|
});
|
||||||
|
// The customer's original is still readable — that is the whole point.
|
||||||
|
expect(versions[1].url).toBe('u1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -104,13 +104,66 @@ export class FilesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace existing file row for the same resource + code (e.g. contract PDF). */
|
/**
|
||||||
async upsertByCode(input: CreateFileInput): Promise<FileRecord> {
|
* Replace the file stored under a resource + code (e.g. contract PDF). The
|
||||||
|
* previous version is retired, not destroyed — pass `replacedBy` to record who
|
||||||
|
* swapped it and why, which is what the version history shows.
|
||||||
|
*/
|
||||||
|
async upsertByCode(
|
||||||
|
input: CreateFileInput,
|
||||||
|
replacedBy?: { userId?: string | null; reason?: string | null },
|
||||||
|
): Promise<FileRecord> {
|
||||||
const { resourceId, resource, code } = input;
|
const { resourceId, resource, code } = input;
|
||||||
await this.filesRepository.deleteByCode(resourceId, resource, code);
|
await this.filesRepository.deleteByCode(
|
||||||
|
resourceId,
|
||||||
|
resource,
|
||||||
|
code,
|
||||||
|
replacedBy,
|
||||||
|
);
|
||||||
return this.upload(input);
|
return this.upload(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every stored version of one document, newest first. `isCurrent` marks the
|
||||||
|
* live row; the rest are superseded uploads kept for audit.
|
||||||
|
*/
|
||||||
|
async versionHistory(
|
||||||
|
resourceId: string,
|
||||||
|
resource: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
mimeType: string;
|
||||||
|
uploadedAt: string;
|
||||||
|
isCurrent: boolean;
|
||||||
|
replacedAt: string | null;
|
||||||
|
replacedByUserId: string | null;
|
||||||
|
replaceReason: string | null;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const rows = await this.filesRepository.findVersionHistory(
|
||||||
|
resourceId,
|
||||||
|
resource,
|
||||||
|
code,
|
||||||
|
);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
url: row.url,
|
||||||
|
size: row.size,
|
||||||
|
mimeType: row.mimeType,
|
||||||
|
uploadedAt: row.createdAt.toISOString(),
|
||||||
|
isCurrent: row.deletedAt == null,
|
||||||
|
replacedAt: row.deletedAt ? row.deletedAt.toISOString() : null,
|
||||||
|
replacedByUserId: row.replacedByUserId,
|
||||||
|
replaceReason: row.replaceReason,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async deleteByCode(
|
async deleteByCode(
|
||||||
resourceId: string,
|
resourceId: string,
|
||||||
resource: string,
|
resource: string,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
|
import { IsArray, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class FirstMileVehicleInput {
|
export class FirstMileVehicleInput {
|
||||||
@@ -8,6 +8,18 @@ export class FirstMileVehicleInput {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
containerNumber?: string;
|
containerNumber?: string;
|
||||||
|
|
||||||
|
/** Bulk: tonnage this truck hauls. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
tons?: number;
|
||||||
|
|
||||||
|
/** Bulk: optional item/piece count. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
quantity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace the full set of vehicles (with their container numbers) on a pickup. */
|
/** Replace the full set of vehicles (with their container numbers) on a pickup. */
|
||||||
|
|||||||
@@ -36,4 +36,12 @@ export class FirstMileVehicleAssignment extends BaseEntity {
|
|||||||
/** Actual distance driven by this truck (km), entered per vehicle. */
|
/** Actual distance driven by this truck (km), entered per vehicle. */
|
||||||
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||||
distanceKm?: number | null;
|
distanceKm?: number | null;
|
||||||
|
|
||||||
|
/** Bulk: tonnage this truck hauls — assigned tonnage draws down the booking total. */
|
||||||
|
@Column({ name: 'tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||||
|
tons?: number | null;
|
||||||
|
|
||||||
|
/** Bulk: optional item/piece count on this truck. */
|
||||||
|
@Column({ name: 'quantity', type: 'integer', nullable: true })
|
||||||
|
quantity?: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -532,17 +532,47 @@ export class FirstMileService {
|
|||||||
*/
|
*/
|
||||||
async setVehicles(
|
async setVehicles(
|
||||||
id: string,
|
id: string,
|
||||||
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
inputs: Array<{
|
||||||
|
vehicleId: string;
|
||||||
|
containerNumber?: string | null;
|
||||||
|
tons?: number | null;
|
||||||
|
quantity?: number | null;
|
||||||
|
}>,
|
||||||
): Promise<FirstMile> {
|
): Promise<FirstMile> {
|
||||||
const existing = await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
// Dedupe by vehicleId, keeping the container number; preserve order.
|
// Dedupe by vehicleId, keeping the load details; preserve order.
|
||||||
const desiredMap = new Map<string, string | null>();
|
const desiredMap = new Map<
|
||||||
|
string,
|
||||||
|
{ containerNumber: string | null; tons: number | null; quantity: number | null }
|
||||||
|
>();
|
||||||
for (const inp of inputs) {
|
for (const inp of inputs) {
|
||||||
if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null);
|
if (inp.vehicleId) {
|
||||||
|
desiredMap.set(inp.vehicleId, {
|
||||||
|
containerNumber: inp.containerNumber ?? null,
|
||||||
|
tons: inp.tons ?? null,
|
||||||
|
quantity: inp.quantity ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const desired = [...desiredMap.keys()];
|
const desired = [...desiredMap.keys()];
|
||||||
const desiredSet = new Set(desired);
|
const desiredSet = new Set(desired);
|
||||||
|
|
||||||
|
// Bulk drawdown: assigned tonnage may not exceed what the booking declares.
|
||||||
|
const totalTons = [...desiredMap.values()].reduce((s, v) => s + (Number(v.tons) || 0), 0);
|
||||||
|
if (totalTons > 0 && existing.bookingId) {
|
||||||
|
const [b]: Array<{ vgm: string | null }> = await this.dataSource.query(
|
||||||
|
`SELECT cargo_total_weight_vgm AS vgm FROM freight.bookings
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[existing.bookingId],
|
||||||
|
);
|
||||||
|
const declared = Number(b?.vgm ?? 0);
|
||||||
|
if (declared > 0 && totalTons > declared + 0.001) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Assigned tonnage (${totalTons} t) exceeds the booking's declared ${declared} t`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const manager = this.dataSource.manager;
|
const manager = this.dataSource.manager;
|
||||||
const current = await manager.find(FirstMileVehicleAssignment, {
|
const current = await manager.find(FirstMileVehicleAssignment, {
|
||||||
where: { firstMileId: id },
|
where: { firstMileId: id },
|
||||||
@@ -555,12 +585,16 @@ export class FirstMileService {
|
|||||||
)];
|
)];
|
||||||
const added = desired.filter((v) => !junctionSet.has(v));
|
const added = desired.filter((v) => !junctionSet.has(v));
|
||||||
const removed = releaseIds.filter((v) => !desiredSet.has(v));
|
const removed = releaseIds.filter((v) => !desiredSet.has(v));
|
||||||
// Vehicles that stay but whose container number changed.
|
// Vehicles that stay but whose load details changed.
|
||||||
const changed = current.filter(
|
const changed = current.filter((a) => {
|
||||||
(a) =>
|
const want = desiredMap.get(a.vehicleId);
|
||||||
desiredMap.has(a.vehicleId) &&
|
if (!want) return false;
|
||||||
(a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null),
|
return (
|
||||||
);
|
(a.containerNumber ?? null) !== want.containerNumber ||
|
||||||
|
(a.tons == null ? null : Number(a.tons)) !== want.tons ||
|
||||||
|
(a.quantity ?? null) !== want.quantity
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await this.dataSource.transaction(async (tx) => {
|
await this.dataSource.transaction(async (tx) => {
|
||||||
if (removed.length) {
|
if (removed.length) {
|
||||||
@@ -570,17 +604,25 @@ export class FirstMileService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const vehicleId of added) {
|
for (const vehicleId of added) {
|
||||||
|
const want = desiredMap.get(vehicleId);
|
||||||
await tx.insert(FirstMileVehicleAssignment, {
|
await tx.insert(FirstMileVehicleAssignment, {
|
||||||
firstMileId: id,
|
firstMileId: id,
|
||||||
vehicleId,
|
vehicleId,
|
||||||
containerNumber: desiredMap.get(vehicleId) ?? null,
|
containerNumber: want?.containerNumber ?? null,
|
||||||
|
tons: want?.tons ?? null,
|
||||||
|
quantity: want?.quantity ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const row of changed) {
|
for (const row of changed) {
|
||||||
|
const want = desiredMap.get(row.vehicleId);
|
||||||
await tx.update(
|
await tx.update(
|
||||||
FirstMileVehicleAssignment,
|
FirstMileVehicleAssignment,
|
||||||
{ firstMileId: id, vehicleId: row.vehicleId },
|
{ firstMileId: id, vehicleId: row.vehicleId },
|
||||||
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
|
{
|
||||||
|
containerNumber: want?.containerNumber ?? null,
|
||||||
|
tons: want?.tons ?? null,
|
||||||
|
quantity: want?.quantity ?? null,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One truck's detention window. Each truck reaches the destination and is
|
||||||
|
* released at its own time, so detention days differ between trucks on the
|
||||||
|
* same delivery. Null clears the value (falls back to the leg-level pair).
|
||||||
|
*/
|
||||||
|
export class TruckDetentionTimeInput {
|
||||||
|
@IsUUID()
|
||||||
|
vehicleId!: string;
|
||||||
|
|
||||||
|
/** Detention clock start — this truck reached the destination. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
destinationArrivedAt?: string | null;
|
||||||
|
|
||||||
|
/** Detention clock end — this truck was released/returned. Omit = still out. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
returnedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SetDetentionTimesDto {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => TruckDetentionTimeInput)
|
||||||
|
trucks!: TruckDetentionTimeInput[];
|
||||||
|
}
|
||||||
@@ -51,6 +51,21 @@ export class LastMileVehicleAssignment extends BaseEntity {
|
|||||||
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||||
departedAt?: Date | null;
|
departedAt?: Date | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detention clock START for THIS truck: reached the delivery destination.
|
||||||
|
* Distinct from `arrivedAt` (warehouse gate-in). Null falls back to the
|
||||||
|
* leg-level `last_mile.arrived_at`.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'destination_arrived_at', type: 'timestamptz', nullable: true })
|
||||||
|
destinationArrivedAt?: Date | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detention clock END for THIS truck: released / returned by the customer.
|
||||||
|
* Null (with no leg-level `delivered_at`) means still out — detention accrues.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'returned_at', type: 'timestamptz', nullable: true })
|
||||||
|
returnedAt?: Date | null;
|
||||||
|
|
||||||
/** Weighed gross on exit, in TONNES (not kg — see the migration note). */
|
/** Weighed gross on exit, in TONNES (not kg — see the migration note). */
|
||||||
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||||
grossWeightTons?: number | null;
|
grossWeightTons?: number | null;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
|||||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||||
import { SetVehiclesDto } from './dto/set-vehicles.dto';
|
import { SetVehiclesDto } from './dto/set-vehicles.dto';
|
||||||
|
import { SetDetentionTimesDto } from './dto/set-detention-times.dto';
|
||||||
import { SetDistancesDto } from './dto/set-distances.dto';
|
import { SetDistancesDto } from './dto/set-distances.dto';
|
||||||
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
|
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
|
||||||
import { LastMileStatus } from './entities/last-mile.entity';
|
import { LastMileStatus } from './entities/last-mile.entity';
|
||||||
@@ -131,6 +132,18 @@ export class LastMileController {
|
|||||||
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
|
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/detention-times')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.lastMile.update)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Set each truck\'s own detention window (arrived at destination / returned)',
|
||||||
|
})
|
||||||
|
async setDetentionTimes(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: SetDetentionTimesDto,
|
||||||
|
) {
|
||||||
|
return this.lastMileService.setDetentionTimes(id, dto.trucks);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/proof-of-delivery')
|
@Post(':id/proof-of-delivery')
|
||||||
@BookingStaff(FREIGHT_PERMS.lastMile.update)
|
@BookingStaff(FREIGHT_PERMS.lastMile.update)
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
|||||||
@@ -869,6 +869,46 @@ export class LastMileService {
|
|||||||
* sum and drives billing; `remainingPayment` (total km × rate) is recomputed
|
* sum and drives billing; `remainingPayment` (total km × rate) is recomputed
|
||||||
* client-side. Does NOT generate an invoice — that's a separate explicit step.
|
* client-side. Does NOT generate an invoice — that's a separate explicit step.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Per-truck detention windows. Each truck reaches the destination and is
|
||||||
|
* released at its own time, so every truck gets its own clock (and therefore
|
||||||
|
* its own chargeable days). Locked once the detention invoice exists.
|
||||||
|
*/
|
||||||
|
async setDetentionTimes(
|
||||||
|
id: string,
|
||||||
|
trucks: Array<{
|
||||||
|
vehicleId: string;
|
||||||
|
destinationArrivedAt?: string | null;
|
||||||
|
returnedAt?: string | null;
|
||||||
|
}>,
|
||||||
|
): Promise<LastMile> {
|
||||||
|
await this.findById(id);
|
||||||
|
|
||||||
|
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
|
||||||
|
if (invoices.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Detention times cannot be changed after the invoice is generated',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const t of trucks) {
|
||||||
|
const start = t.destinationArrivedAt ? new Date(t.destinationArrivedAt) : null;
|
||||||
|
const end = t.returnedAt ? new Date(t.returnedAt) : null;
|
||||||
|
if (start && end && end.getTime() < start.getTime()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A truck cannot be returned before it arrived — check the detention times',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.dataSource.manager.update(
|
||||||
|
LastMileVehicleAssignment,
|
||||||
|
{ lastMileId: id, vehicleId: t.vehicleId },
|
||||||
|
{ destinationArrivedAt: start, returnedAt: end },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
async setDistances(
|
async setDistances(
|
||||||
id: string,
|
id: string,
|
||||||
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
||||||
|
|||||||
@@ -65,25 +65,4 @@ export class CreateLocomotiveDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
overageToleranceMeters?: number;
|
overageToleranceMeters?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 4200 })
|
|
||||||
@IsOptional()
|
|
||||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
powerKw?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 300 })
|
|
||||||
@IsOptional()
|
|
||||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
tractionForceKn?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 120 })
|
|
||||||
@IsOptional()
|
|
||||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
maxSpeedKmh?: number;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,15 +76,6 @@ export class Locomotive extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'current_yard_id' })
|
@JoinColumn({ name: 'current_yard_id' })
|
||||||
currentYard?: Yard | null;
|
currentYard?: Yard | null;
|
||||||
|
|
||||||
@Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
|
||||||
powerKw?: number | null;
|
|
||||||
|
|
||||||
@Column({ name: 'traction_force_kn', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
|
||||||
tractionForceKn?: number | null;
|
|
||||||
|
|
||||||
@Column({ name: 'max_speed_kmh', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
|
||||||
maxSpeedKmh?: number | null;
|
|
||||||
|
|
||||||
@OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive)
|
@OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive)
|
||||||
trainSets?: TrainSet[];
|
trainSets?: TrainSet[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,9 +113,6 @@ export class LocomotivesService {
|
|||||||
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
||||||
overageToleranceTons: dto.overageToleranceTons ?? null,
|
overageToleranceTons: dto.overageToleranceTons ?? null,
|
||||||
overageToleranceMeters: dto.overageToleranceMeters ?? null,
|
overageToleranceMeters: dto.overageToleranceMeters ?? null,
|
||||||
powerKw: dto.powerKw ?? null,
|
|
||||||
tractionForceKn: dto.tractionForceKn ?? null,
|
|
||||||
maxSpeedKmh: dto.maxSpeedKmh ?? null,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,11 +173,6 @@ export class LocomotivesService {
|
|||||||
? locomotive.currentYardId
|
? locomotive.currentYardId
|
||||||
: (dto.currentYardId ?? null),
|
: (dto.currentYardId ?? null),
|
||||||
name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null,
|
name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null,
|
||||||
powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null,
|
|
||||||
tractionForceKn:
|
|
||||||
dto.tractionForceKn === undefined ? locomotive.tractionForceKn : dto.tractionForceKn ?? null,
|
|
||||||
maxSpeedKmh:
|
|
||||||
dto.maxSpeedKmh === undefined ? locomotive.maxSpeedKmh : dto.maxSpeedKmh ?? null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { ConflictException } from '@nestjs/common';
|
||||||
|
import type { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { RoutesService } from './routes.service';
|
||||||
|
import type { RoutesRepository } from './routes.repository';
|
||||||
|
|
||||||
|
type StopSeq = Array<{ yardId: string; sequenceNo: number }>;
|
||||||
|
|
||||||
|
/** DataSource stub whose Route repository returns the given existing routes. */
|
||||||
|
const serviceWith = (
|
||||||
|
existing: Array<{ id: string; milestones: StopSeq }>,
|
||||||
|
): RoutesService => {
|
||||||
|
const dataSource = {
|
||||||
|
getRepository: () => ({ find: async () => existing }),
|
||||||
|
} as unknown as DataSource;
|
||||||
|
return new RoutesService(dataSource, {} as RoutesRepository);
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertNotDuplicate = (
|
||||||
|
service: RoutesService,
|
||||||
|
yardIds: string[],
|
||||||
|
excludeRouteId?: string,
|
||||||
|
): Promise<void> =>
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
assertNotDuplicate: (
|
||||||
|
m: Array<{ yardId: string }>,
|
||||||
|
id?: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
).assertNotDuplicate(
|
||||||
|
yardIds.map((yardId) => ({ yardId })),
|
||||||
|
excludeRouteId,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('RoutesService duplicate guard', () => {
|
||||||
|
const addisAdamaDire: StopSeq = [
|
||||||
|
{ yardId: 'addis', sequenceNo: 1 },
|
||||||
|
{ yardId: 'adama', sequenceNo: 2 },
|
||||||
|
{ yardId: 'dire', sequenceNo: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('rejects an identical stop sequence', async () => {
|
||||||
|
const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertNotDuplicate(service, ['addis', 'adama', 'dire']),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the same endpoints with a different corridor', async () => {
|
||||||
|
// Same origin + destination, but skipping Adama is a genuinely other route.
|
||||||
|
const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertNotDuplicate(service, ['addis', 'dire']),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flag the route being edited against itself', async () => {
|
||||||
|
const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertNotDuplicate(service, ['addis', 'adama', 'dire'], 'r1'),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares stops by sequence, not storage order', async () => {
|
||||||
|
const shuffled: StopSeq = [
|
||||||
|
{ yardId: 'dire', sequenceNo: 3 },
|
||||||
|
{ yardId: 'addis', sequenceNo: 1 },
|
||||||
|
{ yardId: 'adama', sequenceNo: 2 },
|
||||||
|
];
|
||||||
|
const service = serviceWith([{ id: 'r1', milestones: shuffled }]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertNotDuplicate(service, ['addis', 'adama', 'dire']),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { TrainScheduleStatus } from '@edr/types';
|
import { TrainScheduleStatus } from '@edr/types';
|
||||||
import { DataSource, In } from 'typeorm';
|
import { DataSource, In, Not } from 'typeorm';
|
||||||
|
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
@@ -15,7 +15,7 @@ import { CreateRouteDto } from './dto/create-route.dto';
|
|||||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||||
import { UpdateRouteDto } from './dto/update-route.dto';
|
import { UpdateRouteDto } from './dto/update-route.dto';
|
||||||
import { RouteMilestone } from './entities/route-milestone.entity';
|
import { RouteMilestone } from './entities/route-milestone.entity';
|
||||||
import { formatRouteLabel, Route } from './entities/route.entity';
|
import { formatRouteLabel, Route, type RouteStatus } from './entities/route.entity';
|
||||||
import { RoutesRepository } from './routes.repository';
|
import { RoutesRepository } from './routes.repository';
|
||||||
|
|
||||||
/** Order-insensitive key: distances are symmetric. */
|
/** Order-insensitive key: distances are symmetric. */
|
||||||
@@ -88,6 +88,7 @@ export class RoutesService {
|
|||||||
|
|
||||||
async create(dto: CreateRouteDto): Promise<Route> {
|
async create(dto: CreateRouteDto): Promise<Route> {
|
||||||
const validated = await this.validateMilestones(dto.milestones);
|
const validated = await this.validateMilestones(dto.milestones);
|
||||||
|
await this.assertNotDuplicate(validated.milestones);
|
||||||
|
|
||||||
const route = await this.dataSource.transaction(async (manager) => {
|
const route = await this.dataSource.transaction(async (manager) => {
|
||||||
const savedRoute = await manager.getRepository(Route).save(
|
const savedRoute = await manager.getRepository(Route).save(
|
||||||
@@ -123,6 +124,11 @@ export class RoutesService {
|
|||||||
? await this.validateMilestones(dto.milestones)
|
? await this.validateMilestones(dto.milestones)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// An edit can collide with another route just as easily as a create can.
|
||||||
|
if (milestoneInput) {
|
||||||
|
await this.assertNotDuplicate(milestoneInput.milestones, id);
|
||||||
|
}
|
||||||
|
|
||||||
// Milestones or endpoints are about to be rewritten — reject if any
|
// Milestones or endpoints are about to be rewritten — reject if any
|
||||||
// non-terminal schedule still references this route, otherwise its stop list
|
// non-terminal schedule still references this route, otherwise its stop list
|
||||||
// and distances would silently shift under a live plan. Status-only /
|
// and distances would silently shift under a live plan. Status-only /
|
||||||
@@ -187,6 +193,51 @@ export class RoutesService {
|
|||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A route IS its ordered stop list — "Addis → Adama → Dire Dawa" and
|
||||||
|
* "Addis → Dire Dawa" share endpoints but are different corridors. So the
|
||||||
|
* duplicate test compares the full yard sequence, not just origin/destination.
|
||||||
|
*
|
||||||
|
* Decommissioned routes (STOP_WORKING) are ignored: replacing a retired
|
||||||
|
* corridor with a fresh one is exactly what an admin does after deactivating,
|
||||||
|
* and there is no reactivate action to fall back on.
|
||||||
|
*/
|
||||||
|
private async assertNotDuplicate(
|
||||||
|
milestones: Array<{ yardId: string }>,
|
||||||
|
excludeRouteId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const signature = milestones.map((m) => m.yardId).join('>');
|
||||||
|
|
||||||
|
const candidates = await this.dataSource.getRepository(Route).find({
|
||||||
|
where: {
|
||||||
|
originYardId: milestones[0].yardId,
|
||||||
|
destinationYardId: milestones[milestones.length - 1].yardId,
|
||||||
|
status: Not<RouteStatus>('STOP_WORKING'),
|
||||||
|
},
|
||||||
|
relations: {
|
||||||
|
originYard: true,
|
||||||
|
destinationYard: true,
|
||||||
|
milestones: { yard: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const duplicate = candidates.find((route) => {
|
||||||
|
if (route.id === excludeRouteId) return false;
|
||||||
|
const stops = [...(route.milestones ?? [])]
|
||||||
|
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||||
|
.map((m) => m.yardId)
|
||||||
|
.join('>');
|
||||||
|
return stops === signature;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (duplicate) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`This route already exists: ${formatRouteLabel(duplicate)}. ` +
|
||||||
|
'Edit the existing route instead of creating a duplicate.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async validateMilestones(milestones: Array<{ yardId: string }>) {
|
private async validateMilestones(milestones: Array<{ yardId: string }>) {
|
||||||
if (milestones.length < 2) {
|
if (milestones.length < 2) {
|
||||||
throw new BadRequestException('A route requires at least two yards');
|
throw new BadRequestException('A route requires at least two yards');
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
||||||
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
@@ -41,7 +41,7 @@ export class ApprovalRulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('reorder')
|
@Post('reorder')
|
||||||
@RuleEngineManage('approval-rules')
|
@RuleEngineUpdate('approval-rules')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Bulk reorder approval steps within a chain' })
|
@ApiOperation({ summary: 'Bulk reorder approval steps within a chain' })
|
||||||
reorder(@Body() dto: ReorderItemsDto) {
|
reorder(@Body() dto: ReorderItemsDto) {
|
||||||
@@ -49,7 +49,7 @@ export class ApprovalRulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/move-order')
|
@Post(':id/move-order')
|
||||||
@RuleEngineManage('approval-rules')
|
@RuleEngineUpdate('approval-rules')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Move an approval step up or down within its chain' })
|
@ApiOperation({ summary: 'Move an approval step up or down within its chain' })
|
||||||
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
||||||
@@ -64,21 +64,21 @@ export class ApprovalRulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RuleEngineManage('approval-rules')
|
@RuleEngineCreate('approval-rules')
|
||||||
@ApiOperation({ summary: 'Create an approval rule step' })
|
@ApiOperation({ summary: 'Create an approval rule step' })
|
||||||
create(@Body() dto: CreateApprovalRuleDto) {
|
create(@Body() dto: CreateApprovalRuleDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RuleEngineManage('approval-rules')
|
@RuleEngineUpdate('approval-rules')
|
||||||
@ApiOperation({ summary: 'Update an approval rule' })
|
@ApiOperation({ summary: 'Update an approval rule' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RuleEngineManage('approval-rules')
|
@RuleEngineDelete('approval-rules')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete an approval rule' })
|
@ApiOperation({ summary: 'Soft-delete an approval rule' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
|
||||||
import { StaffReference } from '../../../common/booking-guards';
|
import { StaffReference } from '../../../common/booking-guards';
|
||||||
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
||||||
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
@@ -26,7 +26,7 @@ export class CargoTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('reorder')
|
@Post('reorder')
|
||||||
@RuleEngineManage('cargo-types')
|
@RuleEngineUpdate('cargo-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Bulk reorder cargo types by ID list' })
|
@ApiOperation({ summary: 'Bulk reorder cargo types by ID list' })
|
||||||
reorder(@Body() dto: ReorderItemsDto) {
|
reorder(@Body() dto: ReorderItemsDto) {
|
||||||
@@ -34,7 +34,7 @@ export class CargoTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/move-order')
|
@Post(':id/move-order')
|
||||||
@RuleEngineManage('cargo-types')
|
@RuleEngineUpdate('cargo-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Move a cargo type up or down in display order' })
|
@ApiOperation({ summary: 'Move a cargo type up or down in display order' })
|
||||||
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
||||||
@@ -49,21 +49,21 @@ export class CargoTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RuleEngineManage('cargo-types')
|
@RuleEngineCreate('cargo-types')
|
||||||
@ApiOperation({ summary: 'Create a cargo type' })
|
@ApiOperation({ summary: 'Create a cargo type' })
|
||||||
create(@Body() dto: CreateCargoTypeDto) {
|
create(@Body() dto: CreateCargoTypeDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RuleEngineManage('cargo-types')
|
@RuleEngineUpdate('cargo-types')
|
||||||
@ApiOperation({ summary: 'Update a cargo type' })
|
@ApiOperation({ summary: 'Update a cargo type' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoTypeDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoTypeDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RuleEngineManage('cargo-types')
|
@RuleEngineDelete('cargo-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a cargo type' })
|
@ApiOperation({ summary: 'Soft-delete a cargo type' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
|
||||||
import { StaffReference } from '../../../common/booking-guards';
|
import { StaffReference } from '../../../common/booking-guards';
|
||||||
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
||||||
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
@@ -26,7 +26,7 @@ export class ContainerTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('reorder')
|
@Post('reorder')
|
||||||
@RuleEngineManage('container-types')
|
@RuleEngineUpdate('container-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Bulk reorder container types by ID list' })
|
@ApiOperation({ summary: 'Bulk reorder container types by ID list' })
|
||||||
reorder(@Body() dto: ReorderItemsDto) {
|
reorder(@Body() dto: ReorderItemsDto) {
|
||||||
@@ -34,7 +34,7 @@ export class ContainerTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/move-order')
|
@Post(':id/move-order')
|
||||||
@RuleEngineManage('container-types')
|
@RuleEngineUpdate('container-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Move a container type up or down in display order' })
|
@ApiOperation({ summary: 'Move a container type up or down in display order' })
|
||||||
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
||||||
@@ -49,21 +49,21 @@ export class ContainerTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RuleEngineManage('container-types')
|
@RuleEngineCreate('container-types')
|
||||||
@ApiOperation({ summary: 'Create a container type' })
|
@ApiOperation({ summary: 'Create a container type' })
|
||||||
create(@Body() dto: CreateContainerTypeDto) {
|
create(@Body() dto: CreateContainerTypeDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RuleEngineManage('container-types')
|
@RuleEngineUpdate('container-types')
|
||||||
@ApiOperation({ summary: 'Update a container type' })
|
@ApiOperation({ summary: 'Update a container type' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerTypeDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerTypeDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RuleEngineManage('container-types')
|
@RuleEngineDelete('container-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a container type' })
|
@ApiOperation({ summary: 'Soft-delete a container type' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user