mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,6 +33,7 @@ docker-compose.override.yml
|
||||
# cypress e2e artifacts
|
||||
e2e/**/cypress/videos/
|
||||
e2e/**/cypress/screenshots/
|
||||
e2e/**/cypress/reports/
|
||||
e2e/**/cypress/downloads/
|
||||
|
||||
# e2e launcher state (ports of the running stack)
|
||||
|
||||
@@ -24,6 +24,7 @@ Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Fre
|
||||
| ---------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `@edr/types` | Shared TypeScript interfaces and enums |
|
||||
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
|
||||
| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) |
|
||||
| `@edr/ui-common` | Shared React components and theme |
|
||||
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
|
||||
| `@edr/tsconfig` | Shared TypeScript configurations |
|
||||
|
||||
440
E2E_TEST_REPORT.md
Normal file
440
E2E_TEST_REPORT.md
Normal file
@@ -0,0 +1,440 @@
|
||||
# EDR Freight — End-to-End Test Report
|
||||
|
||||
**Date:** 23 July 2026
|
||||
**Branch:** `freight_feature/usermanagement`
|
||||
**Command that was run:**
|
||||
|
||||
```bash
|
||||
E2E_PORTAL_PORT=5374 docker compose -f docker-compose.e2e.yaml --profile cypress \
|
||||
run --rm cypress --spec 'cypress/e2e/flows/*.cy.ts'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Short summary (read this first)
|
||||
|
||||
I ran the end-to-end (E2E) test suite and found **four separate problems**. They are not all the same kind of problem, and this is the most important thing to understand:
|
||||
|
||||
| # | Problem | Kind of problem | Status |
|
||||
|---|---------|-----------------|--------|
|
||||
| 1 | Tests crashed instantly with `exit code 137` | Machine / environment | Explained + how to avoid |
|
||||
| 2 | Portal tests pointed at a dead port (`5374`) | Wrong command setting | Explained + corrected |
|
||||
| 3 | `contract-lifecycle` failed all 5 of its tests | **Real bug in the test code** | ✅ **Fixed and verified** |
|
||||
| 4 | 5 other test files failed | Old app running inside Docker | Diagnosed, needs a rebuild |
|
||||
|
||||
**The only real code bug was problem 3, and it is now fixed.** Problems 1, 2 and 4 are about *how the tests were run*, not about the application logic.
|
||||
|
||||
---
|
||||
|
||||
## 2. Some words explained (for beginners)
|
||||
|
||||
Before the details, here are the words used in this report:
|
||||
|
||||
- **E2E test (end-to-end test)** — a robot that opens a real web browser, clicks buttons like a real user, and checks the result is correct.
|
||||
- **Cypress** — the tool that drives that robot browser.
|
||||
- **Spec** — one test file. It ends with `.cy.ts`. Example: `contract-lifecycle.cy.ts`.
|
||||
- **Docker container** — a small, isolated box that runs one program (the API, the website, the database).
|
||||
- **Docker image** — a *frozen photograph* of your code. A container is started **from** an image. This idea matters a lot in problem 4.
|
||||
- **Port** — a numbered door on your computer. A program listens on one port. If you knock on the wrong door, nobody answers.
|
||||
|
||||
---
|
||||
|
||||
## 3. Result of the full test run
|
||||
|
||||
I ran all 38 test files once, cleanly. This took about 44 minutes.
|
||||
|
||||
```
|
||||
✖ 6 of 38 failed (16%) 44:17 259 tests 223 passing 36 failing
|
||||
```
|
||||
|
||||
The 6 test files that failed:
|
||||
|
||||
| Test file | Tests | Passed | Failed |
|
||||
|-----------|-------|--------|--------|
|
||||
| `contract-lifecycle.cy.ts` | 5 | 0 | **5** |
|
||||
| `export_one_time.cy.ts` | 16 | 9 | 7 |
|
||||
| `import_full_train.cy.ts` | 13 | 8 | 5 |
|
||||
| `intercity_one_time.cy.ts` | 16 | 11 | 5 |
|
||||
| `onboarding.cy.ts` | 3 | 1 | 2 |
|
||||
| `segment_weight.cy.ts` | 13 | 1 | 12 |
|
||||
|
||||
The other 32 test files passed completely.
|
||||
|
||||
---
|
||||
|
||||
## 4. Problem 1 — The tests died immediately with `exit code 137`
|
||||
|
||||
### What you saw
|
||||
|
||||
The command stopped almost at once. There were no test results. The exit code was `137`.
|
||||
|
||||
### What it means
|
||||
|
||||
`137` means the program was **force-killed** by the operating system (it is `128 + 9`, where `9` is the "kill" signal). It is *not* a test failure. The tests never even started.
|
||||
|
||||
### Why it happened
|
||||
|
||||
Look at this part of `docker-compose.e2e.yaml`:
|
||||
|
||||
```yaml
|
||||
cypress:
|
||||
network_mode: host
|
||||
# NOTE: host network shares the abstract X-socket namespace with the host.
|
||||
# Cypress spawns its Xvfb on :99 — run only ONE cypress container at a
|
||||
# time, and don't run it on a host whose X server occupies :99.
|
||||
```
|
||||
|
||||
Cypress needs a screen to draw the browser on. Because there is no real monitor, it creates a fake screen called **Xvfb** on display number **`:99`**.
|
||||
|
||||
Because of `network_mode: host`, that fake screen is shared with the whole computer — **not** kept private inside the container.
|
||||
|
||||
So if **two Cypress runs happen at the same time**, both try to take display `:99`. They fight, Chrome dies, and you get `137`.
|
||||
|
||||
On this machine there was in fact **another Cypress run already going** (from a second terminal session), which is what killed my run.
|
||||
|
||||
### The solution
|
||||
|
||||
**Run only one Cypress container at a time.** Before starting, check nothing else is running:
|
||||
|
||||
```bash
|
||||
docker ps --format '{{.Names}}' | grep cypress
|
||||
```
|
||||
|
||||
If that command prints something, wait for it to finish. If it prints nothing, you are safe to start.
|
||||
|
||||
> This was a problem with the machine being busy — **not** a problem with the tests or the application.
|
||||
|
||||
---
|
||||
|
||||
## 5. Problem 2 — `E2E_PORTAL_PORT=5374` pointed at a dead port
|
||||
|
||||
### What you saw
|
||||
|
||||
Tests that use the customer **portal** website failed. They could not open the page at all.
|
||||
|
||||
### Why it happened
|
||||
|
||||
Your command set the portal port to **5374**:
|
||||
|
||||
```bash
|
||||
E2E_PORTAL_PORT=5374 docker compose ...
|
||||
```
|
||||
|
||||
But the portal container was actually published on port **5373**. Here is the proof:
|
||||
|
||||
```
|
||||
$ cat e2e/freight/.e2e-ports.json
|
||||
{ "E2E_API_PORT": 3101, "E2E_PORTAL_PORT": 5373, "E2E_BACKOFFICE_PORT": 5383, ... }
|
||||
|
||||
$ docker ps
|
||||
edr-freight-e2e-freight-portal-e2e-1 0.0.0.0:5373->80/tcp
|
||||
```
|
||||
|
||||
Here is the important detail. Setting `E2E_PORTAL_PORT=5374` **only changes where Cypress looks**. It does **not** move the already-running portal container. From `cypress.config.ts`:
|
||||
|
||||
```ts
|
||||
portalUrl: process.env.CYPRESS_PORTAL_URL ?? "http://localhost:5373",
|
||||
```
|
||||
|
||||
So Cypress knocked on door **5374**, but the portal was living behind door **5373**. Nobody answered.
|
||||
|
||||
This affected the 5 test files that open the portal:
|
||||
`onboarding`, `contract-lifecycle`, `cross-app`, `export_one_time`, `intercity_one_time`.
|
||||
|
||||
### The solution
|
||||
|
||||
Use the port that matches the running container — simply leave the setting out, because `5373` is already the default:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.e2e.yaml --profile cypress \
|
||||
run --rm cypress --spec 'cypress/e2e/flows/*.cy.ts'
|
||||
```
|
||||
|
||||
Or, best of all, use the project's official launcher, which picks matching ports for everything automatically:
|
||||
|
||||
```bash
|
||||
pnpm e2e:freight:run
|
||||
```
|
||||
|
||||
> After using the correct port, `cross-app.cy.ts` passed 3/3 — proving the port was the only thing wrong there.
|
||||
|
||||
---
|
||||
|
||||
## 6. Problem 3 — The real bug: `contract-lifecycle` failed all 5 tests ✅ FIXED
|
||||
|
||||
This was the one **genuine code problem**, and it is now fixed and verified.
|
||||
|
||||
### What you saw
|
||||
|
||||
```
|
||||
1) customer creates and submits a GENERAL import container contract:
|
||||
AssertionError: Timed out retrying after 10000ms:
|
||||
Expected to find element: `[role="checkbox"][aria-label="20ft Container"]`,
|
||||
but never found it.
|
||||
```
|
||||
|
||||
And then 4 more failures after it.
|
||||
|
||||
### Why it happened
|
||||
|
||||
The test was looking for a **checkbox** to choose the container size (20ft):
|
||||
|
||||
```ts
|
||||
cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
|
||||
cy.get('textarea[placeholder*="Electronics"]').type("E2E electronics shipment scope");
|
||||
```
|
||||
|
||||
But **the application was deliberately changed**. Container contracts now automatically cover **both** 20ft and 40ft sizes, so the checkbox was removed and replaced by a simple information card. The cargo description was also moved to the booking step.
|
||||
|
||||
You can see this clearly in the current application code, `step3-cargo-scope.tsx`:
|
||||
|
||||
```tsx
|
||||
{/* Container scope: the contract always covers BOTH sizes and quotes both
|
||||
rates. Quantities (a size can be 0) and the cargo description are
|
||||
captured at booking time. */}
|
||||
{cargoType === "container" && (
|
||||
...
|
||||
<Text fz={14} fw={700}>20ft & 40ft containers covered</Text>
|
||||
```
|
||||
|
||||
And the validation rules confirm nothing else is needed (`schema.ts`):
|
||||
|
||||
```ts
|
||||
// Container scope needs no validation: both sizes are always in scope and
|
||||
// the cargo description moved to booking time.
|
||||
```
|
||||
|
||||
So: **the application was updated, but the test was not.** The test kept looking for a button that no longer exists.
|
||||
|
||||
**Why all 5 tests failed, not just one.** These 5 tests run in order and build on each other. Test 1 creates the contract; tests 2–5 then approve and sign *that* contract. Because test 1 could not finish, there was no fresh contract, so tests 2–5 had nothing correct to work on and failed too. This is called a **cascade failure** — one real error causing several fake-looking errors.
|
||||
|
||||
### The fix
|
||||
|
||||
I removed the two steps that referred to the deleted fields.
|
||||
|
||||
**`e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts`**
|
||||
|
||||
```diff
|
||||
- // Step 1 — Cargo & Route.
|
||||
+ // Step 1 — Cargo & Route. Container contracts now auto-cover BOTH 20ft &
|
||||
+ // 40ft (no size picker — just an info card) and the cargo description moved
|
||||
+ // to booking time, so the scope select plus the route is all this step needs.
|
||||
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
|
||||
- cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
|
||||
- cy.get('textarea[placeholder*="Electronics"]').type(
|
||||
- "E2E electronics shipment scope",
|
||||
- );
|
||||
cy.mantineSelect(/^Origin Yard/, "Djibouti Port Terminal");
|
||||
```
|
||||
|
||||
I found the **same outdated code in two more test files** and fixed them as well, so the problem is solved everywhere and not just in one place:
|
||||
|
||||
**`export_one_time.cy.ts`**
|
||||
|
||||
```diff
|
||||
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
|
||||
- cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
|
||||
- cy.get('[role="checkbox"][aria-label="40ft Container"]').click();
|
||||
- cy.get('textarea[placeholder*="Electronics"]').type("E2E export electronics");
|
||||
cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
|
||||
```
|
||||
|
||||
**`intercity_one_time.cy.ts`**
|
||||
|
||||
```diff
|
||||
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
|
||||
- cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
|
||||
- cy.get('textarea[placeholder*="Electronics"]').type(
|
||||
- "E2E intercity electronics between Ethiopian yards",
|
||||
- );
|
||||
cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
|
||||
```
|
||||
|
||||
### A second, smaller bug found while checking the fix
|
||||
|
||||
After the first fix, 4 of 5 tests passed and one still failed:
|
||||
|
||||
```
|
||||
AssertionError: expected '<p.mantine-Text-root>' to be 'visible'
|
||||
This element is not visible because its content is being clipped by one of its
|
||||
parent elements, which has a CSS property of overflow: hidden, clip, scroll or auto
|
||||
```
|
||||
|
||||
The contract *was* created correctly. The word "Submitted" *was* on the screen. But it sits inside a side-scrolling list, so Cypress treated it as hidden.
|
||||
|
||||
Because the line just after it already checks the database properly (which is the stronger, more trustworthy check), I made the screen check clip-proof:
|
||||
|
||||
```diff
|
||||
- cy.contains("Submitted", { timeout: 15000 }).should("be.visible");
|
||||
+ // `exist`, not `be.visible`: the status badge sits inside the list's
|
||||
+ // horizontally-scrolling container, so Cypress reports it as clipped by an
|
||||
+ // overflow parent. The DB assertion below is the authoritative check.
|
||||
+ cy.contains("Submitted", { timeout: 15000 }).should("exist");
|
||||
```
|
||||
|
||||
### Proof that it works
|
||||
|
||||
**Before the fix:**
|
||||
|
||||
```
|
||||
contract-lifecycle.cy.ts 5 tests 0 passing 5 failing
|
||||
```
|
||||
|
||||
**After the fix:**
|
||||
|
||||
```
|
||||
✓ customer creates and submits a GENERAL import container contract (7574ms)
|
||||
✓ marketer accepts the submission and approves the LINE_STAFF step (3668ms)
|
||||
✓ director approves the final step — contract PDF becomes ready (2916ms)
|
||||
✓ customer signs the contract with OTP (5143ms)
|
||||
✓ staff counter-signs — GENERAL contract becomes CONTRACT_ACTIVE (3643ms)
|
||||
|
||||
5 passing (29s) EXIT=0
|
||||
```
|
||||
|
||||
✅ **All 5 tests now pass.**
|
||||
|
||||
---
|
||||
|
||||
## 7. Problem 4 — The remaining 5 test files: the app inside Docker is old
|
||||
|
||||
This is the second most important finding, and it explains **almost all remaining failures**.
|
||||
|
||||
### What you saw
|
||||
|
||||
Many strange, unrelated-looking errors, for example:
|
||||
|
||||
```
|
||||
CypressError: cy.request() failed on:
|
||||
http://localhost:3101/api/train-scheduling/schedules/<id>/dispatch
|
||||
The response we received from your web server was:
|
||||
> 400: Bad Request
|
||||
```
|
||||
|
||||
```
|
||||
AssertionError: expected '/dashboard/operations/train-scheduling-v2'
|
||||
to match /\/dashboard\/operations\/train-scheduling-v2\/.+/
|
||||
```
|
||||
|
||||
```
|
||||
AssertionError: Expected to find content: 'Clearance Review' within the selector: '[role="tab"]'
|
||||
```
|
||||
|
||||
### Why it happened
|
||||
|
||||
**The test files are new, but the running application is old.**
|
||||
|
||||
- The test files live on your disk and are shared into the container live, so they are always the newest version.
|
||||
- The API and websites run from **Docker images**, which are frozen photographs of the code. They only change when you **rebuild** them.
|
||||
|
||||
Here are the actual times:
|
||||
|
||||
```
|
||||
freight-api-e2e image built: 2026-07-23 09:22
|
||||
freight-portal-e2e image built: 2026-07-23 09:22
|
||||
freight-backoffice-e2e image built: 2026-07-23 09:22
|
||||
|
||||
latest commit (HEAD): 2026-07-23 20:24 ← 11 hours newer
|
||||
```
|
||||
|
||||
**5 commits were made after those images were built:**
|
||||
|
||||
```
|
||||
668b5e1c add permissions and fix issues
|
||||
40f16f3c changes
|
||||
12767605 changes
|
||||
15a6bab5 revert back the clerance payment
|
||||
13609f8d changes
|
||||
```
|
||||
|
||||
### The clearest proof
|
||||
|
||||
Commit `668b5e1c` changed the **API and its tests together**, in the same commit:
|
||||
|
||||
```
|
||||
apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
|
||||
apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
|
||||
e2e/freight/cypress/e2e/flows/import-utils.ts
|
||||
e2e/freight/cypress/e2e/flows/import_full_train.cy.ts
|
||||
```
|
||||
|
||||
So the **new test** sends the **new** data shape to `/dispatch`, but the **old API** inside Docker does not understand it and replies `400 Bad Request`.
|
||||
|
||||
This is not a bug in the code. It is simply **new tests talking to an old server**.
|
||||
|
||||
The `train-scheduling-v2` failures have the same cause: creating a train schedule fails on the old server, so no train exists, and every later step in those files fails as a cascade (this is why `segment_weight` lost 12 of 13 tests from a single root cause).
|
||||
|
||||
### The solution
|
||||
|
||||
Rebuild the Docker images so they contain the current code, then run the tests again:
|
||||
|
||||
```bash
|
||||
# stop the old stack and rebuild from current code
|
||||
docker compose -f docker-compose.e2e.yaml down
|
||||
docker compose -f docker-compose.e2e.yaml build
|
||||
pnpm e2e:freight:run
|
||||
```
|
||||
|
||||
> ⚠️ Note: `down` deletes the test database (it is a throwaway database, which is normal and safe).
|
||||
> ⚠️ Note: only do this when nobody else is using the same stack.
|
||||
|
||||
---
|
||||
|
||||
## 8. Files I changed
|
||||
|
||||
Only test files were changed. **No application code was modified.**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts` | Removed deleted container-size checkbox + description box; made the "Submitted" check clip-proof; updated the header comment |
|
||||
| `e2e/freight/cypress/e2e/flows/export_one_time.cy.ts` | Removed deleted 20ft + 40ft checkboxes and description box |
|
||||
| `e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts` | Removed deleted 20ft checkbox and description box |
|
||||
|
||||
Evidence the last two fixes helped, even on the old server:
|
||||
|
||||
- `export_one_time` — previously failed at contract creation; now reaches 9 passing tests.
|
||||
- `intercity_one_time` — previously failed at contract creation; now reaches 11 passing tests.
|
||||
|
||||
Their remaining failures are all from problem 4 (old Docker images).
|
||||
|
||||
---
|
||||
|
||||
## 9. How to run the tests correctly
|
||||
|
||||
**Step 1 — make sure no other Cypress is running** (this avoids the `137` crash):
|
||||
|
||||
```bash
|
||||
docker ps --format '{{.Names}}' | grep cypress
|
||||
```
|
||||
|
||||
**Step 2 — rebuild so Docker has the current code:**
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.e2e.yaml down
|
||||
docker compose -f docker-compose.e2e.yaml build
|
||||
```
|
||||
|
||||
**Step 3 — run the tests using the official launcher** (it chooses matching ports for you):
|
||||
|
||||
```bash
|
||||
pnpm e2e:freight:run
|
||||
```
|
||||
|
||||
If you prefer the raw Docker command, **do not** override the portal port unless you also restart the portal container on that same port:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.e2e.yaml --profile cypress \
|
||||
run --rm cypress --spec 'cypress/e2e/flows/*.cy.ts'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Conclusion
|
||||
|
||||
- **One real bug was found and fixed:** three test files were still clicking a container-size checkbox and a description box that the application no longer has, because container contracts now cover both 20ft and 40ft automatically.
|
||||
- `contract-lifecycle.cy.ts` went from **0 of 5 passing** to **5 of 5 passing**, confirmed by running it twice.
|
||||
- The `exit 137` crash was caused by **two Cypress runs at the same time** fighting over the shared virtual screen `:99`.
|
||||
- The portal failures were caused by **`E2E_PORTAL_PORT=5374`**, while the portal was really on **5373**.
|
||||
- The remaining failures are caused by **Docker images that are 11 hours older than the code**. They need a rebuild, not a code fix.
|
||||
|
||||
**Recommended next step:** rebuild the Docker images and run the full suite again. Only then can the remaining 5 test files be judged fairly.
|
||||
@@ -42,8 +42,17 @@ JWT_REFRESH_TOKEN_EXPIRES=7d
|
||||
# IAM seed defaults (used by @tria-plc/iamapi-common on first boot)
|
||||
SUPER_ADMIN_EMAIL=superadmin@tria.com
|
||||
SUPER_ADMIN_PHONE=
|
||||
# Super-admin password. Falls back to DEFAULT_PASSWORD when empty.
|
||||
SUPER_ADMIN_DEFAULT_PASSWORD=
|
||||
DEFAULT_PASSWORD=password@tria
|
||||
|
||||
# IAM baseline shared with edr-passenger-api (roles, IAM app + permissions,
|
||||
# position types, organization types + default units, org/unit settings, super
|
||||
# admin). Replaces the seeder that shipped inside @tria-plc/iamapi-common — see
|
||||
# packages/iam-seed. Seeds by DEFAULT when unset; every write is insert-only.
|
||||
# Set to false to opt out.
|
||||
SEED_IAM_BASELINE=true
|
||||
|
||||
# Freight org + staff (bookings / rule-engine IAM)
|
||||
SEED_EDR_ORG=true
|
||||
SEED_FREIGHT_STAFF=true
|
||||
|
||||
@@ -35,12 +35,12 @@
|
||||
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
|
||||
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
|
||||
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
|
||||
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
"@edr/iam-seed": "workspace:*",
|
||||
"@edr/payment-providers": "workspace:*",
|
||||
"@edr/types": "workspace:*",
|
||||
"@golevelup/nestjs-rabbitmq": "^5.5.0",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ensurePostgresSchemas,
|
||||
APPLICATION_SEARCH_PATH,
|
||||
} from "./config/ensure-postgres-schemas";
|
||||
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
||||
import { IamModule } from "@tria-plc/iamapi-common";
|
||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||
|
||||
@@ -29,6 +30,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||
|
||||
// import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
|
||||
import { TruckTypesModule } from "./modules/truck-types/truck-types.module";
|
||||
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
|
||||
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
||||
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
||||
@@ -152,12 +154,25 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
applications: [EDR_FREIGHT_APPLICATION],
|
||||
permissions: EDR_FREIGHT_PERMISSIONS,
|
||||
}),
|
||||
// Replaces the package's DataSeeder. Shared with edr-passenger-api, which
|
||||
// seeds the same `iam` schema — see packages/iam-seed.
|
||||
IamSeedModule.forRoot({
|
||||
superAdmin: {
|
||||
username: "superadmin",
|
||||
name: { am: "ሱፐር አድሚን", en: "Super Admin" },
|
||||
roleKey: "super_admin",
|
||||
organizationKey: "edr_freight",
|
||||
unitKey: "edr_freight_app",
|
||||
fallbackEmail: "superadmin@tria.com",
|
||||
},
|
||||
}),
|
||||
BookingsModule,
|
||||
ContractsModule,
|
||||
SignaturesModule,
|
||||
FilesModule,
|
||||
ConsignmentsModule,
|
||||
LocomotivesModule,
|
||||
TruckTypesModule,
|
||||
WagonTypesModule,
|
||||
TrainSetsModule,
|
||||
TrainSchedulesModule,
|
||||
@@ -229,7 +244,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
// private readonly seeder: DataSeeder,
|
||||
private readonly iamBaselineSeeder: IamBaselineSeeder,
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
@@ -259,13 +274,22 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
|
||||
// Permissions foundation — keep enabled:
|
||||
// freightPermissionKeyMigration → renames legacy permission keys
|
||||
// seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions
|
||||
// edrOrgSeeder → seeds org/unit + the Permission catalog
|
||||
// iamBaselineSeeder → @edr/iam-seed: IAM app, roles, permissions,
|
||||
// position types, organization types +
|
||||
// default units, org/unit settings and the
|
||||
// super-admin account. Replaces the package's
|
||||
// DataSeeder, and is shared with
|
||||
// edr-passenger-api so one writer owns the
|
||||
// `iam` schema. Runs after edrOrgSeeder
|
||||
// because the super admin attaches to the
|
||||
// edr_freight org/unit.
|
||||
// Writes nothing unless SEED_IAM_BASELINE=true.
|
||||
// freightPositionsSeeder → seeds Position + PositionPermission rows
|
||||
// (depends on edrOrgSeeder, must run after)
|
||||
await this.freightPermissionKeyMigrationSeeder.run();
|
||||
// await this.seeder.run();
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.iamBaselineSeeder.run();
|
||||
await this.freightPositionsSeeder.run();
|
||||
|
||||
// File upload settings — keep enabled.
|
||||
|
||||
@@ -23,11 +23,34 @@ export const StaffReference = () => applyDecorators(UseGuards(JwtGuard));
|
||||
|
||||
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
||||
|
||||
/**
|
||||
* The document-review countdown in the backoffice header. Its own permission so
|
||||
* it can be granted to exactly the position types that decide operation
|
||||
* requests, instead of every holder of bookings:view.
|
||||
*/
|
||||
export const BookingDocReviewAlert = () =>
|
||||
BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert);
|
||||
|
||||
export const TrainSchedulingView = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
|
||||
|
||||
export const TrainSchedulingManage = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
|
||||
// Granular train-scheduling actions replace the retired coarse manage:
|
||||
// create a schedule, update (assign/consist/loading/finalize/dispatch/arrive…),
|
||||
// cancel a schedule, reschedule (+ maintenance), and manage global rules.
|
||||
export const TrainSchedulingCreate = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.create);
|
||||
|
||||
export const TrainSchedulingUpdate = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.update);
|
||||
|
||||
export const TrainSchedulingCancel = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.cancel);
|
||||
|
||||
export const TrainSchedulingReschedule = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.reschedule);
|
||||
|
||||
export const TrainSchedulingRulesManage = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.rulesManage);
|
||||
|
||||
/**
|
||||
* Fleet guards take an optional granular per-resource key (locomotives:create,
|
||||
@@ -58,6 +81,32 @@ export const WagonTransferFulfill = () =>
|
||||
export const WagonTransferHistoryAll = () =>
|
||||
BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll);
|
||||
|
||||
/**
|
||||
* Open the transfer-requests desk. `wagons:view` is accepted as a one-of
|
||||
* fallback so staff who could already reach the queue keep it without a
|
||||
* re-grant — same pattern the granular fleet keys use.
|
||||
*/
|
||||
export const WagonTransferView = () =>
|
||||
BookingStaff([FREIGHT_PERMS.wagons.transferView, FREIGHT_PERMS.wagons.view]);
|
||||
|
||||
/** Withdraw a request that has not moved any wagon yet. */
|
||||
export const WagonTransferCancel = () =>
|
||||
BookingStaff([
|
||||
FREIGHT_PERMS.wagons.transferCancel,
|
||||
FREIGHT_PERMS.wagons.transferRequest,
|
||||
]);
|
||||
|
||||
/**
|
||||
* End a request short of the requested count. Whoever may move wagons may also
|
||||
* declare the yard has no more to give, so fulfil is accepted alongside the
|
||||
* dedicated key.
|
||||
*/
|
||||
export const WagonTransferCloseShort = () =>
|
||||
BookingStaff([
|
||||
FREIGHT_PERMS.wagons.transferCloseShort,
|
||||
FREIGHT_PERMS.wagons.transferFulfill,
|
||||
]);
|
||||
|
||||
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
|
||||
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
assertCanApproveContractStep,
|
||||
canEditContractStep,
|
||||
} from './freight-permission.util';
|
||||
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
||||
|
||||
const userWith = (...keys: string[]) => ({
|
||||
permissions: keys.map((key) => ({ key })),
|
||||
});
|
||||
|
||||
describe('hazardous contract approval steps', () => {
|
||||
it('rejects an approver who only holds ordinary contract-approve permissions', () => {
|
||||
// The blanket "any contract approve permission" fallback must NOT reach
|
||||
// dangerous goods — that is the whole point of the dedicated desks.
|
||||
const lineStaff = userWith(FREIGHT_PERMS.contracts.approveLineStaff);
|
||||
|
||||
expect(() =>
|
||||
assertCanApproveContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE'),
|
||||
).toThrow(ForbiddenException);
|
||||
expect(canEditContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts only the matching hazardous permission', () => {
|
||||
const first = userWith(FREIGHT_PERMS.contracts.hazardousApprovalOne);
|
||||
|
||||
expect(() =>
|
||||
assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_ONE'),
|
||||
).not.toThrow();
|
||||
// Holding step one does not confer step two.
|
||||
expect(() =>
|
||||
assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_TWO'),
|
||||
).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('does not let a hazardous approver stand in for the commercial chain', () => {
|
||||
const hazardOnly = userWith(
|
||||
FREIGHT_PERMS.contracts.hazardousApprovalOne,
|
||||
FREIGHT_PERMS.contracts.hazardousApprovalTwo,
|
||||
);
|
||||
|
||||
expect(() => assertCanApproveContractStep(hazardOnly, 'CEO')).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -151,6 +151,24 @@ const APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
||||
CEO: FREIGHT_PERMS.bookings.approveCeo,
|
||||
};
|
||||
|
||||
/**
|
||||
* Approval-chain roles synthesized for hazardous contracts (see
|
||||
* `instantiateApprovalSteps`). Unlike the legacy roles below they are NOT
|
||||
* position types — they authorize purely on their own dedicated permission, and
|
||||
* they deliberately opt out of the blanket "holds any contract-approve
|
||||
* permission" fallback so a normal approver cannot sign off dangerous goods.
|
||||
*/
|
||||
export const HAZARDOUS_APPROVAL_ROLE_PERMISSION: Record<string, string> = {
|
||||
HAZARDOUS_APPROVAL_ONE: FREIGHT_PERMS.contracts.hazardousApprovalOne,
|
||||
HAZARDOUS_APPROVAL_TWO: FREIGHT_PERMS.contracts.hazardousApprovalTwo,
|
||||
};
|
||||
|
||||
/** The two hazardous steps, in the order they are prepended to the chain. */
|
||||
export const HAZARDOUS_APPROVAL_ROLES = [
|
||||
'HAZARDOUS_APPROVAL_ONE',
|
||||
'HAZARDOUS_APPROVAL_TWO',
|
||||
] as const;
|
||||
|
||||
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
||||
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
||||
@@ -183,6 +201,16 @@ export function assertCanApproveContractStep(
|
||||
): void {
|
||||
if (isFreightApprovalAdmin(user)) return;
|
||||
|
||||
// Hazardous steps are permission-only and strict — no legacy alias, no
|
||||
// blanket approve fallback.
|
||||
const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole];
|
||||
if (hazardousPermission) {
|
||||
if (hasFreightPermission(user, hazardousPermission)) return;
|
||||
throw new ForbiddenException(
|
||||
`Missing permission: ${hazardousPermission}`,
|
||||
);
|
||||
}
|
||||
|
||||
const positionTypes = collectPositionTypeKeys(user);
|
||||
if (positionTypes.includes(requiredRole)) return;
|
||||
|
||||
@@ -219,6 +247,11 @@ export function canEditContractStep(
|
||||
): boolean {
|
||||
if (isFreightApprovalAdmin(user)) return true;
|
||||
|
||||
const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole];
|
||||
if (hazardousPermission) {
|
||||
return hasFreightPermission(user, hazardousPermission);
|
||||
}
|
||||
|
||||
const positionTypes = collectPositionTypeKeys(user);
|
||||
if (positionTypes.includes(requiredRole)) return true;
|
||||
|
||||
|
||||
40
apps/edr-freight-api/src/common/grn.util.spec.ts
Normal file
40
apps/edr-freight-api/src/common/grn.util.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { generateGrnNumber, grnOwnerSlug } from './grn.util';
|
||||
|
||||
/**
|
||||
* The GRN is mapped to the goods owner for BOTH directions, so a note is
|
||||
* identifiable by who owns the cargo. The reference slice stays the uniqueness
|
||||
* anchor — one owner can have several bookings received the same day.
|
||||
*/
|
||||
const date = new Date('2026-07-27T09:15:00Z');
|
||||
const bookingId = '1a2b3c4d-1111-2222-3333-444455556666';
|
||||
|
||||
describe('GRN number', () => {
|
||||
it('maps an import GRN to the owner', () => {
|
||||
expect(generateGrnNumber('IMPORT', bookingId, date, 'Shafici Pharmaceutical')).toBe(
|
||||
'GRN-IMPORT-20260727-SHAFICIPHARM-1A2B3C4D',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps an export GRN to the owner the same way', () => {
|
||||
expect(generateGrnNumber('EXPORT', bookingId, date, 'Tria Trading PLC')).toBe(
|
||||
'GRN-EXPORT-20260727-TRIATRADINGP-1A2B3C4D',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the owner-less format when there is no owner (manual walk-in)', () => {
|
||||
expect(generateGrnNumber('WH', bookingId, date)).toBe('GRN-WH-20260727-1A2B3C4D');
|
||||
expect(generateGrnNumber('WH', bookingId, date, ' ')).toBe('GRN-WH-20260727-1A2B3C4D');
|
||||
});
|
||||
|
||||
it('stays unique per booking for one owner on one day', () => {
|
||||
const a = generateGrnNumber('IMPORT', bookingId, date, 'Acme');
|
||||
const b = generateGrnNumber('IMPORT', 'ffffffff-9999-0000-0000-000000000000', date, 'Acme');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('strips punctuation and caps the owner segment', () => {
|
||||
expect(grnOwnerSlug('Ethio-Djibouti Railway S.C.')).toBe('ETHIODJIBOUT');
|
||||
expect(grnOwnerSlug('a/b c')).toBe('ABC');
|
||||
expect(grnOwnerSlug(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,41 @@
|
||||
/**
|
||||
* Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<REF8>`.
|
||||
* Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<OWNER>-<REF8>`.
|
||||
*
|
||||
* The GRN is mapped to the goods OWNER (the booking's customer / consignee) for
|
||||
* both import and export, so a note is identifiable by who owns the cargo
|
||||
* without opening it. The trailing reference slice stays as the uniqueness
|
||||
* anchor — one owner can have several bookings received on the same day.
|
||||
* Owner-less receipts (manual walk-ins with no booking) fall back to the
|
||||
* original `GRN-<DIRECTION>-<YYYYMMDD>-<REF8>` form.
|
||||
*
|
||||
* Shared so a GRN raised at a load/unload facility is indistinguishable from one
|
||||
* raised in a warehouse — the two live in different tables
|
||||
* (facility_handling_events vs warehouse_inventory), and a second generator would
|
||||
* eventually let their formats drift apart.
|
||||
*/
|
||||
export function generateGrnNumber(direction: string, referenceId: string, date: Date): string {
|
||||
export function generateGrnNumber(
|
||||
direction: string,
|
||||
referenceId: string,
|
||||
date: Date,
|
||||
ownerName?: string | null,
|
||||
): string {
|
||||
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
|
||||
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
|
||||
const owner = grnOwnerSlug(ownerName);
|
||||
const base = `GRN-${direction.toUpperCase()}-${stamp}`;
|
||||
return owner ? `${base}-${owner}-${suffix}` : `${base}-${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner name → GRN-safe token: letters/digits only, upper-cased, capped so a
|
||||
* long company name can't run away with the number. Null when there is nothing
|
||||
* usable, which drops the segment rather than emitting an empty `--`.
|
||||
*/
|
||||
export function grnOwnerSlug(ownerName?: string | null): string | null {
|
||||
const slug = (ownerName ?? '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[^a-zA-Z0-9]+/g, '')
|
||||
.toUpperCase()
|
||||
.slice(0, 12);
|
||||
return slug || null;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ type MileRecord = {
|
||||
bookingContainers?: Array<{
|
||||
units?: Array<{ vgmTons?: number | string | null }> | null;
|
||||
}> | null;
|
||||
/** Attached here: the train schedule the booking rides, for mile alignment. */
|
||||
trainSchedule?: { trainNumber: string | null; departureDate: string | null } | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
@@ -36,6 +38,38 @@ export async function attachMileFinancials(
|
||||
if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3));
|
||||
}
|
||||
|
||||
// Train alignment: which schedule each booking rides (mile pickups/deliveries
|
||||
// are planned against the train's departure).
|
||||
const bookingIds = [...new Set(records.map((r) => r.bookingId).filter(Boolean))] as string[];
|
||||
if (bookingIds.length) {
|
||||
const schedules: Array<{
|
||||
bookingId: string;
|
||||
trainNumber: string | null;
|
||||
departureDate: string | null;
|
||||
}> = await dataSource.query(
|
||||
`SELECT DISTINCT ON (tsb.booking_id)
|
||||
tsb.booking_id AS "bookingId",
|
||||
ts.train_number AS "trainNumber",
|
||||
COALESCE(ts.actual_departure_at, ts.scheduled_departure_date)::text AS "departureDate"
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.train_schedules ts
|
||||
ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
|
||||
WHERE tsb.booking_id = ANY($1::uuid[]) AND tsb.deleted_at IS NULL
|
||||
ORDER BY tsb.booking_id, tsb.created_at DESC`,
|
||||
[bookingIds],
|
||||
);
|
||||
const byBookingSchedule = new Map(schedules.map((s) => [s.bookingId, s]));
|
||||
for (const r of records) {
|
||||
const s = r.bookingId ? byBookingSchedule.get(r.bookingId) : undefined;
|
||||
if (r.booking && s) {
|
||||
r.booking.trainSchedule = {
|
||||
trainNumber: s.trainNumber,
|
||||
departureDate: s.departureDate,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const needAdvance = records.filter(
|
||||
(r) => r.bookingId && !(Number(r.advancedPayment) > 0),
|
||||
);
|
||||
|
||||
@@ -13,9 +13,22 @@ export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
|
||||
);
|
||||
|
||||
export const RuleEngineManage = (slug: RuleEngineResourceSlug) =>
|
||||
// Granular CRUD replaces the retired coarse RuleEngineManage. Each write
|
||||
// endpoint carries the specific action it performs — create on POST-new,
|
||||
// update on PATCH / reorder / move-order, delete on DELETE.
|
||||
export const RuleEngineCreate = (slug: RuleEngineResourceSlug) =>
|
||||
applyDecorators(
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])),
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.create(slug)])),
|
||||
);
|
||||
|
||||
export const RuleEngineUpdate = (slug: RuleEngineResourceSlug) =>
|
||||
applyDecorators(
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.update(slug)])),
|
||||
);
|
||||
|
||||
export const RuleEngineDelete = (slug: RuleEngineResourceSlug) =>
|
||||
applyDecorators(
|
||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.delete(slug)])),
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { hazardClassLabel } from '@edr/types';
|
||||
|
||||
import { ContractsRepository } from '../modules/contracts/contracts.repository';
|
||||
import {
|
||||
@@ -30,6 +31,8 @@ export interface ContractDocumentSignatureView {
|
||||
signerDisplayName: string;
|
||||
signedAt: string;
|
||||
signatureImageUrl?: string | null;
|
||||
/** Company stamp/seal; rendered next to the signature when present. */
|
||||
stampImageUrl?: string | null;
|
||||
}
|
||||
|
||||
/** A single unit-rate row on the contract PDF — price per unit, NO total. */
|
||||
@@ -141,6 +144,11 @@ export class ContractDocumentViewModelBuilder {
|
||||
|
||||
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
|
||||
const hasStaff = signatures.some((s) => s.role === 'STAFF');
|
||||
// Signed before company stamps were required — the customer has to sign
|
||||
// again to attach one, otherwise EDR can never counter-sign the contract.
|
||||
const customerStampMissing = signatures.some(
|
||||
(s) => s.role === 'CUSTOMER' && !s.stampImageUrl,
|
||||
);
|
||||
const hasContractFile = Boolean(
|
||||
contract.files?.some((f) => f.code === 'contract'),
|
||||
);
|
||||
@@ -183,7 +191,9 @@ export class ContractDocumentViewModelBuilder {
|
||||
// Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
|
||||
// view-model's narrower CUSTOMER|STAFF role union.
|
||||
signatures: signatures as unknown as ContractViewModel['signatures'],
|
||||
canSignCustomer: contract.status === 'CONTRACT_READY' && !hasCustomer,
|
||||
canSignCustomer:
|
||||
(contract.status === 'CONTRACT_READY' && !hasCustomer) ||
|
||||
(contract.status === 'SIGNED_CUSTOMER' && customerStampMissing),
|
||||
canSignStaff:
|
||||
contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
|
||||
hasContractDocument: hasContractFile,
|
||||
@@ -208,6 +218,7 @@ export class ContractDocumentViewModelBuilder {
|
||||
signerDisplayName: row.signerDisplayName,
|
||||
signedAt: this.formatDate(row.signedAt),
|
||||
signatureImageUrl: row.signatureFile?.url ?? null,
|
||||
stampImageUrl: row.stampFile?.url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -292,7 +303,16 @@ export class ContractDocumentViewModelBuilder {
|
||||
cargoDescription: this.valueOrDash(cargoName),
|
||||
totalWeightVgm: '—',
|
||||
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
|
||||
hazardousLabel: contract.isHazardous ? 'Yes' : 'No',
|
||||
// A hazardous contract names the declared class + UN number on the
|
||||
// schedule — the flag alone is not a dangerous-goods declaration.
|
||||
hazardousLabel: contract.isHazardous
|
||||
? [
|
||||
hazardClassLabel(contract.hazardClass) ?? 'Yes',
|
||||
contract.unNumber ? `UN ${contract.unNumber}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: 'No',
|
||||
firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress),
|
||||
lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress),
|
||||
};
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
|
||||
<p class="sig-meta"><strong>Role:</strong> Authorized EDR representative</p>
|
||||
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
|
||||
{{#if stampImageUrl}}
|
||||
<div class="sig-stamp">
|
||||
<span class="sig-stamp-label">Company stamp</span>
|
||||
<div class="sig-stamp-box"><img src="{{stampImageUrl}}" alt="Service provider stamp" /></div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{else}}
|
||||
@@ -32,6 +38,12 @@
|
||||
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
|
||||
<p class="sig-meta"><strong>Role:</strong> Authorized client representative</p>
|
||||
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
|
||||
{{#if stampImageUrl}}
|
||||
<div class="sig-stamp">
|
||||
<span class="sig-stamp-label">Company stamp</span>
|
||||
<div class="sig-stamp-box"><img src="{{stampImageUrl}}" alt="Client stamp" /></div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{else}}
|
||||
|
||||
@@ -372,25 +372,30 @@
|
||||
font-size: 9pt;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ── Witnesses ────────────────────────────────────────────────────────── */
|
||||
.witnesses { margin-top: 20px; }
|
||||
.witness-table {
|
||||
font-size: 9.5pt;
|
||||
margin-top: 6px;
|
||||
.sig-stamp {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.witness-table th,
|
||||
.witness-table td {
|
||||
border-bottom: 1px solid #c9e4d9;
|
||||
padding: 9px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
.witness-table th {
|
||||
.sig-stamp-label {
|
||||
color: #0e5b45;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 8.5pt;
|
||||
font-size: 7.5pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4pt;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sig-stamp-box {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 30mm;
|
||||
justify-content: center;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.sig-stamp-box img {
|
||||
display: block;
|
||||
max-height: 30mm;
|
||||
max-width: 45mm;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
|
||||
@@ -147,19 +147,6 @@
|
||||
authorized to sign and execute this Contract Agreement.
|
||||
</p>
|
||||
{{> signatures_block}}
|
||||
|
||||
<div class="witnesses">
|
||||
<p class="sig-title">Witnesses</p>
|
||||
<table class="witness-table">
|
||||
<thead>
|
||||
<tr><th></th><th>Name</th><th>Signature</th><th>Date</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>1.</td><td></td><td></td><td></td></tr>
|
||||
<tr><td>2.</td><td></td><td></td><td></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -2,6 +2,7 @@ import "reflect-metadata";
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import {
|
||||
HttpExceptionFilter,
|
||||
@@ -11,8 +12,25 @@ import {
|
||||
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
/**
|
||||
* JSON body ceiling. Signing posts the signature AND the company stamp as
|
||||
* base64 in one JSON body, and base64 inflates bytes by ~4/3 — a 10MB stamp is
|
||||
* ~13.4MB on the wire. Express defaults to 100kb, which rejected any real stamp
|
||||
* image with a 413 "request entity too large".
|
||||
*/
|
||||
const JSON_BODY_LIMIT = '20mb';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
// Nest's own body-parser API, NOT `app.use(json(...))` from express: express
|
||||
// is not a declared dependency of this app (it arrives under
|
||||
// @nestjs/platform-express), so importing it directly resolved only through
|
||||
// pnpm's hoisted dev store and died as MODULE_NOT_FOUND in the production
|
||||
// image, where `pnpm deploy --prod` installs declared dependencies only.
|
||||
// This also RECONFIGURES the default parsers rather than racing them.
|
||||
app.useBodyParser('json', { limit: JSON_BODY_LIMIT });
|
||||
app.useBodyParser('urlencoded', { limit: JSON_BODY_LIMIT, extended: true });
|
||||
|
||||
// Dev CORS: reflect any localhost origin and allow credentials so the
|
||||
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Bulk tonnage at assignment time. First-mile trucks and export self-haul
|
||||
* trucks carry a planned load (tonnes + optional item count) so bulk bookings
|
||||
* draw down as vehicles are assigned — not only at the weighbridge.
|
||||
*/
|
||||
export class AddMileTonsQuantity2820000000000 implements MigrationInterface {
|
||||
name = 'AddMileTonsQuantity2820000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.first_mile_vehicle_assignments ADD COLUMN IF NOT EXISTS tons numeric(14,3);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.first_mile_vehicle_assignments ADD COLUMN IF NOT EXISTS quantity integer;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.customer_truck_assignments ADD COLUMN IF NOT EXISTS planned_tons numeric(14,3);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.customer_truck_assignments ADD COLUMN IF NOT EXISTS planned_quantity integer;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.customer_truck_assignments DROP COLUMN IF EXISTS planned_quantity;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.customer_truck_assignments DROP COLUMN IF EXISTS planned_tons;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.first_mile_vehicle_assignments DROP COLUMN IF EXISTS quantity;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.first_mile_vehicle_assignments DROP COLUMN IF EXISTS tons;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Truck types become back-office data instead of a hardcoded `VehicleType` enum,
|
||||
* so EDR can add a configuration without a code change.
|
||||
*
|
||||
* `vehicles.vehicle_type` is deliberately LEFT IN PLACE as a denormalised code.
|
||||
* Truck-detention billing groups trucks with raw SQL over that column
|
||||
* (`SELECT v.vehicle_type ... GROUP BY`, warehouse-fee.service.ts) and matches
|
||||
* the result against `warehouse_fee_rules.vehicle_type`. Swapping it for the FK
|
||||
* outright would silently drop detention charges, so the FK is additive and the
|
||||
* service writes the type's code through on every save.
|
||||
*
|
||||
* Raw SQL, `freight.`-qualified, IF NOT EXISTS throughout — the TypeORM builder
|
||||
* API resolves bare names against `public` and crash-loops boot.
|
||||
*/
|
||||
export class AddTruckTypes2840000000000 implements MigrationInterface {
|
||||
name = "AddTruckTypes2840000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.truck_types (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code varchar(32) NOT NULL,
|
||||
name varchar(100) NOT NULL,
|
||||
capacity_tons numeric(10,3),
|
||||
has_trailer boolean NOT NULL DEFAULT false,
|
||||
description text,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_truck_types_code
|
||||
ON freight.truck_types (code)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS ix_truck_types_is_active
|
||||
ON freight.truck_types (is_active)
|
||||
`);
|
||||
|
||||
// Seed one row per legacy enum value so vehicles already carrying that code
|
||||
// keep resolving, plus CASONI as the first rigid (no-trailer) configuration.
|
||||
// has_trailer is true only for the articulated configurations.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.truck_types (code, name, has_trailer)
|
||||
VALUES
|
||||
('TRUCK', 'Truck', true),
|
||||
('TRAILER', 'Trailer', true),
|
||||
('TANKER', 'Tanker', true),
|
||||
('FLATBED', 'Flatbed', true),
|
||||
('VAN', 'Van', false),
|
||||
('CAR', 'Car', false),
|
||||
('BUS', 'Bus', false),
|
||||
('CASONI', 'Casoni (rigid, no trailer)', false)
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD COLUMN IF NOT EXISTS truck_type_id uuid
|
||||
`);
|
||||
|
||||
// Separate DO block: ADD CONSTRAINT has no IF NOT EXISTS in Postgres.
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_vehicles_truck_type'
|
||||
) THEN
|
||||
ALTER TABLE freight.vehicles
|
||||
ADD CONSTRAINT fk_vehicles_truck_type
|
||||
FOREIGN KEY (truck_type_id) REFERENCES freight.truck_types (id)
|
||||
ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
`);
|
||||
|
||||
// Backfill the FK from the code already stored on each vehicle.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.vehicles v
|
||||
SET truck_type_id = t.id
|
||||
FROM freight.truck_types t
|
||||
WHERE v.truck_type_id IS NULL
|
||||
AND upper(trim(v.vehicle_type)) = t.code
|
||||
`);
|
||||
|
||||
// Truck-type codes are varchar(32); the fee-rule column they are matched
|
||||
// against was varchar(20) and would truncate/reject longer codes.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_fee_rules
|
||||
ALTER COLUMN vehicle_type TYPE varchar(32)
|
||||
`);
|
||||
|
||||
// A VIN identifies exactly one vehicle worldwide. Partial index so the many
|
||||
// existing rows without a VIN do not collide.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_vehicles_vin
|
||||
ON freight.vehicles (vin)
|
||||
WHERE vin IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_vehicles_vin`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
DROP CONSTRAINT IF EXISTS fk_vehicles_truck_type
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.vehicles
|
||||
DROP COLUMN IF EXISTS truck_type_id
|
||||
`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.truck_types`);
|
||||
// warehouse_fee_rules.vehicle_type is left widened: narrowing it back would
|
||||
// fail on any row that stored a code longer than 20 characters.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Double handling becomes an explicit per-booking decision instead of an
|
||||
* implicit "every import" charge. Warehouse staff record Yes/No after
|
||||
* unloading (whether the goods actually had to be re-handled); the
|
||||
* DOUBLE_HANDLING_FEE rule only bills when the answer is Yes.
|
||||
*
|
||||
* NULL = not decided yet → no charge, and the UI shows "not set" so the
|
||||
* operator is prompted. Existing rows stay NULL deliberately: back-billing a
|
||||
* fee nobody confirmed would be wrong.
|
||||
*/
|
||||
export class AddBookingDoubleHandling2850000000000 implements MigrationInterface {
|
||||
name = 'AddBookingDoubleHandling2850000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling boolean;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling_set_at timestamptz;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling_set_by varchar(160);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling_set_by;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling_set_at;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-truck detention clocks. Detention was timed once per last-mile leg
|
||||
* (last_mile.arrived_at / delivered_at), so every truck on a multi-truck
|
||||
* delivery shared one window and was billed identical days — wrong the moment
|
||||
* two trucks arrive or return at different times.
|
||||
*
|
||||
* Deliberately NEW columns rather than reusing the existing per-truck
|
||||
* arrived_at / departed_at on this table: those are WAREHOUSE gate-in/gate-out
|
||||
* events stamped by release(), whereas detention runs from arrival at the
|
||||
* DESTINATION until the truck is released/returned.
|
||||
*
|
||||
* Both nullable — a truck without its own window falls back to the leg-level
|
||||
* timestamps, so legacy legs keep billing exactly as before.
|
||||
*/
|
||||
export class AddPerTruckDetentionWindow2860000000000 implements MigrationInterface {
|
||||
name = 'AddPerTruckDetentionWindow2860000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||
ADD COLUMN IF NOT EXISTS destination_arrived_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS returned_at timestamptz;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||
DROP COLUMN IF EXISTS returned_at,
|
||||
DROP COLUMN IF EXISTS destination_arrived_at;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Livestock is billed and counted per head, not per ton — line it up with the
|
||||
* other break-bulk cargo types (Machinery, Truck, Automobile) so bulk
|
||||
* storage/demurrage fees charge per item instead of per ton for it.
|
||||
*/
|
||||
export class LivestockPerItem2900000000000 implements MigrationInterface {
|
||||
name = "LivestockPerItem2900000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.cargo_types
|
||||
SET unit_of_measure = 'PER_ITEM'
|
||||
WHERE code = 'LIVESTOCK'
|
||||
AND unit_of_measure IS DISTINCT FROM 'PER_ITEM'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.cargo_types
|
||||
SET unit_of_measure = 'PER_TON'
|
||||
WHERE code = 'LIVESTOCK'
|
||||
AND unit_of_measure IS DISTINCT FROM 'PER_TON'
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Company stamp (seal) attached alongside the drawn signature, for both the
|
||||
* client and the EDR side. Stored the same way the signature image is: a
|
||||
* FileRecord on the contract (`resource: 'contracts'`, `code: 'stamp_<role>'`)
|
||||
* referenced from the signature row.
|
||||
*
|
||||
* Nullable — existing signature rows predate the stamp requirement. The
|
||||
* "both stamps recorded" gate lives in ContractTransitionService.counterSign,
|
||||
* not in a NOT NULL constraint, so historical rows stay readable.
|
||||
*/
|
||||
export class AddContractSignatureStamp2910000000000 implements MigrationInterface {
|
||||
name = 'AddContractSignatureStamp2910000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_signatures ADD COLUMN IF NOT EXISTS stamp_file_id uuid;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_signatures DROP COLUMN IF EXISTS stamp_file_id;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Store WHO made a contract edit as a name, not just an id. Denormalised on
|
||||
* purpose: an audit trail must still read correctly after the user is renamed,
|
||||
* deactivated or deleted, and `iam.users` lives outside this module's schema.
|
||||
*/
|
||||
export class AddRevisionActorName2920000000000 implements MigrationInterface {
|
||||
name = 'AddRevisionActorName2920000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_document_revisions ADD COLUMN IF NOT EXISTS actor_name varchar(200);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_document_revisions DROP COLUMN IF EXISTS actor_name;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Partial wagon-transfer fulfilment.
|
||||
*
|
||||
* A request for 50 wagons no longer has to be met in one go: OCC moves what the
|
||||
* source yard can spare, whenever it can, and the request stays open until the
|
||||
* full count is met (FULFILLED) or OCC ends it short (CLOSED_SHORT) so the
|
||||
* requester can ask another yard for the rest.
|
||||
*
|
||||
* Existing rows are back-filled so history keeps reading correctly: a FULFILLED
|
||||
* request delivered its whole quantity; anything else delivered nothing.
|
||||
*/
|
||||
export class AddWagonTransferPartialFulfilment2930000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_transfer_requests
|
||||
ADD COLUMN IF NOT EXISTS fulfilled_quantity integer NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS closed_short_at timestamptz NULL,
|
||||
ADD COLUMN IF NOT EXISTS closed_short_by_user_id uuid NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagon_transfer_requests
|
||||
SET fulfilled_quantity = quantity
|
||||
WHERE status = 'FULFILLED'
|
||||
AND fulfilled_quantity = 0
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_transfer_requests
|
||||
DROP COLUMN IF EXISTS fulfilled_quantity,
|
||||
DROP COLUMN IF EXISTS closed_short_at,
|
||||
DROP COLUMN IF EXISTS closed_short_by_user_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Keep every version of a stored document.
|
||||
*
|
||||
* Replacing a file used to DELETE the previous row outright, so a staff
|
||||
* correction erased the customer's original upload with no trail. Superseded
|
||||
* versions are now soft-deleted (already excluded from every read by TypeORM's
|
||||
* soft-delete filter) and stamped with who replaced them and why, which is what
|
||||
* the document's version history reads back.
|
||||
*/
|
||||
export class AddFileVersionHistory2940000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.files
|
||||
ADD COLUMN IF NOT EXISTS replaced_by_user_id uuid NULL,
|
||||
ADD COLUMN IF NOT EXISTS replace_reason text NULL
|
||||
`);
|
||||
// History reads walk one document's versions, deleted rows included.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_files_version_history"
|
||||
ON freight.files (resource, resource_id, code, created_at DESC)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_files_version_history"`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.files
|
||||
DROP COLUMN IF EXISTS replaced_by_user_id,
|
||||
DROP COLUMN IF EXISTS replace_reason
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Transit-assignee handshake before the customs declaration.
|
||||
*
|
||||
* GL Ethiopia must ask GL Djibouti who will handle the shipment in transit, and
|
||||
* Djibouti answers with a name, before the declaration can be filed. The whole
|
||||
* exchange lives on the clearance cycle so it repeats naturally with each cycle
|
||||
* of a GENERAL contract.
|
||||
*/
|
||||
export class AddTransitAssigneeHandshake2950000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_clearance_cycles
|
||||
ADD COLUMN IF NOT EXISTS transit_assignee_requested_at timestamptz NULL,
|
||||
ADD COLUMN IF NOT EXISTS transit_assignee_requested_by_user_id uuid NULL,
|
||||
ADD COLUMN IF NOT EXISTS transit_assignee_request_note text NULL,
|
||||
ADD COLUMN IF NOT EXISTS transit_assignee_name text NULL,
|
||||
ADD COLUMN IF NOT EXISTS transit_assignee_assigned_at timestamptz NULL,
|
||||
ADD COLUMN IF NOT EXISTS transit_assignee_assigned_by_user_id uuid NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contract_clearance_cycles
|
||||
DROP COLUMN IF EXISTS transit_assignee_requested_at,
|
||||
DROP COLUMN IF EXISTS transit_assignee_requested_by_user_id,
|
||||
DROP COLUMN IF EXISTS transit_assignee_request_note,
|
||||
DROP COLUMN IF EXISTS transit_assignee_name,
|
||||
DROP COLUMN IF EXISTS transit_assignee_assigned_at,
|
||||
DROP COLUMN IF EXISTS transit_assignee_assigned_by_user_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Hazardous contracts now declare WHAT the dangerous good is, not just that it
|
||||
* exists: the UN/ADR class (CLASS_1..CLASS_9) and the shipment's UN number. Both
|
||||
* are captured in the portal alongside the hazard documents and reviewed by the
|
||||
* two hazardous approval desks.
|
||||
*
|
||||
* Nullable — non-hazardous contracts leave both null, and contracts created
|
||||
* before this change have no declaration to backfill.
|
||||
*/
|
||||
export class AddContractHazardDeclaration2960000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddContractHazardDeclaration2960000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS hazard_class varchar(16);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS un_number varchar(16);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS un_number;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS hazard_class;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Djibouti GL must record WHEN the vessel arrived and WHEN the Delivery Order
|
||||
* was collected, not just attach the DO file. Both are mandatory on DO upload
|
||||
* (enforced in the clearance services), so the columns are new and nullable —
|
||||
* DOs uploaded before this change have no dates to backfill.
|
||||
*
|
||||
* `vessel_departure_date` is the EXPORT Release-Order date and stays as-is; the
|
||||
* import arrival date gets its own column rather than overloading it.
|
||||
*/
|
||||
export class AddDoCollectionDates2970000000000 implements MigrationInterface {
|
||||
name = 'AddDoCollectionDates2970000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
for (const table of [
|
||||
'freight.contract_clearance_cycles',
|
||||
'freight.bookings',
|
||||
]) {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS vessel_arrival_date date;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS do_collected_date date;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
for (const table of [
|
||||
'freight.contract_clearance_cycles',
|
||||
'freight.bookings',
|
||||
]) {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE ${table} DROP COLUMN IF EXISTS do_collected_date;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE ${table} DROP COLUMN IF EXISTS vessel_arrival_date;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Currency moved from the contract to the shipment: a contract now quotes in
|
||||
* USD and the customer picks the billing currency per booking. On a customs
|
||||
* contract GL books on the customer's behalf, so the shipment request is where
|
||||
* the customer states the currency — GL reads it when creating the booking.
|
||||
*
|
||||
* Nullable: requests submitted before this change fall back to the contract's
|
||||
* own currency, which is exactly what their bookings already used.
|
||||
*/
|
||||
export class AddBookingRequestCurrency2980000000000 implements MigrationInterface {
|
||||
name = 'AddBookingRequestCurrency2980000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_requests ADD COLUMN IF NOT EXISTS payment_currency varchar(5);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_requests DROP COLUMN IF EXISTS payment_currency;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Indode's real 11-yard layout, plus the plumbing to auto-route a booking to
|
||||
* the right yard by cargo type (and, for container yards, trade direction):
|
||||
*
|
||||
* - `warehouse_yards.direction` — IMPORT | EXPORT | BOTH | null. Only
|
||||
* meaningful for CONTAINER_YARD, where import and export stacks are
|
||||
* physically separate (Yard 5 vs Yard 6). Everything else takes cargo
|
||||
* either way. A CONTAINER_YARD left at null/BOTH is a signal too: it means
|
||||
* "not a customer cargo yard" — Yards 10/11 (service/equipment) are
|
||||
* CONTAINER_YARD structurally but must never be offered for ordinary
|
||||
* import/export cargo, so the frontend match requires an EXACT IMPORT/
|
||||
* EXPORT direction hit for container freight rather than treating BOTH as
|
||||
* a wildcard.
|
||||
* - `warehouse_yard_cargo_types` — which cargo types a yard accepts (mirrors
|
||||
* the existing `cargo_type_wagon_types` join table). Empty = open to any
|
||||
* cargo type of the yard's structural type (additive, never restrictive
|
||||
* by default), so this cannot break a yard nobody has configured yet.
|
||||
*
|
||||
* Three cargo types didn't exist yet (Fertilizer, Coffee, Tea) — added here
|
||||
* so Yards 1 and 9 have a real mapping ready for when they reopen.
|
||||
*/
|
||||
export class IndodeYardsAndCargoRouting2990000000000 implements MigrationInterface {
|
||||
name = "IndodeYardsAndCargoRouting2990000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_yards
|
||||
ADD COLUMN IF NOT EXISTS direction varchar(10)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_yard_cargo_types (
|
||||
yard_id uuid NOT NULL REFERENCES freight.warehouse_yards (id) ON DELETE CASCADE,
|
||||
cargo_type_id uuid NOT NULL REFERENCES freight.cargo_types (id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (yard_id, cargo_type_id)
|
||||
)
|
||||
`);
|
||||
|
||||
// New cargo types Indode's yard list names but the catalog didn't have yet.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.cargo_types (code, cargo_type_name, unit_of_measure, is_active)
|
||||
VALUES
|
||||
('FERTILIZER', 'Fertilizer', 'PER_TON', true),
|
||||
('COFFEE', 'Coffee', 'PER_TON', true),
|
||||
('TEA', 'Tea', 'PER_TON', true)
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
`);
|
||||
|
||||
// The 11 real yards at Indode Open Warehouse (code 'IOW').
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.warehouse_yards
|
||||
(warehouse_id, name, code, type, direction, status, is_active)
|
||||
SELECT w.id, y.name, y.code, y.type, y.direction, y.status, y.status = 'ACTIVE'
|
||||
FROM freight.warehouses w
|
||||
CROSS JOIN (VALUES
|
||||
('Y1', 'Bagged Cargo Discharge - Fertilizer', 'BULK_YARD', NULL, 'INACTIVE'),
|
||||
('Y2', 'Break Bulk', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'),
|
||||
('Y3', 'Ro-Ro / Pac', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'),
|
||||
('Y4', 'Dry Bulk', 'BULK_YARD', NULL, 'INACTIVE'),
|
||||
('Y5', 'Container Terminal - Import (Stack Area)', 'CONTAINER_YARD', 'IMPORT', 'ACTIVE'),
|
||||
('Y6', 'Container Terminal - Export', 'CONTAINER_YARD', 'EXPORT', 'ACTIVE'),
|
||||
('Y7', 'Cold Chain', 'COLD_STORAGE_YARD', NULL, 'INACTIVE'),
|
||||
('Y8', 'Chemical', 'HAZARDOUS_YARD', NULL, 'INACTIVE'),
|
||||
('Y9', 'Coffee and Tea', 'GENERAL_CARGO_YARD', NULL, 'INACTIVE'),
|
||||
('Y10', 'Container Service Yard - Maintenance', 'CONTAINER_YARD', 'BOTH', 'ACTIVE'),
|
||||
('Y11', 'Equipment (Empty Container)', 'CONTAINER_YARD', 'BOTH', 'ACTIVE')
|
||||
) AS y(code, name, type, direction, status)
|
||||
WHERE w.code = 'IOW'
|
||||
ON CONFLICT (warehouse_id, code) DO NOTHING
|
||||
`);
|
||||
|
||||
// One default zone per new yard, matching its yard's type — every existing
|
||||
// yard (CY-1, CY-A) already follows this one-zone-per-yard shape.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.warehouse_zones (yard_id, name, code, type, status, is_active)
|
||||
SELECT y.id, y.name || ' Zone 1', 'Z1',
|
||||
CASE y.type
|
||||
WHEN 'CONTAINER_YARD' THEN 'CONTAINER_ZONE'
|
||||
WHEN 'COLD_STORAGE_YARD' THEN 'COLD_STORAGE_ZONE'
|
||||
WHEN 'HAZARDOUS_YARD' THEN 'HAZARDOUS_ZONE'
|
||||
WHEN 'BULK_YARD' THEN 'BULK_ZONE'
|
||||
ELSE 'GENERAL_CARGO_ZONE'
|
||||
END,
|
||||
y.status, y.status = 'ACTIVE'
|
||||
FROM freight.warehouse_yards y
|
||||
JOIN freight.warehouses w ON w.id = y.warehouse_id
|
||||
WHERE w.code = 'IOW' AND y.code LIKE 'Y%'
|
||||
ON CONFLICT (yard_id, code) DO NOTHING
|
||||
`);
|
||||
|
||||
// Cargo-type routing. Yards 5/6/10/11 (CONTAINER_YARD) are intentionally
|
||||
// left with no rows — direction alone decides those, per the entity comment.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.warehouse_yard_cargo_types (yard_id, cargo_type_id)
|
||||
SELECT y.id, ct.id
|
||||
FROM freight.warehouses w
|
||||
JOIN freight.warehouse_yards y ON y.warehouse_id = w.id
|
||||
JOIN (VALUES
|
||||
('Y1', 'FERTILIZER'),
|
||||
('Y2', 'STEEL_BILLET'), ('Y2', 'PLASTIC_BARREL'), ('Y2', 'MACHINERY'), ('Y2', 'LIVESTOCK'),
|
||||
('Y3', 'AUTOMOBILE'), ('Y3', 'TRUCK'),
|
||||
('Y4', 'BARLY'), ('Y4', 'BEANS'), ('Y4', 'BULK'), ('Y4', 'CEREAL'),
|
||||
('Y4', 'EDIBLE_OIL'), ('Y4', 'RICE'), ('Y4', 'SUGAR'), ('Y4', 'WHEAT'),
|
||||
('Y7', 'PERISHABLE'),
|
||||
('Y9', 'COFFEE'), ('Y9', 'TEA')
|
||||
) AS m(yard_code, cargo_code) ON m.yard_code = y.code
|
||||
JOIN freight.cargo_types ct ON ct.code = m.cargo_code
|
||||
WHERE w.code = 'IOW'
|
||||
ON CONFLICT (yard_id, cargo_type_id) DO NOTHING
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.warehouse_zones z
|
||||
USING freight.warehouse_yards y, freight.warehouses w
|
||||
WHERE z.yard_id = y.id AND y.warehouse_id = w.id
|
||||
AND w.code = 'IOW' AND y.code LIKE 'Y%'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.warehouse_yards y
|
||||
USING freight.warehouses w
|
||||
WHERE y.warehouse_id = w.id AND w.code = 'IOW' AND y.code LIKE 'Y%'
|
||||
`);
|
||||
// Cargo types and the join table are left in place — other data may have
|
||||
// started referencing them since; dropping columns/tables is not reversible
|
||||
// once real rows exist, and leaving them is harmless.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Backoffice contract suspension (reversible freeze at any post-signature step)
|
||||
* and customer-initiated contract cancellation.
|
||||
*
|
||||
* Only one new column is needed: the status to restore when the suspension is
|
||||
* lifted. The reason and the actor already have a home — contract_review_notes
|
||||
* rows with note_type SUSPENSION / SUSPENSION_LIFTED / CANCELLATION.
|
||||
*/
|
||||
export class AddContractSuspension3000000000000 implements MigrationInterface {
|
||||
name = 'AddContractSuspension3000000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS status_before_suspension varchar(40);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS status_before_suspension;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
|
||||
import type { Booking } from './entities/booking.entity';
|
||||
|
||||
/**
|
||||
* Who hears "Operations wants changes" depends on who owns the booking. A
|
||||
* customs (Path B) booking is created BY GL Ethiopia on the customer's behalf —
|
||||
* the customer can neither edit nor resubmit it, so the note has to reach the GL
|
||||
* who made it, not the portal.
|
||||
*/
|
||||
describe('BookingLifecycleNotifierService — operation changes requested', () => {
|
||||
const booking = (over: Partial<Booking> = {}): Booking =>
|
||||
({
|
||||
id: 'b-1',
|
||||
reference: 'BKG-0001',
|
||||
companyId: 'co-1',
|
||||
contractId: 'ctr-1',
|
||||
createdByRole: 'CUSTOMER',
|
||||
company: { email: 'customer@example.com' },
|
||||
...over,
|
||||
}) as Booking;
|
||||
|
||||
let notifications: { directSend: jest.Mock };
|
||||
let inbox: { notify: jest.Mock };
|
||||
let service: BookingLifecycleNotifierService;
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
beforeEach(() => {
|
||||
notifications = { directSend: jest.fn().mockResolvedValue(undefined) };
|
||||
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new BookingLifecycleNotifierService(
|
||||
notifications as never,
|
||||
inbox as never,
|
||||
{ query: jest.fn().mockResolvedValue([{ phone: '+251900000000' }]) } as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('sends a GL-created booking back to the GL who created it, not the customer', async () => {
|
||||
service.operationChangesRequested(
|
||||
booking({ createdByRole: 'GL_ET', createdByUserId: 'gl-user-1' }),
|
||||
'Cargo weight does not match the declaration',
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(inbox.notify).toHaveBeenCalledTimes(1);
|
||||
const sent = inbox.notify.mock.calls[0][0];
|
||||
expect(sent.recipients).toEqual({ userIds: ['gl-user-1'] });
|
||||
expect(sent.audience).toBe('BACKOFFICE');
|
||||
expect(sent.body).toContain('Cargo weight does not match the declaration');
|
||||
// Deep-links the clearance page GL works from, not the portal booking.
|
||||
expect(sent.link).toBe('/dashboard/contracts/clearance/ctr-1');
|
||||
// The customer is not told to fix something they cannot touch.
|
||||
expect(notifications.directSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still tells the customer when the booking is their own', async () => {
|
||||
service.operationChangesRequested(booking(), 'Please attach the packing list');
|
||||
await flush();
|
||||
|
||||
const sent = inbox.notify.mock.calls[0][0];
|
||||
expect(sent.recipients).toEqual({ companyId: 'co-1' });
|
||||
expect(sent.audience).toBe('PORTAL');
|
||||
expect(sent.link).toBe('/bookings/b-1');
|
||||
expect(notifications.directSend).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the customer when the GL creator is unknown (legacy rows)', async () => {
|
||||
service.operationChangesRequested(
|
||||
booking({ createdByRole: 'GL_ET', createdByUserId: null }),
|
||||
'Fix the declaration',
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' });
|
||||
});
|
||||
});
|
||||
@@ -179,8 +179,35 @@ export class BookingLifecycleNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Operations returned the operation request for changes. */
|
||||
/**
|
||||
* Operations returned the operation request for changes.
|
||||
*
|
||||
* A customs (Path B) booking was created BY GL Ethiopia on the customer's
|
||||
* behalf — the customer cannot edit or resubmit it, so telling them to "update
|
||||
* from the portal" is a dead end. Those go to the GL who created it, linking
|
||||
* the contract clearance page they work from. Everything else (customer-made
|
||||
* bookings) keeps the portal message.
|
||||
*/
|
||||
operationChangesRequested(b: Booking, note: string): void {
|
||||
if (b.createdByRole === 'GL_ET' && b.createdByUserId) {
|
||||
const msg =
|
||||
`Operations returned booking ${b.reference} for changes: ${note}. ` +
|
||||
`Address it on the contract clearance page and resubmit to Operations.`;
|
||||
this.logger.log(`OPERATION CHANGES REQUESTED (to GL) — ${this.ref(b)}`);
|
||||
void this.inbox.notify({
|
||||
recipients: { userIds: [b.createdByUserId] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: `Booking ${b.reference} needs changes`,
|
||||
body: msg,
|
||||
link: b.contractId
|
||||
? `/dashboard/contracts/clearance/${b.contractId}`
|
||||
: `/dashboard/bookings/${b.id}/clearance`,
|
||||
data: { bookingId: b.id, reference: b.reference, note },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const msg =
|
||||
`Your operation request for booking ${b.reference} needs changes: ${note}. ` +
|
||||
`Please update and resubmit from the portal.`;
|
||||
@@ -197,6 +224,24 @@ export class BookingLifecycleNotifierService {
|
||||
this.inApp(b, 'Operation request accepted', msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia created this booking on the customer's behalf. On a customs
|
||||
* (Path B) contract the customer never books themselves, so without this they
|
||||
* would have no signal that their shipment now exists and is priced.
|
||||
*/
|
||||
createdByGlForCustomer(b: Booking): void {
|
||||
const total = Number(b.totalAmount ?? 0);
|
||||
const priced =
|
||||
total > 0
|
||||
? ` The total is ${total.toLocaleString()} ${b.paymentCurrency}.`
|
||||
: '';
|
||||
const msg =
|
||||
`Global Logistics has created shipment ${b.reference} under your contract.${priced} ` +
|
||||
`You can review it in the portal.`;
|
||||
void this.notifyContact(b, msg, 'CREATED BY GL');
|
||||
this.inApp(b, 'Shipment created for you', msg);
|
||||
}
|
||||
|
||||
/** Shipment started → in transit. */
|
||||
inTransit(b: Booking): void {
|
||||
const msg = `Your shipment for booking ${b.reference} is now in transit.`;
|
||||
|
||||
@@ -473,3 +473,247 @@ describe('BookingPricingService — customs clearance fee billed on the booking
|
||||
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Bulk freight bills in the commodity's own unit: tonnage for a weighed
|
||||
* commodity (PER_TON), item count for a counted one (PER_ITEM). Both read the
|
||||
* booking's cargo amount; PER_WAGON bills the wagons the cargo occupies.
|
||||
*/
|
||||
describe('BookingPricingService — bulk base freight units', () => {
|
||||
const DJ = 'yard-dj-bulk';
|
||||
const DIRE_B = 'yard-dire-bulk';
|
||||
|
||||
const bulkRate = (overrides: Partial<Rate> = {}): Rate =>
|
||||
({
|
||||
id: 'rate-bulk',
|
||||
rateType: 'BULK_IMPORT',
|
||||
appliesTo: 'BULK',
|
||||
trigger: 'ALWAYS',
|
||||
currency: 'USD',
|
||||
rateValue: 200,
|
||||
rateUnit: 'PER_ITEM',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: null,
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: DJ,
|
||||
destinationYardId: DIRE_B,
|
||||
...overrides,
|
||||
}) as Rate;
|
||||
|
||||
const makeService = (liveRates: Rate[], wagonCapacity?: number) =>
|
||||
new BookingPricingService(
|
||||
{
|
||||
calculateWagonCount: jest.fn().mockResolvedValue(0),
|
||||
findContractRateSnapshots: jest.fn().mockResolvedValue([]),
|
||||
} as never,
|
||||
{
|
||||
evaluate: jest.fn().mockResolvedValue({
|
||||
priorityScore: 0,
|
||||
appliedModifiers: [],
|
||||
containerWeightResults: [],
|
||||
warnings: [],
|
||||
hardBlocked: [],
|
||||
requiresDirectorApproval: false,
|
||||
}),
|
||||
} as never,
|
||||
{ findById: jest.fn() } as never,
|
||||
{ findLiveRates: jest.fn().mockResolvedValue(liveRates) } as never,
|
||||
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
{
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [],
|
||||
}),
|
||||
} as never,
|
||||
);
|
||||
|
||||
// 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here.
|
||||
const booking = (overrides: Record<string, unknown> = {}) =>
|
||||
({
|
||||
id: 'b-bulk',
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'IMPORT',
|
||||
paymentCurrency: 'USD',
|
||||
cargoTypeId: 'cargo-machinery',
|
||||
cargoTotalWeightVgm: 12,
|
||||
originYardId: DJ,
|
||||
destinationYardId: DIRE_B,
|
||||
bookingContainers: [],
|
||||
...overrides,
|
||||
}) as unknown as Booking;
|
||||
|
||||
it('bills a PER_ITEM rate on the item count', async () => {
|
||||
const result = await makeService([bulkRate()]).computePriceForBooking(booking());
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
|
||||
expect(line!.unit).toBe('PER_ITEM');
|
||||
expect(line!.quantity).toBe(12);
|
||||
expect(line!.amount).toBe(2400);
|
||||
});
|
||||
|
||||
it('bills a PER_TON rate on the tonnage', async () => {
|
||||
const result = await makeService([
|
||||
bulkRate({ rateUnit: 'PER_TON', rateValue: 35 }),
|
||||
]).computePriceForBooking(booking({ cargoTotalWeightVgm: 120 }));
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
|
||||
expect(line!.unit).toBe('PER_TON');
|
||||
expect(line!.amount).toBe(35 * 120);
|
||||
});
|
||||
|
||||
it('bills a PER_WAGON rate on the wagons the cargo occupies, not zero', async () => {
|
||||
const result = await makeService(
|
||||
[bulkRate({ rateUnit: 'PER_WAGON', rateValue: 500 })],
|
||||
60,
|
||||
).computePriceForBooking(booking({ cargoTotalWeightVgm: 120 }));
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
|
||||
expect(line!.unit).toBe('PER_WAGON');
|
||||
expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon
|
||||
expect(line!.amount).toBe(1000);
|
||||
});
|
||||
|
||||
it('prices off the rate scoped to the booking commodity, not another one', async () => {
|
||||
const result = await makeService([
|
||||
bulkRate({ id: 'rate-wheat', cargoTypeId: 'cargo-wheat', rateUnit: 'PER_TON', rateValue: 35 }),
|
||||
bulkRate({ id: 'rate-machinery', cargoTypeId: 'cargo-machinery', rateValue: 200 }),
|
||||
]).computePriceForBooking(booking());
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
|
||||
expect(line!.unit).toBe('PER_ITEM');
|
||||
expect(line!.amount).toBe(2400);
|
||||
});
|
||||
|
||||
it('hard-blocks when the leg only carries another commodity’s rate', async () => {
|
||||
const result = await makeService([
|
||||
bulkRate({ id: 'rate-wheat', cargoTypeId: 'cargo-wheat' }),
|
||||
]).computePriceForBooking(booking());
|
||||
|
||||
expect(result.lineItems.some((l) => l.code === 'BULK_IMPORT')).toBe(false);
|
||||
expect(result.hardBlocked.some((m) => m.includes('rate is configured'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A PER_WAGON container rate bills the wagons the LINE occupies — two 20ft share
|
||||
* one wagon, a 40ft takes a whole one. Regression cases taken from real
|
||||
* bookings on Doraleh → Gelan, where the 20ft line was being charged for the
|
||||
* 40ft line's wagons as well.
|
||||
*/
|
||||
describe('BookingPricingService — PER_WAGON container freight', () => {
|
||||
const DJ = 'yard-dj-w';
|
||||
const ET = 'yard-et-w';
|
||||
|
||||
const perWagon20: Rate = {
|
||||
id: 'rate-20-wagon',
|
||||
rateType: 'CONTAINER_IMPORT',
|
||||
currency: 'USD',
|
||||
rateValue: 1690,
|
||||
rateUnit: 'PER_WAGON',
|
||||
status: 'LIVE',
|
||||
containerTypeId: 'ct-20',
|
||||
originYardId: DJ,
|
||||
destinationYardId: ET,
|
||||
} as Rate;
|
||||
|
||||
const perContainer40: Rate = {
|
||||
...perWagon20,
|
||||
id: 'rate-40-container',
|
||||
rateValue: 1676,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
containerTypeId: 'ct-40',
|
||||
} as Rate;
|
||||
|
||||
const makeService = () =>
|
||||
new BookingPricingService(
|
||||
{
|
||||
// Booking-wide aggregate — deliberately larger than any single line, so
|
||||
// a regression that reads it instead of the line's own wagons shows up.
|
||||
calculateWagonCount: jest.fn().mockResolvedValue(5),
|
||||
findContractRateSnapshots: jest.fn().mockResolvedValue([]),
|
||||
} as never,
|
||||
{
|
||||
evaluate: jest.fn().mockResolvedValue({
|
||||
priorityScore: 0,
|
||||
appliedModifiers: [],
|
||||
containerWeightResults: [],
|
||||
warnings: [],
|
||||
hardBlocked: [],
|
||||
requiresDirectorApproval: false,
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
findById: jest.fn(async (id: string) => ({
|
||||
id,
|
||||
sizeFt: id === 'ct-40' ? 40 : 20,
|
||||
isReefer: false,
|
||||
code: id === 'ct-40' ? 'C40' : 'C20',
|
||||
label: id === 'ct-40' ? 'C40' : 'C20',
|
||||
})),
|
||||
} as never,
|
||||
{ findLiveRates: jest.fn().mockResolvedValue([perWagon20, perContainer40]) } as never,
|
||||
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
{ findById: jest.fn() } as never,
|
||||
);
|
||||
|
||||
const booking = (
|
||||
lines: Array<{ containerTypeId: string; quantity: number }>,
|
||||
) =>
|
||||
({
|
||||
id: 'b-wagon',
|
||||
freightType: 'CONTAINER',
|
||||
tradeDirection: 'IMPORT',
|
||||
paymentCurrency: 'USD',
|
||||
originYardId: DJ,
|
||||
destinationYardId: ET,
|
||||
bookingContainers: lines.map((l) => ({
|
||||
containerTypeId: l.containerTypeId,
|
||||
quantity: l.quantity,
|
||||
vgmPerUnitTons: 10,
|
||||
})),
|
||||
}) as unknown as Booking;
|
||||
|
||||
const price = async (
|
||||
lines: Array<{ containerTypeId: string; quantity: number }>,
|
||||
) => {
|
||||
const service = makeService();
|
||||
const result = await service.computePriceForBooking(booking(lines));
|
||||
return result.lineItems.filter((l) => l.code === 'CONTAINER_IMPORT');
|
||||
};
|
||||
|
||||
it('bills 2× 20ft as one wagon', async () => {
|
||||
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 2 }]);
|
||||
expect(line.unit).toBe('PER_WAGON');
|
||||
expect(line.quantity).toBe(1);
|
||||
expect(line.amount).toBe(1690);
|
||||
});
|
||||
|
||||
it('bills 10× 20ft as five wagons', async () => {
|
||||
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 10 }]);
|
||||
expect(line.quantity).toBe(5);
|
||||
expect(line.amount).toBe(5 * 1690);
|
||||
});
|
||||
|
||||
it('does not charge the 20ft line for the 40ft line’s wagons', async () => {
|
||||
const lines = await price([
|
||||
{ containerTypeId: 'ct-20', quantity: 4 },
|
||||
{ containerTypeId: 'ct-40', quantity: 1 },
|
||||
]);
|
||||
const twenty = lines.find((l) => l.description.startsWith('C20'))!;
|
||||
const forty = lines.find((l) => l.description.startsWith('C40'))!;
|
||||
// 4× 20ft = 2 wagons, NOT the booking-wide 3.
|
||||
expect(twenty.quantity).toBe(2);
|
||||
expect(twenty.amount).toBe(2 * 1690);
|
||||
// The 40ft line keeps billing per container.
|
||||
expect(forty.quantity).toBe(1);
|
||||
expect(forty.amount).toBe(1676);
|
||||
});
|
||||
|
||||
it('rounds an odd 20ft count up to a whole wagon', async () => {
|
||||
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 5 }]);
|
||||
expect(line.quantity).toBe(3);
|
||||
expect(line.amount).toBe(3 * 1690);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import {
|
||||
@@ -199,7 +200,7 @@ export class BookingPricingService {
|
||||
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
|
||||
const frozen = isDerived
|
||||
? null
|
||||
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency);
|
||||
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, usdToEtb);
|
||||
const unitAmount = frozen
|
||||
? Number(frozen.unitPrice)
|
||||
: isEtbBooking
|
||||
@@ -529,8 +530,6 @@ export class BookingPricingService {
|
||||
const usedRatesMap = new Map<string, Rate>();
|
||||
const warnings: string[] = [];
|
||||
const blocked: string[] = [];
|
||||
const wagonCount = await this.resolveWagonCount(booking);
|
||||
|
||||
for (const container of evalInput.containers) {
|
||||
const rate = this.pickRate(
|
||||
liveRates,
|
||||
@@ -540,14 +539,15 @@ export class BookingPricingService {
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
);
|
||||
// H15: frozen contract rate for this container size, when present — its
|
||||
// unitPrice is already in the booking currency (no USD→currency convert).
|
||||
// It also stands on its own: a contract line prices off the agreed rate
|
||||
// even when nobody configured a live rate for this leg + type yet.
|
||||
// H15: frozen contract rate for this container size, when present —
|
||||
// converted into the booking currency by frozenRateForContainer. It also
|
||||
// stands on its own: a contract line prices off the agreed rate even when
|
||||
// nobody configured a live rate for this leg + type yet.
|
||||
const frozen = await this.frozenRateForContainer(
|
||||
frozenRates,
|
||||
container.containerTypeId,
|
||||
paymentCurrency,
|
||||
usdToEtb,
|
||||
);
|
||||
const label = await this.containerTypeLabel(container.containerTypeId);
|
||||
if (!rate && !frozen) {
|
||||
@@ -565,6 +565,10 @@ export class BookingPricingService {
|
||||
}
|
||||
|
||||
const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER';
|
||||
// A PER_WAGON line bills the wagons THIS line occupies (two 20ft share
|
||||
// one), never the booking-wide count — otherwise a booking with a 20ft
|
||||
// and a 40ft line charges each line for the other's wagons too.
|
||||
const lineWagons = await this.lineWagonCount(container);
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
if (frozen) {
|
||||
@@ -573,11 +577,11 @@ export class BookingPricingService {
|
||||
rateUnit,
|
||||
unitAmount,
|
||||
container.quantity,
|
||||
wagonCount,
|
||||
lineWagons,
|
||||
);
|
||||
} else {
|
||||
const unitUsd = Number(rate!.rateValue);
|
||||
const usdAmount = this.amountForRate(rate!, container.quantity, wagonCount);
|
||||
const usdAmount = this.amountForRate(rate!, container.quantity, lineWagons);
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
}
|
||||
@@ -588,7 +592,7 @@ export class BookingPricingService {
|
||||
amount,
|
||||
unitAmount,
|
||||
unit: rateUnit,
|
||||
quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount),
|
||||
quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, lineWagons),
|
||||
currency: paymentCurrency,
|
||||
});
|
||||
}
|
||||
@@ -600,7 +604,10 @@ export class BookingPricingService {
|
||||
// container type above or stay unpriced with a warning — falling back to
|
||||
// a corridor rate of a DIFFERENT container type billed once (qty 1) is
|
||||
// how a 38-container booking was invoiced 40 USD instead of 1900.
|
||||
const fallback = liveRates.find(
|
||||
// Within the leg, the rate scoped to the booking's own commodity wins over
|
||||
// the commodity-wide catch-all — a per-item machinery rate must never
|
||||
// price a per-ton wheat booking (or the reverse).
|
||||
const onLeg = liveRates.filter(
|
||||
(r) =>
|
||||
r.rateType === rateType &&
|
||||
r.currency === 'USD' &&
|
||||
@@ -608,15 +615,29 @@ export class BookingPricingService {
|
||||
r.originYardId === booking.originYardId &&
|
||||
r.destinationYardId === booking.destinationYardId,
|
||||
);
|
||||
const fallback =
|
||||
(booking.cargoTypeId
|
||||
? onLeg.find((r) => r.cargoTypeId === booking.cargoTypeId)
|
||||
: undefined) ?? onLeg.find((r) => !r.cargoTypeId);
|
||||
if (fallback) {
|
||||
usedRatesMap.set(fallback.id, fallback);
|
||||
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
// Bulk has no container lines to count wagons from, so a PER_WAGON bulk
|
||||
// rate bills the tonnage-derived estimate for the WHOLE booking (there
|
||||
// is only ever this one line).
|
||||
const wagonCount = isBulk
|
||||
? Number(evalInput.bulkWagons ?? 0) ||
|
||||
(await this.bulkWagonCount(booking)) ||
|
||||
0
|
||||
: await this.resolveWagonCount(booking);
|
||||
// Bulk quantity is stored in the commodity's own unit — tonnes for a
|
||||
// PER_TON commodity, item count for a PER_ITEM one.
|
||||
const bulkQuantity = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const quantity =
|
||||
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
|
||||
isBulk && isBulkQuantityUnit(fallback.rateUnit) ? Math.max(bulkQuantity, 0) : 1;
|
||||
const unitUsd = Number(fallback.rateValue);
|
||||
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
|
||||
const frozen = isBulk
|
||||
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency)
|
||||
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, usdToEtb)
|
||||
: null;
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
@@ -719,6 +740,7 @@ export class BookingPricingService {
|
||||
quantity = containerCount;
|
||||
break;
|
||||
case 'PER_TON':
|
||||
case 'PER_ITEM':
|
||||
quantity = bulkTons;
|
||||
break;
|
||||
case 'FLAT':
|
||||
@@ -727,12 +749,13 @@ export class BookingPricingService {
|
||||
break;
|
||||
}
|
||||
|
||||
// H15: frozen mile rate (already in booking currency) when the contract
|
||||
// has one; else the live USD rate converted as before.
|
||||
// H15: frozen mile rate (converted into the booking currency) when the
|
||||
// contract has one; else the live USD rate converted as before.
|
||||
const frozen = this.frozenRateByCode(
|
||||
frozenRates,
|
||||
leg.rateType,
|
||||
paymentCurrency,
|
||||
usdToEtb,
|
||||
);
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
@@ -764,6 +787,34 @@ export class BookingPricingService {
|
||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons ONE container line occupies: two 20ft share a wagon, a 40ft takes a
|
||||
* whole one. This — not the booking-wide total — is what a PER_WAGON base
|
||||
* freight line bills, so a booking of 4×20ft + 1×40ft charges the 20ft line
|
||||
* for 2 wagons and the 40ft line for its own 1, instead of billing each line
|
||||
* for all 3.
|
||||
*/
|
||||
private async lineWagonCount(container: {
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
wagonsPerUnit?: number;
|
||||
}): Promise<number> {
|
||||
let perUnit = container.wagonsPerUnit;
|
||||
if (perUnit == null) {
|
||||
// Preview bookings build their eval input without the fraction — read it
|
||||
// off the container type instead of assuming one wagon per box.
|
||||
try {
|
||||
const ct = await this.containerTypesService.findById(
|
||||
container.containerTypeId,
|
||||
);
|
||||
perUnit = wagonsPerUnitForSize(Number(ct.sizeFt));
|
||||
} catch {
|
||||
perUnit = 1; // unknown type: never under-bill
|
||||
}
|
||||
}
|
||||
return Math.max(1, Math.ceil(container.quantity * perUnit));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate;
|
||||
* an unsaved preview booking (no id) sums the wagonsRequired already computed
|
||||
@@ -804,6 +855,7 @@ export class BookingPricingService {
|
||||
return 1;
|
||||
case 'PER_CONTAINER':
|
||||
case 'PER_TON':
|
||||
case 'PER_ITEM':
|
||||
default:
|
||||
return quantity;
|
||||
}
|
||||
@@ -860,6 +912,7 @@ export class BookingPricingService {
|
||||
case 'PER_WAGON':
|
||||
return unitValue * wagonCount;
|
||||
case 'PER_TON':
|
||||
case 'PER_ITEM':
|
||||
return unitValue * quantity;
|
||||
case 'FLAT':
|
||||
return unitValue;
|
||||
@@ -889,20 +942,45 @@ export class BookingPricingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen snapshot for a rate code, or null when there is none, its price
|
||||
* is negative, or it is in a different currency than the booking (in which
|
||||
* case the live-rate path is safer than a mis-converted frozen price).
|
||||
* The frozen snapshot for a rate code, expressed in the BOOKING's currency.
|
||||
*
|
||||
* A contract quotes in USD and freezes USD unit prices; the customer chooses
|
||||
* the billing currency per booking. So a currency mismatch is the normal case
|
||||
* now, not an error — the snapshot is converted rather than discarded. (It
|
||||
* previously returned null on mismatch, which silently dropped the agreed
|
||||
* contract price and re-priced the booking at whatever the live rate had
|
||||
* drifted to.) Grandfathered ETB contracts convert the other way for the same
|
||||
* reason.
|
||||
*
|
||||
* Returns null only when there is no snapshot or its price is unusable.
|
||||
*/
|
||||
private frozenRateByCode(
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||
code: string,
|
||||
bookingCurrency: string,
|
||||
usdToEtb: number,
|
||||
): ContractRateSnapshot | null {
|
||||
const snap = frozenRates?.get(code);
|
||||
if (!snap) return null;
|
||||
if (snap.currency !== bookingCurrency) return null;
|
||||
if (!(Number(snap.unitPrice) >= 0)) return null;
|
||||
return snap;
|
||||
const unitPrice = Number(snap.unitPrice);
|
||||
if (!(unitPrice >= 0)) return null;
|
||||
if (snap.currency === bookingCurrency) return snap;
|
||||
|
||||
// Only USD <-> ETB exist; a rate of 0/NaN would silently zero the price.
|
||||
if (!(usdToEtb > 0)) return null;
|
||||
const converted =
|
||||
snap.currency === 'USD' && bookingCurrency === 'ETB'
|
||||
? Math.round(unitPrice * usdToEtb)
|
||||
: snap.currency === 'ETB' && bookingCurrency === 'USD'
|
||||
? unitPrice / usdToEtb
|
||||
: null;
|
||||
if (converted == null) return null;
|
||||
|
||||
// A copy — the snapshot rows are shared across the pricing pass.
|
||||
return Object.assign(Object.create(Object.getPrototypeOf(snap)), snap, {
|
||||
unitPrice: converted,
|
||||
currency: bookingCurrency,
|
||||
}) as ContractRateSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -914,6 +992,7 @@ export class BookingPricingService {
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||
containerTypeId: string,
|
||||
bookingCurrency: string,
|
||||
usdToEtb: number,
|
||||
): Promise<ContractRateSnapshot | null> {
|
||||
if (!frozenRates) return null;
|
||||
let sizeFt: number | null = null;
|
||||
@@ -923,7 +1002,7 @@ export class BookingPricingService {
|
||||
return null;
|
||||
}
|
||||
if (!sizeFt) return null;
|
||||
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
|
||||
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, usdToEtb);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -966,7 +1045,7 @@ export class BookingPricingService {
|
||||
const hasPerSizeSnapshot =
|
||||
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
|
||||
frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
|
||||
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
|
||||
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
|
||||
if (legacyFlat && !hasPerSizeSnapshot) {
|
||||
const amount = Number(legacyFlat.unitPrice);
|
||||
if (amount > 0) {
|
||||
@@ -995,7 +1074,7 @@ export class BookingPricingService {
|
||||
// unknown type — falls through to the live per-type lookup below
|
||||
}
|
||||
const frozen = sizeFt
|
||||
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency)
|
||||
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency, usdToEtb)
|
||||
: null;
|
||||
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
|
||||
if (!frozen && !live) {
|
||||
@@ -1030,7 +1109,7 @@ export class BookingPricingService {
|
||||
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
||||
// Live lookup: the rate scoped to the booking's commodity wins; a
|
||||
// commodity-less rate (legacy) is the catch-all fallback.
|
||||
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
|
||||
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
|
||||
const live =
|
||||
(booking.cargoTypeId
|
||||
? onLeg.find(
|
||||
@@ -1044,7 +1123,7 @@ export class BookingPricingService {
|
||||
const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit;
|
||||
const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue));
|
||||
let billedQty = 1;
|
||||
if (unit === 'PER_TON') {
|
||||
if (isBulkQuantityUnit(unit)) {
|
||||
billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0));
|
||||
} else if (unit === 'PER_WAGON') {
|
||||
const wagons = await this.bulkWagonCount(booking);
|
||||
@@ -1081,6 +1160,8 @@ export class BookingPricingService {
|
||||
return 'PER_WAGON';
|
||||
case 'per_ton':
|
||||
return 'PER_TON';
|
||||
case 'per_item':
|
||||
return 'PER_ITEM';
|
||||
case 'per_container':
|
||||
return 'PER_CONTAINER';
|
||||
default:
|
||||
|
||||
@@ -33,41 +33,86 @@ import {
|
||||
BookingReferenceYardDto,
|
||||
} from "./dto/booking-reference-data.dto";
|
||||
|
||||
/**
|
||||
* Reference cargo tree: top-level groups, each carrying its selectable
|
||||
* commodities.
|
||||
*
|
||||
* `cargo_types` is an arbitrary-depth tree (Bulk → Steel Billet → S1 → …), but
|
||||
* only a LEAF is a real commodity — an intermediate node is a container for
|
||||
* finer types, and booking against it would be ambiguous. So each group's
|
||||
* `children` are all of its leaf descendants, flattened, whatever the depth.
|
||||
* Deep leaves carry their path below the group ("Steel Billet → S1") so a
|
||||
* generically-named leaf still reads unambiguously in a dropdown.
|
||||
*
|
||||
* A group with no active descendants is its own leaf and is emitted as its
|
||||
* single child — otherwise it is selectable as a group but offers no commodity,
|
||||
* which dead-ends every form that requires one.
|
||||
*/
|
||||
export function buildCargoTypeTree(
|
||||
rows: CargoType[],
|
||||
): BookingReferenceCargoTypeGroupDto[] {
|
||||
const active = rows.filter((r) => r.isActive);
|
||||
const parents = active
|
||||
.filter((r) => !r.parentGroupId)
|
||||
.sort(
|
||||
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
|
||||
);
|
||||
|
||||
const byOrder = (a: CargoType, b: CargoType) =>
|
||||
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code);
|
||||
|
||||
const childrenOf = new Map<string, CargoType[]>();
|
||||
for (const row of active) {
|
||||
if (!row.parentGroupId) continue;
|
||||
const siblings = childrenOf.get(row.parentGroupId) ?? [];
|
||||
siblings.push(row);
|
||||
childrenOf.set(row.parentGroupId, siblings);
|
||||
}
|
||||
for (const siblings of childrenOf.values()) siblings.sort(byOrder);
|
||||
|
||||
const parents = active.filter((r) => !r.parentGroupId).sort(byOrder);
|
||||
|
||||
/** Depth-first leaf walk; `trail` is the path below the group. */
|
||||
const collectLeaves = (
|
||||
node: CargoType,
|
||||
trail: string[],
|
||||
seen: Set<string>,
|
||||
): BookingReferenceCargoTypeChildDto[] => {
|
||||
// Admin-entered parent pointers could in principle cycle — never loop.
|
||||
if (seen.has(node.id)) return [];
|
||||
seen.add(node.id);
|
||||
|
||||
const kids = childrenOf.get(node.id) ?? [];
|
||||
if (kids.length === 0) {
|
||||
return [
|
||||
{
|
||||
id: node.id,
|
||||
name: [...trail, node.cargoTypeName].join(" → "),
|
||||
code: node.code,
|
||||
unit_of_measure: node.unitOfMeasure ?? null,
|
||||
},
|
||||
];
|
||||
}
|
||||
const nextTrail = [...trail, node.cargoTypeName];
|
||||
return kids.flatMap((kid) => collectLeaves(kid, nextTrail, seen));
|
||||
};
|
||||
|
||||
return parents.map((parent) => {
|
||||
const children = active
|
||||
.filter((r) => r.parentGroupId === parent.id)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
|
||||
)
|
||||
.map(
|
||||
(child): BookingReferenceCargoTypeChildDto => ({
|
||||
id: child.id,
|
||||
name: child.cargoTypeName,
|
||||
code: child.code,
|
||||
unit_of_measure: child.unitOfMeasure ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
const group: BookingReferenceCargoTypeGroupDto = {
|
||||
const kids = childrenOf.get(parent.id) ?? [];
|
||||
const children =
|
||||
kids.length === 0
|
||||
? // The group itself is the commodity.
|
||||
[
|
||||
{
|
||||
id: parent.id,
|
||||
name: parent.cargoTypeName,
|
||||
code: parent.code,
|
||||
unit_of_measure: parent.unitOfMeasure ?? null,
|
||||
},
|
||||
]
|
||||
: kids.flatMap((kid) => collectLeaves(kid, [], new Set<string>()));
|
||||
|
||||
return {
|
||||
id: parent.id,
|
||||
name: parent.cargoTypeName,
|
||||
code: parent.code,
|
||||
children,
|
||||
};
|
||||
if (children.length > 0) {
|
||||
group.children = children;
|
||||
}
|
||||
return group;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
||||
{} as never, // fileUploadSettingsService
|
||||
{} as never, // bookingBatchService
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{ isPhasedCustomsBooking: () => false } as never,
|
||||
{} as never, // workflowService
|
||||
{} as never, // invoiceService
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
@@ -59,6 +59,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
||||
clearanceDocsUploadedToStaff: jest.fn(),
|
||||
dutySlipUploadedToStaff: jest.fn(),
|
||||
} as never, // notifier
|
||||
{ emit: jest.fn() } as never, // events
|
||||
);
|
||||
return { service, bookingsRepository, ruleEngineService, contractService };
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
||||
fileUploadSettingsService as never,
|
||||
{} as never, // bookingBatchService
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{ isPhasedCustomsBooking: () => false } as never,
|
||||
{} as never, // workflowService
|
||||
{} as never, // invoiceService
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
@@ -68,6 +68,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
||||
clearanceDocsUploadedToStaff: jest.fn(),
|
||||
dutySlipUploadedToStaff: jest.fn(),
|
||||
} as never, // notifier
|
||||
{ emit: jest.fn() } as never, // events
|
||||
);
|
||||
return { service, bookingsRepository };
|
||||
}
|
||||
@@ -149,7 +150,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
||||
fileUploadSettingsService as never,
|
||||
{} as never, // bookingBatchService
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{ isPhasedCustomsBooking: () => false } as never,
|
||||
{} as never, // workflowService
|
||||
{} as never, // invoiceService
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
@@ -171,6 +172,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
||||
clearanceDocsUploadedToStaff: jest.fn(),
|
||||
dutySlipUploadedToStaff: jest.fn(),
|
||||
} as never, // notifier
|
||||
{ emit: jest.fn() } as never, // events
|
||||
);
|
||||
return { service, bookingsRepository };
|
||||
}
|
||||
@@ -238,7 +240,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
||||
fileUploadSettingsService as never,
|
||||
{} as never, // bookingBatchService
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{ isPhasedCustomsBooking: () => false } as never,
|
||||
{} as never, // workflowService
|
||||
{} as never, // invoiceService
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
@@ -260,6 +262,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
||||
clearanceDocsUploadedToStaff: jest.fn(),
|
||||
dutySlipUploadedToStaff: jest.fn(),
|
||||
} as never, // notifier
|
||||
{ emit: jest.fn() } as never, // events
|
||||
);
|
||||
return { service, bookingsRepository, filesService };
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('BookingTransitionService — operation review', () => {
|
||||
{} as never, // fileUploadSettingsService
|
||||
bookingBatchService as never,
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{ isPhasedCustomsBooking: () => false } as never,
|
||||
{} as never, // workflowService
|
||||
invoiceService as never,
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
@@ -70,6 +70,7 @@ describe('BookingTransitionService — operation review', () => {
|
||||
clearanceDocsUploadedToStaff: jest.fn(),
|
||||
dutySlipUploadedToStaff: jest.fn(),
|
||||
} as never, // notifier
|
||||
{ emit: jest.fn() } as never, // events
|
||||
);
|
||||
return { service, bookingsRepository, bookingBatchService, invoiceService };
|
||||
}
|
||||
@@ -164,11 +165,12 @@ describe('BookingTransitionService — requestOperation export space gate', () =
|
||||
{} as never, // fileUploadSettingsService
|
||||
bookingBatchService as never,
|
||||
bookingsService as never,
|
||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||
{ isPhasedCustomsBooking: () => false } as never,
|
||||
{} as never, // workflowService
|
||||
{} as never, // invoiceService
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
notifier as never,
|
||||
{ emit: jest.fn() } as never, // events
|
||||
);
|
||||
return { service, bookingsRepository, bookingBatchService };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Logger,
|
||||
Optional,
|
||||
} from "@nestjs/common";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
import { EventEmitter2, OnEvent } from "@nestjs/event-emitter";
|
||||
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
@@ -56,11 +56,12 @@ export class BookingTransitionService {
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly containerValidationService: ContainerValidationService,
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly events: EventEmitter2,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
) {}
|
||||
|
||||
private isPhasedGeneralCustoms(booking: Booking): boolean {
|
||||
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
|
||||
private isPhasedCustoms(booking: Booking): boolean {
|
||||
return this.bookingClearanceService.isPhasedCustomsBooking(booking);
|
||||
}
|
||||
|
||||
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
|
||||
@@ -376,6 +377,8 @@ export class BookingTransitionService {
|
||||
} as never);
|
||||
const fresh = await this.bookingsService.findById(updated!.id);
|
||||
this.notifier.completed(fresh);
|
||||
// A ONE_TIME contract closes on its single shipment being delivered.
|
||||
this.events.emit('booking.completed', { bookingId });
|
||||
// Customer tracking: close out the tail milestones so a finished shipment
|
||||
// never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are
|
||||
// implied by delivery; a storage invoice that was never raised is skipped
|
||||
@@ -491,7 +494,7 @@ export class BookingTransitionService {
|
||||
operationReady?: boolean;
|
||||
}> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (this.isPhasedGeneralCustoms(booking)) {
|
||||
if (this.isPhasedCustoms(booking)) {
|
||||
return this.bookingClearanceService.getClearanceView(bookingId);
|
||||
}
|
||||
const { inputCode, outputCode, includesCustoms } =
|
||||
@@ -650,7 +653,7 @@ export class BookingTransitionService {
|
||||
status: "DOCUMENTS_UNDER_REVIEW",
|
||||
} as never);
|
||||
|
||||
if (this.isPhasedGeneralCustoms(booking)) {
|
||||
if (this.isPhasedCustoms(booking)) {
|
||||
await this.workflowService.onCustomerDocsUploadedForBooking(
|
||||
bookingId,
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
@@ -732,7 +735,7 @@ export class BookingTransitionService {
|
||||
}
|
||||
if (
|
||||
status === 'QUERIED' &&
|
||||
this.isPhasedGeneralCustoms(booking) &&
|
||||
this.isPhasedCustoms(booking) &&
|
||||
booking.preClearanceFinalizedAt
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
@@ -755,7 +758,7 @@ export class BookingTransitionService {
|
||||
"CHANGES_REQUESTED",
|
||||
staffId,
|
||||
);
|
||||
if (this.isPhasedGeneralCustoms(booking)) {
|
||||
if (this.isPhasedCustoms(booking)) {
|
||||
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
|
||||
@@ -767,7 +770,7 @@ export class BookingTransitionService {
|
||||
if (status === "QUERIED") {
|
||||
this.notifier.documentQueried(updated, fileKey, note ?? '');
|
||||
}
|
||||
if (this.isPhasedGeneralCustoms(updated)) {
|
||||
if (this.isPhasedCustoms(updated)) {
|
||||
const allApproved = await this.isClearanceFullyApproved(updated);
|
||||
if (allApproved) {
|
||||
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
|
||||
@@ -817,7 +820,7 @@ export class BookingTransitionService {
|
||||
*/
|
||||
async finalizeClearance(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (this.isPhasedGeneralCustoms(booking)) {
|
||||
if (this.isPhasedCustoms(booking)) {
|
||||
throw new BadRequestException(
|
||||
'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.',
|
||||
);
|
||||
|
||||
@@ -916,14 +916,15 @@ export class BookingsController {
|
||||
async uploadBookingDeliveryOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
|
||||
@Body('vesselArrivalDate') vesselArrivalDate: string | undefined,
|
||||
@Body('doCollectedDate') doCollectedDate: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||||
id,
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
vesselDepartureDate,
|
||||
{ vesselArrivalDate, doCollectedDate },
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { SchedulingStatus } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
import {
|
||||
DataSource,
|
||||
DeepPartial,
|
||||
EntityManager,
|
||||
FindOptionsWhere,
|
||||
In,
|
||||
Repository,
|
||||
SelectQueryBuilder,
|
||||
} from 'typeorm';
|
||||
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
@@ -26,6 +34,22 @@ import {
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
|
||||
|
||||
/** A booking is ready for a batch: commercial = signed, government = approved/paid. */
|
||||
const BATCH_POOL_READY = `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
||||
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`;
|
||||
|
||||
/**
|
||||
* Suspending a contract freezes its bookings, so they drop out of every
|
||||
* scheduling pool. Filtering here (rather than letting the write guard throw)
|
||||
* keeps the batch crons quiet — a frozen contract simply stops being a
|
||||
* candidate until the suspension is lifted.
|
||||
*/
|
||||
const NOT_ON_SUSPENDED_CONTRACT = `(booking.contract_id IS NULL
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM freight.contracts c
|
||||
WHERE c.id = booking.contract_id AND c.status = 'SUSPENDED'
|
||||
))`;
|
||||
|
||||
export interface BookingListFilterOptions {
|
||||
statuses?: string[];
|
||||
status?: string;
|
||||
@@ -68,6 +92,42 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
return this.repository.findOne({ where: { reference } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspending a contract freezes its bookings too, so the single write path
|
||||
* every booking mutation funnels through is the place to enforce it — one
|
||||
* guard instead of one per transition method.
|
||||
*
|
||||
* The batch/scheduling pools filter suspended contracts out up front
|
||||
* (see {@link excludeSuspendedContract}), so the engine and its crons never
|
||||
* reach a frozen booking and this only ever fires on a user-initiated action.
|
||||
*
|
||||
* ponytail: the seven `manager.getRepository(Booking)` writes inside
|
||||
* train-scheduling transactions bypass this — they only run on bookings the
|
||||
* pool already handed out, which the filter above has excluded. Move them onto
|
||||
* this repository if that ever stops holding.
|
||||
*/
|
||||
private async assertContractNotSuspended(id: string): Promise<void> {
|
||||
const row = await this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.select('contract.status', 'status')
|
||||
.innerJoin(Contract, 'contract', 'contract.id = booking.contract_id')
|
||||
.where('booking.id = :id', { id })
|
||||
.getRawOne<{ status: string }>();
|
||||
if (row?.status === 'SUSPENDED') {
|
||||
throw new ConflictException(
|
||||
'This shipment belongs to a suspended contract. EDR must lift the suspension before it can move.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
override async update(
|
||||
id: string,
|
||||
data: DeepPartial<Booking>,
|
||||
): Promise<Booking | null> {
|
||||
await this.assertContractNotSuspended(id);
|
||||
return super.update(id, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Highest NNNNNN sequence already issued for `BK-<year>-…` references.
|
||||
* Includes soft-deleted bookings so the next number clears references that
|
||||
@@ -123,7 +183,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
'booking.files',
|
||||
FileRecord,
|
||||
'file',
|
||||
"file.resource_id = booking.id AND file.resource = 'bookings'",
|
||||
// Superseded versions are soft-deleted, not dropped — keep them out of
|
||||
// the live file list (a manual join condition is not filtered for us).
|
||||
"file.resource_id = booking.id AND file.resource = 'bookings' AND file.deleted_at IS NULL",
|
||||
)
|
||||
.getOne();
|
||||
|
||||
@@ -1030,7 +1092,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
'scheduleBooking.booking_id = booking.id',
|
||||
)
|
||||
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
|
||||
.andWhere('scheduleBooking.id IS NULL');
|
||||
.andWhere('scheduleBooking.id IS NULL')
|
||||
.andWhere(NOT_ON_SUSPENDED_CONTRACT);
|
||||
|
||||
// Day-level pooling: customers no longer set train_schedule_id, so the wizard
|
||||
// surfaces the whole (route, EAT day) pool. Fall back to the legacy
|
||||
@@ -1089,10 +1152,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere('sb.id IS NULL')
|
||||
.andWhere(
|
||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
||||
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
||||
)
|
||||
.andWhere(BATCH_POOL_READY)
|
||||
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||
@@ -1128,10 +1189,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
{ day },
|
||||
)
|
||||
.andWhere('sb.id IS NULL')
|
||||
.andWhere(
|
||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
||||
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
||||
)
|
||||
.andWhere(BATCH_POOL_READY)
|
||||
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||
@@ -1168,10 +1227,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
{ day },
|
||||
)
|
||||
.andWhere('sb.id IS NULL')
|
||||
.andWhere(
|
||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
||||
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
||||
)
|
||||
.andWhere(BATCH_POOL_READY)
|
||||
.andWhere(NOT_ON_SUSPENDED_CONTRACT)
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -62,13 +62,15 @@ describe('clearance.util — clearanceCodesForBooking (intercity)', () => {
|
||||
expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
|
||||
});
|
||||
|
||||
it('ONE_TIME contract drawdowns skip the per-booking set (contract collected it)', () => {
|
||||
it('ONE_TIME contract shipments carry the same per-booking set', () => {
|
||||
// Contracts no longer collect clearance documents — every shipment does,
|
||||
// whatever kind of contract it draws on.
|
||||
const drawdown = clearanceCodesForBooking({
|
||||
...base,
|
||||
contractId: 'c1',
|
||||
contractKind: 'ONE_TIME',
|
||||
} as unknown as Booking);
|
||||
expect(drawdown.inputCode).toBeNull();
|
||||
expect(drawdown.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
|
||||
expect(drawdown.outputCode).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,9 +11,8 @@ type Freight = 'container' | 'bulk';
|
||||
|
||||
/**
|
||||
* The single (admin-configured) document set intercity shipments upload.
|
||||
* DOMESTIC has no customs, so one shared set serves contracts and bookings:
|
||||
* ONE_TIME collects it at contract level, GENERAL per booking — Operations
|
||||
* reviews either way.
|
||||
* DOMESTIC has no customs, so one shared set serves every intercity booking —
|
||||
* ONE_TIME and GENERAL alike, collected per booking and reviewed by Operations.
|
||||
*/
|
||||
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
|
||||
|
||||
@@ -77,16 +76,6 @@ export function clearanceCodesForBooking(booking: Booking): {
|
||||
const includesCustoms =
|
||||
Boolean(booking.serviceType?.includesCustoms) ||
|
||||
Boolean(booking.customsClearingEnabled);
|
||||
// Intercity drawdowns under a ONE_TIME contract already cleared the intercity
|
||||
// document set on the CONTRACT (post-signature); only GENERAL drawdowns and
|
||||
// direct (contract-less) bookings carry the per-booking set.
|
||||
if (
|
||||
booking.tradeDirection === 'DOMESTIC' &&
|
||||
booking.contractId &&
|
||||
booking.contractKind === 'ONE_TIME'
|
||||
) {
|
||||
return { inputCode: null, outputCode: null, includesCustoms: false };
|
||||
}
|
||||
return {
|
||||
inputCode: clearanceSettingCode(
|
||||
booking.tradeDirection,
|
||||
|
||||
@@ -85,6 +85,24 @@ export class CustomerTruckService {
|
||||
if (isBulk) {
|
||||
const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId);
|
||||
assertBulkTonnageRemains(totalTons, remainingTons);
|
||||
|
||||
// Assignment-time drawdown: planned tonnage across live trucks (weighed
|
||||
// net once departed, planned before) may not exceed the declared total.
|
||||
if (totalTons > 0) {
|
||||
const [p]: Array<{ planned: string | null }> = await this.dataSource.query(
|
||||
`SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned
|
||||
FROM freight.customer_truck_assignments a
|
||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
const alreadyPlanned = Number(p?.planned ?? 0);
|
||||
const requestedTons = Number(dto.plannedTons ?? 0);
|
||||
if (requestedTons > 0 && alreadyPlanned + requestedTons > totalTons + 0.001) {
|
||||
throw new BadRequestException(
|
||||
`Planned tonnage exceeds the booking: ${alreadyPlanned} t already assigned of ${totalTons} t — at most ${Math.max(0, totalTons - alreadyPlanned)} t left for this truck`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (requested.length) {
|
||||
@@ -108,6 +126,8 @@ export class CustomerTruckService {
|
||||
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
||||
driverName: dto.driverName.trim(),
|
||||
truckType: dto.truckType.trim(),
|
||||
plannedTons: isBulk ? (dto.plannedTons ?? null) : null,
|
||||
plannedQuantity: isBulk ? (dto.plannedQuantity ?? null) : null,
|
||||
}),
|
||||
);
|
||||
await manager.getRepository(CustomerTruckContainer).save(
|
||||
@@ -186,10 +206,16 @@ export class CustomerTruckService {
|
||||
throw new ConflictException('Cannot edit a truck that has already arrived');
|
||||
}
|
||||
|
||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||
if (requested.length < 1) {
|
||||
// Bulk trucks carry loose tonnage, not containers — planned tonnage is
|
||||
// editable instead, capped by what the other trucks haven't claimed.
|
||||
const isBulk = booking.freightType === 'BULK';
|
||||
const requested = isBulk
|
||||
? []
|
||||
: (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||
if (!isBulk && requested.length < 1) {
|
||||
throw new BadRequestException('Select at least one container for this truck');
|
||||
}
|
||||
if (!isBulk) {
|
||||
assertTruckLoad({
|
||||
containers: requested,
|
||||
bookingContainers: await this.bookingContainerNumbers(bookingId),
|
||||
@@ -197,12 +223,35 @@ export class CustomerTruckService {
|
||||
// Exclude THIS truck's own containers so re-saving the same set is allowed.
|
||||
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
|
||||
});
|
||||
} else if (dto.plannedTons != null) {
|
||||
const { totalTons } = await remainingBulkTons(this.dataSource, bookingId);
|
||||
if (totalTons > 0) {
|
||||
const [p]: Array<{ planned: string | null }> = await this.dataSource.query(
|
||||
`SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned
|
||||
FROM freight.customer_truck_assignments a
|
||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.id <> $2`,
|
||||
[bookingId, assignmentId],
|
||||
);
|
||||
const others = Number(p?.planned ?? 0);
|
||||
if (others + Number(dto.plannedTons) > totalTons + 0.001) {
|
||||
throw new BadRequestException(
|
||||
`Planned tonnage exceeds the booking: ${others} t on other trucks of ${totalTons} t — at most ${Math.max(0, totalTons - others)} t left for this truck`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
||||
driverName: dto.driverName.trim(),
|
||||
truckType: dto.truckType.trim(),
|
||||
...(isBulk
|
||||
? {
|
||||
plannedTons: dto.plannedTons ?? null,
|
||||
plannedQuantity: dto.plannedQuantity ?? null,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||
await manager.getRepository(CustomerTruckContainer).save(
|
||||
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
|
||||
@@ -44,4 +46,16 @@ export class AddCustomerTruckDto {
|
||||
message: 'each container number must match ISO container format, e.g. ABCD1234567',
|
||||
})
|
||||
containerNumbers?: string[];
|
||||
|
||||
/** Bulk: planned tonnage this truck hauls — draws down the booking total at assignment. */
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
plannedTons?: number;
|
||||
|
||||
/** Bulk: optional item/piece count on this truck. */
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
plannedQuantity?: number;
|
||||
}
|
||||
|
||||
@@ -302,6 +302,20 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
|
||||
customerTruckArrivedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* Did the goods need re-handling in the warehouse? Recorded by warehouse
|
||||
* staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule;
|
||||
* null = not yet decided (no charge).
|
||||
*/
|
||||
@Column({ name: 'double_handling', type: 'boolean', nullable: true })
|
||||
doubleHandling?: boolean | null;
|
||||
|
||||
@Column({ name: 'double_handling_set_at', type: 'timestamptz', nullable: true })
|
||||
doubleHandlingSetAt?: Date | null;
|
||||
|
||||
@Column({ name: 'double_handling_set_by', type: 'varchar', length: 160, nullable: true })
|
||||
doubleHandlingSetBy?: string | null;
|
||||
|
||||
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
|
||||
customsClearingEnabled!: boolean;
|
||||
|
||||
@@ -526,6 +540,14 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
|
||||
vesselDepartureDate?: string | null;
|
||||
|
||||
/** Import DO: when the vessel arrived in Djibouti. Required on DO upload. */
|
||||
@Column({ name: 'vessel_arrival_date', type: 'date', nullable: true })
|
||||
vesselArrivalDate?: string | null;
|
||||
|
||||
/** Import DO: when GL Djibouti collected the DO. Required on DO upload. */
|
||||
@Column({ name: 'do_collected_date', type: 'date', nullable: true })
|
||||
doCollectedDate?: string | null;
|
||||
|
||||
@Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true })
|
||||
roAmendmentRequestedAt?: Date | null;
|
||||
|
||||
|
||||
@@ -51,6 +51,14 @@ export class CustomerTruckAssignment extends BaseEntity {
|
||||
@Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
netWeightTons?: number | null;
|
||||
|
||||
/** Bulk: planned tonnage at assignment — draws down the booking before weigh-out. */
|
||||
@Column({ name: 'planned_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
plannedTons?: number | null;
|
||||
|
||||
/** Bulk: optional item/piece count planned on this truck. */
|
||||
@Column({ name: 'planned_quantity', type: 'integer', nullable: true })
|
||||
plannedQuantity?: number | null;
|
||||
|
||||
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||
departedAt?: Date | null;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
import { DataSource, EntityManager } from "typeorm";
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
import { CompanyProfileRepository } from "./company-profile.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
|
||||
// hazard — apply it directly.
|
||||
// hazard — no row lock, just the write.
|
||||
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
|
||||
@@ -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.
|
||||
*/
|
||||
private async applyProfileStatus(
|
||||
manager: EntityManager,
|
||||
existing: CompanyProfile,
|
||||
status: ProfileStatus,
|
||||
note?: string,
|
||||
reviewerId?: string,
|
||||
): 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
|
||||
// (status → Active). Pending/unapproved profiles carry no reference.
|
||||
const patch: Partial<CompanyProfile> = { status };
|
||||
@@ -1190,7 +1202,8 @@ export class CompaniesService {
|
||||
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)
|
||||
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
||||
|
||||
@@ -1208,7 +1221,9 @@ export class CompaniesService {
|
||||
: "approved"
|
||||
: null;
|
||||
if (change) {
|
||||
const company = await this.companiesRepo.findById(updated.companyId);
|
||||
const company = await companyRepo.findOne({
|
||||
where: { id: updated.companyId },
|
||||
});
|
||||
if (company) {
|
||||
this.companyNotifier.profileStatusChanged(
|
||||
company,
|
||||
@@ -1222,7 +1237,7 @@ export class CompaniesService {
|
||||
status === ProfileStatus.Active &&
|
||||
company.status === CompanyStatus.Pending
|
||||
) {
|
||||
await this.companiesRepo.update(updated.companyId, {
|
||||
await companyRepo.update(updated.companyId, {
|
||||
status: CompanyStatus.Active,
|
||||
});
|
||||
this.companyNotifier.companyApproved(company);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from './entities/clearance-milestone.entity';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||
import { assertDoCollectionDates } from './contract-clearance.util';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
@@ -64,6 +65,9 @@ export interface BookingClearanceView {
|
||||
roHold?: boolean;
|
||||
roHoldReason?: string | null;
|
||||
vesselDepartureDate?: string | null;
|
||||
/** Import DO dates recorded by GL Djibouti on upload. */
|
||||
vesselArrivalDate?: string | null;
|
||||
doCollectedDate?: string | null;
|
||||
roAmendmentRequestedAt?: string | null;
|
||||
operationReady?: boolean;
|
||||
preClearanceFinalized?: boolean;
|
||||
@@ -109,13 +113,10 @@ export class BookingClearanceService {
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
) {}
|
||||
|
||||
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
|
||||
private async assertPhasedCustoms(booking: Booking): Promise<void> {
|
||||
if (!booking.customsClearingEnabled) {
|
||||
throw new BadRequestException('Phased clearance applies only to customs bookings.');
|
||||
}
|
||||
if (booking.contractKind !== 'GENERAL') {
|
||||
throw new BadRequestException('Per-booking phased clearance applies to general contracts.');
|
||||
}
|
||||
if (!booking.contractId) {
|
||||
throw new BadRequestException('Booking is not linked to a contract.');
|
||||
}
|
||||
@@ -123,7 +124,7 @@ export class BookingClearanceService {
|
||||
|
||||
private async loadBooking(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
await this.assertPhasedGeneralCustoms(booking);
|
||||
await this.assertPhasedCustoms(booking);
|
||||
return booking;
|
||||
}
|
||||
|
||||
@@ -261,6 +262,8 @@ export class BookingClearanceService {
|
||||
roHold: Boolean(booking.roHoldReason),
|
||||
roHoldReason: booking.roHoldReason ?? null,
|
||||
vesselDepartureDate: booking.vesselDepartureDate ?? null,
|
||||
vesselArrivalDate: booking.vesselArrivalDate ?? null,
|
||||
doCollectedDate: booking.doCollectedDate ?? null,
|
||||
roAmendmentRequestedAt: booking.roAmendmentRequestedAt
|
||||
? booking.roAmendmentRequestedAt.toISOString()
|
||||
: null,
|
||||
@@ -346,11 +349,10 @@ export class BookingClearanceService {
|
||||
);
|
||||
}
|
||||
|
||||
isPhasedGeneralCustomsBooking(booking: Booking): boolean {
|
||||
/** Any contract booking (ONE_TIME or GENERAL) whose service bundles customs. */
|
||||
isPhasedCustomsBooking(booking: Booking): boolean {
|
||||
return (
|
||||
Boolean(booking.customsClearingEnabled) &&
|
||||
booking.contractKind === 'GENERAL' &&
|
||||
Boolean(booking.contractId)
|
||||
Boolean(booking.customsClearingEnabled) && Boolean(booking.contractId)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -547,7 +549,7 @@ export class BookingClearanceService {
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
userId?: string,
|
||||
vesselDepartureDate?: string,
|
||||
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
@@ -556,6 +558,8 @@ export class BookingClearanceService {
|
||||
|
||||
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,
|
||||
// any file type. The DO_COLLECTED milestone (and operation readiness) still
|
||||
// waits for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
||||
@@ -566,11 +570,10 @@ export class BookingClearanceService {
|
||||
file,
|
||||
});
|
||||
|
||||
if (vesselDepartureDate?.trim()) {
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
vesselDepartureDate: vesselDepartureDate.trim(),
|
||||
vesselArrivalDate,
|
||||
doCollectedDate,
|
||||
} as never);
|
||||
}
|
||||
|
||||
if (booking.preClearanceFinalizedAt) {
|
||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
|
||||
@@ -713,7 +716,7 @@ export class BookingClearanceService {
|
||||
]);
|
||||
const filtered: Booking[] = [];
|
||||
for (const b of candidates) {
|
||||
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
|
||||
if (!this.isPhasedCustomsBooking(b)) continue;
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
|
||||
}
|
||||
@@ -726,7 +729,7 @@ export class BookingClearanceService {
|
||||
]);
|
||||
const filtered: Booking[] = [];
|
||||
for (const b of candidates) {
|
||||
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
|
||||
if (!this.isPhasedCustomsBooking(b)) continue;
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
|
||||
if (
|
||||
belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, {
|
||||
|
||||
@@ -30,7 +30,11 @@ export class BookingRequestService {
|
||||
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 {
|
||||
if (
|
||||
contract.contractKind !== 'GENERAL' ||
|
||||
@@ -56,6 +60,11 @@ export class BookingRequestService {
|
||||
'This contract is completed — the full contracted quantity has been booked.',
|
||||
);
|
||||
}
|
||||
if (contract.status === 'SUSPENDED') {
|
||||
throw new ConflictException(
|
||||
'This contract is suspended — shipment requests are on hold until EDR lifts the suspension.',
|
||||
);
|
||||
}
|
||||
if (contract.status !== 'CONTRACT_ACTIVE') {
|
||||
throw new ConflictException(
|
||||
'The contract must be active before requesting a shipment.',
|
||||
@@ -121,7 +130,11 @@ export class BookingRequestService {
|
||||
// instance is created first so a failure leaves no half-linked request.
|
||||
const booking = await this.contractBookingService.initiateForShipmentRequest(
|
||||
contract,
|
||||
{ contractRouteId: dto.contractRouteId, userId },
|
||||
{
|
||||
contractRouteId: dto.contractRouteId,
|
||||
userId,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
},
|
||||
);
|
||||
|
||||
const reference = await this.generateReference();
|
||||
@@ -134,6 +147,11 @@ export class BookingRequestService {
|
||||
status: 'ACCEPTED',
|
||||
createdBookingId: booking.id,
|
||||
requestedLines,
|
||||
// Intercity is invoiced in birr whatever the customer picked.
|
||||
paymentCurrency:
|
||||
contract.tradeDirection === 'DOMESTIC'
|
||||
? 'ETB'
|
||||
: (dto.paymentCurrency ?? contract.paymentCurrency ?? 'USD'),
|
||||
notes: dto.notes ?? null,
|
||||
} as never);
|
||||
this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference);
|
||||
|
||||
@@ -24,7 +24,6 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
{} as never, // containerTypesService
|
||||
{} as never, // ruleEngineService
|
||||
{} as never, // milestoneService
|
||||
{} as never, // workflowService
|
||||
{} as never, // invoiceService
|
||||
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||
{} as never, // dataSource
|
||||
@@ -132,6 +131,82 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
expect(contractsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('completion on booking delivery', () => {
|
||||
function makeDeliveryService(contract: Partial<Contract>) {
|
||||
const contractsRepository = {
|
||||
findById: jest.fn().mockResolvedValue(contract),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const bookingsRepository = {
|
||||
findById: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'b-1', reference: 'BKG-1', contractId: 'c-1' }),
|
||||
};
|
||||
const service = new ContractBookingService(
|
||||
contractsRepository as never,
|
||||
bookingsRepository as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ createdToStaff: jest.fn() } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, contractsRepository };
|
||||
}
|
||||
|
||||
it('completes a ONE_TIME contract when its booking is delivered', async () => {
|
||||
const { service, contractsRepository } = makeDeliveryService({
|
||||
id: 'c-1',
|
||||
reference: 'CTR-1',
|
||||
contractKind: 'ONE_TIME',
|
||||
status: 'CONTRACT_ACTIVE',
|
||||
});
|
||||
jest.spyOn(service, 'splitOutstanding').mockResolvedValue(null);
|
||||
|
||||
await service.onBookingCompleted({ bookingId: 'b-1' });
|
||||
|
||||
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
|
||||
status: 'CONTRACT_CLOSED',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a split ONE_TIME contract open while a remainder is outstanding', async () => {
|
||||
const { service, contractsRepository } = makeDeliveryService({
|
||||
id: 'c-1',
|
||||
reference: 'CTR-1',
|
||||
contractKind: 'ONE_TIME',
|
||||
freightType: 'CONTAINER',
|
||||
status: 'CONTRACT_ACTIVE',
|
||||
});
|
||||
jest.spyOn(service, 'splitOutstanding').mockResolvedValue({
|
||||
bySize: new Map([['20ft', { total: 5, outstanding: 2 }]]),
|
||||
bulk: null,
|
||||
});
|
||||
|
||||
await service.onBookingCompleted({ bookingId: 'b-1' });
|
||||
|
||||
expect(contractsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves a GENERAL contract alone — it closes on cap or expiry', async () => {
|
||||
const { service, contractsRepository } = makeDeliveryService({
|
||||
id: 'c-1',
|
||||
contractKind: 'GENERAL',
|
||||
status: 'CONTRACT_ACTIVE',
|
||||
});
|
||||
|
||||
await service.onBookingCompleted({ bookingId: 'b-1' });
|
||||
|
||||
expect(contractsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('reopens a completed contract when capacity was released', async () => {
|
||||
const { service, contractsRepository } = makeService();
|
||||
contractsRepository.findByIdWithRelations.mockResolvedValue(
|
||||
|
||||
@@ -55,9 +55,11 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
{} as never, // containerTypesService
|
||||
{} as never, // ruleEngineService
|
||||
milestoneService as never,
|
||||
{} as never, // workflowService
|
||||
invoiceService as never,
|
||||
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||
{
|
||||
createdToStaff: jest.fn(),
|
||||
createdByGlForCustomer: jest.fn(),
|
||||
} as never, // bookingNotifier
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
|
||||
/**
|
||||
* Who may open a shipment instance on a customs (Path B) contract. The customer
|
||||
* initiates his own ONE_TIME customs booking and uploads the GL-input documents
|
||||
* on it; GL still clears it and completes it with cargo and price. GENERAL
|
||||
* customs instances come from a shipment request, and completing/creating a
|
||||
* customs booking outright stays GL-only.
|
||||
*/
|
||||
describe('ContractBookingService — customs booking gate', () => {
|
||||
function makeService() {
|
||||
return new ContractBookingService(
|
||||
{} as never, // contractsRepository
|
||||
{} as never, // bookingsRepository
|
||||
{} as never, // bookingPricingService
|
||||
{} as never, // consolidationService
|
||||
{} as never, // containerTypesService
|
||||
{} as never, // ruleEngineService
|
||||
{} as never, // milestoneService
|
||||
{} as never, // invoiceService
|
||||
{} as never, // bookingNotifier
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
);
|
||||
}
|
||||
|
||||
type WithPrivate = {
|
||||
assertGate: (
|
||||
c: Contract,
|
||||
isGlActor: boolean,
|
||||
isInitiate?: boolean,
|
||||
) => Promise<string>;
|
||||
};
|
||||
|
||||
const customsContract = (contractKind: 'ONE_TIME' | 'GENERAL'): Contract =>
|
||||
({
|
||||
id: 'c-1',
|
||||
contractKind,
|
||||
status: 'FULLY_EXECUTED',
|
||||
customsClearingEnabled: true,
|
||||
}) as Contract;
|
||||
|
||||
const gate = (c: Contract, isGl: boolean, isInitiate?: boolean) =>
|
||||
(makeService() as never as WithPrivate).assertGate(c, isGl, isInitiate);
|
||||
|
||||
it('lets the customer initiate a ONE_TIME customs shipment', async () => {
|
||||
await expect(gate(customsContract('ONE_TIME'), false, true)).resolves.toBe(
|
||||
'CUSTOMER',
|
||||
);
|
||||
});
|
||||
|
||||
it('still lets GL initiate on the customer behalf', async () => {
|
||||
await expect(gate(customsContract('ONE_TIME'), true, true)).resolves.toBe(
|
||||
'GL_ET',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a customer creating a customs booking outright (cargo + day)', async () => {
|
||||
await expect(gate(customsContract('ONE_TIME'), false)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a customer initiating a GENERAL customs shipment (request only)', async () => {
|
||||
await expect(
|
||||
gate(customsContract('GENERAL'), false, true),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
@@ -37,16 +37,20 @@ import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import {
|
||||
ContractsRepository,
|
||||
TERMINAL_BOOKING_STATUSES,
|
||||
} from './contracts.repository';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { isEffectivelyExpired } from './utils/contract-expiry.util';
|
||||
import {
|
||||
CreateBookingContainerLineDto,
|
||||
CreateBookingUnderContractDto,
|
||||
} from './dto/create-booking-under-contract.dto';
|
||||
|
||||
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
|
||||
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
|
||||
// TERMINAL_BOOKING_STATUSES (the statuses that free the ONE_TIME active-booking
|
||||
// slot) lives in contracts.repository.ts — the contract cancel gate needs the
|
||||
// same list.
|
||||
|
||||
/** Bookings that never shipped release their quantity hold on the contract. */
|
||||
const RELEASING_BOOKING_STATUSES = ['CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||
@@ -94,7 +98,6 @@ export class ContractBookingService {
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly bookingNotifier: BookingLifecycleNotifierService,
|
||||
private readonly dataSource: DataSource,
|
||||
@@ -187,21 +190,14 @@ export class ContractBookingService {
|
||||
|
||||
const freightType = contract.freightType;
|
||||
|
||||
// GENERAL + customs (Path B) runs per-booking clearance: the booking starts
|
||||
// in the clearance gate (AWAITING_DOCUMENTS) instead of going straight to
|
||||
// operations, and there is NO contract-level clearance cycle to link.
|
||||
const generalCustoms =
|
||||
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
||||
|
||||
// GENERAL without customs (Path A) ALSO clears per booking: the customer
|
||||
// uploads his own clearance proof on each booking and Operations reviews it
|
||||
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
|
||||
// requestOperation machine). GENERAL intercity (DOMESTIC) follows the same
|
||||
// per-booking gate with the intercity document set — ops finalize then puts
|
||||
// the booking straight into the ride-along pool (FULLY_EXECUTED), since
|
||||
// intercity has no shipment-day request step.
|
||||
const generalSelfClear =
|
||||
contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled;
|
||||
// EVERY contract booking clears per booking now — both contract kinds, both
|
||||
// paths, intercity included. Customs (Path B): GL runs the phased ET/DJ
|
||||
// workflow on this booking. Non-customs (Path A) and intercity: the customer
|
||||
// uploads his own document set on the booking and Operations reviews it
|
||||
// (AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
|
||||
// requestOperation; intercity finalize goes straight to the ride-along pool).
|
||||
// So the booking is always born in the clearance gate, never in the
|
||||
// operations queue, and no contract-level clearance cycle exists to link.
|
||||
|
||||
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
|
||||
// there is no window and no date — staff accept them onto a train at
|
||||
@@ -218,24 +214,11 @@ export class ContractBookingService {
|
||||
throw new BadRequestException('A binding shipment day is required');
|
||||
}
|
||||
|
||||
// Booking-window gate (config-driven): an operations booking may only be
|
||||
// created while the route's booking window is open — import: the day's window
|
||||
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
|
||||
// export: within exportBookingLeadHours of departure. Bookings that enter the
|
||||
// clearance gate first (Path B customs AND Path A per-booking self-clearance)
|
||||
// are scheduled later, so they are not gated here.
|
||||
if (!generalCustoms && !generalSelfClear && !isIntercity) {
|
||||
await this.trainSchedulingService.assertBookingWindowOpen({
|
||||
originYardId: route?.originYardId ?? null,
|
||||
destinationYardId: route?.destinationYardId ?? null,
|
||||
scheduledDate: dto.scheduledDate ?? null,
|
||||
direction: contract.tradeDirection ?? null,
|
||||
});
|
||||
// EXPORT rides whole or not at all (no split concept): reject the booking
|
||||
// up front when no single open train on the day can carry it, telling the
|
||||
// customer how much space is still bookable.
|
||||
await this.assertExportTrainSpace(contract, route, dto);
|
||||
}
|
||||
// No booking-window / export-space gate here any more: every contract
|
||||
// booking enters the clearance gate first and is scheduled only once the
|
||||
// documents are approved. Both checks run at that point instead —
|
||||
// `completeUnderContract` (bare instances) and `requestOperation` (bookings
|
||||
// created with cargo) — against the day the customer actually picks.
|
||||
|
||||
// Hard capacity gate: a container line whose total weight exceeds the
|
||||
// container type's max capacity can never be booked — no surcharge path,
|
||||
@@ -265,10 +248,7 @@ export class ContractBookingService {
|
||||
companyProfileId: contract.companyProfileId ?? null,
|
||||
isGovernment: contract.isGovernment,
|
||||
governmentInstitution: contract.governmentInstitution ?? null,
|
||||
status:
|
||||
generalCustoms || generalSelfClear
|
||||
? 'AWAITING_DOCUMENTS'
|
||||
: 'OPERATION_REQUEST_PENDING',
|
||||
status: 'AWAITING_DOCUMENTS',
|
||||
bookingType: 'ONE_TIME',
|
||||
contractId: contract.id,
|
||||
contractRouteId: route?.id ?? null,
|
||||
@@ -277,7 +257,7 @@ export class ContractBookingService {
|
||||
createdByUserId: user?.id ?? null,
|
||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency),
|
||||
contractType: 'NEW',
|
||||
customsClearingEnabled: contract.customsClearingEnabled,
|
||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||
@@ -375,10 +355,7 @@ export class ContractBookingService {
|
||||
// exactly once whether the booking parks for a partner or finalizes inline.
|
||||
this.bookingNotifier.createdToStaff(withContainers ?? booking);
|
||||
|
||||
const intendedStatus =
|
||||
generalCustoms || generalSelfClear
|
||||
? 'AWAITING_DOCUMENTS'
|
||||
: 'OPERATION_REQUEST_PENDING';
|
||||
const intendedStatus = 'AWAITING_DOCUMENTS';
|
||||
if (
|
||||
withContainers &&
|
||||
freightType === 'CONTAINER' &&
|
||||
@@ -404,11 +381,7 @@ export class ContractBookingService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.finalizeContractBooking(
|
||||
booking.id,
|
||||
contract,
|
||||
generalCustoms,
|
||||
);
|
||||
await this.finalizeContractBooking(booking.id, contract);
|
||||
|
||||
await this.maybeCompleteContract(contract);
|
||||
|
||||
@@ -417,13 +390,22 @@ export class ContractBookingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a BARE booking instance under a GENERAL non-customs contract
|
||||
* (Path A per-booking self-clearance). One click, zero input: no schedule
|
||||
* date, no cargo, no window check, no pricing. The instance starts in the
|
||||
* clearance gate (AWAITING_DOCUMENTS); the customer uploads clearance docs,
|
||||
* Operations reviews and finalizes, and only then does the customer complete
|
||||
* the booking (cargo + binding day + window check) via
|
||||
* {@link completeUnderContract} — the same machinery a one-time shipment uses.
|
||||
* Initiate a BARE booking instance under an import/export contract — ONE_TIME
|
||||
* or GENERAL, customs or not. One click, zero input: no schedule date, no
|
||||
* cargo, no window check, no pricing. The instance starts in the clearance
|
||||
* gate (AWAITING_DOCUMENTS) and is where ALL clearance documents live:
|
||||
*
|
||||
* - Path A (self-clearance): the customer initiates, uploads his clearance
|
||||
* proof, Operations reviews and finalizes.
|
||||
* - Path B (customs, ONE_TIME): the customer initiates too, then uploads the
|
||||
* GL-input documents on the instance; GL approves them and runs the phased
|
||||
* ET/DJ workflow (pre-booking milestones are seeded here). GL may still
|
||||
* initiate on his behalf. GENERAL customs instances come from a shipment
|
||||
* request ({@link initiateForShipmentRequest}), not from here.
|
||||
*
|
||||
* Only after the clearance is finalized is the booking completed (cargo +
|
||||
* binding day + window check) via {@link completeUnderContract} — by the
|
||||
* customer on Path A, by GL on Path B.
|
||||
*/
|
||||
async initiateUnderContract(
|
||||
contractId: string,
|
||||
@@ -434,13 +416,12 @@ export class ContractBookingService {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||
|
||||
const generalSelfClear =
|
||||
contract.contractKind === 'GENERAL' &&
|
||||
!contract.customsClearingEnabled &&
|
||||
contract.tradeDirection !== 'DOMESTIC';
|
||||
if (!generalSelfClear) {
|
||||
// Intercity has no shipment day to defer to, so it is booked directly with
|
||||
// its cargo (the documents still live on that booking). Everything else —
|
||||
// ONE_TIME or GENERAL, customs or self-clear — starts as a bare instance.
|
||||
if (contract.tradeDirection === 'DOMESTIC') {
|
||||
throw new BadRequestException(
|
||||
'Initiate booking applies only to general import/export contracts without customs clearing.',
|
||||
'Intercity shipments are booked directly with their cargo — there is no initiate step.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -453,12 +434,28 @@ export class ContractBookingService {
|
||||
const isGlActor =
|
||||
actorPermissions != null &&
|
||||
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
||||
const createdByRole = await this.assertGate(contract, isGlActor);
|
||||
// The customer initiates his own shipment instance on ONE_TIME contracts
|
||||
// (customs or self-clearance); GL may also initiate on a customs contract.
|
||||
// GENERAL customs instances come from a shipment request, not from here.
|
||||
const createdByRole = await this.assertGate(contract, isGlActor, true);
|
||||
|
||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||
}
|
||||
|
||||
// ONE_TIME carries a single shipment at a time; a bare instance occupies the
|
||||
// slot from the moment it is initiated (it is not a terminal status). The
|
||||
// split chain is the one exception — a paid partial frees the slot and
|
||||
// completion enforces that the next booking takes the whole remainder.
|
||||
if (contract.contractKind === 'ONE_TIME' && !(await this.hasSplitBooking(contractId))) {
|
||||
const active = await this.countActiveBookings(contractId);
|
||||
if (active > 0) {
|
||||
throw new BadRequestException(
|
||||
'This one-time contract already has an active booking.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
||||
|
||||
// Bare instance: no cargo, no date, no price. Draws no contract capacity
|
||||
@@ -481,7 +478,7 @@ export class ContractBookingService {
|
||||
createdByUserId: user?.id ?? null,
|
||||
scheduledDate: null,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
paymentCurrency: this.resolveShipmentCurrency(contract, null),
|
||||
contractType: 'NEW',
|
||||
customsClearingEnabled: contract.customsClearingEnabled,
|
||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||
@@ -503,6 +500,16 @@ export class ContractBookingService {
|
||||
} as never),
|
||||
);
|
||||
|
||||
// Customs: the instance runs the phased ET/DJ workflow, so its pre-booking
|
||||
// milestones exist from initiation (the post-booking half is seeded when the
|
||||
// booking is completed). Self-clearance has no milestone timeline.
|
||||
if (contract.customsClearingEnabled) {
|
||||
await this.milestoneService.seedPreBookingMilestonesOnBooking(
|
||||
booking.id,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
this.bookingNotifier.createdToStaff(result ?? booking);
|
||||
return { booking: result ?? booking, warnings: [] };
|
||||
@@ -520,7 +527,12 @@ export class ContractBookingService {
|
||||
*/
|
||||
async initiateForShipmentRequest(
|
||||
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> {
|
||||
const generalCustoms =
|
||||
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
||||
@@ -555,7 +567,7 @@ export class ContractBookingService {
|
||||
createdByUserId: opts.userId ?? null,
|
||||
scheduledDate: null,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
paymentCurrency: this.resolveShipmentCurrency(contract, opts?.paymentCurrency),
|
||||
contractType: 'NEW',
|
||||
customsClearingEnabled: contract.customsClearingEnabled,
|
||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||
@@ -713,6 +725,15 @@ export class ContractBookingService {
|
||||
// after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks
|
||||
// the shipment day.
|
||||
if (!hasCargo) {
|
||||
// ONE_TIME split chain: the instance that follows a paid partial must take
|
||||
// the WHOLE outstanding remainder — same rule a booking created with cargo
|
||||
// passes at creation.
|
||||
if (
|
||||
contract.contractKind === 'ONE_TIME' &&
|
||||
(await this.hasSplitBooking(contract.id))
|
||||
) {
|
||||
await this.assertExactRemainder(contract, dto);
|
||||
}
|
||||
await this.assertWithinQuantityCap(contract, dto);
|
||||
if (freightType === 'CONTAINER') {
|
||||
await this.assertWithinMaxCapacity(contract, dto);
|
||||
@@ -733,6 +754,13 @@ export class ContractBookingService {
|
||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(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);
|
||||
|
||||
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
@@ -805,10 +833,7 @@ export class ContractBookingService {
|
||||
// Invoice the now-priced booking and, for a customs instance, seed the
|
||||
// post-booking milestones (pre-booking ones exist since initiation —
|
||||
// ensure* fills only what is missing). Idempotent, non-blocking.
|
||||
const generalCustoms =
|
||||
contract.contractKind === 'GENERAL' &&
|
||||
Boolean(contract.customsClearingEnabled);
|
||||
await this.finalizeContractBooking(booking.id, contract, generalCustoms);
|
||||
await this.finalizeContractBooking(booking.id, contract);
|
||||
await this.maybeCompleteContract(contract);
|
||||
} else if (freightType === 'CONTAINER') {
|
||||
// Resubmit only re-picks the shipment day — the persisted container
|
||||
@@ -874,33 +899,17 @@ export class ContractBookingService {
|
||||
private async finalizeContractBooking(
|
||||
bookingId: string,
|
||||
contract: Contract,
|
||||
generalCustoms: boolean,
|
||||
): Promise<void> {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||||
if (!booking || booking.status === 'PENDING_CONSOLIDATION') return;
|
||||
|
||||
// ONE_TIME customs (legacy contract-cycle path): link the contract clearance
|
||||
// cycle to this booking, seed post-booking milestones, and lock the contract
|
||||
// to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle
|
||||
// and must stay CONTRACT_ACTIVE so further shipment requests can be accepted.
|
||||
if (contract.customsClearingEnabled && !generalCustoms) {
|
||||
const cycle = await this.contractsRepository.currentCycle(contract.id);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.linkBooking(cycle.id, bookingId);
|
||||
}
|
||||
await this.milestoneService.seedPostBookingMilestones(
|
||||
bookingId,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
await this.contractsRepository.update(contract.id, {
|
||||
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
} as never);
|
||||
} else if (generalCustoms) {
|
||||
// Per-booking clearance: seed the full milestone timeline on the booking.
|
||||
// ensure* skips codes that already exist — an initiated instance carries
|
||||
// its pre-booking milestones from initiation, and a consolidation pairing
|
||||
// replay must not duplicate the timeline.
|
||||
// Customs runs per booking for BOTH contract kinds: seed the full milestone
|
||||
// timeline on the booking. ensure* skips codes that already exist — an
|
||||
// initiated instance carries its pre-booking milestones from initiation, and
|
||||
// a consolidation pairing replay must not duplicate the timeline. The
|
||||
// contract itself is never moved to ACTIVE_SHIPMENT_IN_PROGRESS any more; it
|
||||
// holds no clearance state at all.
|
||||
if (contract.customsClearingEnabled) {
|
||||
await this.milestoneService.ensureBookingMilestones(
|
||||
bookingId,
|
||||
contract.tradeDirection,
|
||||
@@ -921,6 +930,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)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -943,10 +979,7 @@ export class ContractBookingService {
|
||||
booking.contractId,
|
||||
);
|
||||
if (!contract) continue;
|
||||
const generalCustoms =
|
||||
contract.contractKind === 'GENERAL' &&
|
||||
Boolean(contract.customsClearingEnabled);
|
||||
await this.finalizeContractBooking(id, contract, generalCustoms).catch(
|
||||
await this.finalizeContractBooking(id, contract).catch(
|
||||
(err) =>
|
||||
this.logger.error(
|
||||
`Failed to finalize paired contract booking ${booking.reference}: ${
|
||||
@@ -961,36 +994,38 @@ export class ContractBookingService {
|
||||
* Returns the role to stamp on the booking, or throws if the caller is not
|
||||
* allowed to create one for this contract's execution path.
|
||||
*/
|
||||
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
|
||||
private async assertGate(
|
||||
contract: Contract,
|
||||
isGlActor: boolean,
|
||||
isInitiate = false,
|
||||
): Promise<string> {
|
||||
// Suspended contracts are frozen for everyone, GL included — say so instead
|
||||
// of letting the executed-status check below give a misleading reason.
|
||||
if (contract.status === 'SUSPENDED') {
|
||||
throw new BadRequestException(
|
||||
'This contract is suspended — no new shipments can be booked until EDR lifts the suspension.',
|
||||
);
|
||||
}
|
||||
if (contract.customsClearingEnabled) {
|
||||
// Path B — Global Logistics creates the booking ON BEHALF OF the customer.
|
||||
// The customer never books a customs contract himself.
|
||||
if (!isGlActor) {
|
||||
// Path B — the customer OPENS the shipment instance on a ONE_TIME customs
|
||||
// contract (one click, no cargo) and uploads the GL-input documents on it;
|
||||
// GL still runs the phased ET/DJ clearance and completes the booking with
|
||||
// cargo, day and price. A GENERAL customs instance is opened by a shipment
|
||||
// request instead, and completing any customs booking stays GL-only.
|
||||
const customerMayInitiate = isInitiate && contract.contractKind === 'ONE_TIME';
|
||||
if (!isGlActor && !customerMayInitiate) {
|
||||
throw new ForbiddenException(
|
||||
'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.',
|
||||
);
|
||||
}
|
||||
if (contract.contractKind === 'GENERAL') {
|
||||
// GENERAL customs has NO contract clearance cycle — GL books per accepted
|
||||
// shipment request while the contract is active; clearance is per booking.
|
||||
if (contract.status !== 'CONTRACT_ACTIVE') {
|
||||
// No contract clearance cycle exists on either kind now — clearance runs
|
||||
// on the booking, so an executed/active contract is the only gate here.
|
||||
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
|
||||
throw new BadRequestException(
|
||||
'Contract must be active to book a shipment.',
|
||||
'Contract must be fully executed before booking a shipment.',
|
||||
);
|
||||
}
|
||||
return 'GL_ET';
|
||||
}
|
||||
// ONE_TIME customs — pre-booking boundary milestone must be complete.
|
||||
const boundaryOk = await this.workflowService.isBoundaryComplete(
|
||||
contract.id,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
if (!boundaryOk) {
|
||||
throw new BadRequestException(
|
||||
'Pre-booking clearance is not complete — booking cannot be created yet.',
|
||||
);
|
||||
}
|
||||
return 'GL_ET';
|
||||
return isGlActor ? 'GL_ET' : 'CUSTOMER';
|
||||
}
|
||||
|
||||
// Path A — customer (or staff) once the contract is executed.
|
||||
@@ -1002,6 +1037,40 @@ export class ContractBookingService {
|
||||
return isGlActor ? 'STAFF' : 'CUSTOMER';
|
||||
}
|
||||
|
||||
/**
|
||||
* GL fallback worklist: executed ONE_TIME customs contracts with no live
|
||||
* shipment instance yet. The customer normally opens it himself from the
|
||||
* portal; this list lets GL do it on his behalf, and shows the contracts that
|
||||
* are on no other queue (clearance lives on the booking, which does not exist
|
||||
* yet). GENERAL customs is excluded — opened by shipment requests.
|
||||
*/
|
||||
async awaitingShipmentContracts(): Promise<Contract[]> {
|
||||
const { items } = await this.contractsRepository.findAllPaginated({
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
statuses: ['FULLY_EXECUTED'],
|
||||
customsClearingEnabled: true,
|
||||
contractKind: 'ONE_TIME',
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'DESC',
|
||||
} as never);
|
||||
|
||||
const out: Contract[] = [];
|
||||
for (const contract of items) {
|
||||
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
|
||||
continue;
|
||||
}
|
||||
// A split chain frees the slot for the remainder, so those contracts stay
|
||||
// on the list even while the paid partial booking still exists.
|
||||
if (await this.hasSplitBooking(contract.id)) {
|
||||
out.push(contract);
|
||||
continue;
|
||||
}
|
||||
if ((await this.countActiveBookings(contract.id)) === 0) out.push(contract);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private async countActiveBookings(contractId: string): Promise<number> {
|
||||
return this.dataSource
|
||||
.getRepository(Booking)
|
||||
@@ -1379,6 +1448,53 @@ export class ContractBookingService {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A ONE_TIME contract carries exactly one shipment: once that booking is
|
||||
* delivered (COMPLETED) the contract is fulfilled and moves to
|
||||
* CONTRACT_CLOSED — shown as "Completed" and greyed out in both portals, and
|
||||
* blocking any further booking. A split ONE_TIME is the exception: its
|
||||
* remainder chain must be rebooked and delivered first, so the contract stays
|
||||
* open while the split remainder is outstanding.
|
||||
*
|
||||
* GENERAL contracts are untouched — they close on cap exhaustion or expiry.
|
||||
* Best-effort: a status hiccup must never fail the booking that completed.
|
||||
*/
|
||||
@OnEvent('booking.completed')
|
||||
async onBookingCompleted(payload: { bookingId: string }): Promise<void> {
|
||||
try {
|
||||
const booking = await this.bookingsRepository.findById(payload.bookingId);
|
||||
if (!booking?.contractId) return;
|
||||
const contract = await this.contractsRepository.findById(booking.contractId);
|
||||
if (!contract || contract.contractKind === 'GENERAL') return;
|
||||
// Already closed/expired/cancelled — nothing to do.
|
||||
if (isEffectivelyExpired(contract)) return;
|
||||
|
||||
const outstanding = await this.splitOutstanding(contract);
|
||||
if (outstanding) {
|
||||
// 0.001 tolerance absorbs bulk-ton float rounding, same as the
|
||||
// cap-exhaustion path below.
|
||||
const exhausted =
|
||||
contract.freightType === 'CONTAINER'
|
||||
? [...outstanding.bySize.values()].every((s) => s.outstanding <= 0)
|
||||
: (outstanding.bulk?.outstanding ?? 0) <= 0.001;
|
||||
if (!exhausted) return;
|
||||
}
|
||||
|
||||
await this.contractsRepository.update(contract.id, {
|
||||
status: 'CONTRACT_CLOSED',
|
||||
} as never);
|
||||
this.logger.log(
|
||||
`Contract ${contract.reference} completed — its one-time booking ${booking.reference} was delivered.`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Could not close contract for completed booking ${payload.bookingId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the contract once its quantity cap is fully consumed. Runs after
|
||||
* every booking created under a GENERAL contract, and under a ONE_TIME
|
||||
@@ -1565,6 +1681,23 @@ export class ContractBookingService {
|
||||
* booking-level override (dto.equipmentReturn ?? contract default) applies.
|
||||
* 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(
|
||||
contract: Contract,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
@@ -1795,7 +1928,7 @@ export class ContractBookingService {
|
||||
contractId: contract.id,
|
||||
freightType: contract.freightType,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency),
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
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 {
|
||||
ContractDocPhase,
|
||||
type ClearanceFinalInvoiceSummary,
|
||||
@@ -13,7 +18,10 @@ import { FilesService } from '../files/files.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractsService, PaginatedContracts } from './contracts.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 { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
@@ -26,7 +34,7 @@ import { Contract } from './entities/contract.entity';
|
||||
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
import { buildWorkflowFiles, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
|
||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||
|
||||
@@ -72,9 +80,23 @@ export interface ContractClearanceView {
|
||||
blockedReason?: string | null;
|
||||
} | 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;
|
||||
roHoldReason?: string | null;
|
||||
vesselDepartureDate?: string | null;
|
||||
/** Import DO dates recorded by GL Djibouti on upload. */
|
||||
vesselArrivalDate?: string | null;
|
||||
doCollectedDate?: string | null;
|
||||
roAmendmentRequestedAt?: string | null;
|
||||
bookingReady?: boolean;
|
||||
preClearanceFinalized?: boolean;
|
||||
@@ -84,12 +106,30 @@ export interface ContractClearanceView {
|
||||
/** Reference + status of the GL-created shipment booking, once it exists. */
|
||||
linkedBookingReference?: 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?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
declarationSerial?: string | null;
|
||||
noticeFile?: { id: string; name: string; url: string } | 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>;
|
||||
/** Import post-allocation T1 transit document state (null until a booking is linked). */
|
||||
t1?: ClearanceT1State | null;
|
||||
@@ -239,6 +279,19 @@ export class ContractClearanceService {
|
||||
contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary);
|
||||
const phase = this.workflowService.resolvePhase(contract, cycle, 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(
|
||||
files,
|
||||
contract.tradeDirection ?? 'IMPORT',
|
||||
@@ -301,11 +354,24 @@ export class ContractClearanceService {
|
||||
// shortly" message. Reuse the export booking load; fetch for import too.
|
||||
let linkedBookingReference: string | null = null;
|
||||
let linkedBookingStatus: string | null = null;
|
||||
let linkedBookingReviewNote: string | null = null;
|
||||
let linkedBookingScheduledDate: string | null = null;
|
||||
if (cycle?.bookingId) {
|
||||
const booking = await this.bookingsService.findById(cycle.bookingId);
|
||||
if (booking) {
|
||||
linkedBookingReference = booking.reference ?? 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') {
|
||||
nextAction = this.workflowService.computeNextActionForBooking(
|
||||
booking,
|
||||
@@ -340,6 +406,8 @@ export class ContractClearanceService {
|
||||
roHold: Boolean(cycle?.roHoldReason),
|
||||
roHoldReason: cycle?.roHoldReason ?? null,
|
||||
vesselDepartureDate: cycle?.vesselDepartureDate ?? null,
|
||||
vesselArrivalDate: cycle?.vesselArrivalDate ?? null,
|
||||
doCollectedDate: cycle?.doCollectedDate ?? null,
|
||||
roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt
|
||||
? cycle.roAmendmentRequestedAt.toISOString()
|
||||
: null,
|
||||
@@ -349,7 +417,11 @@ export class ContractClearanceService {
|
||||
linkedBookingId: cycle?.bookingId ?? null,
|
||||
linkedBookingReference,
|
||||
linkedBookingStatus,
|
||||
linkedBookingReviewNote,
|
||||
linkedBookingScheduledDate,
|
||||
dutyAdvice,
|
||||
dutyDispute,
|
||||
transitAssignee,
|
||||
workflowFiles,
|
||||
t1,
|
||||
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
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
contractId: string,
|
||||
fileKey: string,
|
||||
@@ -876,46 +1060,6 @@ export class ContractClearanceService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Operations queue: self-clearance (Path A) contracts awaiting Operations
|
||||
* review of the customer's own clearance documents.
|
||||
*/
|
||||
/**
|
||||
* Statuses a non-customs contract passes through around Operations
|
||||
* clearance review — the set a caller may narrow {@link opsQueue} to.
|
||||
*/
|
||||
private static readonly OPS_CLEARANCE_STATUSES = [
|
||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
'FULLY_EXECUTED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
'CONTRACT_CLOSED',
|
||||
'CANCELLED',
|
||||
];
|
||||
|
||||
async opsQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||
// Callers may narrow to any subset of the ops-clearance lifecycle (the
|
||||
// hub's status filter sends an explicit list); anything outside the
|
||||
// whitelist is dropped so this endpoint can't become a general contract
|
||||
// browser. No statuses given → the original under-review queue.
|
||||
const requested = (filter.statuses ?? filter.status ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) =>
|
||||
ContractClearanceService.OPS_CLEARANCE_STATUSES.includes(s),
|
||||
);
|
||||
return this.contractsRepository.findAllPaginated({
|
||||
page: filter.page ?? 1,
|
||||
pageSize: filter.pageSize ?? 100,
|
||||
statuses: requested.length ? requested : ['CLEARANCE_UNDER_REVIEW'],
|
||||
customsClearingEnabled: false,
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
/** GL ET history: contracts that completed Path B clearance. */
|
||||
async history(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||
@@ -945,6 +1089,68 @@ export class ContractClearanceService {
|
||||
// ── Phased clearance actions (ONE_TIME customs, Phase 1) ───────────────────
|
||||
|
||||
/** 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(
|
||||
contractId: string,
|
||||
contract: Contract,
|
||||
@@ -955,6 +1161,16 @@ export class ContractClearanceService {
|
||||
'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 docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
|
||||
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
|
||||
@@ -1061,6 +1277,67 @@ export class ContractClearanceService {
|
||||
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(
|
||||
contractId: string,
|
||||
file: Express.Multer.File,
|
||||
@@ -1175,7 +1452,7 @@ export class ContractClearanceService {
|
||||
contractId: string,
|
||||
file: Express.Multer.File,
|
||||
userId?: string,
|
||||
vesselDepartureDate?: string,
|
||||
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
@@ -1185,6 +1462,8 @@ export class ContractClearanceService {
|
||||
|
||||
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,
|
||||
// any file type. The DO_COLLECTED milestone (and booking readiness) still waits
|
||||
// for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
||||
@@ -1196,9 +1475,10 @@ export class ContractClearanceService {
|
||||
});
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (cycle && vesselDepartureDate?.trim()) {
|
||||
if (cycle) {
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
vesselDepartureDate: vesselDepartureDate.trim(),
|
||||
vesselArrivalDate,
|
||||
doCollectedDate,
|
||||
});
|
||||
}
|
||||
if (cycle?.preClearanceFinalizedAt) {
|
||||
@@ -1373,80 +1653,4 @@ export class ContractClearanceService {
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */
|
||||
async etQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||
const base = await this.contractsRepository.findAllPaginated({
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
|
||||
customsClearingEnabled: true,
|
||||
contractKind: 'ONE_TIME',
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
|
||||
const filtered: typeof base.items = [];
|
||||
for (const c of base.items) {
|
||||
const milestones = await this.workflowService.listMilestones(c.id);
|
||||
if (belongsOnEtClearanceQueue(milestones)) filtered.push(c);
|
||||
}
|
||||
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 50;
|
||||
const start = (page - 1) * pageSize;
|
||||
const items = filtered.slice(start, start + pageSize);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: filtered.length,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total: filtered.length,
|
||||
totalPages: Math.ceil(filtered.length / pageSize) || 1,
|
||||
hasNextPage: start + pageSize < filtered.length,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** GL DJ queue: customs ONE_TIME contracts handed off to or handled by Djibouti GL. */
|
||||
async djQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||
const base = await this.contractsRepository.findAllPaginated({
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
statuses: [...DJ_CONTRACT_QUEUE_STATUSES],
|
||||
customsClearingEnabled: true,
|
||||
contractKind: 'ONE_TIME',
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
|
||||
const filtered: typeof base.items = [];
|
||||
for (const c of base.items) {
|
||||
const cycle = await this.contractsRepository.currentCycle(c.id);
|
||||
const milestones = await this.workflowService.listMilestones(c.id);
|
||||
if (belongsOnDjClearanceQueue(c.tradeDirection, cycle, milestones)) {
|
||||
filtered.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 50;
|
||||
const start = (page - 1) * pageSize;
|
||||
const items = filtered.slice(start, start + pageSize);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: filtered.length,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total: filtered.length,
|
||||
totalPages: Math.ceil(filtered.length / pageSize) || 1,
|
||||
hasNextPage: start + pageSize < filtered.length,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util';
|
||||
|
||||
@@ -84,3 +86,47 @@ export function contractClearanceCodes(contract: Contract): {
|
||||
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';
|
||||
|
||||
/**
|
||||
* One recorded change between two document snapshots. Granularity is per
|
||||
* article: a body edit is reported as "the body changed", not as a text diff.
|
||||
* One recorded change between two document snapshots. A body edit carries the
|
||||
* 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 =
|
||||
| { kind: 'ARTICLE_ADDED'; articleId: string; title: string }
|
||||
@@ -16,7 +17,14 @@ export type ContractDocumentChange =
|
||||
title: 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';
|
||||
articleId: string;
|
||||
@@ -25,7 +33,18 @@ export type ContractDocumentChange =
|
||||
toOrder: number;
|
||||
}
|
||||
| { 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<
|
||||
ContractDocumentSnapshot,
|
||||
@@ -109,6 +128,8 @@ export function diffSnapshots(
|
||||
kind: 'ARTICLE_BODY_CHANGED',
|
||||
articleId: article.id,
|
||||
title: article.title,
|
||||
fromBody: previous.body,
|
||||
toBody: article.body,
|
||||
});
|
||||
}
|
||||
if (previous.order !== article.order) {
|
||||
@@ -134,6 +155,60 @@ export function diffSnapshots(
|
||||
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". */
|
||||
export function summarizeChanges(changes: ContractDocumentChange[]): string {
|
||||
if (changes.length === 0) return 'No changes';
|
||||
@@ -148,6 +223,7 @@ export function summarizeChanges(changes: ContractDocumentChange[]): string {
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
const parts: string[] = [];
|
||||
const fields: string[] = [];
|
||||
|
||||
for (const change of changes) {
|
||||
const verb = articleVerbs[change.kind];
|
||||
@@ -157,9 +233,19 @@ export function summarizeChanges(changes: ContractDocumentChange[]): string {
|
||||
parts.push('document title changed');
|
||||
} else if (change.kind === 'WHEREAS_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(
|
||||
([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`,
|
||||
);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/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 type { ContractDocumentSnapshot } from './entities/contract.entity';
|
||||
|
||||
@@ -12,6 +16,39 @@ export interface RecordRevisionInput {
|
||||
after: ContractDocumentSnapshot | null;
|
||||
actorId?: 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;
|
||||
}
|
||||
|
||||
@@ -22,6 +59,7 @@ export class ContractDocumentHistoryService {
|
||||
constructor(
|
||||
@InjectRepository(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.
|
||||
*/
|
||||
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 {
|
||||
const changes = diffSnapshots(input.before, input.after);
|
||||
if (changes.length === 0) return;
|
||||
if (input.changes.length === 0) return;
|
||||
|
||||
await this.revisionRepo.save(
|
||||
this.revisionRepo.create({
|
||||
contractId: input.contractId,
|
||||
actorId: input.actorId ?? null,
|
||||
actorRole: input.actorRole ?? null,
|
||||
actorName:
|
||||
input.actorName ?? (await this.resolveActorName(input.actorId)),
|
||||
stepId: input.stepId ?? null,
|
||||
summary: summarizeChanges(changes),
|
||||
changes,
|
||||
summary: summarizeChanges(input.changes),
|
||||
changes: input.changes,
|
||||
}),
|
||||
);
|
||||
} 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. */
|
||||
list(contractId: string): Promise<ContractDocumentRevision[]> {
|
||||
return this.revisionRepo.find({
|
||||
async list(contractId: string): Promise<ContractDocumentRevision[]> {
|
||||
const revisions = await this.revisionRepo.find({
|
||||
where: { contractId },
|
||||
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,79 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
|
||||
import { ContractsService } from './contracts.service';
|
||||
import type { CreateContractDto } from './dto/create-contract.dto';
|
||||
|
||||
/**
|
||||
* The duplicate guard blocks a new request only when EVERY commercial
|
||||
* dimension matches a live contract — service type, operation type, contract
|
||||
* kind, cargo scope and route. Any one differing must let the request through.
|
||||
*/
|
||||
describe('ContractsService duplicate guard', () => {
|
||||
const LANE = { originYardId: 'yard-dj', destinationYardId: 'yard-mj' };
|
||||
|
||||
const existing = {
|
||||
id: 'c-1',
|
||||
reference: 'CTR-2026-00001',
|
||||
status: 'PENDING_APPROVAL',
|
||||
contractValidUntil: null,
|
||||
tradeDirection: 'IMPORT',
|
||||
contractKind: 'ONE_TIME',
|
||||
freightType: 'CONTAINER',
|
||||
routes: [LANE],
|
||||
cargoScope: [{ containerSize: '20ft' }, { containerSize: '40ft' }],
|
||||
};
|
||||
|
||||
const dto = (overrides: Partial<CreateContractDto> = {}) =>
|
||||
({
|
||||
serviceTypeId: 'svc-1',
|
||||
tradeDirection: 'IMPORT',
|
||||
contractKind: 'ONE_TIME',
|
||||
freightType: 'CONTAINER',
|
||||
routes: [LANE],
|
||||
cargoScope: [{ containerSize: '20ft' }, { containerSize: '40ft' }],
|
||||
...overrides,
|
||||
}) as CreateContractDto;
|
||||
|
||||
const guard = (input: CreateContractDto) => {
|
||||
const service = new ContractsService(
|
||||
{} as never,
|
||||
{ findDuplicateCandidates: async () => [existing] } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return (
|
||||
service as unknown as {
|
||||
assertNoDuplicateContract(companyId: string, dto: CreateContractDto): Promise<void>;
|
||||
}
|
||||
).assertNoDuplicateContract('company-1', input);
|
||||
};
|
||||
|
||||
it('blocks an identical request', async () => {
|
||||
await expect(guard(dto())).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['operation type', { tradeDirection: 'EXPORT' }],
|
||||
['contract kind', { contractKind: 'GENERAL' }],
|
||||
['freight type', { freightType: 'BULK' }],
|
||||
['cargo scope', { cargoScope: [{ containerSize: '20ft' }] }],
|
||||
['route', { routes: [{ originYardId: 'yard-dj', destinationYardId: 'yard-aa' }] }],
|
||||
])('allows a request with a different %s', async (_label, overrides) => {
|
||||
await expect(guard(dto(overrides as Partial<CreateContractDto>))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores quantity caps when comparing cargo scope', async () => {
|
||||
await expect(
|
||||
guard(
|
||||
dto({
|
||||
cargoScope: [
|
||||
{ containerSize: '20ft', quantityCap: 10 },
|
||||
{ containerSize: '40ft', quantityCap: 5 },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -143,6 +143,33 @@ export class ContractNotifierService {
|
||||
this.inApp(c, 'Contract rejected', msg);
|
||||
}
|
||||
|
||||
/** Backoffice froze the contract — every action on it is blocked until lifted. */
|
||||
suspended(c: Contract, reason: string): void {
|
||||
const msg =
|
||||
`Your contract ${c.reference} has been suspended. Reason: ${reason}. ` +
|
||||
`No new shipments can be booked and existing shipments are on hold until the suspension is lifted.`;
|
||||
void this.notifyContact(c, msg, 'SUSPENDED');
|
||||
this.inApp(c, 'Contract suspended', msg);
|
||||
}
|
||||
|
||||
/** Backoffice lifted the suspension — the contract resumes where it left off. */
|
||||
suspensionLifted(c: Contract, note?: string | null): void {
|
||||
const msg =
|
||||
`The suspension on your contract ${c.reference} has been lifted. ` +
|
||||
`You can continue where you left off.${note ? ` Note: ${note}` : ''}`;
|
||||
void this.notifyContact(c, msg, 'SUSPENSION LIFTED');
|
||||
this.inApp(c, 'Contract suspension lifted', msg);
|
||||
}
|
||||
|
||||
/** Customer cancelled their own contract — staff-side record. */
|
||||
cancelledByCustomer(c: Contract, reason: string): void {
|
||||
this.inAppStaff(
|
||||
c,
|
||||
'Contract cancelled by customer',
|
||||
`Contract ${c.reference} was cancelled by the customer. Reason: ${reason}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A later approver sent the contract back to an earlier stage of the chain.
|
||||
* Staff-only: the customer is not involved in an internal send-back — their
|
||||
@@ -192,6 +219,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. */
|
||||
clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void {
|
||||
const msg =
|
||||
|
||||
@@ -35,6 +35,8 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
|
||||
switch (rateUnit) {
|
||||
case 'PER_TON':
|
||||
return 'per_ton';
|
||||
case 'PER_ITEM':
|
||||
return 'per_item';
|
||||
case 'PER_KM':
|
||||
return 'per_km';
|
||||
case 'PER_WAGON':
|
||||
@@ -115,9 +117,19 @@ export class ContractPricingService {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const bulkRate =
|
||||
liveRates.find((r) => r.rateType === baseType && r.currency === 'USD') ?? null;
|
||||
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) {
|
||||
lineItems.push({
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
import type { Contract } from './entities/contract.entity';
|
||||
|
||||
/**
|
||||
* Suspension is only worth having if it is reversible and if it actually
|
||||
* freezes things, and the customer's own cancel is only safe while no shipment
|
||||
* is running. Those three rules are the whole feature — everything else is
|
||||
* plumbing.
|
||||
*/
|
||||
describe('ContractTransitionService — suspend / resume / customer cancel', () => {
|
||||
const contract = (over: Partial<Contract> = {}): Contract =>
|
||||
({
|
||||
id: 'c-1',
|
||||
reference: 'CTR-2026-00042',
|
||||
companyId: 'co-1',
|
||||
status: 'CONTRACT_ACTIVE',
|
||||
freightType: 'CONTAINER',
|
||||
...over,
|
||||
}) as Contract;
|
||||
|
||||
let current: Contract;
|
||||
let repo: {
|
||||
update: jest.Mock;
|
||||
createReviewNote: jest.Mock;
|
||||
countActiveBookings: jest.Mock;
|
||||
};
|
||||
let notifier: {
|
||||
suspended: jest.Mock;
|
||||
suspensionLifted: jest.Mock;
|
||||
cancelledByCustomer: jest.Mock;
|
||||
};
|
||||
let service: ContractTransitionService;
|
||||
|
||||
/** A staff user holding the suspend key — authorization is tested elsewhere. */
|
||||
const staff = {
|
||||
permissions: [{ key: 'edr_freight_app:contracts:suspend' }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
current = contract();
|
||||
repo = {
|
||||
// Mirror the real repository: the update patches the row the next
|
||||
// findById returns, so resume() reads what suspend() wrote.
|
||||
update: jest.fn().mockImplementation((_id: string, patch: object) => {
|
||||
current = { ...current, ...patch } as Contract;
|
||||
return Promise.resolve(current);
|
||||
}),
|
||||
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||
countActiveBookings: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
notifier = {
|
||||
suspended: jest.fn(),
|
||||
suspensionLifted: jest.fn(),
|
||||
cancelledByCustomer: jest.fn(),
|
||||
};
|
||||
// These three transitions touch only the repository, the read-back service
|
||||
// and the notifier — the other 14 constructor deps stay unused, so the
|
||||
// instance is built bare and only what is exercised is injected.
|
||||
service = Object.create(
|
||||
ContractTransitionService.prototype,
|
||||
) as ContractTransitionService;
|
||||
Object.assign(service, {
|
||||
contractsRepository: repo,
|
||||
contractsService: { findById: () => Promise.resolve(current) },
|
||||
notifier,
|
||||
});
|
||||
});
|
||||
|
||||
it('freezes at the current step and remembers where to come back to', async () => {
|
||||
current = contract({ status: 'CLEARANCE_UNDER_REVIEW' });
|
||||
|
||||
await service.suspend('c-1', 'Unpaid demurrage', 'staff-1', staff as never);
|
||||
|
||||
expect(repo.update).toHaveBeenCalledWith('c-1', {
|
||||
status: 'SUSPENDED',
|
||||
statusBeforeSuspension: 'CLEARANCE_UNDER_REVIEW',
|
||||
});
|
||||
expect(notifier.suspended).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the pre-suspension status when the suspension is lifted', async () => {
|
||||
current = contract({ status: 'ACTIVE_SHIPMENT_IN_PROGRESS' });
|
||||
await service.suspend('c-1', 'Docs missing', 'staff-1', staff as never);
|
||||
|
||||
await service.resume('c-1', undefined, 'staff-1', staff as never);
|
||||
|
||||
expect(repo.update).toHaveBeenLastCalledWith('c-1', {
|
||||
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
statusBeforeSuspension: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to suspend a contract the customer has not signed yet', async () => {
|
||||
current = contract({ status: 'PENDING_APPROVAL' });
|
||||
|
||||
await expect(
|
||||
service.suspend('c-1', 'too early', 'staff-1', staff as never),
|
||||
).rejects.toThrow(/PENDING_APPROVAL/);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets the customer cancel a contract with no live shipment', async () => {
|
||||
await service.cancelByCustomer('c-1', 'Changed supplier', 'user-1');
|
||||
|
||||
expect(repo.update).toHaveBeenCalledWith('c-1', { status: 'CANCELLED' });
|
||||
expect(repo.createReviewNote).toHaveBeenCalledWith(
|
||||
'c-1',
|
||||
'Changed supplier',
|
||||
'CANCELLATION',
|
||||
'user-1',
|
||||
'CUSTOMER',
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks the customer cancel while a shipment is still running', async () => {
|
||||
repo.countActiveBookings.mockResolvedValue(2);
|
||||
|
||||
await expect(
|
||||
service.cancelByCustomer('c-1', undefined, 'user-1'),
|
||||
).rejects.toThrow(/2 active shipments/);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a customer cancel on a suspended contract — only staff can lift it', async () => {
|
||||
current = contract({ status: 'SUSPENDED' });
|
||||
|
||||
await expect(
|
||||
service.cancelByCustomer('c-1', undefined, 'user-1'),
|
||||
).rejects.toThrow(/suspended/);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -22,11 +22,13 @@ import {
|
||||
assertCanApproveContractStep,
|
||||
assertFreightPermission,
|
||||
canEditContractStep,
|
||||
HAZARDOUS_APPROVAL_ROLES,
|
||||
} from '../../common/freight-permission.util';
|
||||
import {
|
||||
FREIGHT_PERMS,
|
||||
forFreightType,
|
||||
} from '../../seed/freight-permissions.registry';
|
||||
import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util';
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
||||
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||
@@ -37,10 +39,8 @@ import { OtpService } from '../otp/otp.service';
|
||||
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { contractClearanceSettingCode } from './contract-clearance.util';
|
||||
import {
|
||||
Contract,
|
||||
ContractDocumentArticle,
|
||||
@@ -130,6 +130,21 @@ function maskSignerContacts(contacts: { phone?: string; email?: string }): strin
|
||||
.join(' and ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the backoffice may freeze a contract: every step from the customer's
|
||||
* signature onward, up to (but not including) the terminal states. Suspending
|
||||
* an unsigned contract is meaningless — staff reject or request changes there.
|
||||
*/
|
||||
export const SUSPENDABLE_CONTRACT_STATUSES = [
|
||||
'SIGNED_CUSTOMER',
|
||||
'FULLY_EXECUTED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
] as const;
|
||||
|
||||
/** Status-machine guard mirroring booking-status.util. */
|
||||
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
||||
if (!allowed.includes(contract.status)) {
|
||||
@@ -153,7 +168,6 @@ export class ContractTransitionService {
|
||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly signaturesService: SignaturesService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly documentViewModelBuilder: ContractDocumentViewModelBuilder,
|
||||
private readonly renderer: ContractRendererService,
|
||||
private readonly pdfService: ContractPdfService,
|
||||
@@ -243,6 +257,7 @@ export class ContractTransitionService {
|
||||
validityDays: number,
|
||||
documentSnapshot?: ContractDocumentSnapshotInput | null,
|
||||
user?: TCurrentUser | null,
|
||||
window?: { validFrom?: string | null; validUntil?: string | null },
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
// The route guard passes on either arm; the contract's freight type decides
|
||||
@@ -259,11 +274,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 validUntil = new Date(validFrom);
|
||||
validUntil.setDate(validUntil.getDate() + validityDays);
|
||||
const validFrom = picked ? new Date(window!.validFrom!) : new Date();
|
||||
const validUntil = picked ? new Date(window!.validUntil!) : new Date(validFrom);
|
||||
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);
|
||||
|
||||
@@ -273,6 +297,20 @@ export class ContractTransitionService {
|
||||
// shared six templates are never written here.
|
||||
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, {
|
||||
status: 'PENDING_APPROVAL',
|
||||
approvedByStaffId: actorId,
|
||||
@@ -530,12 +568,26 @@ export class ContractTransitionService {
|
||||
);
|
||||
}
|
||||
|
||||
for (const rule of chain) {
|
||||
await this.contractsRepository.createApprovalStep({
|
||||
contractId: contract.id,
|
||||
stepOrder: rule.stepOrder,
|
||||
// Dangerous goods clear two dedicated hazardous desks BEFORE the commercial
|
||||
// chain — if either refuses, the contract never reaches the approvers who
|
||||
// would price and sign it. Steps are renumbered sequentially so the prefix
|
||||
// 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,
|
||||
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',
|
||||
});
|
||||
}
|
||||
@@ -928,23 +980,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(
|
||||
signatures: Array<{ signatureImageUrl?: string | null }>,
|
||||
signatures: Array<{
|
||||
signatureImageUrl?: string | null;
|
||||
stampImageUrl?: string | null;
|
||||
}>,
|
||||
): Promise<void> {
|
||||
for (const sig of signatures) {
|
||||
if (!sig.signatureImageUrl) continue;
|
||||
sig.signatureImageUrl = await this.inlineImageUrl(sig.signatureImageUrl);
|
||||
sig.stampImageUrl = await this.inlineImageUrl(sig.stampImageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/** MinIO URL → data URI. Returns the input unchanged if absent or on failure. */
|
||||
private async inlineImageUrl(
|
||||
url?: string | null,
|
||||
): Promise<string | null | undefined> {
|
||||
if (!url || url.startsWith('data:')) return url;
|
||||
try {
|
||||
if (sig.signatureImageUrl.startsWith('data:')) continue;
|
||||
const objectName = this.minioService.getObjectNameFromUrl(
|
||||
sig.signatureImageUrl,
|
||||
);
|
||||
const objectName = this.minioService.getObjectNameFromUrl(url);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
const buffer = await this.streamToBuffer(stream);
|
||||
sig.signatureImageUrl = `data:image/png;base64,${buffer.toString('base64')}`;
|
||||
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 {
|
||||
/* keep original url */
|
||||
}
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -959,6 +1029,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). */
|
||||
private async applySignature(
|
||||
contract: Contract,
|
||||
@@ -985,29 +1095,28 @@ export class ContractTransitionService {
|
||||
);
|
||||
}
|
||||
|
||||
const raw = imageBase64.includes(',')
|
||||
? imageBase64.split(',')[1]!
|
||||
: imageBase64;
|
||||
const buffer = Buffer.from(raw, 'base64');
|
||||
const sigFile: Express.Multer.File = {
|
||||
fieldname: `signature_${role.toLowerCase()}`,
|
||||
originalname: `signature-${role.toLowerCase()}-${contract.reference}.png`,
|
||||
encoding: '7bit',
|
||||
mimetype: 'image/png',
|
||||
size: buffer.length,
|
||||
buffer,
|
||||
stream: Readable.from(buffer),
|
||||
destination: '',
|
||||
filename: '',
|
||||
path: '',
|
||||
};
|
||||
// The company stamp is a separate image from the drawn signature. Both
|
||||
// parties to the contract (client + EDR) must seal it; DIRECTOR/CEO rows
|
||||
// are internal approval signatures, not party seals, so they stay exempt.
|
||||
const stampRequired = role === 'CUSTOMER' || role === 'STAFF';
|
||||
if (stampRequired && !dto.stampImageBase64) {
|
||||
throw new BadRequestException(
|
||||
'A company stamp is required to sign this contract.',
|
||||
);
|
||||
}
|
||||
|
||||
const fileRecord = await this.filesService.upsertByCode({
|
||||
resourceId: contract.id,
|
||||
resource: 'contracts',
|
||||
code: `signature_${role.toLowerCase()}`,
|
||||
file: sigFile,
|
||||
});
|
||||
const fileRecord = await this.uploadSignatureAsset(
|
||||
contract,
|
||||
`signature_${role.toLowerCase()}`,
|
||||
imageBase64,
|
||||
);
|
||||
const stampRecord = dto.stampImageBase64
|
||||
? await this.uploadSignatureAsset(
|
||||
contract,
|
||||
`stamp_${role.toLowerCase()}`,
|
||||
dto.stampImageBase64,
|
||||
)
|
||||
: null;
|
||||
|
||||
await this.contractsRepository.saveSignature({
|
||||
contractId: contract.id,
|
||||
@@ -1015,6 +1124,7 @@ export class ContractTransitionService {
|
||||
signerDisplayName,
|
||||
signedAt: new Date(),
|
||||
signatureFileId: fileRecord.id,
|
||||
stampFileId: stampRecord?.id ?? null,
|
||||
consentText: dto.consentText ?? null,
|
||||
});
|
||||
|
||||
@@ -1053,7 +1163,17 @@ export class ContractTransitionService {
|
||||
options.signerUserId,
|
||||
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);
|
||||
await this.otpService.sendOtp(signerContacts);
|
||||
@@ -1077,9 +1197,16 @@ export class ContractTransitionService {
|
||||
options.signerUserId,
|
||||
contract,
|
||||
);
|
||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||
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');
|
||||
}
|
||||
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
||||
@@ -1127,6 +1254,19 @@ export class ContractTransitionService {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
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);
|
||||
|
||||
const now = new Date();
|
||||
@@ -1135,43 +1275,16 @@ export class ContractTransitionService {
|
||||
lockedAt: now,
|
||||
};
|
||||
|
||||
// A clearance gate applies whenever a clearance doc set resolves — Path B
|
||||
// (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the
|
||||
// intercity document set (DOMESTIC, ops-reviewed like Path A).
|
||||
const clearanceCode = contractClearanceSettingCode(
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
contract.customsClearingEnabled ?? false,
|
||||
);
|
||||
|
||||
// GENERAL contracts run clearance PER BOOKING, not at the contract level —
|
||||
// both paths. Customs (Path B): the customer files shipment requests, GL
|
||||
// books each one and the booking carries its own clearance. Self-clearance
|
||||
// (Path A): the customer books, then uploads the clearance docs on that
|
||||
// booking for Operations to review. Only ONE_TIME contracts keep the
|
||||
// contract-level cycle below.
|
||||
const isGeneral = contract.contractKind === 'GENERAL';
|
||||
|
||||
if (clearanceCode && !isGeneral) {
|
||||
// Open a clearance cycle, seed the pre-booking milestones, and route the
|
||||
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
|
||||
// distinction is enforced at the review/finalize endpoints, not here.
|
||||
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
|
||||
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
|
||||
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
|
||||
// No prepay gate: the customs clearance service fee (Path B) is billed on
|
||||
// the booking invoice together with the freight, so the document step
|
||||
// opens immediately.
|
||||
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
|
||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||
updates.clearanceCycleNumber = cycleNumber;
|
||||
} else {
|
||||
// No contract-level clearance gate — DOMESTIC, or any GENERAL contract
|
||||
// (which clears per booking). Ready for shipment requests / direct booking.
|
||||
// Clearance ALWAYS runs per booking — both contract kinds, both paths, and
|
||||
// intercity. A signed contract carries no clearance cycle and collects no
|
||||
// documents: the shipment instance created after signature does. Customs
|
||||
// (Path B): the customer initiates the booking (GENERAL: via a shipment
|
||||
// request) and uploads on it, GL reviews and completes it. Self-clearance
|
||||
// (Path A) and intercity: the customer initiates/books and Operations
|
||||
// reviews the booking documents.
|
||||
updates.status =
|
||||
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
||||
updates.clearanceStatus = 'NOT_APPLICABLE';
|
||||
}
|
||||
|
||||
await this.contractsRepository.update(contractId, updates as never);
|
||||
await this.regenerateContractPdf(contractId, contract.reference);
|
||||
@@ -1181,6 +1294,123 @@ export class ContractTransitionService {
|
||||
}
|
||||
|
||||
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
|
||||
/**
|
||||
* Backoffice freeze, available at every step from the customer signature
|
||||
* onward. The pre-suspension status is stashed so {@link resume} can put the
|
||||
* contract back exactly where it was — a suspension you cannot lift is just a
|
||||
* cancellation under another name.
|
||||
*
|
||||
* While SUSPENDED nothing moves: no new bookings or shipment requests
|
||||
* (ContractBookingService / BookingRequestService), and no writes to the
|
||||
* contract's existing bookings (BookingsRepository.update).
|
||||
*/
|
||||
async suspend(
|
||||
contractId: string,
|
||||
reason: string,
|
||||
actorId: string,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertFreightPermission(user, FREIGHT_PERMS.contracts.suspend);
|
||||
assertContractStatus(contract, [...SUSPENDABLE_CONTRACT_STATUSES]);
|
||||
|
||||
await this.contractsRepository.createReviewNote(
|
||||
contractId,
|
||||
reason,
|
||||
'SUSPENSION',
|
||||
actorId,
|
||||
'STAFF',
|
||||
);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SUSPENDED',
|
||||
statusBeforeSuspension: contract.status,
|
||||
} as never);
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
this.notifier.suspended(updated, reason);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Lift a suspension — the contract returns to the status it was frozen at. */
|
||||
async resume(
|
||||
contractId: string,
|
||||
note: string | undefined,
|
||||
actorId: string,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertFreightPermission(user, FREIGHT_PERMS.contracts.suspend);
|
||||
assertContractStatus(contract, ['SUSPENDED']);
|
||||
|
||||
// Legacy safety net: a row suspended before the column existed has nothing
|
||||
// to restore. CONTRACT_ACTIVE is the post-signature resting state for both
|
||||
// contract kinds, so it is the only sane default.
|
||||
const restored = contract.statusBeforeSuspension ?? 'CONTRACT_ACTIVE';
|
||||
|
||||
if (note?.trim()) {
|
||||
await this.contractsRepository.createReviewNote(
|
||||
contractId,
|
||||
note.trim(),
|
||||
'SUSPENSION_LIFTED',
|
||||
actorId,
|
||||
'STAFF',
|
||||
);
|
||||
}
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: restored,
|
||||
statusBeforeSuspension: null,
|
||||
} as never);
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
this.notifier.suspensionLifted(updated, note ?? null);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer cancels their own contract so they can request a fresh one for the
|
||||
* same lane — the duplicate-contract guard treats CANCELLED as released.
|
||||
* Blocked while any booking on the contract is still live: cancelling a
|
||||
* contract with cargo in motion would strand it.
|
||||
*/
|
||||
async cancelByCustomer(
|
||||
contractId: string,
|
||||
reason: string | undefined,
|
||||
userId?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) {
|
||||
throw new ConflictException(
|
||||
`Contract is already ${contract.status.toLowerCase().replace(/_/g, ' ')}.`,
|
||||
);
|
||||
}
|
||||
if (contract.status === 'SUSPENDED') {
|
||||
throw new ConflictException(
|
||||
'This contract is suspended by EDR — contact us to lift the suspension first.',
|
||||
);
|
||||
}
|
||||
|
||||
const active = await this.contractsRepository.countActiveBookings(contractId);
|
||||
if (active > 0) {
|
||||
throw new BadRequestException(
|
||||
`This contract has ${active} active shipment${active === 1 ? '' : 's'}. ` +
|
||||
'Cancel or complete them before cancelling the contract.',
|
||||
);
|
||||
}
|
||||
|
||||
const body = reason?.trim() || 'Cancelled by the customer.';
|
||||
await this.contractsRepository.createReviewNote(
|
||||
contractId,
|
||||
body,
|
||||
'CANCELLATION',
|
||||
userId,
|
||||
'CUSTOMER',
|
||||
);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'CANCELLED',
|
||||
} as never);
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
this.notifier.cancelledByCustomer(updated, body);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async renew(contractId: string, userId?: string): Promise<Contract> {
|
||||
const source = await this.contractsService.findById(contractId);
|
||||
|
||||
|
||||
@@ -66,9 +66,12 @@ import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
|
||||
import { AcceptContractDto } from './dto/accept-contract.dto';
|
||||
import { UpdateContractDocumentDto } from './dto/contract-document.dto';
|
||||
import {
|
||||
CancelContractDto,
|
||||
RejectContractDto,
|
||||
RejectStepDto,
|
||||
RequestChangesDto,
|
||||
ResumeContractDto,
|
||||
SuspendContractDto,
|
||||
} from './dto/approve-step.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||
@@ -273,6 +276,18 @@ export class ContractsController {
|
||||
return this.clearanceService.queue(filter);
|
||||
}
|
||||
|
||||
// Must stay ABOVE @Get(':id') — declared after it, Nest matched the literal
|
||||
// path as an id and ParseUUIDPipe answered 400 "uuid is expected".
|
||||
@Get('awaiting-shipment')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'GL worklist: executed one-time customs contracts with no shipment instance yet — GL initiates the booking the customer then uploads documents on.',
|
||||
})
|
||||
awaitingShipmentContracts() {
|
||||
return this.contractBookingService.awaitingShipmentContracts();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get contract by ID (routes, cargo scope, unit rates)' })
|
||||
async findOne(
|
||||
@@ -303,8 +318,10 @@ export class ContractsController {
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateContractDto,
|
||||
@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')
|
||||
@@ -358,6 +375,7 @@ export class ContractsController {
|
||||
dto.validityDays,
|
||||
dto.documentSnapshot,
|
||||
user,
|
||||
{ validFrom: dto.validFrom, validUntil: dto.validUntil },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -450,6 +468,65 @@ export class ContractsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/suspend')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.suspend)
|
||||
@ApiOperation({
|
||||
summary: 'Staff freeze a signed contract (reversible, any post-signature step)',
|
||||
})
|
||||
suspend(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SuspendContractDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.suspend(
|
||||
id,
|
||||
dto.reason,
|
||||
resolveAuthUserId(user),
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/resume')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.suspend)
|
||||
@ApiOperation({ summary: 'Staff lift a suspension — contract returns to its prior status' })
|
||||
resume(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ResumeContractDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.resume(
|
||||
id,
|
||||
dto.note,
|
||||
resolveAuthUserId(user),
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@ApiOperation({
|
||||
summary: 'Customer cancels their own contract (blocked while a booking is live)',
|
||||
})
|
||||
async cancel(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CancelContractDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Same ownership rule as renew: staff with bookings.view/contracts.view pass
|
||||
// through, everyone else must own the contract's company.
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||
) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
return this.transitionService.cancelByCustomer(
|
||||
id,
|
||||
dto.reason,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/approve')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||||
@@ -526,6 +603,8 @@ export class ContractsController {
|
||||
contractId: view.bookingId,
|
||||
reference: view.reference,
|
||||
status: view.status,
|
||||
// Drives the per-freight-type sign permission on the client.
|
||||
freightType: contract.freightType,
|
||||
templateKey: view.templateKey,
|
||||
title: view.template.title,
|
||||
html,
|
||||
@@ -577,19 +656,21 @@ export class ContractsController {
|
||||
@Post(':id/contract/sign')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||||
signContract(
|
||||
async signContract(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SignContractDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Each staff signing role maps to the permission that step already requires;
|
||||
// customers sign their own contract with no permission key.
|
||||
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: FREIGHT_PERMS.contracts.signStaff,
|
||||
STAFF: forFreightType(FREIGHT_PERMS.contracts.signStaff, contract.freightType),
|
||||
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
||||
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
||||
};
|
||||
if (dto.role !== 'CUSTOMER') {
|
||||
assertFreightPermission(user, signRolePermission[dto.role]);
|
||||
}
|
||||
return this.transitionService.sign(id, dto, {
|
||||
@@ -734,6 +815,92 @@ export class ContractsController {
|
||||
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')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@@ -762,19 +929,21 @@ export class ContractsController {
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@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(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
|
||||
@Body('vesselArrivalDate') vesselArrivalDate: string | undefined,
|
||||
@Body('doCollectedDate') doCollectedDate: string | undefined,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.uploadDeliveryOrder(
|
||||
id,
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
vesselDepartureDate,
|
||||
);
|
||||
return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user), {
|
||||
vesselArrivalDate,
|
||||
doCollectedDate,
|
||||
});
|
||||
}
|
||||
|
||||
@Post(':id/clearance/release-order')
|
||||
@@ -829,31 +998,8 @@ export class ContractsController {
|
||||
return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Get('clearance/et-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' })
|
||||
etClearanceQueue(@Query() filter: FilterContractDto) {
|
||||
return this.clearanceService.etQueue(filter);
|
||||
}
|
||||
|
||||
@Get('clearance/dj-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' })
|
||||
djClearanceQueue(@Query() filter: FilterContractDto) {
|
||||
return this.clearanceService.djQueue(filter);
|
||||
}
|
||||
|
||||
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
|
||||
|
||||
@Get('clearance/ops-queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
|
||||
@ApiOperation({
|
||||
summary: 'Operations queue: self-clearance (non-customs) contracts awaiting review',
|
||||
})
|
||||
opsClearanceQueue(@Query() filter: FilterContractDto) {
|
||||
return this.clearanceService.opsQueue(filter);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/ops-review')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
|
||||
@ApiOperation({
|
||||
@@ -922,7 +1068,7 @@ export class ContractsController {
|
||||
@Post(':id/bookings/initiate')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Initiate a bare booking instance under a GENERAL non-customs contract — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS).',
|
||||
'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.',
|
||||
})
|
||||
initiateBooking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -1240,7 +1386,7 @@ export class ContractsController {
|
||||
) {
|
||||
const file = (files ?? [])[0];
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking)) {
|
||||
if (this.bookingClearanceService.isPhasedCustomsBooking(booking)) {
|
||||
return this.bookingClearanceService.uploadDutySlip(bookingId, file);
|
||||
}
|
||||
return this.glOperationsService.uploadDutySlip(bookingId, file);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ContractTemplatesModule } from '../contract-templates/contract-template
|
||||
import { ContractsController } from './contracts.controller';
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractExpiryService } from './contract-expiry.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
@@ -105,6 +106,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
providers: [
|
||||
ContractsService,
|
||||
ContractsRepository,
|
||||
ContractExpiryService,
|
||||
ContractPricingService,
|
||||
ContractNotifierService,
|
||||
ContractTransitionService,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
|
||||
@@ -14,6 +15,19 @@ import {
|
||||
import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
|
||||
import { ContractReviewNote, ContractReviewNoteType } from './entities/contract-review-note.entity';
|
||||
import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity';
|
||||
import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util';
|
||||
|
||||
/**
|
||||
* Booking statuses that release whatever the booking was holding — contract
|
||||
* capacity, the one-time active slot, the cancel gate. Everything else counts
|
||||
* as a live booking.
|
||||
*/
|
||||
export const TERMINAL_BOOKING_STATUSES = [
|
||||
'EXPIRED',
|
||||
'CANCELLED',
|
||||
'COMPLETED',
|
||||
'REJECTED',
|
||||
];
|
||||
|
||||
export interface ContractListFilterOptions {
|
||||
statuses?: string[];
|
||||
@@ -66,6 +80,83 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
return Number(row?.max ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-terminal contracts for the same company + service type, with routes and
|
||||
* cargo scope loaded — candidates for the duplicate-contract check on
|
||||
* create() (which also compares operation type, kind and scope). 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')
|
||||
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
|
||||
.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. */
|
||||
async findByIdWithRelations(id: string): Promise<Contract | null> {
|
||||
if (!id) return null;
|
||||
@@ -88,7 +179,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
'contract.files',
|
||||
FileRecord,
|
||||
'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();
|
||||
|
||||
@@ -434,7 +527,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
findSignatures(contractId: string): Promise<ContractSignature[]> {
|
||||
return this.dataSource.getRepository(ContractSignature).find({
|
||||
where: { contractId },
|
||||
relations: ['signatureFile'],
|
||||
relations: ['signatureFile', 'stampFile'],
|
||||
order: { signedAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
@@ -445,7 +538,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
): Promise<ContractSignature | null> {
|
||||
return this.dataSource.getRepository(ContractSignature).findOne({
|
||||
where: { contractId, role },
|
||||
relations: ['signatureFile'],
|
||||
relations: ['signatureFile', 'stampFile'],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -463,6 +556,23 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
|
||||
// ── Review notes ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Bookings on the contract that have not reached a terminal state. Gates the
|
||||
* customer's own contract cancellation (a contract carrying live cargo may
|
||||
* not be cancelled) and is surfaced on the detail response so the portal can
|
||||
* disable the button instead of failing the call.
|
||||
*/
|
||||
async countActiveBookings(contractId: string): Promise<number> {
|
||||
return this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('b')
|
||||
.where('b.contract_id = :contractId', { contractId })
|
||||
.andWhere('b.status NOT IN (:...terminal)', {
|
||||
terminal: TERMINAL_BOOKING_STATUSES,
|
||||
})
|
||||
.getCount();
|
||||
}
|
||||
|
||||
async createReviewNote(
|
||||
contractId: string,
|
||||
body: string,
|
||||
@@ -482,6 +592,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(
|
||||
contractId: string,
|
||||
noteType?: ContractReviewNoteType,
|
||||
@@ -649,12 +770,20 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
ContractClearanceCycle,
|
||||
| 'dutyRequired'
|
||||
| 'vesselDepartureDate'
|
||||
| 'vesselArrivalDate'
|
||||
| 'doCollectedDate'
|
||||
| 'roAmendmentRequestedAt'
|
||||
| 'roHoldReason'
|
||||
| 'currentPhase'
|
||||
| 'status'
|
||||
| 'preClearanceFinalizedAt'
|
||||
| 'completedAt'
|
||||
| 'transitAssigneeRequestedAt'
|
||||
| 'transitAssigneeRequestedByUserId'
|
||||
| 'transitAssigneeRequestNote'
|
||||
| 'transitAssigneeName'
|
||||
| 'transitAssigneeAssignedAt'
|
||||
| 'transitAssigneeAssignedByUserId'
|
||||
>
|
||||
>,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@@ -9,7 +10,7 @@ import { DataSource } from 'typeorm';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
|
||||
import { YardCountry } from '@edr/types';
|
||||
|
||||
//
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
@@ -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 { ContractRoute } from './entities/contract-route.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';
|
||||
|
||||
/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */
|
||||
@@ -40,6 +44,59 @@ 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(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Order-independent identity of a cargo scope — two contracts cover the same
|
||||
* cargo only when they list the same container sizes / commodities. Quantity
|
||||
* caps are deliberately ignored: they size a GENERAL contract, they don't make
|
||||
* it a different scope.
|
||||
*/
|
||||
function cargoScopeKey(
|
||||
scope?: Array<
|
||||
Pick<ContractCargoScope, 'containerSize' | 'cargoTypeId' | 'cargoFreeText'>
|
||||
> | null,
|
||||
): string {
|
||||
if (!scope?.length) return '';
|
||||
return scope
|
||||
.map((row) =>
|
||||
[
|
||||
row.containerSize?.trim().toLowerCase() ?? '',
|
||||
row.cargoTypeId ?? '',
|
||||
row.cargoFreeText?.trim().toLowerCase() ?? '',
|
||||
].join('|'),
|
||||
)
|
||||
.sort()
|
||||
.join(',');
|
||||
}
|
||||
|
||||
const NEEDS_ACTION_STATUSES = [
|
||||
'SUBMITTED',
|
||||
'PENDING_APPROVAL',
|
||||
@@ -55,6 +112,7 @@ export class ContractsService {
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly documentHistory: ContractDocumentHistoryService,
|
||||
) {}
|
||||
|
||||
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
|
||||
@@ -155,6 +213,51 @@ export class ContractsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A live contract only blocks a new request when EVERY commercial dimension
|
||||
* of the wizard matches it: service type, operation type (trade direction),
|
||||
* contract kind, cargo scope and route. Change any one of them — a different
|
||||
* lane, bulk instead of containers, GENERAL instead of ONE_TIME — and the
|
||||
* customer may request another contract.
|
||||
*
|
||||
* A route "overlaps" if any origin/destination pair matches; cargo scope
|
||||
* matches only when the two scope sets are identical (same freight type and
|
||||
* the same container sizes / commodities).
|
||||
*/
|
||||
private async assertNoDuplicateContract(
|
||||
companyId: string,
|
||||
dto: CreateContractDto,
|
||||
): Promise<void> {
|
||||
const candidates = await this.contractsRepository.findDuplicateCandidates(
|
||||
companyId,
|
||||
dto.serviceTypeId,
|
||||
);
|
||||
const incomingScope = cargoScopeKey(dto.cargoScope);
|
||||
const duplicate = candidates.find(
|
||||
(c) =>
|
||||
!isEffectivelyExpired(c) &&
|
||||
c.tradeDirection === dto.tradeDirection &&
|
||||
c.contractKind === dto.contractKind &&
|
||||
c.freightType === dto.freightType &&
|
||||
cargoScopeKey(c.cargoScope) === incomingScope &&
|
||||
(c.routes ?? []).some((existingRoute) =>
|
||||
dto.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, operation type, contract kind, cargo scope and route (${duplicate.reference}, valid until ${until}). Change any one of them, or wait until this contract expires or is rejected/cancelled.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
|
||||
async create(
|
||||
dto: CreateContractDto,
|
||||
@@ -186,6 +289,9 @@ export class ContractsService {
|
||||
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
|
||||
this.assertRouteShape(dto.contractKind, dto.routes);
|
||||
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
|
||||
if (companyId) {
|
||||
await this.assertNoDuplicateContract(companyId, dto);
|
||||
}
|
||||
|
||||
// Stamp the operational profile for portal scoping. A forwarder contract
|
||||
// pins its profile explicitly (trade direction can't tell it apart from a
|
||||
@@ -291,7 +397,11 @@ export class ContractsService {
|
||||
tradeDirection: dto.tradeDirection,
|
||||
freightType: dto.freightType,
|
||||
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,
|
||||
customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
|
||||
equipmentReturn: dto.equipmentReturn ?? null,
|
||||
@@ -302,6 +412,10 @@ export class ContractsService {
|
||||
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
|
||||
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
|
||||
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,
|
||||
contractType: dto.contractType ?? null,
|
||||
status: 'DRAFT',
|
||||
@@ -445,6 +559,7 @@ export class ContractsService {
|
||||
id: string,
|
||||
dto: UpdateContractDto,
|
||||
files: Express.Multer.File[],
|
||||
actorId?: string,
|
||||
): Promise<{ contract: Contract; warnings: string[] }> {
|
||||
const existing = await this.findById(id);
|
||||
if (!CONTRACT_CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
|
||||
@@ -471,9 +586,18 @@ export class ContractsService {
|
||||
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
|
||||
freightType,
|
||||
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,
|
||||
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,
|
||||
firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress,
|
||||
firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat,
|
||||
@@ -524,7 +648,52 @@ export class ContractsService {
|
||||
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. */
|
||||
@@ -677,6 +846,24 @@ export class ContractsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Why the contract is frozen — shown to staff and customer alike.
|
||||
if (contract.status === 'SUSPENDED') {
|
||||
try {
|
||||
const note = await this.contractsRepository.findLatestReviewNote(
|
||||
contract.id,
|
||||
'SUSPENSION',
|
||||
);
|
||||
contract.latestSuspensionNote = note?.body ?? null;
|
||||
} catch {
|
||||
contract.latestSuspensionNote = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Lets the portal disable "Cancel contract" instead of letting the customer
|
||||
// click it and read a 400. The API re-checks on cancel regardless.
|
||||
contract.activeBookingCount =
|
||||
await this.contractsRepository.countActiveBookings(contract.id);
|
||||
|
||||
return contract;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { 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';
|
||||
|
||||
@@ -18,6 +25,21 @@ export class AcceptContractDto {
|
||||
@Max(3650)
|
||||
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
|
||||
* dialog. When present its articles are frozen onto THIS contract; when
|
||||
|
||||
@@ -50,3 +50,17 @@ export class CancelContractDto {
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class SuspendContractDto {
|
||||
@ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class ResumeContractDto {
|
||||
@ApiPropertyOptional({ description: 'Optional note recorded when the suspension is lifted' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@@ -91,6 +92,15 @@ export class CreateBookingRequestDto {
|
||||
@Type(() => 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()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { PAYMENT_CURRENCIES } from './create-contract.dto';
|
||||
|
||||
/** Per-shipment equipment return — "NA" stays contract-level only. */
|
||||
const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
|
||||
|
||||
@@ -150,6 +152,20 @@ export class CreateBookingUnderContractDto {
|
||||
@IsUUID()
|
||||
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({
|
||||
description:
|
||||
'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.',
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { HAZARD_CLASS_VALUES } from '@edr/types';
|
||||
|
||||
import { CONTRACT_KINDS } from '../entities/contract.entity';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||
@@ -156,9 +158,20 @@ export class CreateContractDto {
|
||||
@IsUUID()
|
||||
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])
|
||||
paymentCurrency!: string;
|
||||
paymentCurrency?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Whether EDR/GL handles customs clearance' })
|
||||
@IsOptional()
|
||||
@@ -228,6 +241,28 @@ export class CreateContractDto {
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
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' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -17,6 +17,17 @@ export class SignContractDto {
|
||||
@MinLength(20)
|
||||
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()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
|
||||
@@ -43,6 +43,14 @@ export class BookingRequest extends BaseEntity {
|
||||
@Column({ name: 'requested_lines', type: 'jsonb', default: () => "'{}'::jsonb" })
|
||||
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 })
|
||||
notes?: string | null;
|
||||
|
||||
|
||||
@@ -43,6 +43,41 @@ export class ContractClearanceCycle extends BaseEntity {
|
||||
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
|
||||
vesselDepartureDate?: string | null;
|
||||
|
||||
/** Import DO: when the vessel arrived in Djibouti. Required on DO upload. */
|
||||
@Column({ name: 'vessel_arrival_date', type: 'date', nullable: true })
|
||||
vesselArrivalDate?: string | null;
|
||||
|
||||
/** Import DO: when GL Djibouti collected the DO. Required on DO upload. */
|
||||
@Column({ name: 'do_collected_date', type: 'date', nullable: true })
|
||||
doCollectedDate?: string | null;
|
||||
|
||||
/**
|
||||
* 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 })
|
||||
roAmendmentRequestedAt?: Date | null;
|
||||
|
||||
|
||||
@@ -21,6 +21,13 @@ export class ContractDocumentRevision extends BaseEntity {
|
||||
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
|
||||
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. */
|
||||
@Column({ name: 'actor_role', type: 'varchar', length: 64, nullable: true })
|
||||
actorRole?: string | null;
|
||||
|
||||
@@ -8,6 +8,17 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [
|
||||
'STAFF_NOTE',
|
||||
'CUSTOMER_NOTE',
|
||||
'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',
|
||||
/** Backoffice froze the contract; body is the reason shown to the customer. */
|
||||
'SUSPENSION',
|
||||
/** Backoffice lifted a suspension; body is the optional lift note. */
|
||||
'SUSPENSION_LIFTED',
|
||||
/** Customer cancelled their own contract; body is their reason. */
|
||||
'CANCELLATION',
|
||||
] as const;
|
||||
export type ContractReviewNoteType =
|
||||
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];
|
||||
|
||||
@@ -29,6 +29,14 @@ export class ContractSignature extends BaseEntity {
|
||||
@JoinColumn({ name: 'signature_file_id' })
|
||||
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 })
|
||||
consentText?: string | null;
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ export const CONTRACT_STATUSES = [
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
// Reversible backoffice freeze — see statusBeforeSuspension.
|
||||
'SUSPENDED',
|
||||
'CONTRACT_CLOSED',
|
||||
'EXPIRED',
|
||||
'REJECTED',
|
||||
@@ -188,6 +190,14 @@ export class Contract extends BaseEntity {
|
||||
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||
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 })
|
||||
isReefer!: boolean;
|
||||
|
||||
@@ -209,6 +219,14 @@ export class Contract extends BaseEntity {
|
||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
|
||||
status!: string;
|
||||
|
||||
/**
|
||||
* Status the contract held when the backoffice suspended it, restored when
|
||||
* the suspension is lifted. Null unless the contract is (or once was)
|
||||
* SUSPENDED. A suspension without this would just be a cancellation.
|
||||
*/
|
||||
@Column({ name: 'status_before_suspension', type: 'varchar', length: 40, nullable: true })
|
||||
statusBeforeSuspension?: string | null;
|
||||
|
||||
@Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' })
|
||||
clearanceStatus!: string;
|
||||
|
||||
@@ -335,4 +353,18 @@ export class Contract extends BaseEntity {
|
||||
* contract_review_notes, not a column here.
|
||||
*/
|
||||
latestSendBackNote?: string | null;
|
||||
|
||||
/**
|
||||
* Body of the most recent SUSPENSION review note, attached by
|
||||
* ContractsService.findById while the contract is SUSPENDED so both sides see
|
||||
* why it was frozen. Lives in contract_review_notes, not a column here.
|
||||
*/
|
||||
latestSuspensionNote?: string | null;
|
||||
|
||||
/**
|
||||
* Count of this contract's non-terminal bookings, attached by
|
||||
* ContractsService.findById. The portal disables customer cancellation while
|
||||
* it is > 0 (the API enforces the same). Not a column.
|
||||
*/
|
||||
activeBookingCount?: number;
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
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 } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
code: string,
|
||||
replacedBy?: { userId?: string | null; reason?: string | null },
|
||||
): 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;
|
||||
await this.filesRepository.deleteByCode(resourceId, resource, code);
|
||||
await this.filesRepository.deleteByCode(
|
||||
resourceId,
|
||||
resource,
|
||||
code,
|
||||
replacedBy,
|
||||
);
|
||||
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(
|
||||
resourceId: 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';
|
||||
|
||||
export class FirstMileVehicleInput {
|
||||
@@ -8,6 +8,18 @@ export class FirstMileVehicleInput {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
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. */
|
||||
|
||||
@@ -36,4 +36,12 @@ export class FirstMileVehicleAssignment extends BaseEntity {
|
||||
/** Actual distance driven by this truck (km), entered per vehicle. */
|
||||
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
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;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user