diff --git a/.gitignore b/.gitignore index 316f08dc3..b9dc16c3c 100644 --- a/.gitignore +++ b/.gitignore @@ -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) diff --git a/CLAUDE.md b/CLAUDE.md index d67e6c3d5..b90d3b1dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/E2E_TEST_REPORT.md b/E2E_TEST_REPORT.md new file mode 100644 index 000000000..186bc3f5d --- /dev/null +++ b/E2E_TEST_REPORT.md @@ -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" && ( + ... + 20ft & 40ft containers covered +``` + +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 '' 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//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. diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index d80ce75c6..58fb60b18 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -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 diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 66909a037..0531d056b 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -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", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 9270dd0a1..64df33f35 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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. diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index a412bf990..854594ffc 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -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); diff --git a/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts b/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts new file mode 100644 index 000000000..4658a5434 --- /dev/null +++ b/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts @@ -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, + ); + }); +}); diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts index 429c910d3..56c0e77c2 100644 --- a/apps/edr-freight-api/src/common/freight-permission.util.ts +++ b/apps/edr-freight-api/src/common/freight-permission.util.ts @@ -151,6 +151,24 @@ const APPROVE_ROLE_PERMISSION: Record = { 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 = { + 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 = { 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; diff --git a/apps/edr-freight-api/src/common/grn.util.spec.ts b/apps/edr-freight-api/src/common/grn.util.spec.ts new file mode 100644 index 000000000..d95c934ca --- /dev/null +++ b/apps/edr-freight-api/src/common/grn.util.spec.ts @@ -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(); + }); +}); diff --git a/apps/edr-freight-api/src/common/grn.util.ts b/apps/edr-freight-api/src/common/grn.util.ts index 5cae30302..128e0496a 100644 --- a/apps/edr-freight-api/src/common/grn.util.ts +++ b/apps/edr-freight-api/src/common/grn.util.ts @@ -1,13 +1,41 @@ /** - * Goods Received Note number: `GRN---`. + * Goods Received Note number: `GRN----`. + * + * 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---` 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; } diff --git a/apps/edr-freight-api/src/common/mile-financials.util.ts b/apps/edr-freight-api/src/common/mile-financials.util.ts index 2f5288048..f22e86925 100644 --- a/apps/edr-freight-api/src/common/mile-financials.util.ts +++ b/apps/edr-freight-api/src/common/mile-financials.util.ts @@ -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), ); diff --git a/apps/edr-freight-api/src/common/rule-engine-guards.ts b/apps/edr-freight-api/src/common/rule-engine-guards.ts index 14c0385ee..ba8096b5b 100644 --- a/apps/edr-freight-api/src/common/rule-engine-guards.ts +++ b/apps/edr-freight-api/src/common/rule-engine-guards.ts @@ -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)])), ); /** diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index 363008660..eb9541d8e 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -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), }; diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs index 05dd450e4..59cfceb0b 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs @@ -11,6 +11,12 @@

Name: {{signerDisplayName}}

Role: Authorized EDR representative

Date: {{signedAt}}

+ {{#if stampImageUrl}} +
+ Company stamp +
Service provider stamp
+
+ {{/if}} {{/if}} {{/each}} {{else}} @@ -32,6 +38,12 @@

Name: {{signerDisplayName}}

Role: Authorized client representative

Date: {{signedAt}}

+ {{#if stampImageUrl}} +
+ Company stamp +
Client stamp
+
+ {{/if}} {{/if}} {{/each}} {{else}} diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs index 118606065..d9bc9927f 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs @@ -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; } diff --git a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs index 0e06d9f7b..9c4957b2b 100644 --- a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs +++ b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs @@ -147,19 +147,6 @@ authorized to sign and execute this Contract Agreement.

{{> signatures_block}} - -
-

Witnesses

- - - - - - - - -
NameSignatureDate
1.
2.
-
diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index 5b027448c..9efc7fa21 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -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(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) diff --git a/apps/edr-freight-api/src/migrations/2820000000000-AddMileTonsQuantity.ts b/apps/edr-freight-api/src/migrations/2820000000000-AddMileTonsQuantity.ts new file mode 100644 index 000000000..e1e02c418 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2820000000000-AddMileTonsQuantity.ts @@ -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 { + 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 { + 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;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2840000000000-AddTruckTypes.ts b/apps/edr-freight-api/src/migrations/2840000000000-AddTruckTypes.ts new file mode 100644 index 000000000..fb22cbee9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2840000000000-AddTruckTypes.ts @@ -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 { + 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 { + 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. + } +} diff --git a/apps/edr-freight-api/src/migrations/2850000000000-AddBookingDoubleHandling.ts b/apps/edr-freight-api/src/migrations/2850000000000-AddBookingDoubleHandling.ts new file mode 100644 index 000000000..be98729e7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2850000000000-AddBookingDoubleHandling.ts @@ -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 { + 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 { + 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;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2860000000000-AddPerTruckDetentionWindow.ts b/apps/edr-freight-api/src/migrations/2860000000000-AddPerTruckDetentionWindow.ts new file mode 100644 index 000000000..710ad12ad --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2860000000000-AddPerTruckDetentionWindow.ts @@ -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 { + 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 { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + DROP COLUMN IF EXISTS returned_at, + DROP COLUMN IF EXISTS destination_arrived_at; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2900000000000-LivestockPerItem.ts b/apps/edr-freight-api/src/migrations/2900000000000-LivestockPerItem.ts new file mode 100644 index 000000000..ce05a7ce1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2900000000000-LivestockPerItem.ts @@ -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 { + 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 { + 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' + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2910000000000-AddContractSignatureStamp.ts b/apps/edr-freight-api/src/migrations/2910000000000-AddContractSignatureStamp.ts new file mode 100644 index 000000000..e28748cb7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2910000000000-AddContractSignatureStamp.ts @@ -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_'`) + * 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 { + await queryRunner.query( + `ALTER TABLE freight.contract_signatures ADD COLUMN IF NOT EXISTS stamp_file_id uuid;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_signatures DROP COLUMN IF EXISTS stamp_file_id;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2920000000000-AddRevisionActorName.ts b/apps/edr-freight-api/src/migrations/2920000000000-AddRevisionActorName.ts new file mode 100644 index 000000000..2263d78fc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2920000000000-AddRevisionActorName.ts @@ -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 { + await queryRunner.query( + `ALTER TABLE freight.contract_document_revisions ADD COLUMN IF NOT EXISTS actor_name varchar(200);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_document_revisions DROP COLUMN IF EXISTS actor_name;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2930000000000-AddWagonTransferPartialFulfilment.ts b/apps/edr-freight-api/src/migrations/2930000000000-AddWagonTransferPartialFulfilment.ts new file mode 100644 index 000000000..a9cc5e74d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2930000000000-AddWagonTransferPartialFulfilment.ts @@ -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 { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2940000000000-AddFileVersionHistory.ts b/apps/edr-freight-api/src/migrations/2940000000000-AddFileVersionHistory.ts new file mode 100644 index 000000000..ad9bae99c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2940000000000-AddFileVersionHistory.ts @@ -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 { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2950000000000-AddTransitAssigneeHandshake.ts b/apps/edr-freight-api/src/migrations/2950000000000-AddTransitAssigneeHandshake.ts new file mode 100644 index 000000000..bd9647d44 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2950000000000-AddTransitAssigneeHandshake.ts @@ -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 { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2960000000000-AddContractHazardDeclaration.ts b/apps/edr-freight-api/src/migrations/2960000000000-AddContractHazardDeclaration.ts new file mode 100644 index 000000000..810f59a07 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2960000000000-AddContractHazardDeclaration.ts @@ -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 { + 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 { + 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;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2970000000000-AddDoCollectionDates.ts b/apps/edr-freight-api/src/migrations/2970000000000-AddDoCollectionDates.ts new file mode 100644 index 000000000..90b3a9298 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2970000000000-AddDoCollectionDates.ts @@ -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 { + 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 { + 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;`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/2980000000000-AddBookingRequestCurrency.ts b/apps/edr-freight-api/src/migrations/2980000000000-AddBookingRequestCurrency.ts new file mode 100644 index 000000000..5f92cff68 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2980000000000-AddBookingRequestCurrency.ts @@ -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 { + await queryRunner.query( + `ALTER TABLE freight.booking_requests ADD COLUMN IF NOT EXISTS payment_currency varchar(5);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_requests DROP COLUMN IF EXISTS payment_currency;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts b/apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts new file mode 100644 index 000000000..d89e0c04b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts @@ -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 { + 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 { + 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. + } +} diff --git a/apps/edr-freight-api/src/migrations/3000000000000-AddContractSuspension.ts b/apps/edr-freight-api/src/migrations/3000000000000-AddContractSuspension.ts new file mode 100644 index 000000000..0e0484345 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3000000000000-AddContractSuspension.ts @@ -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 { + await queryRunner.query( + `ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS status_before_suspension varchar(40);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contracts DROP COLUMN IF EXISTS status_before_suspension;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts new file mode 100644 index 000000000..2c21d804c --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts @@ -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 => + ({ + 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' }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index caa41f5e9..6bef20630 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -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.`; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 667abe842..4996f45a9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -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 => + ({ + 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 = {}) => + ({ + 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 89825e100..ec2fc5f2e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -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(); 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 { + 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 | 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 | null, containerTypeId: string, bookingCurrency: string, + usdToEtb: number, ): Promise { 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: diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index 507ef43d8..06268342b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -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(); + 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, + ): BookingReferenceCargoTypeChildDto[] => { + // Admin-entered parent pointers could in principle cycle — never loop. + if (seen.has(node.id)) return []; + seen.add(node.id); + + const kids = childrenOf.get(node.id) ?? []; + if (kids.length === 0) { + return [ + { + id: node.id, + name: [...trail, node.cargoTypeName].join(" → "), + code: node.code, + unit_of_measure: node.unitOfMeasure ?? null, + }, + ]; + } + const nextTrail = [...trail, node.cargoTypeName]; + return kids.flatMap((kid) => collectLeaves(kid, nextTrail, seen)); + }; return parents.map((parent) => { - const children = active - .filter((r) => r.parentGroupId === parent.id) - .sort( - (a, b) => - a.displayOrder - b.displayOrder || a.code.localeCompare(b.code), - ) - .map( - (child): BookingReferenceCargoTypeChildDto => ({ - id: child.id, - name: child.cargoTypeName, - code: child.code, - unit_of_measure: child.unitOfMeasure ?? null, - }), - ); + const kids = childrenOf.get(parent.id) ?? []; + const children = + kids.length === 0 + ? // The group itself is the commodity. + [ + { + id: parent.id, + name: parent.cargoTypeName, + code: parent.code, + unit_of_measure: parent.unitOfMeasure ?? null, + }, + ] + : kids.flatMap((kid) => collectLeaves(kid, [], new Set())); - const group: BookingReferenceCargoTypeGroupDto = { + return { id: parent.id, name: parent.cargoTypeName, code: parent.code, + children, }; - if (children.length > 0) { - group.children = children; - } - return group; }); } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index 068ed53af..871d03c72 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -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 }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index 890ae6344..0cc7e59e4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -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 }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index cbbb999ef..5f82a8e3b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -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 }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index ec6e466b2..d7b24d514 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -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 { 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.', ); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index c292a0395..4ebfc9fab 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -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); } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 590bd7262..0d7708789 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -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 { 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 { + 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, + ): Promise { + await this.assertContractNotSuspended(id); + return super.update(id, data); + } + /** * Highest NNNNNN sequence already issued for `BK--…` references. * Includes soft-deleted bookings so the next number clears references that @@ -123,7 +183,9 @@ export class BookingsRepository extends BaseRepository { '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 { '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 { .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 { { 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 { { 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') diff --git a/apps/edr-freight-api/src/modules/bookings/cargo-type-tree.spec.ts b/apps/edr-freight-api/src/modules/bookings/cargo-type-tree.spec.ts new file mode 100644 index 000000000..aaa819505 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/cargo-type-tree.spec.ts @@ -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'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts index 0e858e702..81e5c833a 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts @@ -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(); }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts index 5a63beca6..242cee9d3 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index 4ca578f0b..3df681d9c 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -85,6 +85,24 @@ export class CustomerTruckService { if (isBulk) { const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId); assertBulkTonnageRemains(totalTons, remainingTons); + + // Assignment-time drawdown: planned tonnage across live trucks (weighed + // net once departed, planned before) may not exceed the declared total. + if (totalTons > 0) { + const [p]: Array<{ planned: string | null }> = await this.dataSource.query( + `SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned + FROM freight.customer_truck_assignments a + WHERE a.booking_id = $1 AND a.deleted_at IS NULL`, + [bookingId], + ); + const alreadyPlanned = Number(p?.planned ?? 0); + const requestedTons = Number(dto.plannedTons ?? 0); + if (requestedTons > 0 && alreadyPlanned + requestedTons > totalTons + 0.001) { + throw new BadRequestException( + `Planned tonnage exceeds the booking: ${alreadyPlanned} t already assigned of ${totalTons} t — at most ${Math.max(0, totalTons - alreadyPlanned)} t left for this truck`, + ); + } + } } if (requested.length) { @@ -108,6 +126,8 @@ export class CustomerTruckService { plateNumber: dto.truckPlateNumber.trim().toUpperCase(), driverName: dto.driverName.trim(), truckType: dto.truckType.trim(), + plannedTons: isBulk ? (dto.plannedTons ?? null) : null, + plannedQuantity: isBulk ? (dto.plannedQuantity ?? null) : null, }), ); await manager.getRepository(CustomerTruckContainer).save( @@ -186,23 +206,52 @@ export class CustomerTruckService { throw new ConflictException('Cannot edit a truck that has already arrived'); } - const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); - if (requested.length < 1) { + // Bulk trucks carry loose tonnage, not containers — planned tonnage is + // editable instead, capped by what the other trucks haven't claimed. + const isBulk = booking.freightType === 'BULK'; + const requested = isBulk + ? [] + : (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (!isBulk && requested.length < 1) { throw new BadRequestException('Select at least one container for this truck'); } - assertTruckLoad({ - containers: requested, - bookingContainers: await this.bookingContainerNumbers(bookingId), - sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), - // Exclude THIS truck's own containers so re-saving the same set is allowed. - assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId), - }); + if (!isBulk) { + assertTruckLoad({ + containers: requested, + bookingContainers: await this.bookingContainerNumbers(bookingId), + sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), + // Exclude THIS truck's own containers so re-saving the same set is allowed. + assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId), + }); + } else if (dto.plannedTons != null) { + const { totalTons } = await remainingBulkTons(this.dataSource, bookingId); + if (totalTons > 0) { + const [p]: Array<{ planned: string | null }> = await this.dataSource.query( + `SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned + FROM freight.customer_truck_assignments a + WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.id <> $2`, + [bookingId, assignmentId], + ); + const others = Number(p?.planned ?? 0); + if (others + Number(dto.plannedTons) > totalTons + 0.001) { + throw new BadRequestException( + `Planned tonnage exceeds the booking: ${others} t on other trucks of ${totalTons} t — at most ${Math.max(0, totalTons - others)} t left for this truck`, + ); + } + } + } await this.dataSource.transaction(async (manager) => { await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { plateNumber: dto.truckPlateNumber.trim().toUpperCase(), driverName: dto.driverName.trim(), truckType: dto.truckType.trim(), + ...(isBulk + ? { + plannedTons: dto.plannedTons ?? null, + plannedQuantity: dto.plannedQuantity ?? null, + } + : {}), }); await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); await manager.getRepository(CustomerTruckContainer).save( diff --git a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts index 4356d66ec..9816b3405 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 821b9c9f6..4ab9e397e 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts index 3892d2a97..94ab3b212 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 3867bef3a..6650dfca5 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 { + // 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 = { 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); diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 810232993..3f0fc7abc 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -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 { + private async assertPhasedCustoms(booking: Booking): Promise { 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 { 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 { 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(), - } as never); - } + await this.bookingsRepository.update(bookingId, { + 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, { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index a17406d64..1ca7cd84f 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index 563f056d2..c222ffc16 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -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) { + 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( diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index bb74f062c..84465145d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts new file mode 100644 index 000000000..6b5f54c91 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts @@ -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; + }; + + 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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 8e102e5a1..c567f71ce 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -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 { 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 { 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 { + private async assertGate( + contract: Contract, + isGlActor: boolean, + isInitiate = false, + ): Promise { + // 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') { - throw new BadRequestException( - 'Contract must be active to book 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) { + // 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( - 'Pre-booking clearance is not complete — booking cannot be created yet.', + 'Contract must be fully executed before booking a shipment.', ); } - 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 { + 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 { 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 { + 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'), diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index d76391f42..31fc1f252 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -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; /** 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 { + 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 { + 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 { - // 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 { @@ -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 { + 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 { + 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 { + 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 { 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 { - 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 { - 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, - }, - }; - } } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts index 2d26f51dc..512ce5288 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts index 154777aea..2c2c4e5de 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-diff.util.ts @@ -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 = { + 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, + after: Record, +): 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(); 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}`, ); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts index 2808ea6cf..e38f737c6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-document-history.service.ts @@ -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 | 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, + @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 { + 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 { 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 { + 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> { + const resolved = new Map(); + 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; + 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 { - return this.revisionRepo.find({ + async list(contractId: string): Promise { + 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; } } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-duplicate-guard.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-duplicate-guard.spec.ts new file mode 100644 index 000000000..1290b2e90 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-duplicate-guard.spec.ts @@ -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 = {}) => + ({ + 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; + } + ).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))).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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts new file mode 100644 index 000000000..0ba682294 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts @@ -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 => + ({ + 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; + } + ).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(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts new file mode 100644 index 000000000..0551cfc7a --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts @@ -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 => + ({ + 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(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts new file mode 100644 index 000000000..1ef84c141 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts @@ -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 { + 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 { + 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}`, + ); + } + } + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-field-diff.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-field-diff.spec.ts new file mode 100644 index 000000000..8726f6c64 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-field-diff.spec.ts @@ -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'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index fd81083fc..92a313569 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -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 = diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 149643441..a4e4b43b5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -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', diff --git a/apps/edr-freight-api/src/modules/contracts/contract-revision-actor.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-revision-actor.spec.ts new file mode 100644 index 000000000..3f579d32b --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-revision-actor.spec.ts @@ -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> = []; + 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) => row, + save: jest.fn((row: Record) => { + 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'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-signature-asset.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-signature-asset.spec.ts new file mode 100644 index 000000000..e372f0cb2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-signature-asset.spec.ts @@ -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; + }).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; + }).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; + }).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; + }).inlineImageUrl.bind(service); + + await expect(inline(null)).resolves.toBeNull(); + await expect(inline(pngPixel)).resolves.toBe(pngPixel); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-stamp-resign.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-stamp-resign.spec.ts new file mode 100644 index 000000000..2c8bc8fa7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-stamp-resign.spec.ts @@ -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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-suspension.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-suspension.spec.ts new file mode 100644 index 000000000..5cf24d747 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-suspension.spec.ts @@ -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 => + ({ + 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(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 54158c383..8f616d7a4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -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 { 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 { for (const sig of signatures) { - if (!sig.signatureImageUrl) continue; - try { - if (sig.signatureImageUrl.startsWith('data:')) continue; - const objectName = this.minioService.getObjectNameFromUrl( - sig.signatureImageUrl, - ); - const stream = await this.minioService.getFileStream(objectName); - const buffer = await this.streamToBuffer(stream); - sig.signatureImageUrl = `data:image/png;base64,${buffer.toString('base64')}`; - } catch { - /* keep original url */ - } + 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 { + if (!url || url.startsWith('data:')) return url; + try { + const objectName = this.minioService.getObjectNameFromUrl(url); + const stream = await this.minioService.getFileStream(objectName); + const buffer = await this.streamToBuffer(stream); + const extension = objectName.split('.').pop()?.toLowerCase(); + const mime = + extension === 'jpg' || extension === 'jpeg' + ? 'image/jpeg' + : extension === 'webp' + ? 'image/webp' + : 'image/png'; + return `data:${mime};base64,${buffer.toString('base64')}`; + } catch { + return url; } } @@ -959,6 +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 { + 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. - updates.status = - contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED'; - updates.clearanceStatus = 'NOT_APPLICABLE'; - } + // 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 { + 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 { + 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 { + 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 { const source = await this.contractsService.findById(contractId); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index ba80cc035..ab330ae9c 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -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. - const signRolePermission: Record = { - STAFF: FREIGHT_PERMS.contracts.signStaff, - DIRECTOR: FREIGHT_PERMS.contracts.approveDirector, - CEO: FREIGHT_PERMS.contracts.approveCeo, - }; 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 = { + STAFF: forFreightType(FREIGHT_PERMS.contracts.signStaff, contract.freightType), + DIRECTOR: FREIGHT_PERMS.contracts.approveDirector, + CEO: FREIGHT_PERMS.contracts.approveCeo, + }; 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); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index bb12648a4..679786c13 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 67a3bd101..49ef3a29f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -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 { 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 { + 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 { + 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 { + 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 { if (!id) return null; @@ -88,7 +179,9 @@ export class ContractsRepository extends BaseRepository { '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 { findSignatures(contractId: string): Promise { 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 { ): Promise { return this.dataSource.getRepository(ContractSignature).findOne({ where: { contractId, role }, - relations: ['signatureFile'], + relations: ['signatureFile', 'stampFile'], }); } @@ -463,6 +556,23 @@ export class ContractsRepository extends BaseRepository { // ── 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 { + 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 { ); } + /** Review notes of one type, newest first — the duty advice/dispute rounds. */ + async findReviewNotes( + contractId: string, + noteType: ContractReviewNoteType, + ): Promise { + 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 { ContractClearanceCycle, | 'dutyRequired' | 'vesselDepartureDate' + | 'vesselArrivalDate' + | 'doCollectedDate' | 'roAmendmentRequestedAt' | 'roHoldReason' | 'currentPhase' | 'status' | 'preClearanceFinalizedAt' | 'completedAt' + | 'transitAssigneeRequestedAt' + | 'transitAssigneeRequestedByUserId' + | 'transitAssigneeRequestNote' + | 'transitAssigneeName' + | 'transitAssigneeAssignedAt' + | 'transitAssigneeAssignedByUserId' > >, ): Promise { diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 65f0637f0..24aa2b597 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -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 + > | 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 { + 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 { + 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 { + 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; } diff --git a/apps/edr-freight-api/src/modules/contracts/do-collection-dates.spec.ts b/apps/edr-freight-api/src/modules/contracts/do-collection-dates.spec.ts new file mode 100644 index 000000000..37ed6c267 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/do-collection-dates.spec.ts @@ -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/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts index d3eaa73a3..8b4e12793 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts index 9a86a5b4e..6aa36b13d 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts index 11d50494a..9f596bbef 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 93a0e9e76..2ab616c00 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -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.', diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index fb4f40654..88ab8beb7 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts index 5ddffad6c..91759d75e 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts @@ -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) diff --git a/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts index 6582376d3..b530bb5e9 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts index 3c101f58e..8f5aa7e81 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts index bc7e12e3e..a832cb50c 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-document-revision.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts index 5b744338e..5b64fe5f7 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts @@ -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]; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-signature.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-signature.entity.ts index bed79d8b1..a17601bf8 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-signature.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-signature.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index b8635199d..ddd0ee837 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts b/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts new file mode 100644 index 000000000..7bd109430 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts @@ -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 => + ({ 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 | 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(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts new file mode 100644 index 000000000..da4b2f34b --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts @@ -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 => + ({ + 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 = {}) => ({ + 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; + } + ).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(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/utils/contract-expiry.util.ts b/apps/edr-freight-api/src/modules/contracts/utils/contract-expiry.util.ts new file mode 100644 index 000000000..b9e491d29 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/utils/contract-expiry.util.ts @@ -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, +): boolean { + if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) { + return true; + } + return Boolean(contract.contractValidUntil && contract.contractValidUntil < new Date()); +} diff --git a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts index 1fbd1459f..7624800d8 100644 --- a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts +++ b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/files/files.repository.ts b/apps/edr-freight-api/src/modules/files/files.repository.ts index 583e221ed..9a2552c7a 100644 --- a/apps/edr-freight-api/src/modules/files/files.repository.ts +++ b/apps/edr-freight-api/src/modules/files/files.repository.ts @@ -41,12 +41,47 @@ export class FilesRepository extends BaseRepository { 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 { - 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 { + return this.repository.find({ + where: { resourceId, resource, code }, + withDeleted: true, + order: { createdAt: "DESC" }, + }); } /** diff --git a/apps/edr-freight-api/src/modules/files/files.service.spec.ts b/apps/edr-freight-api/src/modules/files/files.service.spec.ts new file mode 100644 index 000000000..fc6fb82d3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.service.spec.ts @@ -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'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index e959fa556..ea11e5fe8 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -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 { + /** + * 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 { 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, diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts index 8656b2109..512c22566 100644 --- a/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts +++ b/apps/edr-freight-api/src/modules/first-mile/dto/set-vehicles.dto.ts @@ -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. */ diff --git a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts index 39bf51a50..5c9f02c42 100644 --- a/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/first-mile/entities/first-mile-vehicle-assignment.entity.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 948853e22..b11912b2c 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -532,17 +532,47 @@ export class FirstMileService { */ async setVehicles( id: string, - inputs: Array<{ vehicleId: string; containerNumber?: string | null }>, + inputs: Array<{ + vehicleId: string; + containerNumber?: string | null; + tons?: number | null; + quantity?: number | null; + }>, ): Promise { const existing = await this.findById(id); - // Dedupe by vehicleId, keeping the container number; preserve order. - const desiredMap = new Map(); + // Dedupe by vehicleId, keeping the load details; preserve order. + const desiredMap = new Map< + string, + { containerNumber: string | null; tons: number | null; quantity: number | null } + >(); for (const inp of inputs) { - if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null); + if (inp.vehicleId) { + desiredMap.set(inp.vehicleId, { + containerNumber: inp.containerNumber ?? null, + tons: inp.tons ?? null, + quantity: inp.quantity ?? null, + }); + } } const desired = [...desiredMap.keys()]; const desiredSet = new Set(desired); + // Bulk drawdown: assigned tonnage may not exceed what the booking declares. + const totalTons = [...desiredMap.values()].reduce((s, v) => s + (Number(v.tons) || 0), 0); + if (totalTons > 0 && existing.bookingId) { + const [b]: Array<{ vgm: string | null }> = await this.dataSource.query( + `SELECT cargo_total_weight_vgm AS vgm FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [existing.bookingId], + ); + const declared = Number(b?.vgm ?? 0); + if (declared > 0 && totalTons > declared + 0.001) { + throw new BadRequestException( + `Assigned tonnage (${totalTons} t) exceeds the booking's declared ${declared} t`, + ); + } + } + const manager = this.dataSource.manager; const current = await manager.find(FirstMileVehicleAssignment, { where: { firstMileId: id }, @@ -555,12 +585,16 @@ export class FirstMileService { )]; const added = desired.filter((v) => !junctionSet.has(v)); const removed = releaseIds.filter((v) => !desiredSet.has(v)); - // Vehicles that stay but whose container number changed. - const changed = current.filter( - (a) => - desiredMap.has(a.vehicleId) && - (a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null), - ); + // Vehicles that stay but whose load details changed. + const changed = current.filter((a) => { + const want = desiredMap.get(a.vehicleId); + if (!want) return false; + return ( + (a.containerNumber ?? null) !== want.containerNumber || + (a.tons == null ? null : Number(a.tons)) !== want.tons || + (a.quantity ?? null) !== want.quantity + ); + }); await this.dataSource.transaction(async (tx) => { if (removed.length) { @@ -570,17 +604,25 @@ export class FirstMileService { }); } for (const vehicleId of added) { + const want = desiredMap.get(vehicleId); await tx.insert(FirstMileVehicleAssignment, { firstMileId: id, vehicleId, - containerNumber: desiredMap.get(vehicleId) ?? null, + containerNumber: want?.containerNumber ?? null, + tons: want?.tons ?? null, + quantity: want?.quantity ?? null, }); } for (const row of changed) { + const want = desiredMap.get(row.vehicleId); await tx.update( FirstMileVehicleAssignment, { firstMileId: id, vehicleId: row.vehicleId }, - { containerNumber: desiredMap.get(row.vehicleId) ?? null }, + { + containerNumber: want?.containerNumber ?? null, + tons: want?.tons ?? null, + quantity: want?.quantity ?? null, + }, ); } }); diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-detention-times.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-detention-times.dto.ts new file mode 100644 index 000000000..9f103e15b --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-detention-times.dto.ts @@ -0,0 +1,29 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator'; + +/** + * One truck's detention window. Each truck reaches the destination and is + * released at its own time, so detention days differ between trucks on the + * same delivery. Null clears the value (falls back to the leg-level pair). + */ +export class TruckDetentionTimeInput { + @IsUUID() + vehicleId!: string; + + /** Detention clock start — this truck reached the destination. */ + @IsOptional() + @IsDateString() + destinationArrivedAt?: string | null; + + /** Detention clock end — this truck was released/returned. Omit = still out. */ + @IsOptional() + @IsDateString() + returnedAt?: string | null; +} + +export class SetDetentionTimesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TruckDetentionTimeInput) + trucks!: TruckDetentionTimeInput[]; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts index e57c16b8a..f2b4e2467 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts @@ -51,6 +51,21 @@ export class LastMileVehicleAssignment extends BaseEntity { @Column({ name: 'departed_at', type: 'timestamptz', nullable: true }) departedAt?: Date | null; + /** + * Detention clock START for THIS truck: reached the delivery destination. + * Distinct from `arrivedAt` (warehouse gate-in). Null falls back to the + * leg-level `last_mile.arrived_at`. + */ + @Column({ name: 'destination_arrived_at', type: 'timestamptz', nullable: true }) + destinationArrivedAt?: Date | null; + + /** + * Detention clock END for THIS truck: released / returned by the customer. + * Null (with no leg-level `delivered_at`) means still out — detention accrues. + */ + @Column({ name: 'returned_at', type: 'timestamptz', nullable: true }) + returnedAt?: Date | null; + /** Weighed gross on exit, in TONNES (not kg — see the migration note). */ @Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) grossWeightTons?: number | null; diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 29e857e2f..e8fa57cdc 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -23,6 +23,7 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { SetVehiclesDto } from './dto/set-vehicles.dto'; +import { SetDetentionTimesDto } from './dto/set-detention-times.dto'; import { SetDistancesDto } from './dto/set-distances.dto'; import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto'; import { LastMileStatus } from './entities/last-mile.entity'; @@ -131,6 +132,18 @@ export class LastMileController { return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment); } + @Post(':id/detention-times') + @BookingStaff(FREIGHT_PERMS.lastMile.update) + @ApiOperation({ + summary: 'Set each truck\'s own detention window (arrived at destination / returned)', + }) + async setDetentionTimes( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetDetentionTimesDto, + ) { + return this.lastMileService.setDetentionTimes(id, dto.trucks); + } + @Post(':id/proof-of-delivery') @BookingStaff(FREIGHT_PERMS.lastMile.update) @UseInterceptors(AnyFilesInterceptor()) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 601e03aec..d7c45bb3c 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -869,6 +869,46 @@ export class LastMileService { * sum and drives billing; `remainingPayment` (total km × rate) is recomputed * client-side. Does NOT generate an invoice — that's a separate explicit step. */ + /** + * Per-truck detention windows. Each truck reaches the destination and is + * released at its own time, so every truck gets its own clock (and therefore + * its own chargeable days). Locked once the detention invoice exists. + */ + async setDetentionTimes( + id: string, + trucks: Array<{ + vehicleId: string; + destinationArrivedAt?: string | null; + returnedAt?: string | null; + }>, + ): Promise { + await this.findById(id); + + const invoices = await this.billing.findBySourceIds('last_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Detention times cannot be changed after the invoice is generated', + ); + } + + for (const t of trucks) { + const start = t.destinationArrivedAt ? new Date(t.destinationArrivedAt) : null; + const end = t.returnedAt ? new Date(t.returnedAt) : null; + if (start && end && end.getTime() < start.getTime()) { + throw new BadRequestException( + 'A truck cannot be returned before it arrived — check the detention times', + ); + } + await this.dataSource.manager.update( + LastMileVehicleAssignment, + { lastMileId: id, vehicleId: t.vehicleId }, + { destinationArrivedAt: start, returnedAt: end }, + ); + } + + return this.findById(id); + } + async setDistances( id: string, distances: Array<{ vehicleId: string; distanceKm: number }>, diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts index d46622ba4..0cde28fb7 100644 --- a/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts +++ b/apps/edr-freight-api/src/modules/locomotives/dto/create-locomotive.dto.ts @@ -65,25 +65,4 @@ export class CreateLocomotiveDto { @IsNumber() @Min(0) overageToleranceMeters?: number; - - @ApiPropertyOptional({ example: 4200 }) - @IsOptional() - @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) - @IsNumber() - @Min(0) - powerKw?: number; - - @ApiPropertyOptional({ example: 300 }) - @IsOptional() - @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) - @IsNumber() - @Min(0) - tractionForceKn?: number; - - @ApiPropertyOptional({ example: 120 }) - @IsOptional() - @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) - @IsNumber() - @Min(0) - maxSpeedKmh?: number; } diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts index a42dcd220..0c3b2aa51 100644 --- a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts +++ b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts @@ -1,13 +1,16 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsIn, IsOptional, IsUUID } from 'class-validator'; +import { IsBoolean, IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator'; +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES, } from '../entities/locomotive.entity'; -export class FilterLocomotivesDto { +// Extends the shared pagination DTO for `page`/`pageSize`/`search`; those are +// only read by `GET /locomotives/paged` — the plain list ignores them. +export class FilterLocomotivesDto extends PaginationQueryDto { @ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES }) @IsOptional() @IsIn([...LOCOMOTIVE_STATUSES]) @@ -47,4 +50,14 @@ export class FilterLocomotivesDto { @IsOptional() @IsUUID() excludeTrainId?: string; + + @ApiPropertyOptional({ description: 'Registered on or after this day (YYYY-MM-DD)' }) + @IsOptional() + @IsDateString() + createdFrom?: string; + + @ApiPropertyOptional({ description: 'Registered on or before this day (YYYY-MM-DD)' }) + @IsOptional() + @IsDateString() + createdTo?: string; } diff --git a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts index d7a875c24..d73795375 100644 --- a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts +++ b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts @@ -76,15 +76,6 @@ export class Locomotive extends BaseEntity { @JoinColumn({ name: 'current_yard_id' }) currentYard?: Yard | null; - @Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true }) - powerKw?: number | null; - - @Column({ name: 'traction_force_kn', type: 'numeric', precision: 10, scale: 3, nullable: true }) - tractionForceKn?: number | null; - - @Column({ name: 'max_speed_kmh', type: 'numeric', precision: 10, scale: 3, nullable: true }) - maxSpeedKmh?: number | null; - @OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive) trainSets?: TrainSet[]; } diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts index 77d8b0df2..3883f8d99 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts @@ -24,6 +24,14 @@ export class LocomotivesController { return this.locomotivesService.findAll(filter); } + // Must be declared before @Get(':id') so the path isn't captured as an id. + @Get('paged') + @StaffReference() + @ApiOperation({ summary: 'List locomotives, paginated ({items, meta})' }) + findAllPaged(@Query() filter: FilterLocomotivesDto) { + return this.locomotivesService.findAllPaged(filter); + } + @Get(':id') @StaffReference() @ApiOperation({ summary: 'Get a locomotive by ID' }) diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts index 3ad5b3650..11300cfee 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts @@ -1,7 +1,7 @@ import { BaseRepository } from '@edr/api-common'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, SelectQueryBuilder } from 'typeorm'; import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity'; import { TrainLocomotive } from '../trains/entities/train-locomotive.entity'; @@ -16,18 +16,22 @@ export class LocomotivesRepository extends BaseRepository { } /** - * List locomotives for the train-builder coupling picker: the usual - * status/type/yard filters, plus optional exclusion of any loco already - * coupled to a built train. `keepTrainId` spares that one train's own locos - * from the exclusion so they stay selectable while editing its consist. + * Filter/sort builder shared by the coupling picker and the paginated list: + * the usual status/type/yard filters, free-text over code + name, a + * registration-day range, and optional exclusion of any loco already coupled + * to a built train. `keepTrainId` spares that one train's own locos from the + * exclusion so they stay selectable while editing its consist. */ - findForCoupling(opts: { + buildListQuery(opts: { status?: LocomotiveStatus; locomotiveType?: LocomotiveType; currentYardId?: string; excludeCoupled?: boolean; keepTrainId?: string; - }): Promise { + search?: string; + createdFrom?: string; + createdTo?: string; + }): SelectQueryBuilder { const qb = this.repository .createQueryBuilder('locomotive') .leftJoinAndSelect('locomotive.currentYard', 'currentYard') @@ -39,6 +43,25 @@ export class LocomotivesRepository extends BaseRepository { if (opts.currentYardId) qb.andWhere('locomotive.currentYardId = :yardId', { yardId: opts.currentYardId }); + const search = opts.search?.trim(); + if (search) { + qb.andWhere('(locomotive.code ILIKE :search OR locomotive.name ILIKE :search)', { + search: `%${search}%`, + }); + } + + // Registration-day range, both ends inclusive (the UI picks whole days). + if (opts.createdFrom) { + qb.andWhere('locomotive.createdAt >= CAST(:createdFrom AS date)', { + createdFrom: opts.createdFrom, + }); + } + if (opts.createdTo) { + qb.andWhere("locomotive.createdAt < CAST(:createdTo AS date) + INTERVAL '1 day'", { + createdTo: opts.createdTo, + }); + } + if (opts.excludeCoupled) { // NOT EXISTS a link to a DIFFERENT train. Own-train links are kept so the // consist being edited still lists its current locomotives. @@ -53,7 +76,11 @@ export class LocomotivesRepository extends BaseRepository { qb.andWhere(`NOT EXISTS (${sub.getQuery()})`).setParameters(sub.getParameters()); } - return qb.getMany(); + return qb; + } + + findForCoupling(opts: Parameters[0]): Promise { + return this.buildListQuery(opts).getMany(); } /** diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts index 46e0ae415..1da03072e 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -1,6 +1,9 @@ +import { PaginatedResponse } from '@edr/types'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { paginateQuery } from '../../common/utils/pagination.util'; + import { CreateLocomotiveDto } from './dto/create-locomotive.dto'; import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; import { UpdateLocomotiveDto } from './dto/update-locomotive.dto'; @@ -53,6 +56,21 @@ export class LocomotivesService { }); } + /** Same filters as `findAll` plus search/date range, on the shared list envelope. */ + findAllPaged(filter: FilterLocomotivesDto): Promise> { + const qb = this.locomotivesRepository.buildListQuery({ + status: filter.status as LocomotiveStatus | undefined, + locomotiveType: filter.locomotiveType as LocomotiveType | undefined, + currentYardId: filter.currentYardId, + excludeCoupled: filter.excludeCoupled, + keepTrainId: filter.excludeTrainId, + search: filter.search, + createdFrom: filter.createdFrom, + createdTo: filter.createdTo, + }); + return paginateQuery(qb, filter); + } + /** Default max pull weight (tons) applied when the caller omits it. */ private static readonly DEFAULT_MAX_PULL_WEIGHT_TONS = 2500; @@ -113,9 +131,6 @@ export class LocomotivesService { maxTrainLengthMeters: dto.maxTrainLengthMeters, overageToleranceTons: dto.overageToleranceTons ?? null, overageToleranceMeters: dto.overageToleranceMeters ?? null, - powerKw: dto.powerKw ?? null, - tractionForceKn: dto.tractionForceKn ?? null, - maxSpeedKmh: dto.maxSpeedKmh ?? null, }); } @@ -176,11 +191,6 @@ export class LocomotivesService { ? locomotive.currentYardId : (dto.currentYardId ?? null), name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null, - powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null, - tractionForceKn: - dto.tractionForceKn === undefined ? locomotive.tractionForceKn : dto.tractionForceKn ?? null, - maxSpeedKmh: - dto.maxSpeedKmh === undefined ? locomotive.maxSpeedKmh : dto.maxSpeedKmh ?? null, }); if (!updated) { diff --git a/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts index 59188a2a6..dfd9a1a91 100644 --- a/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts +++ b/apps/edr-freight-api/src/modules/routes/dto/filter-routes.dto.ts @@ -1,14 +1,13 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { IsEnum, IsOptional } from 'class-validator'; +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; import { RouteStatus } from '../entities/route.entity'; -export class FilterRoutesDto { - @ApiPropertyOptional({ description: 'Search origin/destination yard codes or names' }) - @IsOptional() - @IsString() - search?: string; - +// `search` (origin/destination/milestone yard codes and names) plus +// `page`/`pageSize` come from the shared pagination DTO; the page window is only +// read by `GET /routes/paged`. +export class FilterRoutesDto extends PaginationQueryDto { @ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] }) @IsOptional() @IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING']) diff --git a/apps/edr-freight-api/src/modules/routes/routes.controller.ts b/apps/edr-freight-api/src/modules/routes/routes.controller.ts index cf2314156..259dd4c9f 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.controller.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.controller.ts @@ -21,6 +21,13 @@ export class RoutesController { return this.routesService.findAll(filter); } + // Must be declared before @Get(':id') so the path isn't captured as an id. + @Get('paged') + @ApiOperation({ summary: 'List routes, paginated ({items, meta})' }) + findAllPaged(@Query() filter: FilterRoutesDto) { + return this.routesService.findAllPaged(filter); + } + @Get(':id') @ApiOperation({ summary: 'Get route by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/routes/routes.duplicate.spec.ts b/apps/edr-freight-api/src/modules/routes/routes.duplicate.spec.ts new file mode 100644 index 000000000..43194ce08 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.duplicate.spec.ts @@ -0,0 +1,80 @@ +import { ConflictException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; + +import { RoutesService } from './routes.service'; +import type { RoutesRepository } from './routes.repository'; + +type StopSeq = Array<{ yardId: string; sequenceNo: number }>; + +/** DataSource stub whose Route repository returns the given existing routes. */ +const serviceWith = ( + existing: Array<{ id: string; milestones: StopSeq }>, +): RoutesService => { + const dataSource = { + getRepository: () => ({ find: async () => existing }), + } as unknown as DataSource; + return new RoutesService(dataSource, {} as RoutesRepository); +}; + +const assertNotDuplicate = ( + service: RoutesService, + yardIds: string[], + excludeRouteId?: string, +): Promise => + ( + service as unknown as { + assertNotDuplicate: ( + m: Array<{ yardId: string }>, + id?: string, + ) => Promise; + } + ).assertNotDuplicate( + yardIds.map((yardId) => ({ yardId })), + excludeRouteId, + ); + +describe('RoutesService duplicate guard', () => { + const addisAdamaDire: StopSeq = [ + { yardId: 'addis', sequenceNo: 1 }, + { yardId: 'adama', sequenceNo: 2 }, + { yardId: 'dire', sequenceNo: 3 }, + ]; + + it('rejects an identical stop sequence', async () => { + const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]); + + await expect( + assertNotDuplicate(service, ['addis', 'adama', 'dire']), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('allows the same endpoints with a different corridor', async () => { + // Same origin + destination, but skipping Adama is a genuinely other route. + const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]); + + await expect( + assertNotDuplicate(service, ['addis', 'dire']), + ).resolves.toBeUndefined(); + }); + + it('does not flag the route being edited against itself', async () => { + const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]); + + await expect( + assertNotDuplicate(service, ['addis', 'adama', 'dire'], 'r1'), + ).resolves.toBeUndefined(); + }); + + it('compares stops by sequence, not storage order', async () => { + const shuffled: StopSeq = [ + { yardId: 'dire', sequenceNo: 3 }, + { yardId: 'addis', sequenceNo: 1 }, + { yardId: 'adama', sequenceNo: 2 }, + ]; + const service = serviceWith([{ id: 'r1', milestones: shuffled }]); + + await expect( + assertNotDuplicate(service, ['addis', 'adama', 'dire']), + ).rejects.toBeInstanceOf(ConflictException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts index 96e6c7fd1..8c2989b87 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.service.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -4,9 +4,10 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { TrainScheduleStatus } from '@edr/types'; -import { DataSource, In } from 'typeorm'; +import { PaginatedResponse, TrainScheduleStatus } from '@edr/types'; +import { DataSource, In, Not } from 'typeorm'; +import { paginateArray } from '../../common/utils/pagination.util'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; import { YardDistance } from '../rule-engine/entities/yard-distance.entity'; @@ -15,7 +16,7 @@ import { CreateRouteDto } from './dto/create-route.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto'; import { UpdateRouteDto } from './dto/update-route.dto'; import { RouteMilestone } from './entities/route-milestone.entity'; -import { formatRouteLabel, Route } from './entities/route.entity'; +import { formatRouteLabel, Route, type RouteStatus } from './entities/route.entity'; import { RoutesRepository } from './routes.repository'; /** Order-insensitive key: distances are symmetric. */ @@ -68,6 +69,18 @@ export class RoutesService { }); } + /** + * `findAll` on the shared `{items, meta}` envelope. + * + * ponytail: slices in memory — the corridor table is small (tens of rows) and + * both the ordering (formatted "A → B → C" label) and the search span the + * milestone collection, which a single SQL page window cannot express. Move to + * a query builder if routes ever grow past a few hundred. + */ + async findAllPaged(filter: FilterRoutesDto): Promise> { + return paginateArray(await this.findAll(filter), filter); + } + async findById(id: string): Promise { const route = await this.dataSource.getRepository(Route).findOne({ where: { id }, @@ -88,6 +101,7 @@ export class RoutesService { async create(dto: CreateRouteDto): Promise { const validated = await this.validateMilestones(dto.milestones); + await this.assertNotDuplicate(validated.milestones); const route = await this.dataSource.transaction(async (manager) => { const savedRoute = await manager.getRepository(Route).save( @@ -123,6 +137,11 @@ export class RoutesService { ? await this.validateMilestones(dto.milestones) : null; + // An edit can collide with another route just as easily as a create can. + if (milestoneInput) { + await this.assertNotDuplicate(milestoneInput.milestones, id); + } + // Milestones or endpoints are about to be rewritten — reject if any // non-terminal schedule still references this route, otherwise its stop list // and distances would silently shift under a live plan. Status-only / @@ -187,6 +206,51 @@ export class RoutesService { return this.findById(id); } + /** + * A route IS its ordered stop list — "Addis → Adama → Dire Dawa" and + * "Addis → Dire Dawa" share endpoints but are different corridors. So the + * duplicate test compares the full yard sequence, not just origin/destination. + * + * Decommissioned routes (STOP_WORKING) are ignored: replacing a retired + * corridor with a fresh one is exactly what an admin does after deactivating, + * and there is no reactivate action to fall back on. + */ + private async assertNotDuplicate( + milestones: Array<{ yardId: string }>, + excludeRouteId?: string, + ): Promise { + const signature = milestones.map((m) => m.yardId).join('>'); + + const candidates = await this.dataSource.getRepository(Route).find({ + where: { + originYardId: milestones[0].yardId, + destinationYardId: milestones[milestones.length - 1].yardId, + status: Not('STOP_WORKING'), + }, + relations: { + originYard: true, + destinationYard: true, + milestones: { yard: true }, + }, + }); + + const duplicate = candidates.find((route) => { + if (route.id === excludeRouteId) return false; + const stops = [...(route.milestones ?? [])] + .sort((a, b) => a.sequenceNo - b.sequenceNo) + .map((m) => m.yardId) + .join('>'); + return stops === signature; + }); + + if (duplicate) { + throw new ConflictException( + `This route already exists: ${formatRouteLabel(duplicate)}. ` + + 'Edit the existing route instead of creating a duplicate.', + ); + } + } + private async validateMilestones(milestones: Array<{ yardId: string }>) { if (milestones.length < 2) { throw new BadRequestException('A route requires at least two yards'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts index 5d17efe1e..ae50b9622 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto'; import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -41,7 +41,7 @@ export class ApprovalRulesController { } @Post('reorder') - @RuleEngineManage('approval-rules') + @RuleEngineUpdate('approval-rules') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Bulk reorder approval steps within a chain' }) reorder(@Body() dto: ReorderItemsDto) { @@ -49,7 +49,7 @@ export class ApprovalRulesController { } @Post(':id/move-order') - @RuleEngineManage('approval-rules') + @RuleEngineUpdate('approval-rules') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Move an approval step up or down within its chain' }) moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { @@ -64,21 +64,21 @@ export class ApprovalRulesController { } @Post() - @RuleEngineManage('approval-rules') + @RuleEngineCreate('approval-rules') @ApiOperation({ summary: 'Create an approval rule step' }) create(@Body() dto: CreateApprovalRuleDto) { return this.service.create(dto); } @Patch(':id') - @RuleEngineManage('approval-rules') + @RuleEngineUpdate('approval-rules') @ApiOperation({ summary: 'Update an approval rule' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) { return this.service.update(id, dto); } @Delete(':id') - @RuleEngineManage('approval-rules') + @RuleEngineDelete('approval-rules') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete an approval rule' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts index 3b1bf6ce1..8bd9d90f5 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts @@ -3,7 +3,7 @@ import { Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards'; import { StaffReference } from '../../../common/booking-guards'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -26,7 +26,7 @@ export class CargoTypesController { } @Post('reorder') - @RuleEngineManage('cargo-types') + @RuleEngineUpdate('cargo-types') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Bulk reorder cargo types by ID list' }) reorder(@Body() dto: ReorderItemsDto) { @@ -34,7 +34,7 @@ export class CargoTypesController { } @Post(':id/move-order') - @RuleEngineManage('cargo-types') + @RuleEngineUpdate('cargo-types') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Move a cargo type up or down in display order' }) moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { @@ -49,21 +49,21 @@ export class CargoTypesController { } @Post() - @RuleEngineManage('cargo-types') + @RuleEngineCreate('cargo-types') @ApiOperation({ summary: 'Create a cargo type' }) create(@Body() dto: CreateCargoTypeDto) { return this.service.create(dto); } @Patch(':id') - @RuleEngineManage('cargo-types') + @RuleEngineUpdate('cargo-types') @ApiOperation({ summary: 'Update a cargo type' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoTypeDto) { return this.service.update(id, dto); } @Delete(':id') - @RuleEngineManage('cargo-types') + @RuleEngineDelete('cargo-types') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a cargo type' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts index 9ae96af9b..82bda724a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts @@ -3,7 +3,7 @@ import { Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards'; import { StaffReference } from '../../../common/booking-guards'; import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -26,7 +26,7 @@ export class ContainerTypesController { } @Post('reorder') - @RuleEngineManage('container-types') + @RuleEngineUpdate('container-types') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Bulk reorder container types by ID list' }) reorder(@Body() dto: ReorderItemsDto) { @@ -34,7 +34,7 @@ export class ContainerTypesController { } @Post(':id/move-order') - @RuleEngineManage('container-types') + @RuleEngineUpdate('container-types') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Move a container type up or down in display order' }) moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { @@ -49,21 +49,21 @@ export class ContainerTypesController { } @Post() - @RuleEngineManage('container-types') + @RuleEngineCreate('container-types') @ApiOperation({ summary: 'Create a container type' }) create(@Body() dto: CreateContainerTypeDto) { return this.service.create(dto); } @Patch(':id') - @RuleEngineManage('container-types') + @RuleEngineUpdate('container-types') @ApiOperation({ summary: 'Update a container type' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerTypeDto) { return this.service.update(id, dto); } @Delete(':id') - @RuleEngineManage('container-types') + @RuleEngineDelete('container-types') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a container type' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts index 0b3334431..3188d0846 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts @@ -3,7 +3,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto'; import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -49,14 +49,14 @@ export class PriorityConfigsController { } @Post() - @RuleEngineManage('priority-configs') + @RuleEngineCreate('priority-configs') @ApiOperation({ summary: 'Create a priority config' }) create(@Body() dto: CreatePriorityConfigDto) { return this.service.create(dto); } @Post('reorder') - @RuleEngineManage('priority-configs') + @RuleEngineUpdate('priority-configs') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Bulk reorder priority configs by ID list' }) reorder(@Body() dto: ReorderItemsDto) { @@ -64,7 +64,7 @@ export class PriorityConfigsController { } @Post(':id/move-order') - @RuleEngineManage('priority-configs') + @RuleEngineUpdate('priority-configs') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Move a priority config up or down in display order' }) moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { @@ -72,14 +72,14 @@ export class PriorityConfigsController { } @Patch(':id') - @RuleEngineManage('priority-configs') + @RuleEngineUpdate('priority-configs') @ApiOperation({ summary: 'Update a priority config' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityConfigDto) { return this.service.update(id, dto); } @Delete(':id') - @RuleEngineManage('priority-configs') + @RuleEngineDelete('priority-configs') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a priority config' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts index 942b2c32c..2633a0cf1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts @@ -11,7 +11,7 @@ import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger' import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineCreate, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards'; import { isSuperAdmin } from '../../../common/freight-permission.util'; import { DecidePriorityRuleChangeDto, @@ -32,7 +32,7 @@ export class PriorityRuleChangeRequestsController { constructor(private readonly service: PriorityRuleChangeRequestsService) {} @Post() - @RuleEngineManage('priority-configs') + @RuleEngineCreate('priority-configs') @ApiOperation({ summary: 'Submit a priority-rule change for approval' }) submit( @Body() dto: SubmitPriorityRuleChangeDto, @@ -50,7 +50,7 @@ export class PriorityRuleChangeRequestsController { } @Post(':id/approve') - @RuleEngineManage('priority-configs') + @RuleEngineUpdate('priority-configs') @ApiOperation({ summary: 'Approve and apply a pending change' }) approve( @Param('id', ParseUUIDPipe) id: string, @@ -63,7 +63,7 @@ export class PriorityRuleChangeRequestsController { } @Post(':id/reject') - @RuleEngineManage('priority-configs') + @RuleEngineUpdate('priority-configs') @ApiOperation({ summary: 'Reject a pending change' }) reject( @Param('id', ParseUUIDPipe) id: string, diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts index 9972ab06a..8c3a99c9d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/rate-change-requests.controller.ts @@ -4,7 +4,7 @@ import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { isSuperAdmin } from '../../../common/freight-permission.util'; -import { RuleEngineApprove, RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineApprove, RuleEngineCreate, RuleEngineView } from '../../../common/rule-engine-guards'; import { DecideRateChangeDto, SubmitRateChangeDto } from '../dto/rate-change-request.dto'; import { RateChangeStatus } from '../entities/rate-change-request.entity'; import { RateChangeRequestsService } from '../services/rate-change-requests.service'; @@ -21,7 +21,7 @@ export class RateChangeRequestsController { constructor(private readonly service: RateChangeRequestsService) {} @Post() - @RuleEngineManage('rates') + @RuleEngineCreate('rates') @ApiOperation({ summary: 'Propose a change to a LIVE rate' }) submit(@Body() dto: SubmitRateChangeDto, @CurrentUser() user: TCurrentUser) { return this.service.submit(dto, user?.id); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts index 92ac7f5d6..f10c0afab 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts @@ -5,7 +5,7 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards'; import { isSuperAdmin } from '../../../common/freight-permission.util'; import { CreateRateDto } from '../dto/create-rate.dto'; import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -44,7 +44,7 @@ export class RatesController { } @Post() - @RuleEngineManage('rates') + @RuleEngineCreate('rates') @ApiOperation({ summary: 'Create a rate (DRAFT)' }) create( @Body() dto: CreateRateDto, @@ -54,21 +54,21 @@ export class RatesController { } @Patch(':id') - @RuleEngineManage('rates') + @RuleEngineUpdate('rates') @ApiOperation({ summary: 'Update a DRAFT rate' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRateDto) { return this.service.update(id, dto); } @Post(':id/submit') - @RuleEngineManage('rates') + @RuleEngineUpdate('rates') @ApiOperation({ summary: 'Submit rate for CEO approval' }) submit(@Param('id', ParseUUIDPipe) id: string) { return this.service.submitForApproval(id); } @Post(':id/approve') - @RuleEngineManage('rates') + @RuleEngineUpdate('rates') @ApiOperation({ summary: 'CEO approves a rate' }) approve( @Param('id', ParseUUIDPipe) id: string, @@ -80,7 +80,7 @@ export class RatesController { } @Delete(':id') - @RuleEngineManage('rates') + @RuleEngineDelete('rates') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a rate' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts index 85d76b326..34b4f53a5 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; -import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards'; import { StaffReference } from '../../../common/booking-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; @@ -26,7 +26,7 @@ export class ServiceTypesController { } @Post('reorder') - @RuleEngineManage('service-types') + @RuleEngineUpdate('service-types') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Bulk reorder service types by ID list' }) reorder(@Body() dto: ReorderItemsDto) { @@ -34,7 +34,7 @@ export class ServiceTypesController { } @Post(':id/move-order') - @RuleEngineManage('service-types') + @RuleEngineUpdate('service-types') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Move a service type up or down in display order' }) moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { @@ -49,21 +49,21 @@ export class ServiceTypesController { } @Post() - @RuleEngineManage('service-types') + @RuleEngineCreate('service-types') @ApiOperation({ summary: 'Create a service type' }) create(@Body() dto: CreateServiceTypeDto) { return this.service.create(dto); } @Patch(':id') - @RuleEngineManage('service-types') + @RuleEngineUpdate('service-types') @ApiOperation({ summary: 'Update a service type' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateServiceTypeDto) { return this.service.update(id, dto); } @Delete(':id') - @RuleEngineManage('service-types') + @RuleEngineDelete('service-types') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a service type' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts index f078624eb..62ca5f575 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; -import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards'; import { StaffReference } from '../../../common/booking-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateShippingLineDto } from '../dto/create-shipping-line.dto'; @@ -31,21 +31,21 @@ export class ShippingLinesController { } @Post() - @RuleEngineManage('shipping-lines') + @RuleEngineCreate('shipping-lines') @ApiOperation({ summary: 'Create a shipping line' }) create(@Body() dto: CreateShippingLineDto) { return this.service.create(dto); } @Patch(':id') - @RuleEngineManage('shipping-lines') + @RuleEngineUpdate('shipping-lines') @ApiOperation({ summary: 'Update a shipping line' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateShippingLineDto) { return this.service.update(id, dto); } @Delete(':id') - @RuleEngineManage('shipping-lines') + @RuleEngineDelete('shipping-lines') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a shipping line' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts index 98c45798e..b51ac13ff 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -30,21 +30,21 @@ export class WeightLimitRulesController { } @Post() - @RuleEngineManage('weight-limit-rules') + @RuleEngineCreate('weight-limit-rules') @ApiOperation({ summary: 'Create a weight limit rule' }) create(@Body() dto: CreateWeightLimitRuleDto) { return this.service.create(dto); } @Patch(':id') - @RuleEngineManage('weight-limit-rules') + @RuleEngineUpdate('weight-limit-rules') @ApiOperation({ summary: 'Update a weight limit rule' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWeightLimitRuleDto) { return this.service.update(id, dto); } @Delete(':id') - @RuleEngineManage('weight-limit-rules') + @RuleEngineDelete('weight-limit-rules') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a weight limit rule' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts index b0ba2c8d5..320a62a3e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts @@ -12,7 +12,7 @@ import { Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards'; import { StaffReference } from '../../../common/booking-guards'; import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto'; import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -40,21 +40,21 @@ export class YardDistancesController { } @Post() - @RuleEngineManage('yard-distances') + @RuleEngineCreate('yard-distances') @ApiOperation({ summary: 'Create a yard distance' }) create(@Body() dto: CreateYardDistanceDto) { return this.service.create(dto); } @Patch(':id') - @RuleEngineManage('yard-distances') + @RuleEngineUpdate('yard-distances') @ApiOperation({ summary: 'Update a yard distance' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDistanceDto) { return this.service.update(id, dto); } @Delete(':id') - @RuleEngineManage('yard-distances') + @RuleEngineDelete('yard-distances') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a yard distance' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts index b8f88b6b3..883793e03 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; -import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards'; import { StaffReference } from '../../../common/booking-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateYardDto } from '../dto/create-yard.dto'; @@ -28,7 +28,7 @@ export class YardsController { } @Post('reorder') - @RuleEngineManage('yards') + @RuleEngineUpdate('yards') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Bulk reorder yards by ID list' }) reorder(@Body() dto: ReorderItemsDto) { @@ -36,7 +36,7 @@ export class YardsController { } @Post(':id/move-order') - @RuleEngineManage('yards') + @RuleEngineUpdate('yards') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Move a yard up or down in display order' }) moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) { @@ -51,21 +51,21 @@ export class YardsController { } @Post() - @RuleEngineManage('yards') + @RuleEngineCreate('yards') @ApiOperation({ summary: 'Create a yard' }) create(@Body() dto: CreateYardDto) { return this.service.create(dto); } @Patch(':id') - @RuleEngineManage('yards') + @RuleEngineUpdate('yards') @ApiOperation({ summary: 'Update a yard' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDto) { return this.service.update(id, dto); } @Delete(':id') - @RuleEngineManage('yards') + @RuleEngineDelete('yards') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a yard' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts new file mode 100644 index 000000000..e44dcdb5f --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts @@ -0,0 +1,70 @@ +import { allowedRateUnits, isBulkQuantityUnit } from "./rate-unit.util"; + +/** + * A bulk rate's weighting unit follows how its commodity is counted: wheat is + * weighed (per ton), machinery is counted (per item). Per-wagon is offered + * either way. + */ +describe("allowedRateUnits — bulk unit of measure", () => { + it("offers per-ton for a weighed commodity", () => { + expect( + allowedRateUnits({ + appliesTo: "BULK", + trigger: "ALWAYS", + cargoUnitOfMeasure: "PER_TON", + }), + ).toEqual(["PER_TON", "PER_WAGON"]); + }); + + it("offers per-item for a counted commodity", () => { + expect( + allowedRateUnits({ + appliesTo: "BULK", + trigger: "ALWAYS", + cargoUnitOfMeasure: "PER_ITEM", + }), + ).toEqual(["PER_ITEM", "PER_WAGON"]); + }); + + it("falls back to per-ton when the rate is not scoped to a commodity", () => { + expect(allowedRateUnits({ appliesTo: "BULK", trigger: "ALWAYS" })).toEqual([ + "PER_TON", + "PER_WAGON", + ]); + }); + + it("swaps the per-ton slot for counted commodities on every bulk-capable shape", () => { + expect( + allowedRateUnits({ + appliesTo: "OTHER", + trigger: "CUSTOMS_CLEARANCE", + cargoKind: "BULK", + cargoUnitOfMeasure: "PER_ITEM", + }), + ).toEqual(["PER_ITEM", "PER_WAGON"]); + expect( + allowedRateUnits({ + appliesTo: "INTERCITY", + trigger: "ALWAYS", + cargoUnitOfMeasure: "PER_ITEM", + }), + ).toEqual(["PER_CONTAINER", "PER_ITEM", "PER_WAGON", "PER_KM"]); + }); + + it("never offers per-item for overweight, which is always per excess ton", () => { + expect( + allowedRateUnits({ + appliesTo: "OTHER", + trigger: "OVERWEIGHT", + cargoUnitOfMeasure: "PER_TON", + }), + ).toEqual(["PER_TON"]); + }); + + it("treats per-ton and per-item as the same booking quantity", () => { + expect(isBulkQuantityUnit("PER_TON")).toBe(true); + expect(isBulkQuantityUnit("PER_ITEM")).toBe(true); + expect(isBulkQuantityUnit("PER_WAGON")).toBe(false); + expect(isBulkQuantityUnit("FLAT")).toBe(false); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index 3519a0c06..1de36bcfd 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -1,5 +1,17 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity'; +/** How the bulk commodity a rate is scoped to is counted (cargo_types.unit_of_measure). */ +export type CargoUom = 'PER_TON' | 'PER_ITEM' | null | undefined; + +/** + * Units billed against a booking's bulk quantity. That quantity is recorded in + * the commodity's own unit — tonnes for a PER_TON commodity, item count for a + * PER_ITEM one — so both units scale off the same field and only differ in what + * they are called. + */ +export const isBulkQuantityUnit = (unit: string): boolean => + unit === 'PER_TON' || unit === 'PER_ITEM'; + /** * Which rate units make sense for a given rate shape. The weighting basis is * driven by the *type* of thing being billed — a container leg bills per @@ -8,6 +20,10 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity'; * ton. This keeps the rate table dynamic yet non-conflicting: the admin can * only pick a unit the pricing engine knows how to apply. * + * A rate scoped to a break-bulk commodity (unit_of_measure = PER_ITEM) offers + * PER_ITEM wherever a weighed commodity offers PER_TON — machinery is priced + * per unit shipped, wheat per tonne. Per-wagon is offered either way. + * * Returned lists are ordered with the most natural/default unit first. */ export function allowedRateUnits(input: { @@ -15,6 +31,19 @@ export function allowedRateUnits(input: { trigger: RateTrigger; /** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */ cargoKind?: 'CONTAINER' | 'BULK' | null; + /** Unit of measure of the bulk commodity the rate is scoped to, when any. */ + cargoUnitOfMeasure?: CargoUom; +}): RateUnit[] { + const units = unitsForShape(input); + return input.cargoUnitOfMeasure === 'PER_ITEM' + ? units.map((u) => (u === 'PER_TON' ? 'PER_ITEM' : u)) + : units; +} + +function unitsForShape(input: { + appliesTo: RateAppliesTo; + trigger: RateTrigger; + cargoKind?: 'CONTAINER' | 'BULK' | null; }): RateUnit[] { const { appliesTo, trigger } = input; @@ -81,6 +110,7 @@ export function isRateUnitAllowed(input: { appliesTo: RateAppliesTo; trigger: RateTrigger; cargoKind?: 'CONTAINER' | 'BULK' | null; + cargoUnitOfMeasure?: CargoUom; unit: RateUnit; }): boolean { return allowedRateUnits(input).includes(input.unit); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index cd2a6e14b..d62d5647f 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -34,6 +34,9 @@ export type RateStatus = typeof RATE_STATUSES[number]; export const RATE_UNITS = [ 'PER_WAGON', 'PER_TON', + // Break-bulk commodities are counted, not weighed (cargo_types.unit_of_measure + // = PER_ITEM) — their rates bill per item off the same booking quantity field. + 'PER_ITEM', 'PER_CONTAINER', 'PER_KM', 'PER_INVOICE', diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index 76203edef..b3ee54d20 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -14,7 +14,8 @@ export interface IRatesRepository { findLiveRatesDetailed(): Promise; findByPattern(pattern: { rateType: string; - rateUnit: string; + /** Omitted for singly-resolved rates — see the repository implementation. */ + rateUnit?: string; containerTypeId?: string | null; cargoTypeId?: string | null; tradeDirection?: string | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index bdec72e46..abd10cd98 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -18,10 +18,17 @@ export class RatesRepository implements IRatesRepository { return this.repo.findOne({ where: { id } }); } + /** + * Every LIVE rate, newest first. The ordering is load-bearing: pricing picks + * the first match for a pattern, so without it Postgres heap order decided + * which of two overlapping rates a booking was billed at. Newest-first also + * means the most recent configuration wins where legacy overlaps still exist. + */ findLiveRates(): Promise { return this.repo .createQueryBuilder('rate') .where('rate.status = :status', { status: 'LIVE' }) + .orderBy('rate.created_at', 'DESC') .getMany(); } @@ -47,9 +54,21 @@ export class RatesRepository implements IRatesRepository { * insert so the admin gets a friendly error instead of a raw constraint fault. * NULL scope columns are matched with IS NULL, mirroring the COALESCE index. */ + /** + * The live/draft rate already covering a pricing pattern, if any. + * + * `rateUnit` is optional on purpose. Where pricing resolves ONE rate for a + * lane (base freight, customs, lashing, empty return) the unit is not part of + * the identity — a per-container and a per-wagon row for the same lane are + * two answers to one question and the engine picks whichever came back first, + * so the caller omits it and the second row is rejected. Additive surcharges + * (hazard, reefer, demurrage…) are the opposite: the engine bills every + * matching rate by its own unit, so one per freight shape is the design and + * the caller passes the unit to keep them apart. + */ findByPattern(pattern: { rateType: string; - rateUnit: string; + rateUnit?: string; containerTypeId?: string | null; cargoTypeId?: string | null; tradeDirection?: string | null; @@ -59,9 +78,12 @@ export class RatesRepository implements IRatesRepository { const qb = this.repo .createQueryBuilder('rate') .where('rate.rate_type = :rateType', { rateType: pattern.rateType }) - .andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit }) .andWhere('rate.status <> :superseded', { superseded: 'SUPERSEDED' }); + if (pattern.rateUnit) { + qb.andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit }); + } + if (pattern.containerTypeId) { qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId }); } else { diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 3ad97bb53..ac2e854e4 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -2,6 +2,7 @@ import { Inject, Injectable, BadRequestException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity'; import { Rate, RateTrigger } from './entities/rate.entity'; +import { isBulkQuantityUnit } from './entities/rate-unit.util'; import { ICargoTypesRepository, CARGO_TYPES_REPOSITORY, @@ -377,6 +378,9 @@ export class RuleEngineService { let calculatedAmount: number; switch (rate.rateUnit) { + // PER_ITEM is PER_TON for a counted (break-bulk) commodity — the bulk + // quantity is recorded in the commodity's own unit either way. + case 'PER_ITEM': case 'PER_TON': // OVERWEIGHT bills the excess tons; every other PER_TON surcharge // (e.g. bulk reefer) bills the full bulk tonnage. @@ -608,7 +612,7 @@ export class RuleEngineService { if (!rate) return modifiers; const billedQty = - rate.rateUnit === 'PER_TON' + isBulkQuantityUnit(rate.rateUnit) ? Math.max(0, Number(input.bulkTons ?? 0)) : rate.rateUnit === 'PER_WAGON' ? Math.max(0, Number(input.bulkWagons ?? 0)) diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts new file mode 100644 index 000000000..23d96da89 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts @@ -0,0 +1,138 @@ +import { ConflictException } from '@nestjs/common'; + +import { RatesService } from './rates.service'; +import type { Rate } from '../entities/rate.entity'; + +/** + * One rate per lane + scope, whatever the unit. + * + * Pricing resolves a single rate for a (type, container type, leg) and then + * applies whatever unit it carries — it has no way to choose between a + * per-container and a per-wagon row for the same 20ft lane, and used to bill + * whichever the database happened to return first. So the unit is NOT part of a + * rate's identity: changing how a lane is billed means editing its rate. + */ +describe('RatesService — one rate per pattern', () => { + const DJ = '11111111-1111-4000-8000-000000000001'; + const ET = '11111111-1111-4000-8000-000000000002'; + const CT20 = '11111111-1111-4000-8000-000000000003'; + + const existing = (over: Partial = {}): Rate => + ({ + id: 'rate-existing', + rateType: 'CONTAINER_IMPORT', + rateUnit: 'PER_WAGON', + rateValue: 1690, + containerTypeId: CT20, + cargoTypeId: null, + tradeDirection: 'IMPORT', + originYardId: DJ, + destinationYardId: ET, + status: 'LIVE', + ...over, + }) as Rate; + + const dto = { + appliesTo: 'CONTAINER', + trigger: 'ALWAYS', + tradeDirection: 'IMPORT', + containerTypeId: CT20, + originYardId: DJ, + destinationYardId: ET, + rateValue: 845, + rateUnit: 'PER_CONTAINER', + }; + + let repository: { findByPattern: jest.Mock; create: jest.Mock }; + let service: RatesService; + + beforeEach(() => { + repository = { + findByPattern: jest.fn().mockResolvedValue(null), + create: jest.fn(async (r) => ({ id: 'rate-new', ...r })), + }; + service = new RatesService( + repository as never, + { + findById: jest.fn(async (id: string) => ({ + id, + country: id === DJ ? 'Djibouti' : 'Ethiopia', + label: id === DJ ? 'Doraleh' : 'Gelan', + })), + } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + ); + }); + + it('refuses a second rate on the same lane that only differs by unit', async () => { + repository.findByPattern.mockResolvedValue(existing()); + + await expect(service.create(dto as never, 'staff-1')).rejects.toBeInstanceOf( + ConflictException, + ); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('looks the pattern up without the unit, so either order collides', async () => { + await service.create(dto as never, 'staff-1'); + + const pattern = repository.findByPattern.mock.calls[0][0]; + expect(pattern).not.toHaveProperty('rateUnit'); + expect(pattern).toMatchObject({ + rateType: 'CONTAINER_IMPORT', + containerTypeId: CT20, + originYardId: DJ, + destinationYardId: ET, + }); + }); + + it('still allows the same unit on a different lane', async () => { + await service.create(dto as never, 'staff-1'); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + rateUnit: 'PER_CONTAINER', + rateValue: 845, + status: 'DRAFT', + }), + ); + }); + + /** + * Additive surcharges are billed per matching rate, each by its own unit, so + * hazard is legitimately per-container for boxes AND per-ton for bulk. The + * unit stays part of their identity or the second one could never be created. + */ + it('keeps the unit in the key for an additive surcharge', async () => { + await service.create( + { + appliesTo: 'OTHER', + trigger: 'HAZARDOUS', + rateValue: 300, + rateUnit: 'PER_CONTAINER', + } as never, + 'staff-1', + ); + + expect(repository.findByPattern.mock.calls[0][0]).toMatchObject({ + rateType: 'HAZARD_SURCHARGE', + rateUnit: 'PER_CONTAINER', + }); + }); + + it('treats lashing as singly resolved — one unit per direction', async () => { + await service.create( + { + appliesTo: 'OTHER', + trigger: 'LASHING', + tradeDirection: 'IMPORT', + rateValue: 40, + rateUnit: 'PER_TON', + } as never, + 'staff-1', + ); + + expect(repository.findByPattern.mock.calls[0][0]).not.toHaveProperty( + 'rateUnit', + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 700d6366c..d8ceb97ab 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -12,7 +12,11 @@ import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; import { Rate } from '../entities/rate.entity'; import { deriveRateType } from '../entities/rate-type.util'; -import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util'; +import { CargoUom, allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util'; +import { + CARGO_TYPES_REPOSITORY, + ICargoTypesRepository, +} from '../interfaces/cargo-types.repository.interface'; import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface'; @@ -32,6 +36,8 @@ export class RatesService { private readonly repository: IRatesRepository, @Inject(YARDS_REPOSITORY) private readonly yardsRepository: IYardsRepository, + @Inject(CARGO_TYPES_REPOSITORY) + private readonly cargoTypesRepository: ICargoTypesRepository, ) {} /** List rates — standard paginated envelope with server-side search. */ @@ -63,25 +69,37 @@ export class RatesService { * Normalise + validate the weighting unit for a rate shape. Overweight is * always billed per excess ton, so its unit is forced to PER_TON regardless * of what the client sent. Every other shape must pick a unit the pricing - * engine can actually apply (see `allowedRateUnits`). + * engine can actually apply (see `allowedRateUnits`) — for a rate scoped to a + * bulk commodity that means the commodity's own unit of measure: a PER_ITEM + * commodity bills per item where a weighed one bills per ton. */ - private resolveRateUnit( + private async resolveRateUnit( appliesTo: Rate['appliesTo'], trigger: Rate['trigger'], requestedUnit: Rate['rateUnit'] | undefined, cargoKind?: 'CONTAINER' | 'BULK' | null, - ): Rate['rateUnit'] { + cargoTypeId?: string | null, + ): Promise { // Overweight is per-ton, full stop — the admin form hides the unit field // for it and omits rateUnit from the payload entirely. if (trigger === 'OVERWEIGHT') return 'PER_TON'; - const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind }); + const cargoUnitOfMeasure = await this.cargoUnitOfMeasure(cargoTypeId); + const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind, cargoUnitOfMeasure }); if (!requestedUnit) { throw new BadRequestException( `Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`, ); } - if (!isRateUnitAllowed({ appliesTo, trigger, cargoKind, unit: requestedUnit })) { + if ( + !isRateUnitAllowed({ + appliesTo, + trigger, + cargoKind, + cargoUnitOfMeasure, + unit: requestedUnit, + }) + ) { throw new BadRequestException( `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`, ); @@ -89,6 +107,13 @@ export class RatesService { return requestedUnit; } + /** Unit of measure of the bulk commodity a rate is scoped to; null when unscoped. */ + private async cargoUnitOfMeasure(cargoTypeId?: string | null): Promise { + if (!cargoTypeId) return null; + const cargo = await this.cargoTypesRepository.findById(cargoTypeId); + return cargo?.unitOfMeasure ?? null; + } + /** Base rail freight is priced per leg; surcharges and truck legs are not. */ private isBaseFreight(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean { return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo); @@ -107,6 +132,24 @@ export class RatesService { ); } + /** + * True when pricing resolves exactly ONE rate for this shape (base freight, + * customs clearance, lashing, empty-container return — all `find()`-based + * lookups). For those the unit is not part of the rate's identity: two rows + * for the same lane differing only by unit are a duplicate the engine cannot + * choose between. + * + * The additive surcharges are the opposite — the engine bills EVERY matching + * rate by its own unit, which is how hazard can be per-container for boxes + * and per-ton for bulk at the same time — so their unit stays part of the key. + */ + private resolvesSingleRate( + appliesTo: Rate['appliesTo'], + trigger: Rate['trigger'], + ): boolean { + return this.isRouteScoped(appliesTo, trigger) || trigger === 'LASHING'; + } + /** * Which country each end of the leg must sit in, given what the rate is for. * The railway only sells three shapes: import lands at the Djibouti ports and @@ -301,10 +344,17 @@ export class RatesService { * Reject a second rate with the same identity pattern (rateType + scope). With * effective-date windows gone, two LIVE/DRAFT rates for the same pattern would * make pricing ambiguous — so we allow exactly one per pattern. + * + * The UNIT is not part of that identity. Pricing resolves one rate per lane + + * scope and then applies whatever unit it carries; a per-container and a + * per-wagon row for the same 20ft lane are two answers to one question, and + * the engine silently picked one of them. Changing how a lane is billed means + * editing its rate, not adding a second. */ private async assertNoDuplicatePattern(pattern: { rateType: string; - rateUnit: string; + /** Passed only for additive surcharges — see {@link resolvesSingleRate}. */ + rateUnit?: string; containerTypeId: string | null; cargoTypeId: string | null; tradeDirection: string | null; @@ -380,16 +430,17 @@ export class RatesService { tradeDirection, isBulk: this.resolvesToBulk(appliesTo, intercityKind), }); - const rateUnit = this.resolveRateUnit( + const rateUnit = await this.resolveRateUnit( appliesTo, trigger, dto.rateUnit as Rate['rateUnit'] | undefined, cargoKind, + cargoTypeId, ); await this.assertNoDuplicatePattern({ rateType, - rateUnit, + ...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }), containerTypeId, cargoTypeId, tradeDirection, @@ -562,12 +613,19 @@ export class RatesService { // Re-validate the unit against the (possibly changed) shape; overweight is // forced to PER_TON. const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit; - updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit, cargoKind); + const rateUnit = await this.resolveRateUnit( + appliesTo, + trigger, + requestedUnit, + cargoKind, + updates.cargoTypeId, + ); + updates.rateUnit = rateUnit; // Guard the pattern uniqueness for the new identity, ignoring this row. await this.assertNoDuplicatePattern({ rateType, - rateUnit: updates.rateUnit, + ...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }), containerTypeId: updates.containerTypeId, cargoTypeId: updates.cargoTypeId, tradeDirection: updates.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts index 5be404ead..e4f8a1e46 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '@edr/api-common'; -import { TrainSchedulingManage } from '../../common/booking-guards'; +import { TrainSchedulingReschedule } from '../../common/booking-guards'; import { type AuthUserPayload, resolveAuthUserId, @@ -18,7 +18,7 @@ export class SchedulingRescheduleController { constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {} @Post('preview') - @TrainSchedulingManage() + @TrainSchedulingReschedule() @ApiOperation({ summary: 'Preview reschedule / government preempt plan' }) preview( @Param('id', ParseUUIDPipe) id: string, @@ -28,7 +28,7 @@ export class SchedulingRescheduleController { } @Post('execute') - @TrainSchedulingManage() + @TrainSchedulingReschedule() @ApiOperation({ summary: 'Execute a confirmed reschedule plan' }) execute( @Param('id', ParseUUIDPipe) id: string, @@ -50,7 +50,7 @@ export class SchedulingMaintenanceController { constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {} @Post('maintenance') - @TrainSchedulingManage() + @TrainSchedulingReschedule() @ApiOperation({ summary: 'Reschedule train for maintenance (new departure + rebalance)' }) maintenance( @Param('id', ParseUUIDPipe) id: string, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index b308a5921..a402e6237 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -1,5 +1,6 @@ import { BookingBatchService } from './booking-batch.service'; import { Booking } from '../bookings/entities/booking.entity'; +import { WagonStockLedger } from './wagon-stock-ledger.util'; describe('BookingBatchService — PAID reconcile', () => { const scheduleId = 'schedule-1'; @@ -40,10 +41,12 @@ describe('BookingBatchService — PAID reconcile', () => { previewPaidBookingWagonShortage: jest.Mock; getBookableSchedules: jest.Mock; getWindowConfig: jest.Mock; + wagonStockForSchedule: jest.Mock; }; let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; + query: jest.Mock; }; let notifier: { payNow: jest.Mock; @@ -90,6 +93,13 @@ describe('BookingBatchService — PAID reconcile', () => { }), // No shortage by default — paid bookings link as before. previewPaidBookingWagonShortage: jest.fn().mockResolvedValue(null), + // No physical stock configured → the wagon-type gate stands down and these + // specs keep testing the abstract capacity budget on its own. + wagonStockForSchedule: jest.fn().mockResolvedValue({ + mode: 'YARD', + remainingByTypeId: new Map(), + codesByTypeId: new Map(), + }), getBookableSchedules: jest.fn().mockResolvedValue([]), getWindowConfig: jest.fn().mockResolvedValue({ importWindowLeadDays: 3, @@ -116,6 +126,10 @@ describe('BookingBatchService — PAID reconcile', () => { }; await fn(manager); }), + // cargo/container type -> allowed wagon type lookups (loadAllowedWagonTypeIds). + // Empty = unresolvable, so the physical-stock gate stands down and these + // specs keep exercising the abstract capacity budget alone. + query: jest.fn().mockResolvedValue([]), }; notifier = { @@ -1237,6 +1251,7 @@ describe('BookingBatchService — built-train wagon capacity', () => { return genericRepo; }), transaction: jest.fn(), + query: jest.fn().mockResolvedValue([]), }; const service = new BookingBatchService( dataSource as never, @@ -1344,3 +1359,106 @@ describe('BookingBatchService — built-train wagon capacity', () => { }); }); }); + +/** + * The reported failure: a train advertising 20 free wagons where only 16 are of + * the type the booking can ride. Selecting all 20 took the customer's money for + * space that never existed and then stalled at allocation on wagon 17. + */ +describe('BookingBatchService — physical wagon-type gate', () => { + const NW5 = 'wagon-type-nw5'; + const PW2 = 'wagon-type-pw2'; + const WHOLE_LEG = { fromEdge: 0, toEdge: 1 }; + + /** 16 NW5 + 4 PW2 = 20 wagons on the train, but only 16 usable by an NW5 booking. */ + const mixedStock = () => new WagonStockLedger(new Map([[NW5, 16], [PW2, 4]]), 1); + + const internals = (svc: BookingBatchService) => + svc as unknown as { + hasWagonStock: ( + stock: WagonStockLedger, + ids: string[], + needed: number, + leg: { fromEdge: number; toEdge: number }, + ) => boolean; + maybeOfferPartial: ( + booking: Booking, + isPair: boolean, + candidates: unknown[], + need: { wagons: number; weightTons: number; lengthMeters: number }, + ids: string[], + ) => Promise; + tryPartialOffer: unknown; + isSplitEligible: unknown; + }; + + const service = () => + new BookingBatchService( + { getRepository: jest.fn(), transaction: jest.fn(), query: jest.fn() } as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + undefined, + { findOpenOffer: jest.fn() } as never, + ); + + it('refuses a 20-wagon NW5 booking on a train holding only 16 NW5', () => { + const svc = internals(service()); + const stock = mixedStock(); + expect(svc.hasWagonStock(stock, [NW5], 20, WHOLE_LEG)).toBe(false); + expect(svc.hasWagonStock(stock, [NW5], 16, WHOLE_LEG)).toBe(true); + // A booking that may ride either type sees all 20. + expect(svc.hasWagonStock(stock, [NW5, PW2], 20, WHOLE_LEG)).toBe(true); + }); + + it('stands down when the booking has no allowed wagon type configured', () => { + // Unresolvable configuration must not strand every booking that uses it — + // the abstract capacity budget still governs. + expect(internals(service()).hasWagonStock(mixedStock(), [], 999, WHOLE_LEG)).toBe(true); + }); + + it('sizes the split offer to the wagons that physically exist, not the free slots', async () => { + const svc = service(); + const inner = internals(svc); + // Isolate the sizing decision: eligibility and offer creation are covered + // elsewhere, what matters here is the room handed to tryPartialOffer. + (inner as { isSplitEligible: unknown }).isSplitEligible = () => true; + const tryPartial = jest + .fn() + .mockResolvedValue({ wagons: 16, weightTons: 1600, lengthMeters: 224 }); + (inner as { tryPartialOffer: unknown }).tryPartialOffer = tryPartial; + + const stock = mixedStock(); + const candidate = { + id: 'schedule-1', + // 20 abstract slots free, weight and length wide open. + budget: { + legOf: () => WHOLE_LEG, + remainingFor: () => ({ wagons: 20, weightTons: 99_999, lengthMeters: 99_999 }), + subtract: jest.fn(), + }, + armed: false, + stock, + }; + + const offered = await inner.maybeOfferPartial( + { id: 'b1', reference: 'BK-1', originYardId: 'a', destinationYardId: 'b' } as Booking, + false, + [candidate], + { wagons: 20, weightTons: 2000, lengthMeters: 280 }, + [NW5], + ); + + expect(offered).toBe(true); + // 16, not the 20 free slots — the customer is billed for what can be loaded. + expect(tryPartial.mock.calls[0][2]).toMatchObject({ wagons: 16 }); + // Those 16 are now held, so the next booking in the pass cannot re-take them. + expect(stock.availableFor([NW5], WHOLE_LEG)).toBe(0); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 591812880..586ef2f7c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -87,6 +87,7 @@ import { OverageTolerance, stopYardsFor, } from './corridor-capacity.util'; +import { WagonStockLedger } from './wagon-stock-ledger.util'; export type { Capacity } from './corridor-capacity.util'; @@ -1619,6 +1620,8 @@ export class BookingBatchService implements OnModuleInit { const limits = await this.capacityLimits(locomotive); await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); + const stock = await this.stockLedgerFor(schedule, budget); + const allowedWagonTypes = await this.loadAllowedWagonTypeIds(); const minPerWagon = this.minPerWagonNeed(wagonDims); if (budget.isExhausted(minPerWagon)) { await this.setWindow(scheduleId, "FULL"); @@ -1655,14 +1658,19 @@ export class BookingBatchService implements OnModuleInit { // Consolidated partners always share one corridor, so the primary's leg // stands for the pair. const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); + const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes); + // Abstract room AND real wagons of a type this booking can ride — see + // fillRouteDayInternal for why both gates are needed. + const stocked = this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg); - // Per-unit fit trace: which axis (wagons/weight/length) admits or rejects. + // Per-unit fit trace: which axis (wagons/weight/length/stock) admits or rejects. this.logger.debug( `[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` + - `roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`, + `roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)} ` + + `stocked=${stocked}`, ); - if (!budget.fits(need, leg)) { + if (!budget.fits(need, leg) || !stocked) { if (isGov) { const freed = await this.preemptForGovernment( scheduleId, @@ -1677,16 +1685,19 @@ export class BookingBatchService implements OnModuleInit { // Doesn't fit whole. A split-eligible import booking is offered the part // that fits in the remaining room (top-up path splits the boundary // booking, mirroring fillRouteDay); otherwise skip and try the next. - const cand: { id: string; budget: CorridorBudget; armed: boolean } = { - id: scheduleId, - budget, - armed, - }; - if (await this.maybeOfferPartial(booking, isPair, [cand], need)) { + const cand: { + id: string; + budget: CorridorBudget; + armed: boolean; + stock: WagonStockLedger; + } = { id: scheduleId, budget, armed, stock }; + if ( + await this.maybeOfferPartial(booking, isPair, [cand], need, wagonTypeIds) + ) { armed = cand.armed; continue; } - continue; // skip a unit that exceeds weight/length/wagons, try the next + continue; // skip a unit that exceeds weight/length/wagons/stock, try the next } } @@ -1704,6 +1715,8 @@ export class BookingBatchService implements OnModuleInit { commercialReserved += 1; } budget.subtract(need, leg); + // Hold the physical wagons too — the next unit must not re-count them. + stock.consume(wagonTypeIds, need.wagons, leg); reservedThisPass += 1; } catch (err) { this.logger.error( @@ -1823,11 +1836,14 @@ export class BookingBatchService implements OnModuleInit { } const wagonDims = await this.loadWagonDims(); + const allowedWagonTypes = await this.loadAllowedWagonTypeIds(); - // Live per-schedule corridor budget + arm/changed flags, in departure order. + // Live per-schedule corridor budget + physical wagon-type stock + arm/changed + // flags, in departure order. const trains: Array<{ id: string; budget: CorridorBudget; + stock: WagonStockLedger; armed: boolean; changed: boolean; }> = []; @@ -1844,7 +1860,8 @@ export class BookingBatchService implements OnModuleInit { const limits = await this.capacityLimits(locomotive); await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); - trains.push({ id, budget, armed: false, changed: false }); + const stock = await this.stockLedgerFor(schedule, budget); + trains.push({ id, budget, stock, armed: false, changed: false }); } if (trains.length === 0) return { scheduleIds, commercialReserved: 0 }; @@ -1884,12 +1901,20 @@ export class BookingBatchService implements OnModuleInit { const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null => t.budget.legOf(booking.originYardId, booking.destinationYardId); + // Consolidated pairs share one wagon set; the primary's types stand for both. + const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes); // First train (earliest departure) whose corridor carries this booking's - // leg and still fits it as-is. + // leg, still fits it as-is AND physically holds enough wagons of a type the + // booking can ride. Both gates matter: abstract room without the right + // wagon type is space the allocator can never turn into a loaded consist. let target = trains.find((t) => { const leg = legOn(t); - return leg != null && t.budget.fits(need, leg); + return ( + leg != null && + t.budget.fits(need, leg) && + this.hasWagonStock(t.stock, wagonTypeIds, need.wagons, leg) + ); }); // Per-unit trace: chosen train + each train's remaining room on this leg. @@ -1934,7 +1959,13 @@ export class BookingBatchService implements OnModuleInit { // already consumed most of the room). Consolidated pairs / government / // non-import never split — isSplitEligible guards that. Passing the live // `trains` entries lets maybeOfferPartial mutate the chosen budget/armed. - const offered = await this.maybeOfferPartial(booking, isPair, trains, need); + const offered = await this.maybeOfferPartial( + booking, + isPair, + trains, + need, + wagonTypeIds, + ); if (offered) { // A partial offer opens a real commercial pay window, same as reserve(). commercialReserved += 1; @@ -1964,6 +1995,9 @@ export class BookingBatchService implements OnModuleInit { commercialReserved += 1; } target.budget.subtract(need, legOn(target)!); + // Hold the physical wagons too, so the next unit in this pass sees them + // gone — otherwise two bookings both "fit" the same 16 NW5. + target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!); target.changed = true; reservedThisPass += 1; } catch (err) { @@ -2027,14 +2061,32 @@ export class BookingBatchService implements OnModuleInit { private async maybeOfferPartial( booking: Booking, isPair: boolean, - candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean }>, + candidates: Array<{ + id: string; + budget: CorridorBudget; + armed: boolean; + stock?: WagonStockLedger; + }>, need: Capacity, + wagonTypeIds: string[] = [], ): Promise { if (!this.isSplitEligible(booking, isPair)) return false; const target = candidates .map((c) => { const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId); - return leg ? { c, leg, room: c.budget.remainingFor(leg) } : null; + if (!leg) return null; + const room = c.budget.remainingFor(leg); + // The offer may never exceed the wagons that physically exist in a type + // this booking can ride. This is what turns "20 free wagons, only 16 of + // them NW5" into an offer for 16 — the customer pays for 16 and the + // other 4 leave as the usual remainder booking, instead of paying for + // 20 and stalling at allocation on wagon 17. + const physical = wagonTypeIds.length + ? c.stock?.availableFor(wagonTypeIds, leg) + : undefined; + const wagons = + physical == null ? room.wagons : Math.min(room.wagons, physical); + return { c, leg, room: { ...room, wagons } }; }) .filter((x): x is NonNullable => x != null && x.room.wagons >= 1) .sort((a, b) => b.room.wagons - a.room.wagons)[0]; @@ -2047,6 +2099,7 @@ export class BookingBatchService implements OnModuleInit { ); if (!offered) return false; target.c.budget.subtract(offered, target.leg); + target.c.stock?.consume(wagonTypeIds, offered.wagons, target.leg); target.c.armed = true; return true; } @@ -3000,6 +3053,22 @@ export class BookingBatchService implements OnModuleInit { } } + /** + * How many bookings on this route-day would be expired if document review + * ended right now — i.e. requests staff have neither accepted nor rejected. + * Same query the doc-review-end sweep runs, so the number staff see is + * exactly what is at risk. + */ + async countUnacceptedForRouteDay(group: RouteDayGroup): Promise { + const corridorYards = await this.corridorYardsForRouteDay(group); + if (corridorYards.length === 0) return 0; + const unaccepted = await this.bookingsRepository.findUnacceptedForRouteDay( + corridorYards, + group.day, + ); + return unaccepted.length; + } + /** * Free capacity for a government booking by displacing the lowest-priority commercial * bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified. @@ -3452,6 +3521,131 @@ export class BookingBatchService implements OnModuleInit { return dims.length ? dims : [fallback]; } + /** + * Physical wagon-type stock for one schedule, on the same corridor edges its + * {@link CorridorBudget} uses. Sourced from the scheduling service so the + * batch counts exactly the wagons the allocator will later plan against. + */ + private async stockLedgerFor( + schedule: TrainSchedule, + budget: CorridorBudget, + ): Promise { + const stock = await this.trainSchedulingService.wagonStockForSchedule( + schedule.id, + schedule.originStationId, + budget.stops, + ); + return new WagonStockLedger( + stock.remainingByTypeId, + Math.max(1, budget.stops.length - 1), + ); + } + + /** + * Whether the train holds enough PHYSICAL wagons of the types this booking may + * ride. Unresolvable configuration (no allowed wagon type) returns true: the + * abstract budget still governs, and a mis-configured cargo type must not + * silently strand every booking that uses it. + */ + private hasWagonStock( + stock: WagonStockLedger, + wagonTypeIds: string[], + wagonsNeeded: number, + leg: CorridorLeg, + ): boolean { + if (!wagonTypeIds.length) return true; + return stock.availableFor(wagonTypeIds, leg) >= wagonsNeeded; + } + + private allowedWagonTypeCache: { + byCargoTypeId: Map; + byContainerTypeId: Map; + expiresAt: number; + } | null = null; + + /** + * Wagon-type ids each cargo / container type may ride, read straight from the + * join tables. + * + * The batch pool finders deliberately do NOT join `cargoType.wagonTypes` / + * `containerType.wagonTypes` — those many-to-many joins multiply rows badly on + * a hot path. So the pool's booking entities carry the type FK but not the + * allowed list, and resolving it per booking through the relation would come + * back empty. Two small lookups, cached for a minute like {@link loadWagonDims}, + * give the same answer without touching the pool query. + */ + private async loadAllowedWagonTypeIds(): Promise<{ + byCargoTypeId: Map; + byContainerTypeId: Map; + }> { + if (this.allowedWagonTypeCache && this.allowedWagonTypeCache.expiresAt > Date.now()) { + return this.allowedWagonTypeCache; + } + // Inactive wagon types are excluded, matching loadAllowedWagonTypes() in the + // scheduling service — the allocator will not plan against them either. + const [cargoRows, containerRows]: [ + Array<{ typeId: string; wagonTypeId: string }>, + Array<{ typeId: string; wagonTypeId: string }>, + ] = await Promise.all([ + this.dataSource.query( + `SELECT ct.cargo_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId" + FROM freight.cargo_type_wagon_types ct + JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id + WHERE wt.is_active IS NOT FALSE`, + ), + this.dataSource.query( + `SELECT ct.container_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId" + FROM freight.container_type_wagon_types ct + JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id + WHERE wt.is_active IS NOT FALSE`, + ), + ]); + + const collect = (rows: Array<{ typeId: string; wagonTypeId: string }>) => { + const map = new Map(); + for (const row of rows) { + const list = map.get(row.typeId) ?? []; + list.push(row.wagonTypeId); + map.set(row.typeId, list); + } + return map; + }; + + const value = { + byCargoTypeId: collect(cargoRows), + byContainerTypeId: collect(containerRows), + }; + this.allowedWagonTypeCache = { ...value, expiresAt: Date.now() + 60_000 }; + return value; + } + + /** + * Every wagon-type id this booking may ride. Empty means "unresolvable" — the + * caller must then skip the physical-stock gate rather than block the booking + * on missing configuration. + */ + private allowedWagonTypeIdsFor( + booking: Booking, + allowed: { + byCargoTypeId: Map; + byContainerTypeId: Map; + }, + ): string[] { + if (booking.freightType === "BULK") { + const cargoTypeId = booking.cargoTypeId ?? booking.cargoType?.id; + return cargoTypeId ? (allowed.byCargoTypeId.get(cargoTypeId) ?? []) : []; + } + const ids = new Set(); + for (const line of booking.bookingContainers ?? []) { + const containerTypeId = line.containerTypeId ?? line.containerType?.id; + if (!containerTypeId) continue; + for (const id of allowed.byContainerTypeId.get(containerTypeId) ?? []) { + ids.add(id); + } + } + return [...ids]; + } + /** * Ordered stop yards of the schedule's route (origin → milestones → * destination); the legacy two-stop pseudo-route when milestones are absent. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index db18d6804..b4520a43c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -5,6 +5,7 @@ import { NotFoundException, Optional, } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource, EntityManager, In } from 'typeorm'; import { Freight } from '@edr/types'; @@ -48,6 +49,7 @@ export class BookingJourneyService { @InjectDataSource() private readonly dataSource: DataSource, private readonly yardFacilities: YardFacilitiesService, private readonly facilityHandling: FacilityHandlingService, + private readonly events: EventEmitter2, @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} @@ -145,6 +147,12 @@ export class BookingJourneyService { }); }); + // Intercity ends here — a ONE_TIME contract closes on its shipment being + // delivered (import/export emit this from booking-transition.complete). + if (nextStatus === 'COMPLETED') { + this.events.emit('booking.completed', { bookingId }); + } + // Customer tracking: THIS booking arrived (train may still be rolling). void this.completeMilestones(booking, [ ...(booking.tradeDirection === 'IMPORT' @@ -303,6 +311,12 @@ export class BookingJourneyService { RETURNING b.id, b.trade_direction`, [schedule.id, schedule.destinationStationId, now], ); + // Intercity rows just completed — let a ONE_TIME contract close on delivery. + for (const row of rows) { + if (row.trade_direction === 'DOMESTIC') { + this.events.emit('booking.completed', { bookingId: row.id }); + } + } return rows.map((r) => r.id); } @@ -487,7 +501,13 @@ export class BookingJourneyService { currentYardId: booking.destinationYardId, currentTrainScheduleId: null, trainSetWagonId: null, - status: Freight.WagonStatus.Available, + // A wagon that belongs to a built train stays coupled to it (ASSIGNED); + // only loose wagons return to the open AVAILABLE pool. Marking a + // coupled wagon AVAILABLE made it show up in the train-builder's + // "available wagons" picker, where attaching it always 409'd. + status: wagon.trainId + ? Freight.WagonStatus.Assigned + : Freight.WagonStatus.Available, }); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts index abd07c129..79b279393 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts @@ -22,6 +22,7 @@ describe('BookingWindowService — window state machine', () => { expireLeftoverDayPool: jest.Mock; expireLeftoverExportDay: jest.Mock; fillFromWaitingList: jest.Mock; + countUnacceptedForRouteDay: jest.Mock; }; let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock }; let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock }; @@ -79,6 +80,7 @@ describe('BookingWindowService — window state machine', () => { expireLeftoverExportDay: jest.fn().mockResolvedValue(undefined), // No waiting booking fits by default, so conclude proceeds to reopen/DONE. fillFromWaitingList: jest.fn().mockResolvedValue(0), + countUnacceptedForRouteDay: jest.fn().mockResolvedValue(0), }; trainSchedulesRepository = { findById: jest.fn().mockResolvedValue(null), @@ -267,4 +269,87 @@ describe('BookingWindowService — window state machine', () => { expect(s.windowPhase).toBe('OPEN'); expect(batch.setWindow).not.toHaveBeenCalled(); }); + + // ---- header alarm --------------------------------------------------------- + + describe('getDocReviewAlert', () => { + const reviewing = (over: Partial): TrainSchedule => + baseSchedule({ + windowPhase: 'DOC_REVIEW', + docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'), + ...over, + }); + + it('returns null when nothing is under document review', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + baseSchedule({ windowPhase: 'OPEN' }), + ]); + expect(await service.getDocReviewAlert()).toBeNull(); + }); + + it('returns null when every request on the route-day is decided', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([reviewing({})]); + batch.countUnacceptedForRouteDay.mockResolvedValue(0); + expect(await service.getDocReviewAlert()).toBeNull(); + }); + + it('reports the deadline, its own pending count and the phase length', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([reviewing({})]); + batch.countUnacceptedForRouteDay.mockResolvedValue(3); + + const alert = await service.getDocReviewAlert(); + + expect(alert).toMatchObject({ + scheduleId, + originYardId: 'yard-o', + destinationYardId: 'yard-d', + tradeDirection: 'IMPORT', + pendingCount: 3, + docReviewMinutes: 30, + docReviewEndsAt: '2026-07-01T01:30:00.000Z', + }); + }); + + it('skips the nearest deadline when it has nothing pending', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + reviewing({ + id: 'sched-later', + destinationStationId: 'yard-far', + docReviewEndsAt: new Date('2026-07-01T02:00:00.000Z'), + }), + reviewing({ id: 'sched-soon' }), + ]); + // Nearest (sched-soon, yard-d) is clear; the later route-day still isn't. + batch.countUnacceptedForRouteDay.mockImplementation( + async (g: { destinationYardId: string }) => + g.destinationYardId === 'yard-far' ? 2 : 0, + ); + + const alert = await service.getDocReviewAlert(); + + expect(alert?.scheduleId).toBe('sched-later'); + expect(alert?.pendingCount).toBe(2); + }); + + it('counts a route-day once when sibling trains share the review phase', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + reviewing({ id: 'sched-a' }), + reviewing({ id: 'sched-b' }), + ]); + batch.countUnacceptedForRouteDay.mockResolvedValue(4); + + const alert = await service.getDocReviewAlert(); + + expect(alert?.pendingCount).toBe(4); + expect(batch.countUnacceptedForRouteDay).toHaveBeenCalledTimes(1); + }); + + it('ignores a phase staff already completed early', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + reviewing({ docReviewCompletedAt: new Date('2026-07-01T01:10:00.000Z') }), + ]); + batch.countUnacceptedForRouteDay.mockResolvedValue(5); + expect(await service.getDocReviewAlert()).toBeNull(); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 3b9bb25d3..5ae751164 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -30,6 +30,28 @@ import { } from './batch-window.util'; import { type BookingWindowConfig } from './booking-window.config'; +/** + * The most urgent document-review deadline that still has un-accepted booking + * requests behind it. Backoffice counts down to it and warns staff, because + * everything still pending when the phase ends is expired automatically. + */ +export interface DocReviewAlert { + /** A schedule of the route-day group under review (deep-link target). */ + scheduleId: string; + originYardId: string; + destinationYardId: string; + /** EAT booking day of the group, YYYY-MM-DD. */ + day: string; + /** IMPORT (the usual) or DOMESTIC — both run a review phase; export does not. */ + tradeDirection: string; + /** ISO deadline the review phase ends at. */ + docReviewEndsAt: string; + /** Full length of the review phase — the client warns past its halfway mark. */ + docReviewMinutes: number; + /** Requests neither accepted nor rejected — they expire at the deadline. */ + pendingCount: number; +} + /** * Drives the one-booking-day window cycle for IMPORT schedules and the FCFS * booking window for EXPORT schedules. All state lives in DB timestamps on the @@ -130,6 +152,65 @@ export class BookingWindowService implements OnModuleInit { } } + /** + * The route-day currently in document review whose deadline is nearest and + * which still has un-accepted requests. Null when nothing is under review or + * every request has been decided — the backoffice header shows nothing then. + * + * One card, one deadline, one count: route-days are checked in deadline order + * and the first with pending work wins, so the number always belongs to the + * clock beside it. + */ + async getDocReviewAlert(): Promise { + const reviewing = ( + await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, + ], + }) + ) + .filter( + (s) => + s.windowPhase === 'DOC_REVIEW' && + s.docReviewCompletedAt == null && + s.docReviewEndsAt != null && + s.scheduledDepartureDate != null, + ) + .sort((a, b) => a.docReviewEndsAt!.getTime() - b.docReviewEndsAt!.getTime()); + if (reviewing.length === 0) return null; + + const liveCfg = await this.trainSchedulingService.getWindowConfig(); + const seen = new Set(); + for (const schedule of reviewing) { + const group = { + originYardId: schedule.originStationId, + destinationYardId: schedule.destinationStationId, + day: eatDay(schedule.scheduledDepartureDate), + }; + // Sibling trains share one review phase for the route-day pool — count it once. + const key = `${group.originYardId}|${group.destinationYardId}|${group.day}`; + if (seen.has(key)) continue; + seen.add(key); + + const pendingCount = + await this.bookingBatchService.countUnacceptedForRouteDay(group); + if (pendingCount === 0) continue; + + return { + scheduleId: schedule.id, + ...group, + // Carried so the backoffice list opens on the same direction the + // at-risk requests belong to (import corridor, or a domestic day). + tradeDirection: schedule.direction ?? 'IMPORT', + docReviewEndsAt: schedule.docReviewEndsAt!.toISOString(), + docReviewMinutes: effectiveWindowConfig(schedule, liveCfg).docReviewMinutes, + pendingCount, + }; + } + return null; + } + /** Staff finished document review early — start the batch/payment phase now. */ async completeDocReview(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findById(scheduleId); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.spec.ts new file mode 100644 index 000000000..44fff49e8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.spec.ts @@ -0,0 +1,94 @@ +import { orderConsistWagons } from './consist-order.util'; + +// Built train: A-B-C-D coupled in that order. Slots are created by the wagon +// PLAN, so their sequenceNo says nothing about where the wagon actually sits. +const TRAIN = ['A', 'B', 'C', 'D']; + +const slot = (sequenceNo: number, physicalWagonId: string | null) => ({ + sequenceNo, + physicalWagonId, +}); + +describe('orderConsistWagons', () => { + it('draws slots in the train coupling order, not slot order', () => { + // Plan order says D then B; the train says B sits ahead of D. + const drawn = orderConsistWagons([slot(1, 'D'), slot(2, 'B')], { + physicalWagonIdsInOrder: TRAIN, + }); + + expect(drawn.map((w) => w.physicalWagonId)).toEqual(['B', 'D']); + expect(drawn.map((w) => w.position)).toEqual([1, 2]); + }); + + it('interleaves empty consist wagons in their real place', () => { + // Loaded slots on A and C; B and D ride along empty. The empties used to be + // appended after every loaded slot, so the drawing was never the train. + const drawn = orderConsistWagons( + [slot(1, 'A'), slot(2, 'C'), slot(98, 'B'), slot(99, 'D')], + { physicalWagonIdsInOrder: TRAIN }, + ); + + expect(drawn.map((w) => w.physicalWagonId)).toEqual(['A', 'B', 'C', 'D']); + }); + + it('keeps every wagon in place when a load moves between wagons', () => { + // Load sat on A (slot 1); staff drag it onto empty D. The move repins the + // slot, so the SAME slot now reads as wagon D and A falls back to empty. + const before = orderConsistWagons([slot(1, 'A'), slot(98, 'D')], { + physicalWagonIdsInOrder: TRAIN, + }); + const after = orderConsistWagons([slot(1, 'D'), slot(98, 'A')], { + physicalWagonIdsInOrder: TRAIN, + }); + + // A is drawn first and D last, before and after — the train did not shuffle. + expect(before.map((w) => w.physicalWagonId)).toEqual(['A', 'D']); + expect(after.map((w) => w.physicalWagonId)).toEqual(['A', 'D']); + }); + + it('follows a train-builder reorder without touching any slot row', () => { + const slots = [slot(1, 'A'), slot(2, 'B')]; + + // Builder swaps the coupling order; the slots are untouched. + const drawn = orderConsistWagons(slots, { + physicalWagonIdsInOrder: ['B', 'A', 'C', 'D'], + }); + + expect(drawn.map((w) => w.physicalWagonId)).toEqual(['B', 'A']); + }); + + it('draws back-to-front when the caller reverses the train', () => { + const drawn = orderConsistWagons([slot(1, 'A'), slot(2, 'C')], { + physicalWagonIdsInOrder: [...TRAIN].reverse(), + reverseWagonOrder: true, + }); + + expect(drawn.map((w) => w.physicalWagonId)).toEqual(['C', 'A']); + }); + + it('parks unpinned slots last, in slot order', () => { + const drawn = orderConsistWagons([slot(9, null), slot(4, null), slot(1, 'C')], { + physicalWagonIdsInOrder: TRAIN, + }); + + expect(drawn.map((w) => [w.physicalWagonId, w.sequenceNo])).toEqual([ + ['C', 1], + [null, 4], + [null, 9], + ]); + }); + + it('falls back to slot order when there is no built train', () => { + // Frozen schedules and loose-wagon schedules pass no physical order. + const drawn = orderConsistWagons([slot(2, 'X'), slot(1, 'Y')], { + physicalWagonIdsInOrder: [], + }); + expect(drawn.map((w) => w.sequenceNo)).toEqual([1, 2]); + + const reversed = orderConsistWagons([slot(1, 'X'), slot(2, 'Y')], { + physicalWagonIdsInOrder: [], + reverseWagonOrder: true, + }); + expect(reversed.map((w) => w.sequenceNo)).toEqual([2, 1]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.ts new file mode 100644 index 000000000..496b91952 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/consist-order.util.ts @@ -0,0 +1,54 @@ +/** + * Draw order for a schedule's consist. + * + * A slot's stored `sequenceNo` is its place in the wagon PLAN, not its place in + * the train. The train's real coupling order lives on the physical wagons + * (`wagons.sequence_number`), which the caller passes in already ordered — ASC + * normally, DESC for a `reverseWagonOrder` schedule. + * + * Ordering by the physical wagon is what keeps the drawing honest: + * - moving a load between wagons repaints WHICH wagon is loaded and never + * shuffles the train, because each slot is drawn wherever its wagon sits; + * - a train-builder reorder lands on the next read, allocations included, + * since the order is derived on every read instead of copied at pin time. + * + * Slots with no physical wagon (not pinned yet, or a schedule that isn't tied + * to a built train) have no place in the consist — they keep slot order, last. + */ +export interface ConsistOrderable { + sequenceNo: number; + physicalWagonId?: string | null; +} + +export interface ConsistOrderOptions { + /** + * Every wagon coupled to the built train, in real coupling order (already + * reversed by the caller for a `reverseWagonOrder` schedule). Empty for a + * frozen schedule or one with no built train — the consist then keeps slot + * order. + */ + physicalWagonIdsInOrder: string[]; + reverseWagonOrder?: boolean; +} + +export const orderConsistWagons = ( + wagons: T[], + { physicalWagonIdsInOrder, reverseWagonOrder }: ConsistOrderOptions, +): (T & { position: number })[] => { + const physicalOrder = new Map(physicalWagonIdsInOrder.map((id, index) => [id, index])); + const bySlotSequence = (a: T, b: T) => + reverseWagonOrder ? b.sequenceNo - a.sequenceNo : a.sequenceNo - b.sequenceNo; + + const ordered = physicalOrder.size + ? [...wagons].sort((a, b) => { + const ai = a.physicalWagonId ? physicalOrder.get(a.physicalWagonId) : undefined; + const bi = b.physicalWagonId ? physicalOrder.get(b.physicalWagonId) : undefined; + if (ai == null && bi == null) return bySlotSequence(a, b); + if (ai == null) return 1; + if (bi == null) return -1; + return ai - bi; + }) + : [...wagons].sort(bySlotSequence); + + return ordered.map((wagon, index) => ({ ...wagon, position: index + 1 })); +}; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 8ad256a16..9dfea0781 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -34,11 +34,11 @@ export class CreateContainerTrainScheduleDto { type: [String], format: 'uuid', description: - 'Hand-picked locomotives pulling the train (minimum 2 — front and back). Ignored when trainId is provided.', + 'Hand-picked locomotives pulling the train (minimum 1). Ignored when trainId is provided.', }) @IsOptional() @IsArray() - @ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' }) + @ArrayMinSize(1, { message: 'A train must be pulled by at least one locomotive' }) @IsUUID('all', { each: true }) locomotiveIds?: string[]; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts index c0e0b7c5f..4fe20dbf5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts @@ -48,10 +48,12 @@ export class FacilityHandlingService { if (!facility?.hasFacility) return null; const occurredAt = input.occurredAt ?? new Date(); + // Mapped to the goods owner, same as every warehouse-raised GRN. const grnNumber = generateGrnNumber( booking.tradeDirection ?? 'DOMESTIC', booking.id, occurredAt, + booking.company?.name ?? null, ); // Link the storage record when this facility keeps cargo — that link is @@ -67,6 +69,21 @@ export class FacilityHandlingService { inventoryId = inv?.id ?? null; } + // The handed-over weight: the booking's declared VGM, else what its + // containers actually carry. A GRN without a weight is not a receipt. + let weightTons = Number(booking.cargoTotalWeightVgm) || null; + if (!weightTons) { + const [sum]: Array<{ tons: string | null }> = await manager.query( + `SELECT SUM(bcu.vgm_tons) AS tons + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`, + [booking.id], + ); + weightTons = Number(sum?.tons) || null; + } + const repo = manager.getRepository(FacilityHandlingEvent); await repo.save( repo.create({ @@ -75,7 +92,7 @@ export class FacilityHandlingService { trainScheduleId: input.trainScheduleId ?? null, eventType, grnNumber, - weightTons: Number(booking.cargoTotalWeightVgm) || null, + weightTons, inventoryId, performedBy: input.performedBy ?? null, occurredAt, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index 8342eae47..0b8d4ad05 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -5,7 +5,7 @@ import { consistViolations, deriveTrainCapacityFromLocomotive, grossWagonWeightTons, - minLocomotiveLimits, + combinedLocomotiveLimits, sizePartialOfferWagons, trainSetLocomotiveLimits, } from './train-capacity.util'; @@ -197,42 +197,75 @@ describe('train-capacity.util', () => { expect(bookingTrainLengthMeters('BULK', 3, { container: 14, bulk: 18 })).toBe(54); }); - it('takes the weakest locomotive across a multi-locomotive set', () => { - const limits = minLocomotiveLimits([ - { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, - { maxPullWeightTons: 4000, maxTrainLengthMeters: 760, overageToleranceTons: 20 }, + it('SUMS pull weight and weight tolerance across a multi-locomotive set', () => { + // Two units haul together: 1750 + 1750 = 3500T base, 90 + 90 = 180T overage. + const limits = combinedLocomotiveLimits([ + { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, + { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, ]); expect(limits?.maxPullWeightTons).toBe(3500); - expect(limits?.overageToleranceTons).toBe(20); + expect(limits?.overageToleranceTons).toBe(180); + // A single locomotive is just its own limit — no doubling, no halving. + expect( + combinedLocomotiveLimits([ + { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, + ])?.maxPullWeightTons, + ).toBe(1750); + }); + + it('takes the MINIMUM train length — a second locomotive does not lengthen the siding', () => { + const limits = combinedLocomotiveLimits([ + { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceMeters: 20 }, + { maxPullWeightTons: 1750, maxTrainLengthMeters: 700, overageToleranceMeters: 5 }, + ]); + expect(limits?.maxTrainLengthMeters).toBe(700); + expect(limits?.overageToleranceMeters).toBe(5); }); it('ignores unconfigured (null) tolerances instead of zeroing the set (S-2026-00024)', () => { // LOCO-019 had 90T tolerance, LOCO-020 had none configured: the set must - // keep the 90, not collapse to 0 and reject 3547.6T on a 3500T train. - const limits = minLocomotiveLimits([ - { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, - { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: null }, + // keep the 90 rather than collapse to 0 — an unset value abstains. + const limits = combinedLocomotiveLimits([ + { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, + { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: null }, ]); expect(limits?.overageToleranceTons).toBe(90); // All unconfigured → no tolerance. - const none = minLocomotiveLimits([ + const none = combinedLocomotiveLimits([ { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, ]); expect(none?.overageToleranceTons).toBe(0); }); + it('reports no pull limit when NO locomotive has one configured', () => { + // Summing must not turn "unset" into 0 and strand every booking; an + // all-unset set keeps the old "no opinion" behaviour. + const limits = combinedLocomotiveLimits([ + { maxPullWeightTons: 0, maxTrainLengthMeters: 760 }, + { maxPullWeightTons: 0, maxTrainLengthMeters: 760 }, + ]); + expect(limits?.maxPullWeightTons).toBe(Infinity); + // One configured, one not → only the configured one contributes. + expect( + combinedLocomotiveLimits([ + { maxPullWeightTons: 1750, maxTrainLengthMeters: 760 }, + { maxPullWeightTons: 0, maxTrainLengthMeters: 760 }, + ])?.maxPullWeightTons, + ).toBe(1750); + }); + it('trainSetLocomotiveLimits prefers link rows and falls back to the legacy single loco', () => { - const l1 = { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }; - const l2 = { maxPullWeightTons: 3600, maxTrainLengthMeters: 700, overageToleranceTons: null }; + const l1 = { maxPullWeightTons: 1750, maxTrainLengthMeters: 760, overageToleranceTons: 90 }; + const l2 = { maxPullWeightTons: 1800, maxTrainLengthMeters: 700, overageToleranceTons: null }; expect( trainSetLocomotiveLimits({ locomotive: null, locomotives: [{ locomotive: l1 }, { locomotive: l2 }] }), ).toEqual({ - maxPullWeightTons: 3500, + maxPullWeightTons: 3550, maxTrainLengthMeters: 700, overageToleranceTons: 90, overageToleranceMeters: 0, }); - expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(3500); + expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(1750); expect(trainSetLocomotiveLimits(null)).toBeNull(); expect(trainSetLocomotiveLimits({ locomotive: null, locomotives: [] })).toBeNull(); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index b4b3a64de..7f9586c3c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -256,27 +256,40 @@ function round3(value: number): number { } /** - * Effective pull limits for a train set with multiple locomotives: the weakest - * locomotive caps the train, so take the minimum pull weight and minimum length - * across all assigned locomotives. Returns null when no locomotives are given. + * Effective limits for a train set, per axis: + * + * - **Pull weight ADDS UP.** Locomotives haul together, so two 1750T units pull + * 3500T. Only CONFIGURED pull weights are summed; a set with none configured + * reports Infinity (no opinion), exactly as before. + * - **Weight tolerance ADDS UP**, following its axis — each locomotive brings its + * own overage allowance, so 2 × 90T gives the set 180T. Unset abstains (0). + * - **Length takes the MINIMUM.** Train length is a siding/loop constraint, not + * a haulage one: coupling a second locomotive does not lengthen the track, so + * the most restrictive locomotive still governs (and its tolerance with it). + * + * Returns null when no locomotives are given. */ -export function minLocomotiveLimits( +export function combinedLocomotiveLimits( locomotives: Array< Pick & Partial> >, ): LocomotiveLimits | null { if (!locomotives.length) return null; + const configuredPulls = locomotives + .map((l) => num(l.maxPullWeightTons)) + .filter((v) => v > 0); + return { - maxPullWeightTons: Math.min( - ...locomotives.map((l) => num(l.maxPullWeightTons, Infinity) || Infinity), - ), + maxPullWeightTons: configuredPulls.length + ? round3(configuredPulls.reduce((sum, v) => sum + v, 0)) + : Infinity, maxTrainLengthMeters: Math.min( ...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity), ), - // Weakest CONFIGURED tolerance governs the set — a locomotive with no - // tolerance set has no opinion, it does not zero out the others. - overageToleranceTons: minConfigured(locomotives.map((l) => l.overageToleranceTons)), + overageToleranceTons: sumConfigured(locomotives.map((l) => l.overageToleranceTons)), + // Paired with the length axis, so it stays the weakest CONFIGURED value — a + // locomotive with no tolerance set has no opinion, it does not zero the others. overageToleranceMeters: minConfigured(locomotives.map((l) => l.overageToleranceMeters)), }; } @@ -286,10 +299,16 @@ function minConfigured(values: Array): number { return configured.length ? Math.min(...configured) : 0; } +function sumConfigured(values: Array): number { + const configured = values.filter((v) => v != null).map((v) => num(v)); + return configured.length ? round3(configured.reduce((sum, v) => sum + v, 0)) : 0; +} + /** - * Effective limits for a whole train set: min across its linked locomotives, - * falling back to the legacy single `locomotive` column for sets created - * before multi-loco support. Null when the set has no locomotive at all. + * Effective limits for a whole train set: {@link combinedLocomotiveLimits} over + * its linked locomotives, falling back to the legacy single `locomotive` column + * for sets created before multi-loco support. Null when the set has no + * locomotive at all. */ export function trainSetLocomotiveLimits( trainSet?: { @@ -306,7 +325,7 @@ export function trainSetLocomotiveLimits( : trainSet.locomotive ? [trainSet.locomotive] : []; - return minLocomotiveLimits(pool); + return combinedLocomotiveLimits(pool); } /** Per-booking train length from wagon count and freight-specific wagon type length. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index b1f79733e..b7e9a750f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -1,23 +1,19 @@ -import { - Body, - Controller, - Delete, - Get, - Param, - ParseUUIDPipe, - Patch, - Post, - Query, - Res, -} from "@nestjs/common"; -import { CurrentUser } from "@edr/api-common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import type { Response } from "express"; import type { AuthUserPayload } from "../../common/resolve-auth-user-id"; import { resolveAuthUserId } from "../../common/resolve-auth-user-id"; import { - TrainSchedulingManage, + Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res, +} from "@nestjs/common"; +import { CurrentUser } from "@edr/api-common"; +import { + BookingDocReviewAlert, + TrainSchedulingCancel, + TrainSchedulingCreate, + TrainSchedulingReschedule, + TrainSchedulingRulesManage, + TrainSchedulingUpdate, TrainSchedulingView, } from "../../common/booking-guards"; import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto"; @@ -114,7 +110,7 @@ export class TrainSchedulingController { } @Patch("global-rules") - @TrainSchedulingManage() + @TrainSchedulingRulesManage() @ApiOperation({ summary: "Update global train scheduling rules (singleton)" }) updateGlobalRules(@Body() dto: UpdateTrainSchedulingGlobalRulesDto) { return this.trainSchedulingService.updateTrainSchedulingGlobalRules(dto); @@ -181,7 +177,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/adjust-consist") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", @@ -283,21 +279,21 @@ export class TrainSchedulingController { } @Post("container/schedules") - @TrainSchedulingManage() + @TrainSchedulingCreate() @ApiOperation({ summary: "Create a container train schedule" }) createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) { return this.trainSchedulingService.createContainerTrainSchedule(dto); } @Post("bulk/schedules") - @TrainSchedulingManage() + @TrainSchedulingCreate() @ApiOperation({ summary: "Create a bulk train schedule" }) createBulkTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) { return this.trainSchedulingService.createContainerTrainSchedule(dto); } @Post("schedules/:id/assign-bookings") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Assign bookings to a train schedule (mixed-capable)", }) @@ -309,7 +305,7 @@ export class TrainSchedulingController { } @Post("container/schedules/:id/assign-bookings") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Assign container bookings to a train schedule" }) assignContainerBookings( @Param("id", ParseUUIDPipe) id: string, @@ -323,7 +319,7 @@ export class TrainSchedulingController { } @Post("bulk/schedules/:id/assign-bookings") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Assign bulk bookings to a train schedule" }) assignBulkBookings( @Param("id", ParseUUIDPipe) id: string, @@ -337,7 +333,7 @@ export class TrainSchedulingController { } @Delete("schedules/:id/bookings/:bookingId") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Unassign a booking from a train schedule" }) unassignBooking( @Param("id", ParseUUIDPipe) id: string, @@ -352,7 +348,7 @@ export class TrainSchedulingController { } @Delete("schedules/:id/wagons/:trainSetWagonId") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Remove an empty wagon slot from a train" }) removeWagonSlot( @Param("id", ParseUUIDPipe) id: string, @@ -365,7 +361,7 @@ export class TrainSchedulingController { } @Patch("schedules/:id/container-items/:itemId") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Update a container number on a wagon slot" }) updateContainerItem( @Param("id", ParseUUIDPipe) id: string, @@ -376,7 +372,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/wagons/:wagonId/move-load") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", @@ -397,7 +393,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/assign-unassigned-booking") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Assign one linked unallocated booking to wagons (preserves existing assignments)", @@ -429,7 +425,7 @@ export class TrainSchedulingController { } @Patch("schedules/:id/import-loading-status") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)", @@ -442,7 +438,7 @@ export class TrainSchedulingController { } @Patch("schedules/:id/loading-status") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)", @@ -455,21 +451,21 @@ export class TrainSchedulingController { } @Post("schedules/:id/pin-wagons") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Pin physical wagons to train set slots" }) pinWagons(@Param("id", ParseUUIDPipe) id: string, @Body() dto: PinWagonsDto) { return this.trainSchedulingService.pinWagons(id, dto); } @Post("schedules/:id/finalize") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Finalize a draft train schedule" }) finalizeSchedule(@Param("id", ParseUUIDPipe) id: string) { return this.trainSchedulingService.finalizeSchedule(id); } @Post("schedules/:id/dispatch") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Dispatch a scheduled train" }) dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) { return this.trainSchedulingService.dispatchSchedule(id); @@ -496,7 +492,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/intercity/accept") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)", @@ -519,7 +515,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/bookings/:bookingId/load") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)", @@ -532,7 +528,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/bookings/:bookingId/unload") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival", @@ -545,7 +541,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/intercity/:bookingId/load") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)", }) @@ -557,7 +553,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/intercity/:bookingId/unload") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", @@ -577,7 +573,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/import-djibouti/documents") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Upload/check an import Djibouti-side document" }) uploadImportDjiboutiDocument( @Param("id", ParseUUIDPipe) id: string, @@ -587,7 +583,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/import-djibouti/gatepass-granted") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Mark import Djibouti gatepass permission granted" }) grantImportDjiboutiGatepass( @Param("id", ParseUUIDPipe) id: string, @@ -597,7 +593,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/import-djibouti/ready-for-loading") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Mark import train ready for loading at Djibouti" }) markImportReadyForLoading( @Param("id", ParseUUIDPipe) id: string, @@ -607,7 +603,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/import-djibouti/loaded-on-train") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Confirm import cargo loaded on train at Djibouti" }) confirmImportLoadedOnTrain( @Param("id", ParseUUIDPipe) id: string, @@ -617,7 +613,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/confirm-loading") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)", @@ -630,7 +626,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/import-djibouti/depart") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Depart loaded import train from Djibouti" }) departImportFromDjibouti( @Param("id", ParseUUIDPipe) id: string, @@ -640,7 +636,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/import-djibouti/load-list") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Generate import load list / marshalling document summary" }) generateImportLoadList( @Param("id", ParseUUIDPipe) id: string, @@ -680,7 +676,7 @@ export class TrainSchedulingController { // ---- batch / booking-window staff actions ---- @Post("schedules/:id/run-batch") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Manually run the batch fill for a schedule" }) async runBatch(@Param("id", ParseUUIDPipe) id: string) { await this.bookingBatchService.fillSchedule(id); @@ -688,7 +684,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/run-allocation") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Run wagon-level allocation for all eligible linked bookings", }) @@ -697,7 +693,7 @@ export class TrainSchedulingController { } @Patch("schedules/:id/booking-window") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Open or close a schedule booking window" }) async setBookingWindow( @Param("id", ParseUUIDPipe) id: string, @@ -711,7 +707,7 @@ export class TrainSchedulingController { } @Patch("schedules/:id/window-rule") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", @@ -725,7 +721,7 @@ export class TrainSchedulingController { } @Patch("schedules/:id/schedule-date") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window", @@ -739,7 +735,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/maintenance") - @TrainSchedulingManage() + @TrainSchedulingReschedule() @ApiOperation({ summary: "Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged", @@ -752,8 +748,20 @@ export class TrainSchedulingController { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Get("doc-review-alert") + // Dedicated permission, not scheduling or bookings:view — the alarm is meant + // for the position types that actually decide operation requests. + @BookingDocReviewAlert() + @ApiOperation({ + summary: + "Nearest document-review deadline that still has un-accepted booking requests behind it (null when there is none) — drives the backoffice header countdown", + }) + async getDocReviewAlert() { + return this.bookingWindowService.getDocReviewAlert(); + } + @Post("schedules/:id/doc-review-complete") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)", @@ -764,7 +772,7 @@ export class TrainSchedulingController { } @Post("bookings/:bookingId/mark-paid") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Staff: mark a reserved booking paid and allocate it now", }) @@ -774,7 +782,7 @@ export class TrainSchedulingController { } @Post("bookings/:bookingId/expire") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Staff: expire a reservation and free its capacity", }) @@ -784,7 +792,7 @@ export class TrainSchedulingController { } @Post("bookings/:bookingId/move-schedule") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Re-point a booking to another OPEN same-route schedule", }) @@ -806,7 +814,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/checkpoints") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Log the train passing a station (final station triggers arrival)", }) @@ -818,7 +826,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/arrive") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Mark a dispatched train arrived (move assets to destination yard, free assets)", @@ -856,14 +864,14 @@ export class TrainSchedulingController { } @Post("container/schedules/:id/cancel") - @TrainSchedulingManage() + @TrainSchedulingCancel() @ApiOperation({ summary: "Cancel container train schedule" }) cancelTrainSchedule(@Param("id", ParseUUIDPipe) id: string) { return this.trainSchedulingService.cancelTrainSchedule(id); } @Post('bulk/schedules/:id/cancel') - @TrainSchedulingManage() + @TrainSchedulingCancel() @ApiOperation({ summary: "Cancel bulk train schedule" }) cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) { return this.trainSchedulingService.cancelTrainSchedule(id); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 6757a6e21..0d724a040 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -1133,7 +1133,12 @@ describe('TrainSchedulingService', () => { let slotB: Record; let allocsByWagon: Record>>; let allocRepo: { find: jest.Mock; update: jest.Mock }; - let slotRepo: { update: jest.Mock }; + let slotRepo: { + update: jest.Mock; + create: jest.Mock; + save: jest.Mock; + createQueryBuilder: jest.Mock; + }; let wagonRepo: { findOne: jest.Mock }; const makeSchedule = (over: Record = {}) => ({ @@ -1147,6 +1152,7 @@ describe('TrainSchedulingService', () => { beforeEach(() => { slotA = { id: 'wA', + trainSetId: 'ts-1', sequenceNo: 1, capacityTons: 61, lengthMeters: 14, @@ -1158,6 +1164,7 @@ describe('TrainSchedulingService', () => { }; slotB = { id: 'wB', + trainSetId: 'ts-1', sequenceNo: 2, capacityTons: 61, lengthMeters: 14, @@ -1186,7 +1193,18 @@ describe('TrainSchedulingService', () => { ), update: jest.fn().mockResolvedValue(undefined), }; - slotRepo = { update: jest.fn().mockResolvedValue(undefined) }; + slotRepo = { + update: jest.fn().mockResolvedValue(undefined), + create: jest.fn((row: Record) => row), + save: jest.fn((row: Record) => + Promise.resolve({ id: 'slot-new', ...row }), + ), + createQueryBuilder: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ maxSequenceNo: 2 }), + })), + }; wagonRepo = { findOne: jest.fn().mockResolvedValue(null) }; dataSource.getRepository.mockImplementation((entity: unknown) => { if (entity === WagonBookingAllocation) return allocRepo; @@ -1245,7 +1263,7 @@ describe('TrainSchedulingService', () => { }); }); - it('repins the slot onto an empty consist-only wagon (the 404 case)', async () => { + it('moves the load onto an empty consist-only wagon without renaming wagons', async () => { wagonRepo.findOne.mockResolvedValue({ id: 'phys-9', wagonTypeId: 'wt-1', @@ -1258,14 +1276,57 @@ describe('TrainSchedulingService', () => { expect(wagonRepo.findOne).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'phys-9', trainId: 'train-1' } }), ); - // Repin: wagon identity moves onto the slot; allocations stay put. + // A slot is created ON the target wagon, carrying the source's load + // fields. sequence_no appends past the existing max so it clears the + // (train_set_id, sequence_no) unique index. + expect(slotRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + trainSetId: 'ts-1', + physicalWagonId: 'phys-9', + wagonTypeId: 'wt-1', + sequenceNo: 3, + capacityTons: 70, + lengthMeters: 14, + assignedWeightTons: 40, + status: 'RESERVED', + boardYardId: 'yard-1', + alightYardId: null, + }), + ); + // The whole load crosses onto that new slot… + expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'slot-new' }); + expect(allocRepo.update).toHaveBeenCalledWith('alloc-a2', { trainSetWagonId: 'slot-new' }); + // …and the source wagon stays itself, just empty. expect(slotRepo.update).toHaveBeenCalledWith('wA', { - physicalWagonId: 'phys-9', - wagonTypeId: 'wt-1', - capacityTons: 70, - lengthMeters: 14, + assignedWeightTons: 0, + status: 'PLANNED', + boardYardId: null, + alightYardId: null, }); - expect(allocRepo.update).not.toHaveBeenCalled(); + // The bug this replaced: the source slot must NOT be repinned to another + // physical wagon — that reorders the train instead of moving the load. + expect(slotRepo.update).not.toHaveBeenCalledWith( + 'wA', + expect.objectContaining({ physicalWagonId: expect.anything() }), + ); + }); + + it('reuses the existing slot when the target wagon is addressed by wagon id', async () => { + // wB is already pinned to physical wagon phys-B. Addressing that wagon + // directly must land in wB, not mint a second slot on the same wagon. + (slotB as Record).physicalWagonId = 'phys-B'; + wagonRepo.findOne.mockResolvedValue({ + id: 'phys-B', + wagonTypeId: 'wt-1', + wagonNumber: 'WGN-B', + wagonType: containerType, + }); + + await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'phys-B' }); + + expect(slotRepo.save).not.toHaveBeenCalled(); + expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'wB' }); + expect(allocRepo.update).toHaveBeenCalledWith('alloc-b1', { trainSetWagonId: 'wA' }); }); it('rejects a bulk load onto a wagon whose type only supports containers', async () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 03b293e4c..418338257 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -133,7 +133,7 @@ import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util'; import { bookingCargoTons, deriveTrainCapacityFromLocomotive, - minLocomotiveLimits, + combinedLocomotiveLimits, trainSetLocomotiveLimits, wagonTypeDimensionsFromEntity, LocomotiveLimits, @@ -147,6 +147,7 @@ import { DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_TARE_TONS, } from './booking-batch.constants'; +import { orderConsistWagons } from './consist-order.util'; import { computeExportWindowTimes, computeImportWindowTimes, @@ -1299,9 +1300,9 @@ export class TrainSchedulingService { .slice() .sort((a, b) => a.sequenceNo - b.sequenceNo) .map((link) => link.locomotiveId); - if (locomotiveIds.length < 2) { + if (locomotiveIds.length < 1) { throw new BadRequestException( - `Train ${builtTrain.code} has fewer than two locomotives; rebuild it before scheduling`, + `Train ${builtTrain.code} has no locomotive; rebuild it before scheduling`, ); } if (builtTrain.currentYardId !== route.originYardId) { @@ -1322,8 +1323,8 @@ export class TrainSchedulingService { } } else { locomotiveIds = [...new Set(dto.locomotiveIds ?? [])]; - if (locomotiveIds.length < 2) { - throw new BadRequestException('A train must be pulled by at least two locomotives'); + if (locomotiveIds.length < 1) { + throw new BadRequestException('A train must be pulled by at least one locomotive'); } } @@ -1381,7 +1382,7 @@ export class TrainSchedulingService { builtTrain?.id ?? null, ); // Effective capacity is capped by the weakest locomotive in the set. - const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined; + const limitLoco = combinedLocomotiveLimits(lockedLocomotives) ?? undefined; const departure = new Date(dto.scheduleDate); // Every schedule starts with a CLOSED customer window; the window engine opens // it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT @@ -1571,7 +1572,7 @@ export class TrainSchedulingService { }; const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet); - const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined; + const limitLoco = combinedLocomotiveLimits(setLocomotives) ?? undefined; const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco); // Callers that add bookings without hand-picking container slots (the @@ -2303,14 +2304,19 @@ export class TrainSchedulingService { ); if (direction !== 'EXPORT') return; + // Only bookings boarding at the schedule's ORIGIN station gate dispatch — + // a mid-corridor boarder (origin B on an A→B→C→D run) is loaded when the + // train reaches its yard, so its warehouse state says nothing at departure. const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query( `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT DISTINCT b.reference AS "reference", inv.status AS "status" FROM sched_bookings sb JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL + JOIN freight.train_schedules ts ON ts.id = sb.schedule_id JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL WHERE sb.schedule_id = $1 + AND b.origin_yard_id = ts.origin_station_id AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`, [scheduleId], ); @@ -3965,36 +3971,12 @@ export class TrainSchedulingService { const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId); const originYardId = dto.originStationId; - let stock: WagonStock; - if (builtTrainId) { - stock = await this.builtTrainStock(builtTrainId); - } else { - // Dynamic consist: a slot's physical wagon may ride from the train's origin - // OR already sit at the booking's own boarding yard and attach there — so - // the usable fleet is the union across the origin and every boarding yard. - const boardYardIds = [ - ...new Set( - [originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean), - ), - ]; - const fleetCountsByYard = await Promise.all( - boardYardIds.map((yardId) => - this.countFleetAvailability(yardId, targetScheduleId), - ), - ); - const remainingByTypeId = new Map(); - const codesByTypeId = new Map(); - for (const rows of fleetCountsByYard) { - for (const row of rows) { - remainingByTypeId.set( - row.wagonTypeId, - (remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available, - ); - codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode); - } - } - stock = { mode: 'YARD', remainingByTypeId, codesByTypeId }; - } + const stock: WagonStock = await this.wagonStockForSchedule( + targetScheduleId, + originYardId, + bookings.map((b) => b.originYardId), + builtTrainId, + ); // Leg-aware stock: each booking consumes wagons only on the edges it rides, // so a ride-along on an empty leg never competes with cargo on a full one. @@ -4123,7 +4105,7 @@ export class TrainSchedulingService { // warning (it must arrive before dispatch), but a set too weak to pull the train // is a hard violation. const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId); - const setLimits = minLocomotiveLimits(assignedLocomotives); + const setLimits = combinedLocomotiveLimits(assignedLocomotives); if (offYard) { warnings.push( `Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`, @@ -4793,6 +4775,51 @@ export class TrainSchedulingService { * type. This is the whole plannable pool for its schedules — the plan is * full when every consist wagon is allocated. */ + /** + * The physical wagons a schedule can actually plan against, by wagon type. + * + * A schedule built from a Train Builder train plans against ONLY that train's + * own consist. A legacy/dynamic-consist schedule plans against the boarding + * yards' loose pool: a slot's wagon may ride from the train's origin OR + * already sit at the booking's own boarding yard and attach there, so the + * usable fleet is the union across the origin and every boarding yard. + * + * Public because batch fill needs the SAME stock the allocator will later + * validate against — selecting a booking the allocator cannot place is how + * customers ended up paying for wagons that were never there. + */ + async wagonStockForSchedule( + scheduleId: string | undefined, + originYardId: string, + boardingYardIds: Array = [], + preloadedBuiltTrainId?: string | null, + ): Promise { + const builtTrainId = + preloadedBuiltTrainId !== undefined + ? preloadedBuiltTrainId + : await this.builtTrainIdOfSchedule(scheduleId); + if (builtTrainId) return this.builtTrainStock(builtTrainId); + + const boardYardIds = [ + ...new Set([originYardId, ...boardingYardIds].filter((id): id is string => Boolean(id))), + ]; + const fleetCountsByYard = await Promise.all( + boardYardIds.map((yardId) => this.countFleetAvailability(yardId, scheduleId)), + ); + const remainingByTypeId = new Map(); + const codesByTypeId = new Map(); + for (const rows of fleetCountsByYard) { + for (const row of rows) { + remainingByTypeId.set( + row.wagonTypeId, + (remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available, + ); + codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode); + } + } + return { mode: 'YARD', remainingByTypeId, codesByTypeId }; + } + private async builtTrainStock(builtTrainId: string): Promise { const wagons = await this.dataSource.getRepository(Wagon).find({ where: { trainId: builtTrainId }, @@ -5341,7 +5368,7 @@ export class TrainSchedulingService { * schedule-creation picker. Mirrors the locomotive picker's advance-scheduling * philosophy: nothing serviceable is filtered out — staff see the status, * whether the train sits at the origin yard yet, and its future schedules. - * Trains with fewer than two locomotives are omitted (never schedulable). + * Trains with no locomotive at all are omitted (never schedulable). */ async getAvailableTrainsForRoute(routeId: string) { const route = await this.getSchedulableRoute(routeId); @@ -5379,7 +5406,7 @@ export class TrainSchedulingService { const futureCounts = new Map(counts.map((c) => [c.train_id, Number(c.future_count)])); return trains - .filter((train) => (train.locomotives ?? []).length >= 2) + .filter((train) => (train.locomotives ?? []).length >= 1) .map((train) => { const wagons = train.wagons ?? []; return { @@ -5411,7 +5438,15 @@ export class TrainSchedulingService { totalLengthMeters: roundTons( wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0), ), - maxPullWeightTons: roundTons(Number(train.capacityTons)), + // Live from the coupled set — `capacity_tons` still holds the old + // single-locomotive figure on trains built before pull weight summed. + maxPullWeightTons: roundTons( + combinedLocomotiveLimits( + (train.locomotives ?? []) + .map((link) => link.locomotive) + .filter((loco): loco is Locomotive => Boolean(loco)), + )?.maxPullWeightTons ?? Number(train.capacityTons), + ), atOriginYard: train.currentYardId === route.originYardId, futureScheduleCount: futureCounts.get(train.id) ?? 0, }; @@ -5461,7 +5496,7 @@ export class TrainSchedulingService { ); const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(); - const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); + const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0)); const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0); const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0)); @@ -5584,7 +5619,7 @@ export class TrainSchedulingService { .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) .map((slot) => slot.physicalWagonId as string), ); - const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); + const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); const pullCapTons = roundTons( Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0), ); @@ -5625,9 +5660,12 @@ export class TrainSchedulingService { // --- validate additions: AVAILABLE, loose, standing in the train's yard --- const added: Wagon[] = []; for (const wagonId of addWagonIds) { + // No `relations` on this query: Postgres refuses FOR UPDATE through the + // nullable side of the wagonType LEFT JOIN ("FOR UPDATE cannot be + // applied to the nullable side of an outer join"). Lock the row alone, + // then attach its type with a separate unlocked lookup. const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId }, - relations: { wagonType: true }, lock: { mode: 'pessimistic_write' }, }); if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`); @@ -5644,6 +5682,10 @@ export class TrainSchedulingService { `Wagon ${wagon.wagonNumber} is not in the train's yard — only wagons in the same yard can be coupled`, ); } + wagon.wagonType = + (await manager + .getRepository(WagonType) + .findOne({ where: { id: wagon.wagonTypeId } })) ?? undefined; added.push(wagon); } @@ -6647,6 +6689,8 @@ export class TrainSchedulingService { : slot.physicalWagonId ?? null; if (physicalId) coveredPhysicalIds.add(physicalId); } + // Fallback only — real empty rows below carry the wagon's OWN physical + // sequenceNumber, not an invented tail position (see emptyConsistWagons). const maxSlotSequenceNo = Math.max( 0, ...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo), @@ -6657,7 +6701,11 @@ export class TrainSchedulingService { // Physical wagon id — there is no TrainSetWagon slot behind this // row, so remove/edit affordances must stay disabled (consistOnly). id: wagon.id, - sequenceNo: maxSlotSequenceNo + index + 1, + // The wagon's REAL coupling position, so an empty wagon in the middle + // of the train draws in the middle — not appended after every loaded + // slot. Falls back to a tail position only if the wagon somehow has + // no sequence number of its own. + sequenceNo: wagon.sequenceNumber ?? maxSlotSequenceNo + index + 1, capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)), lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)), assignedWeightTons: 0, @@ -6678,6 +6726,18 @@ export class TrainSchedulingService { consistOnly: true, })); + // The consist is DRAWN in the built train's real coupling order (rawConsistWagons + // is already ASC/DESC per reverseWagonOrder), not in slot order — see + // consist-order.util. `position` is the drawn place, 1..n; `sequenceNo` stays + // the slot's own stored value. + const drawConsist = ( + list: T[], + ) => + orderConsistWagons(list, { + physicalWagonIdsInOrder: rawConsistWagons.map((wagon) => wagon.id), + reverseWagonOrder: schedule.reverseWagonOrder, + }); + return { id: schedule.id, reference: schedule.reference ?? null, @@ -6767,8 +6827,8 @@ export class TrainSchedulingService { maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)), maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)), })), - wagons: [...(schedule.trainSet.wagons ?? [])] - .sort((a, b) => a.sequenceNo - b.sequenceNo) + wagons: drawConsist( + (schedule.trainSet.wagons ?? []) .map((wagon) => { // Frozen schedules read the wagon number + allocations from the // snapshot slot; the immutable slot geometry (capacity/type) still @@ -6776,9 +6836,20 @@ export class TrainSchedulingService { const frozenSlot = isWagonAllocationFrozen ? snapshotSlotByTrainSetWagonId.get(wagon.id) : undefined; + // Draw the slot at its physical wagon's REAL coupling position, + // not the planning-time slot index — the two diverge once a + // load has been dragged onto a different wagon (moveWagonLoad + // repoints physicalWagonId but a slot keeps its own sequenceNo), + // or once wagon types were interleaved at pinning time. Frozen + // and not-yet-pinned slots have no live physical wagon to trust, + // so they keep their own slot sequence. + const sequenceNo = + frozenSlot || !wagon.physicalWagon + ? wagon.sequenceNo + : (wagon.physicalWagon.sequenceNumber ?? wagon.sequenceNo); return { id: wagon.id, - sequenceNo: wagon.sequenceNo, + sequenceNo, capacityTons: roundTons(Number(wagon.capacityTons)), lengthMeters: roundTons(Number(wagon.lengthMeters)), assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)), @@ -6848,6 +6919,7 @@ export class TrainSchedulingService { }; }) .concat(emptyConsistWagons), + ), } : null, bookings: @@ -7406,8 +7478,8 @@ export class TrainSchedulingService { // Target: a slot of this train set, or an empty consist-only wagon of the // built train (physical wagon with no slot row yet). - const targetSlot = slots.find((w) => w.id === dto.targetWagonId) ?? null; - const consistWagon = targetSlot + const slotById = slots.find((w) => w.id === dto.targetWagonId) ?? null; + const wagonForTarget = slotById ? null : schedule.trainSet?.trainId ? await this.dataSource.getRepository(Wagon).findOne({ @@ -7415,18 +7487,34 @@ export class TrainSchedulingService { relations: { wagonType: true }, }) : null; - if (!targetSlot && !consistWagon) { + if (!slotById && !wagonForTarget) { throw new NotFoundException('Target wagon is not part of this schedule'); } + // A physical wagon holds at most one slot. When the caller addressed the + // wagon directly but a slot is already pinned to it, move into that slot + // rather than minting a second one on the same wagon. + const targetSlot = + slotById ?? + (wagonForTarget + ? (slots.find((w) => w.physicalWagonId === wagonForTarget.id) ?? null) + : null); + const consistWagon = targetSlot ? null : wagonForTarget; const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : []; + if (targetSlot && targetSlot.id === source.id) { + return this.getTrainScheduleById(scheduleId); + } const loadTypesOf = (allocs: WagonBookingAllocation[]) => [ ...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())), ]; const cargoOf = (allocs: WagonBookingAllocation[]) => allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0); - const wagonLabel = (slot: { sequenceNo: number } | null, wagon: Wagon | null) => - slot ? `#${slot.sequenceNo}` : (wagon?.wagonNumber ?? 'the target wagon'); + // Name wagons by their physical number — the consist is drawn in the train's + // coupling order, so a slot's sequenceNo is not the position staff can see. + const slotLabel = (slot: TrainSetWagon) => + slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`; + const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) => + slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon'); const checkReceives = ( allocs: WagonBookingAllocation[], label: string, @@ -7468,7 +7556,7 @@ export class TrainSchedulingService { if (targetAllocs.length) { checkReceives( targetAllocs, - `#${source.sequenceNo}`, + slotLabel(source), source.wagonType, Number(source.capacityTons), ); @@ -7478,19 +7566,6 @@ export class TrainSchedulingService { const slotRepo = manager.getRepository(TrainSetWagon); const allocs = manager.getRepository(WagonBookingAllocation); - // Empty consist wagon: repin the loaded slot onto that physical wagon. - // Allocations and load fields stay put; only the wagon identity changes. - if (consistWagon) { - await slotRepo.update(source.id, { - physicalWagonId: consistWagon.id, - wagonTypeId: consistWagon.wagonTypeId, - capacityTons: roundTons(Number(consistWagon.wagonType?.capacityTons ?? source.capacityTons)), - lengthMeters: roundTons(Number(consistWagon.wagonType?.lengthMeters ?? source.lengthMeters)), - }); - return; - } - - const target = targetSlot as TrainSetWagon; // Load-coupled slot fields travel with the load; wagon identity stays. const loadFieldsOf = (slot: TrainSetWagon) => ({ assignedWeightTons: slot.assignedWeightTons, @@ -7505,6 +7580,47 @@ export class TrainSchedulingService { alightYardId: null, }; const sourceLoadFields = loadFieldsOf(source); + + // Empty consist wagon with no slot row yet: give it one, then move the + // load into it. Repinning the SOURCE slot onto that wagon would have been + // fewer writes, but it renames the wagons instead of moving the load — + // the loaded slot becomes wagon B and B's identity pops out as an empty + // wagon where A used to be. Staff read that as the train re-ordering + // itself. A wagon must never change place because a container moved. + if (consistWagon) { + const { maxSequenceNo } = (await slotRepo + .createQueryBuilder('slot') + .select('COALESCE(MAX(slot.sequence_no), 0)', 'maxSequenceNo') + .where('slot.train_set_id = :trainSetId', { trainSetId: source.trainSetId }) + .getRawOne<{ maxSequenceNo: string | number }>()) ?? { maxSequenceNo: 0 }; + + const created = await slotRepo.save( + slotRepo.create({ + trainSetId: source.trainSetId, + wagonTypeId: consistWagon.wagonTypeId, + physicalWagonId: consistWagon.id, + // Plan-order key only — the consist is drawn in the train's coupling + // order (wagons.sequence_number), so appending here moves nothing. + // It just has to clear the (train_set_id, sequence_no) unique index. + sequenceNo: Number(maxSequenceNo) + 1, + capacityTons: roundTons( + Number(consistWagon.wagonType?.capacityTons ?? source.capacityTons), + ), + lengthMeters: roundTons( + Number(consistWagon.wagonType?.lengthMeters ?? source.lengthMeters), + ), + ...sourceLoadFields, + }), + ); + + for (const alloc of sourceAllocs) { + await allocs.update(alloc.id, { trainSetWagonId: created.id }); + } + await slotRepo.update(source.id, emptyLoadFields); + return; + } + + const target = targetSlot as TrainSetWagon; const targetLoadFields = targetAllocs.length ? loadFieldsOf(target) : emptyLoadFields; for (const alloc of sourceAllocs) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts new file mode 100644 index 000000000..47823cddd --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts @@ -0,0 +1,70 @@ +import { WagonStockLedger } from './wagon-stock-ledger.util'; + +const WHOLE = { fromEdge: 0, toEdge: 1 }; + +describe('WagonStockLedger', () => { + it('reports the wagons of a booking\'s OWN types, not the train total', () => { + // The reported case: 20 free wagons on the train, but only 16 of them NW5. + const ledger = new WagonStockLedger( + new Map([ + ['nw5', 16], + ['pw2', 4], + ]), + 1, + ); + expect(ledger.availableFor(['nw5'], WHOLE)).toBe(16); + expect(ledger.availableFor(['pw2'], WHOLE)).toBe(4); + // A cargo type mapped to both may ride either, so they add up. + expect(ledger.availableFor(['nw5', 'pw2'], WHOLE)).toBe(20); + // Duplicates must not double-count. + expect(ledger.availableFor(['nw5', 'nw5'], WHOLE)).toBe(16); + // An unconfigured type has no stock. + expect(ledger.availableFor(['unknown'], WHOLE)).toBe(0); + }); + + it('consumes what it can and reports the shortfall', () => { + const ledger = new WagonStockLedger(new Map([['nw5', 16]]), 1); + // A 20-wagon booking can only take 16 — the caller splits on that number. + expect(ledger.consume(['nw5'], 20, WHOLE)).toBe(16); + expect(ledger.availableFor(['nw5'], WHOLE)).toBe(0); + expect(ledger.consume(['nw5'], 1, WHOLE)).toBe(0); + }); + + it('drains the deepest stock first across candidate types', () => { + const ledger = new WagonStockLedger( + new Map([ + ['nw5', 10], + ['nw7', 3], + ]), + 1, + ); + expect(ledger.consume(['nw5', 'nw7'], 12, WHOLE)).toBe(12); + // 10 from NW5 then 2 from NW7 — one NW7 left. + expect(ledger.availableFor(['nw7'], WHOLE)).toBe(1); + expect(ledger.availableFor(['nw5'], WHOLE)).toBe(0); + }); + + it('frees stock past an alight yard — disjoint legs never compete', () => { + // Three stops (A→B→C) = two edges. An intercity booking riding A→B must + // not consume the wagon on B→C. + const ledger = new WagonStockLedger(new Map([['nw5', 5]]), 2); + const firstLeg = { fromEdge: 0, toEdge: 1 }; + const secondLeg = { fromEdge: 1, toEdge: 2 }; + + ledger.consume(['nw5'], 5, firstLeg); + expect(ledger.availableFor(['nw5'], firstLeg)).toBe(0); + expect(ledger.availableFor(['nw5'], secondLeg)).toBe(5); + + // A whole-route booking sees the busiest edge it crosses, so it is blocked. + expect(ledger.availableFor(['nw5'], { fromEdge: 0, toEdge: 2 })).toBe(0); + }); + + it('counts the busiest edge within a leg, not the sum of edges', () => { + const ledger = new WagonStockLedger(new Map([['nw5', 10]]), 3); + ledger.consume(['nw5'], 4, { fromEdge: 0, toEdge: 1 }); + ledger.consume(['nw5'], 6, { fromEdge: 1, toEdge: 2 }); + // Edge 0 uses 4, edge 1 uses 6 — a booking over both needs 10 free at once. + expect(ledger.availableFor(['nw5'], { fromEdge: 0, toEdge: 2 })).toBe(4); + expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 3 })).toBe(10); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts new file mode 100644 index 000000000..0e4f6949d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts @@ -0,0 +1,86 @@ +import type { CorridorLeg } from './corridor-capacity.util'; + +/** + * Physical wagon-type stock for one train, consumed per corridor edge. + * + * The {@link CorridorBudget} tracks ABSTRACT capacity — slots, pull weight, + * length. It cannot tell a NW5 from a PW2, so a train showing "20 free wagons" + * would admit a 20-wagon booking whose cargo only rides NW5 even when the yard + * holds 16 NW5 and 4 PW2. The batch selected all 20, the customer paid for 20, + * and allocation then failed on wagon 17 with "No NW5 wagon available at the + * yard" — money taken for space that never existed. + * + * This ledger is the missing axis: how many wagons of the types a booking may + * actually ride are free. Batch fill consults it alongside the budget, so a + * booking is admitted whole only when both agree, and is otherwise offered a + * split sized to the wagons that genuinely exist. + * + * Stock is consumed PER EDGE, mirroring `planWagonsWithStock`: a wagon freed at + * an alight yard is available again downstream, so an intercity ride-along on + * Gelan→Adama never competes for stock with an export on Adama→Doraleh. + */ +export class WagonStockLedger { + private readonly usedPerEdge = new Map(); + + constructor( + private readonly remainingByTypeId: Map, + private readonly edgeCount: number, + ) {} + + /** Free wagons of ONE type on a leg: total minus its busiest edge within that leg. */ + private availableForType(wagonTypeId: string, leg: CorridorLeg): number { + const total = this.remainingByTypeId.get(wagonTypeId) ?? 0; + const row = this.usedPerEdge.get(wagonTypeId); + if (!row) return total; + let busiest = 0; + for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) { + busiest = Math.max(busiest, row[edge] ?? 0); + } + return Math.max(0, total - busiest); + } + + /** + * Free wagons across every type a booking may ride. A cargo/container type + * mapped to several wagon types can use any of them, so they add up. + */ + availableFor(wagonTypeIds: readonly string[], leg: CorridorLeg): number { + let total = 0; + for (const id of new Set(wagonTypeIds)) { + total += this.availableForType(id, leg); + } + return total; + } + + /** + * Take `wagons` from the candidate types, deepest stock first so the consist + * drains evenly (same tie-break as the wagon planner). Returns how many were + * actually taken — less than asked when the stock is short. + */ + consume(wagonTypeIds: readonly string[], wagons: number, leg: CorridorLeg): number { + let outstanding = Math.max(0, Math.floor(wagons)); + const candidates = [...new Set(wagonTypeIds)]; + let taken = 0; + + while (outstanding > 0) { + const deepest = candidates + .map((id) => ({ id, free: this.availableForType(id, leg) })) + .filter((c) => c.free > 0) + .sort((a, b) => b.free - a.free)[0]; + if (!deepest) break; + + const take = Math.min(outstanding, deepest.free); + let row = this.usedPerEdge.get(deepest.id); + if (!row) { + row = new Array(this.edgeCount).fill(0); + this.usedPerEdge.set(deepest.id, row); + } + for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) { + row[edge] = (row[edge] ?? 0) + take; + } + outstanding -= take; + taken += take; + } + + return taken; + } +} diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts index 4ad52a226..5c26d2475 100644 --- a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts @@ -6,7 +6,7 @@ import { TrainSet } from './train-set.entity'; /** * Link row joining a train set to one of its locomotives. A train set must be - * pulled by at least two locomotives (front + back); `sequenceNo` is a plain + * pulled by at least one locomotive; `sequenceNo` is a plain * order index — no front/rear semantics are modelled yet. */ @Entity({ schema: 'freight', name: 'train_set_locomotives' }) diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts index c82cfd2eb..5f98ea608 100644 --- a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts @@ -29,7 +29,7 @@ export class TrainSet extends BaseEntity { @JoinColumn({ name: 'locomotive_id' }) locomotive?: Locomotive; - /** All locomotives pulling this train set (minimum 2). */ + /** All locomotives pulling this train set (minimum 1). */ @OneToMany(() => TrainSetLocomotive, (link) => link.trainSet) locomotives?: TrainSetLocomotive[]; diff --git a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts index 5ba77fb10..54322c789 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts @@ -33,10 +33,10 @@ export class BuildTrainDto { @ApiProperty({ type: [String], format: 'uuid', - description: 'Locomotives pulling the train (minimum 2 — front and back), in consist order', + description: 'Locomotives pulling the train (minimum 1), in consist order', }) @IsArray() - @ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' }) + @ArrayMinSize(1, { message: 'A train must be pulled by at least one locomotive' }) @IsUUID('all', { each: true }) locomotiveIds!: string[]; diff --git a/apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts index 36562e970..0fab5ec5b 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts @@ -5,10 +5,10 @@ export class UpdateTrainLocomotivesDto { @ApiProperty({ type: [String], format: 'uuid', - description: 'Full replacement locomotive set (minimum 2), in consist order', + description: 'Full replacement locomotive set (minimum 1), in consist order', }) @IsArray() - @ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' }) + @ArrayMinSize(1, { message: 'A train must be pulled by at least one locomotive' }) @IsUUID('all', { each: true }) locomotiveIds!: string[]; } diff --git a/apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts index 681b39a55..0c834379e 100644 --- a/apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts +++ b/apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts @@ -6,7 +6,7 @@ import { Train } from './train.entity'; /** * Link row joining a built train to one of its locomotives. A train must be - * pulled by at least two locomotives (front + back); `sequenceNo` is the order + * pulled by at least one locomotive; `sequenceNo` is the order * in the consist — 0 is the lead locomotive. * * Mirrors `train_set_locomotives`, but for the persistent fleet `Train` built diff --git a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts index 493e564e8..7a1ea5b64 100644 --- a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts +++ b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts @@ -80,7 +80,7 @@ export class Train extends BaseEntity { @OneToMany(() => Wagon, (wagon) => wagon.train) wagons!: Wagon[]; - /** Locomotives pulling this train (minimum 2), ordered by sequenceNo. */ + /** Locomotives pulling this train (minimum 1), ordered by sequenceNo. */ @OneToMany(() => TrainLocomotive, (link) => link.train) locomotives?: TrainLocomotive[]; } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index 19d631fbc..2b74c0253 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -62,7 +62,7 @@ export class TrainBuilderController { @Put(':id/locomotives') @FleetManage(FREIGHT_PERMS.trains.update) - @ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' }) + @ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' }) setLocomotives( @Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTrainLocomotivesDto, diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 662b83534..6aed88f07 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -3,6 +3,7 @@ import { BadRequestException, ConflictException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { DataSource, EntityManager, ILike, In } from 'typeorm'; @@ -10,7 +11,7 @@ import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; -import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util'; +import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; @@ -55,12 +56,14 @@ export interface ActiveScheduleRef { */ @Injectable() export class TrainBuilderService { + private readonly logger = new Logger(TrainBuilderService.name); + constructor(private readonly dataSource: DataSource) {} async buildTrain(dto: BuildTrainDto) { const locomotiveIds = [...new Set(dto.locomotiveIds)]; - if (locomotiveIds.length < 2) { - throw new BadRequestException('A train must be pulled by at least two locomotives'); + if (locomotiveIds.length < 1) { + throw new BadRequestException('A train must be pulled by at least one locomotive'); } const trainId = await this.dataSource.transaction(async (manager) => { @@ -96,7 +99,7 @@ export class TrainBuilderService { ); // Effective haul capacity is capped by the weakest locomotive in the set. - const limits = minLocomotiveLimits(locomotives); + const limits = combinedLocomotiveLimits(locomotives); const train = await manager.getRepository(Train).save( manager.getRepository(Train).create({ code, @@ -283,7 +286,7 @@ export class TrainBuilderService { : null, })); - const limits = minLocomotiveLimits( + const limits = combinedLocomotiveLimits( (train.locomotives ?? []) .map((link) => link.locomotive) .filter((loco): loco is Locomotive => Boolean(loco)), @@ -339,11 +342,11 @@ export class TrainBuilderService { }; } - /** Replace the locomotive set (still minimum 2, same-yard rule applies). */ + /** Replace the locomotive set (minimum 1, same-yard rule applies). */ async setLocomotives(id: string, dto: UpdateTrainLocomotivesDto) { const locomotiveIds = [...new Set(dto.locomotiveIds)]; - if (locomotiveIds.length < 2) { - throw new BadRequestException('A train must be pulled by at least two locomotives'); + if (locomotiveIds.length < 1) { + throw new BadRequestException('A train must be pulled by at least one locomotive'); } await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); @@ -360,7 +363,7 @@ export class TrainBuilderService { train.id, ); await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds); - const limits = minLocomotiveLimits(locomotives); + const limits = combinedLocomotiveLimits(locomotives); await manager .getRepository(Train) .update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) }); @@ -520,6 +523,28 @@ export class TrainBuilderService { sequenceNumber: null, status: WagonStatus.Maintenance, }); + // Audit row: which train it came off and when. The wagon does not change + // yard here, so from/to are the same — the ledger is the wagon's history + // surface, and a maintenance detach has to be in it. + const yardId = wagon.currentYardId ?? train.currentYardId ?? null; + if (yardId) { + await manager.getRepository(WagonMovement).save( + manager.getRepository(WagonMovement).create({ + wagonId: wagon.id, + fromYardId: yardId, + toYardId: yardId, + kind: WagonMovementKind.Maintenance, + note: `Sent to maintenance from train ${train.trainNumber ?? train.code}`, + occurredAt: new Date(), + }), + ); + } else { + // to_yard_id is NOT NULL — a yard-less wagon still goes to maintenance, + // it just cannot carry a ledger row. + this.logger.warn( + `Wagon ${wagon.wagonNumber} sent to maintenance with no yard — ledger row skipped`, + ); + } await this.resequenceWagons(manager, train.id); }); return this.getComposition(id); @@ -548,6 +573,26 @@ export class TrainBuilderService { return rows.length > 0; } + /** Batched form of {@link isWagonPinnedToLiveSchedule} for a whole consist. */ + private async isAnyWagonPinnedToLiveSchedule( + manager: EntityManager, + wagonIds: string[], + ): Promise { + if (!wagonIds.length) return false; + const rows: { exists: boolean }[] = await manager.query( + `SELECT TRUE AS exists + FROM freight.train_set_wagons tsw + JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id + WHERE tsw.physical_wagon_id = ANY($1::uuid[]) + AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') + AND ts.deleted_at IS NULL + AND tsw.deleted_at IS NULL + LIMIT 1`, + [wagonIds], + ); + return rows.length > 0; + } + /** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */ async reorderWagons(id: string, dto: ReorderTrainWagonsDto) { await this.dataSource.transaction(async (manager) => { @@ -560,6 +605,17 @@ export class TrainBuilderService { if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) { throw new BadRequestException('Reorder must include every wagon of the train exactly once'); } + // A live schedule (DRAFT/SCHEDULED/DISPATCHED) reads each wagon's slot at + // its OWN frozen sequenceNo, never the wagon's live sequenceNumber — so + // renumbering here would silently desync that schedule's drawn consist + // from the built train's real order (loaded slots keep the old order, + // empty ones show the new one). Same guard as remove/maintenance. + if (await this.isAnyWagonPinnedToLiveSchedule(manager, [...current])) { + throw new ConflictException( + 'This train has wagons pinned to an active schedule and cannot be reordered — ' + + "it would desync the schedule's consist view from the built train's real order.", + ); + } for (let i = 0; i < dto.wagonIds.length; i++) { await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 }); } @@ -712,7 +768,16 @@ export class TrainBuilderService { totalLengthMeters: round( wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0), ), - maxPullWeightTons: round(train.capacityTons), + // Derived live from the coupled set, NOT from the stored capacity_tons. + // That column is written at build/re-couple time, so every train built + // before pull weight became additive still holds the old single-locomotive + // figure. Computing it here keeps the board honest without a backfill; + // the column self-heals the next time the locomotive set is saved. + maxPullWeightTons: round( + combinedLocomotiveLimits(locomotives)?.maxPullWeightTons ?? + Number(train.capacityTons) ?? + 0, + ), }; } @@ -847,7 +912,7 @@ export class TrainBuilderService { where: { trainId: train.id }, relations: { locomotive: true }, }); - const limits = minLocomotiveLimits( + const limits = combinedLocomotiveLimits( links .map((link) => link.locomotive) .filter((loco): loco is Locomotive => Boolean(loco)), diff --git a/apps/edr-freight-api/src/modules/truck-types/dto/create-truck-type.dto.ts b/apps/edr-freight-api/src/modules/truck-types/dto/create-truck-type.dto.ts new file mode 100644 index 000000000..ec673a471 --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/dto/create-truck-type.dto.ts @@ -0,0 +1,55 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +const toNumber = ({ value }: { value: unknown }) => + value === '' || value == null ? value : Number(value); + +const toBoolean = ({ value }: { value: unknown }) => { + if (typeof value === 'boolean') return value; + if (value === 'true') return true; + if (value === 'false') return false; + return value; +}; + +export class CreateTruckTypeDto { + @ApiProperty({ maxLength: 32, example: 'CASONI' }) + @IsString() + @MaxLength(32) + code!: string; + + @ApiProperty({ maxLength: 100, example: 'Casoni (rigid, no trailer)' }) + @IsString() + @MaxLength(100) + name!: string; + + @ApiPropertyOptional({ + description: 'Payload capacity in metric tons — pre-fills a vehicle registered against this type', + example: 30, + }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0) + capacityTons?: number; + + @ApiPropertyOptional({ + description: 'Whether this configuration pulls a trailer. False (e.g. Casoni) forbids a trailer plate.', + default: false, + }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + hasTrailer?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/truck-types/dto/update-truck-type.dto.ts b/apps/edr-freight-api/src/modules/truck-types/dto/update-truck-type.dto.ts new file mode 100644 index 000000000..269bce685 --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/dto/update-truck-type.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/mapped-types'; + +import { CreateTruckTypeDto } from './create-truck-type.dto'; + +export class UpdateTruckTypeDto extends PartialType(CreateTruckTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/truck-types/entities/truck-type.entity.ts b/apps/edr-freight-api/src/modules/truck-types/entities/truck-type.entity.ts new file mode 100644 index 000000000..4a09dcd40 --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/entities/truck-type.entity.ts @@ -0,0 +1,40 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * A truck configuration EDR registers vehicles against — back-office managed so + * new configurations arrive without a code change. + * + * Two fields drive vehicle registration: + * - `capacityTons` pre-fills a vehicle's capacity (capacity belongs to the type, + * not to each individual truck). + * - `hasTrailer` decides whether a trailer plate applies at all. A rigid truck + * (e.g. Casoni) has none, and registering one with a trailer plate is rejected. + */ +@Entity({ schema: 'freight', name: 'truck_types' }) +@Index(['code']) +@Index(['isActive']) +export class TruckType extends BaseEntity { + /** + * Matching key, upper-case. Denormalised onto `vehicles.vehicle_type`, which + * truck-detention billing groups and matches fee rules by — so a code change + * here is a billing-visible change. + */ + @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) + code!: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name!: string; + + @Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3, nullable: true }) + capacityTons?: number | null; + + @Column({ name: 'has_trailer', type: 'boolean', default: false }) + hasTrailer!: boolean; + + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.controller.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.controller.ts new file mode 100644 index 000000000..516d1d7d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.controller.ts @@ -0,0 +1,79 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + RuleEngineCreate, + RuleEngineDelete, + RuleEngineUpdate, + RuleEngineView, +} from '../../common/rule-engine-guards'; + +import { CreateTruckTypeDto } from './dto/create-truck-type.dto'; +import { UpdateTruckTypeDto } from './dto/update-truck-type.dto'; +import { TruckTypesService } from './truck-types.service'; + +@ApiTags('truck-types') +@Controller('truck-types') +@ApiBearerAuth() +export class TruckTypesController { + constructor(private readonly truckTypesService: TruckTypesService) {} + + @Get() + @RuleEngineView('truck-types') + @ApiOperation({ summary: 'List truck types' }) + findAll(@Query() query: Record) { + return this.truckTypesService.findAll({ + isActive: + query.isActive === 'all' + ? undefined + : query.isActive !== undefined + ? query.isActive === 'true' + : true, + page: query.page ? parseInt(query.page, 10) : undefined, + pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + }); + } + + @Get(':id') + @RuleEngineView('truck-types') + @ApiOperation({ summary: 'Get a truck type by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.truckTypesService.findById(id); + } + + @Post() + @RuleEngineCreate('truck-types') + @ApiOperation({ summary: 'Create a truck type' }) + create(@Body() dto: CreateTruckTypeDto) { + return this.truckTypesService.create(dto); + } + + @Patch(':id') + @RuleEngineUpdate('truck-types') + @ApiOperation({ summary: 'Update a truck type' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTruckTypeDto) { + return this.truckTypesService.update(id, dto); + } + + @Delete(':id') + @RuleEngineDelete('truck-types') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a truck type' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.truckTypesService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.module.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.module.ts new file mode 100644 index 000000000..466caa91f --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { TruckType } from './entities/truck-type.entity'; +import { TruckTypesController } from './truck-types.controller'; +import { TruckTypesRepository } from './truck-types.repository'; +import { TruckTypesService } from './truck-types.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([TruckType])], + controllers: [TruckTypesController], + providers: [TruckTypesRepository, TruckTypesService], + exports: [TruckTypesRepository, TruckTypesService], +}) +export class TruckTypesModule {} diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.repository.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.repository.ts new file mode 100644 index 000000000..bb803bd8c --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.repository.ts @@ -0,0 +1,20 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TruckType } from './entities/truck-type.entity'; + +@Injectable() +export class TruckTypesRepository extends BaseRepository { + constructor( + @InjectRepository(TruckType) + repository: Repository, + ) { + super(repository); + } + + findByCode(code: string): Promise { + return this.repository.findOne({ where: { code } }); + } +} diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.service.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.service.ts new file mode 100644 index 000000000..1cc6421d6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.service.ts @@ -0,0 +1,116 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { FindOptionsOrder } from 'typeorm'; + +import { CreateTruckTypeDto } from './dto/create-truck-type.dto'; +import { UpdateTruckTypeDto } from './dto/update-truck-type.dto'; +import { TruckType } from './entities/truck-type.entity'; +import { TruckTypesRepository } from './truck-types.repository'; + +type TruckTypeListFilter = { + isActive?: boolean; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: string; +}; + +@Injectable() +export class TruckTypesService { + constructor(private readonly truckTypesRepository: TruckTypesRepository) {} + + async findAll(filter: TruckTypeListFilter = {}): Promise<{ + data: TruckType[]; + meta: { total: number; page: number; pageSize: number; totalPages: number }; + }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 500; + const sortBy = ['code', 'name', 'capacityTons', 'hasTrailer', 'isActive'].includes( + filter.sortBy ?? '', + ) + ? (filter.sortBy as keyof TruckType) + : 'code'; + const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + const [data, total] = await this.truckTypesRepository.findAndCount({ + where: filter.isActive === undefined ? {} : { isActive: filter.isActive }, + order: { [sortBy]: sortOrder } as FindOptionsOrder, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + return { + data, + meta: { + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }, + }; + } + + async findById(id: string): Promise { + const truckType = await this.truckTypesRepository.findById(id); + + if (!truckType) { + throw new NotFoundException(`Truck type ${id} not found`); + } + + return truckType; + } + + async findByCode(code: string): Promise { + const truckType = await this.truckTypesRepository.findByCode(code); + if (!truckType) { + throw new NotFoundException(`Truck type ${code} not found`); + } + return truckType; + } + + async create(dto: CreateTruckTypeDto): Promise { + const code = dto.code.trim().toUpperCase(); + const existing = await this.truckTypesRepository.findByCode(code); + + if (existing) { + throw new ConflictException(`Truck type code "${code}" already exists`); + } + + return this.truckTypesRepository.create({ + code, + name: dto.name.trim(), + capacityTons: dto.capacityTons ?? null, + hasTrailer: dto.hasTrailer ?? false, + description: dto.description?.trim() ?? null, + isActive: dto.isActive ?? true, + }); + } + + async update(id: string, dto: UpdateTruckTypeDto): Promise { + const truckType = await this.findById(id); + const nextCode = dto.code?.trim().toUpperCase(); + + if (nextCode && nextCode !== truckType.code) { + const existing = await this.truckTypesRepository.findByCode(nextCode); + if (existing) { + throw new ConflictException(`Truck type code "${nextCode}" already exists`); + } + } + + const updated = await this.truckTypesRepository.update(id, { + ...dto, + ...(nextCode ? { code: nextCode } : {}), + ...(dto.name ? { name: dto.name.trim() } : {}), + }); + + if (!updated) { + throw new NotFoundException(`Truck type ${id} not found`); + } + + return updated; + } + + async remove(id: string): Promise { + await this.findById(id); + await this.truckTypesRepository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index 4dda3c053..79ecc76c5 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -1,6 +1,11 @@ import { IsString, IsEnum, IsNumber, IsOptional, IsUUID, Matches } from 'class-validator'; import { Transform } from 'class-transformer'; -import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity'; +import { + FuelType, + VehicleAvailability, + VehicleOwnership, + VehicleStatus, +} from '../entities/vehicle.entity'; /** * A vehicle plate is two or three letters, a hyphen, then two to six digits — @@ -28,8 +33,9 @@ export class CreateVehicleDto { @IsString() plateNumber!: string; - @IsEnum(VehicleType) - vehicleType!: VehicleType; + /** Truck configuration from `freight.truck_types` — drives capacity and whether a trailer plate applies. */ + @IsUUID() + truckTypeId!: string; @IsString() manufacturer!: string; @@ -43,8 +49,18 @@ export class CreateVehicleDto { @IsEnum(FuelType) fuelType!: FuelType; + /** Defaults to the truck type's capacity when omitted. */ + @IsOptional() @IsNumber() - capacity!: number; + capacity?: number; + + @IsOptional() + @IsString() + vin?: string; + + @IsOptional() + @IsEnum(VehicleOwnership) + ownership?: VehicleOwnership; @IsEnum(VehicleStatus) status!: VehicleStatus; diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts index 534019bc4..59725fc5e 100644 --- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts +++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts @@ -1,6 +1,17 @@ import { Entity, Column } from 'typeorm'; import { BaseEntity } from '@edr/api-common'; +/** + * Legacy classification. Truck configurations are now back-office data in + * `freight.truck_types` — register a vehicle with `truckTypeId`, not this. + * + * The `vehicle_type` COLUMN survives as a denormalised copy of the truck type's + * code because truck-detention billing groups by it in raw SQL and matches it + * against `warehouse_fee_rules.vehicle_type`. The service writes it through on + * every save; nothing should set it by hand. + * + * @deprecated use `truckTypeId` / `freight.truck_types` + */ export enum VehicleType { TRUCK = 'TRUCK', VAN = 'VAN', @@ -11,6 +22,12 @@ export enum VehicleType { FLATBED = 'FLATBED', } +/** Who supplies the truck. Supplier selection is deferred until EDR commits to outsourcing. */ +export enum VehicleOwnership { + OWNED = 'OWNED', + OUTSOURCED = 'OUTSOURCED', +} + export enum FuelType { PETROL = 'PETROL', DIESEL = 'DIESEL', @@ -47,8 +64,12 @@ export class Vehicle extends BaseEntity { @Column({ name: 'registration_number', unique: true, nullable: true }) registrationNumber?: string; + /** Denormalised `truck_types.code` — written through by the service, never set by hand. */ @Column({ name: 'vehicle_type', type: 'varchar', nullable: true }) - vehicleType?: VehicleType; + vehicleType?: string; + + @Column({ name: 'truck_type_id', type: 'uuid', nullable: true }) + truckTypeId?: string | null; @Column({ nullable: true }) manufacturer?: string; @@ -101,7 +122,7 @@ export class Vehicle extends BaseEntity { @Column({ name: 'vin', type: 'varchar', nullable: true }) vin?: string; - /** Owned | Leased | Rented */ + /** OWNED | OUTSOURCED — see {@link VehicleOwnership}. */ @Column({ name: 'ownership', type: 'varchar', nullable: true }) ownership?: string; diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts index cb810abaf..6171602d7 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts @@ -11,6 +11,7 @@ describe('VehiclesService driver assignment guard', () => { new VehiclesService( { findOne, create: jest.fn((x) => x), save: jest.fn(async (x) => x) } as any, { record: jest.fn() } as any, + { findById: jest.fn(async () => ({ code: 'TRUCK', name: 'Truck', hasTrailer: true })) } as any, ); it('rejects create when the driver is on another truck', async () => { @@ -18,7 +19,7 @@ describe('VehiclesService driver assignment guard', () => { const findOne = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(otherTruck); const svc = makeService(findOne); await expect( - svc.create({ plateNumber: '3-22222', vehicleType: 'TRUCK', assignedDriverId: 'd1' } as any), + svc.create({ plateNumber: '3-22222', truckTypeId: 'tt1', assignedDriverId: 'd1' } as any), ).rejects.toThrow(ConflictException); }); diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts index 07aa4bd2f..a3febac70 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Vehicle } from './entities/vehicle.entity'; import { VehiclesService } from './vehicles.service'; import { VehiclesController } from './vehicles.controller'; +import { TruckTypesModule } from '../truck-types/truck-types.module'; @Module({ - imports: [TypeOrmModule.forFeature([Vehicle])], + imports: [TypeOrmModule.forFeature([Vehicle]), TruckTypesModule], providers: [VehiclesService], controllers: [VehiclesController], exports: [VehiclesService], diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 98d38cbce..30c72e1ba 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -1,9 +1,16 @@ -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Not, Repository } from 'typeorm'; import { CreateVehicleDto } from './dto/create-vehicle.dto'; import { UpdateVehicleDto } from './dto/update-vehicle.dto'; import { Vehicle, VehicleAvailability, VehicleStatus } from './entities/vehicle.entity'; +import { TruckType } from '../truck-types/entities/truck-type.entity'; +import { TruckTypesService } from '../truck-types/truck-types.service'; import { FirstMile, FirstMileStatus } from '../first-mile/entities/first-mile.entity'; import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity'; import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity'; @@ -18,8 +25,26 @@ export class VehiclesService { @InjectRepository(Vehicle) private readonly vehicleRepo: Repository, private readonly history: FleetHistoryService, + private readonly truckTypes: TruckTypesService, ) {} + /** + * A trailer plate only exists on a configuration that pulls a trailer — a + * rigid truck (Casoni) has none. Checked against the RESULTING record, not + * just the patch, so switching an articulated truck to a rigid type cannot + * leave its old trailer plate stranded on the row. + */ + private assertTrailerPlateAllowed( + truckType: TruckType, + trailerPlateNo?: string | null, + ): void { + if (!truckType.hasTrailer && trailerPlateNo) { + throw new BadRequestException( + `${truckType.name} has no trailer — remove the trailer plate number`, + ); + } + } + /** * A driver holds one truck at a time — reassignment requires detaching them * from their current truck first. @@ -54,10 +79,17 @@ export class VehiclesService { await this.assertDriverUnassigned(dto.assignedDriverId); } - const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`; + const truckType = await this.truckTypes.findById(dto.truckTypeId); + this.assertTrailerPlateAllowed(truckType, dto.trailerPlateNo); + + const registrationNumber = `REG-${truckType.code}-${Date.now()}`; const vehicle = this.vehicleRepo.create({ ...dto, registrationNumber, + // Denormalised for truck-detention billing, which groups on this column. + vehicleType: truckType.code, + // Capacity belongs to the type; an explicit value still wins for one-offs. + capacity: dto.capacity ?? truckType.capacityTons ?? undefined, }); const saved = await this.vehicleRepo.save(vehicle); @@ -148,6 +180,17 @@ export class VehiclesService { await this.assertDriverUnassigned(dto.assignedDriverId, id); } + // Re-resolve the truck type whenever the type OR the trailer plate moves — + // either edit can produce a rigid truck holding a trailer plate. + const nextTruckTypeId = dto.truckTypeId ?? vehicle.truckTypeId; + let nextTruckType: TruckType | null = null; + if (nextTruckTypeId && (dto.truckTypeId !== undefined || dto.trailerPlateNo !== undefined)) { + nextTruckType = await this.truckTypes.findById(nextTruckTypeId); + const nextTrailerPlate = + dto.trailerPlateNo !== undefined ? dto.trailerPlateNo : vehicle.trailerPlateNo; + this.assertTrailerPlateAllowed(nextTruckType, nextTrailerPlate); + } + const prev = { assignedDriverId: vehicle.assignedDriverId, assignedDriverName: vehicle.assignedDriverName, @@ -156,6 +199,11 @@ export class VehiclesService { }; Object.assign(vehicle, dto); + // After the patch is applied, so the denormalised billing code always + // reflects the type the vehicle actually ends up on. + if (nextTruckType) { + vehicle.vehicleType = nextTruckType.code; + } const saved = await this.vehicleRepo.save(vehicle); // Driver (re)assignment — emit an unassign for the old driver and/or an diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.trailer-plate-guard.spec.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.trailer-plate-guard.spec.ts new file mode 100644 index 000000000..e8f73a38f --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.trailer-plate-guard.spec.ts @@ -0,0 +1,81 @@ +import { BadRequestException } from '@nestjs/common'; + +import { VehiclesService } from './vehicles.service'; + +// A trailer plate only exists on a configuration that pulls a trailer. A rigid +// truck (Casoni) has none, so registering or editing one into a trailer plate +// must be refused server-side — the form hiding the field is not enforcement. +describe('VehiclesService trailer plate guard', () => { + const CASONI = { code: 'CASONI', name: 'Casoni (rigid, no trailer)', hasTrailer: false, capacityTons: 30 }; + const ARTIC = { code: 'TRUCK', name: 'Truck', hasTrailer: true, capacityTons: 40 }; + + const makeService = (findOne: jest.Mock, truckType: unknown) => { + const save = jest.fn(async (x) => x); + const svc = new VehiclesService( + { findOne, create: jest.fn((x) => x), save } as any, + { record: jest.fn() } as any, + { findById: jest.fn(async () => truckType) } as any, + ); + return { svc, save }; + }; + + it('rejects creating a rigid truck that carries a trailer plate', async () => { + const findOne = jest.fn().mockResolvedValueOnce(null); // plate is free + const { svc } = makeService(findOne, CASONI); + await expect( + svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-casoni', trailerPlateNo: 'ET-1234' } as any), + ).rejects.toThrow(BadRequestException); + }); + + it('accepts a rigid truck with no trailer plate, and takes capacity from the type', async () => { + const findOne = jest.fn().mockResolvedValueOnce(null); + const { svc } = makeService(findOne, CASONI); + const saved = await svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-casoni' } as any); + expect(saved.capacity).toBe(30); + // Denormalised code is what truck-detention billing groups on. + expect(saved.vehicleType).toBe('CASONI'); + }); + + it('keeps an explicit capacity over the type default', async () => { + const findOne = jest.fn().mockResolvedValueOnce(null); + const { svc } = makeService(findOne, CASONI); + const saved = await svc.create({ + plateNumber: 'ET-9875', + truckTypeId: 'tt-casoni', + capacity: 25, + } as any); + expect(saved.capacity).toBe(25); + }); + + it('allows a trailer plate on an articulated type', async () => { + const findOne = jest.fn().mockResolvedValueOnce(null); + const { svc } = makeService(findOne, ARTIC); + await expect( + svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-truck', trailerPlateNo: 'ET-1234' } as any), + ).resolves.toBeDefined(); + }); + + // The regression that motivated validating the RESULT rather than the patch: + // switching type alone leaves the stored trailer plate behind. + it('rejects switching an existing truck to a rigid type while its trailer plate stands', async () => { + const findOne = jest + .fn() + .mockResolvedValueOnce({ id: 'v1', plateNumber: 'ET-9875', trailerPlateNo: 'ET-1234' }); + const { svc } = makeService(findOne, CASONI); + await expect(svc.update('v1', { truckTypeId: 'tt-casoni' } as any)).rejects.toThrow( + BadRequestException, + ); + }); + + it('allows the switch when the trailer plate is cleared in the same edit', async () => { + const findOne = jest + .fn() + .mockResolvedValueOnce({ id: 'v1', plateNumber: 'ET-9875', trailerPlateNo: 'ET-1234' }); + const { svc } = makeService(findOne, CASONI); + const saved = await svc.update('v1', { + truckTypeId: 'tt-casoni', + trailerPlateNo: null, + } as any); + expect(saved.vehicleType).toBe('CASONI'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts index 8f6417220..fca35c124 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts @@ -13,7 +13,7 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { RuleEngineManage, RuleEngineView } from '../../common/rule-engine-guards'; +import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../common/rule-engine-guards'; import { CreateWagonTypeDto } from './dto/create-wagon-type.dto'; import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto'; @@ -51,21 +51,21 @@ export class WagonTypesController { } @Post() - @RuleEngineManage('wagon-types') + @RuleEngineCreate('wagon-types') @ApiOperation({ summary: 'Create a wagon type' }) create(@Body() dto: CreateWagonTypeDto) { return this.wagonTypesService.create(dto); } @Patch(':id') - @RuleEngineManage('wagon-types') + @RuleEngineUpdate('wagon-types') @ApiOperation({ summary: 'Update a wagon type' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonTypeDto) { return this.wagonTypesService.update(id, dto); } @Delete(':id') - @RuleEngineManage('wagon-types') + @RuleEngineDelete('wagon-types') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a wagon type' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/wagons/dto/close-short-transfer-request.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/close-short-transfer-request.dto.ts new file mode 100644 index 000000000..a277cfe72 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/close-short-transfer-request.dto.ts @@ -0,0 +1,17 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +/** + * OCC ends a transfer request with fewer wagons than asked for. The note is + * carried into the requester's notification — it is what tells them WHY the + * yard could not give the rest. + */ +export class CloseShortTransferRequestDto { + @ApiPropertyOptional({ + description: 'Why the source yard cannot supply the remainder', + }) + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-transfer-requests-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-transfer-requests-query.dto.ts new file mode 100644 index 000000000..733b774bb --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-transfer-requests-query.dto.ts @@ -0,0 +1,44 @@ +import { WagonTransferRequestStatus } from '@edr/types'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsIn, IsOptional, IsUUID } from 'class-validator'; + +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; + +const SORT_FIELDS = ['createdAt', 'quantity', 'status'] as const; + +/** + * Transfer-desk list query. `status` accepts a comma-separated list so the + * "Open" tab can ask for PENDING + PARTIALLY_FULFILLED in one call. + */ +export class ListTransferRequestsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ + description: 'One status or a comma-separated list', + enum: WagonTransferRequestStatus, + }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim() : undefined, + ) + status?: string; + + @ApiPropertyOptional({ description: 'Source yard' }) + @IsOptional() + @IsUUID() + fromYardId?: string; + + @ApiPropertyOptional({ description: 'Destination yard' }) + @IsOptional() + @IsUUID() + toYardId?: string; + + @ApiPropertyOptional({ description: 'Wagon type' }) + @IsOptional() + @IsUUID() + wagonTypeId?: string; + + @ApiPropertyOptional({ enum: SORT_FIELDS, default: 'createdAt' }) + @IsOptional() + @IsIn([...SORT_FIELDS]) + sortBy?: (typeof SORT_FIELDS)[number]; +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts index c7eecfc02..328a6eaa8 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts @@ -1,7 +1,17 @@ import { WagonStatus } from '@edr/types'; import { ApiPropertyOptional } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; +import { Transform, Type } from 'class-transformer'; +import { + IsBoolean, + IsDateString, + IsEnum, + IsInt, + IsOptional, + IsString, + IsUUID, + Max, + Min, +} from 'class-validator'; export class ListWagonsQueryDto { @ApiPropertyOptional({ description: 'Search wagon number (partial match)' }) @@ -29,6 +39,15 @@ export class ListWagonsQueryDto { @IsUUID() trainId?: string; + @ApiPropertyOptional({ + description: + 'Only loose wagons (not coupled to a built train) — what a picker can actually take.', + }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => value === true || value === 'true') + @IsBoolean() + unassigned?: boolean; + @ApiPropertyOptional({ description: 'Filter by run number — matches export OR import run (e.g. 8001).', }) @@ -53,11 +72,21 @@ export class ListWagonsQueryDto { @Min(1) page?: number; - @ApiPropertyOptional({ minimum: 1, maximum: 500 }) + @ApiPropertyOptional({ default: 10, minimum: 1, maximum: 100 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) - @Max(500) - limit?: number; + @Max(100) + pageSize?: number; + + @ApiPropertyOptional({ description: 'Registered on or after this day (YYYY-MM-DD)' }) + @IsOptional() + @IsDateString() + createdFrom?: string; + + @ApiPropertyOptional({ description: 'Registered on or before this day (YYYY-MM-DD)' }) + @IsOptional() + @IsDateString() + createdTo?: string; } diff --git a/apps/edr-freight-api/src/modules/wagons/dto/reorder-wagons.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/reorder-wagons.dto.ts deleted file mode 100644 index 0395adb8f..000000000 --- a/apps/edr-freight-api/src/modules/wagons/dto/reorder-wagons.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsArray, IsUUID } from 'class-validator'; - -export class ReorderWagonsDto { - @IsArray() - @IsUUID(4, { each: true }) - wagonIds!: string[]; -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts index c81b6c365..c39b12b9d 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts @@ -40,6 +40,14 @@ export class WagonTransferRequest extends BaseEntity { @Column({ name: 'quantity', type: 'int' }) quantity!: number; + /** + * How many have actually moved so far. OCC sends what the yard can spare, + * whenever it can — the request stays open until this reaches `quantity` or + * OCC closes it short. + */ + @Column({ name: 'fulfilled_quantity', type: 'int', default: 0 }) + fulfilledQuantity!: number; + @Column({ name: 'status', type: 'varchar', @@ -54,9 +62,17 @@ export class WagonTransferRequest extends BaseEntity { @Column({ name: 'fulfilled_by_user_id', type: 'uuid', nullable: true }) fulfilledByUserId?: string | null; + /** When the LAST transfer against this request ran (not necessarily the full count). */ @Column({ name: 'fulfilled_at', type: 'timestamptz', nullable: true }) fulfilledAt?: Date | null; + /** Set when OCC ended the request with fewer wagons than asked for. */ + @Column({ name: 'closed_short_at', type: 'timestamptz', nullable: true }) + closedShortAt?: Date | null; + + @Column({ name: 'closed_short_by_user_id', type: 'uuid', nullable: true }) + closedShortByUserId?: string | null; + @Column({ name: 'note', type: 'text', nullable: true }) note?: string | null; diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts index b00e4a64f..9e69c3bf6 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts @@ -1,4 +1,3 @@ -import { WagonTransferRequestStatus } from '@edr/types'; import { Body, Controller, @@ -13,26 +12,36 @@ import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { - FleetManage, - FleetView, + WagonTransferCancel, + WagonTransferCloseShort, WagonTransferFulfill, WagonTransferHistoryAll, WagonTransferRequest, + WagonTransferView, } from '../../common/booking-guards'; -import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto'; +import { CloseShortTransferRequestDto } from './dto/close-short-transfer-request.dto'; import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto'; +import { ListTransferRequestsQueryDto } from './dto/list-transfer-requests-query.dto'; import { WagonTransferRequestsService } from './wagon-transfer-requests.service'; +/** Query-string number, or undefined when absent/garbage (service defaults it). */ +const toInt = (value?: string): number | undefined => { + const n = Number.parseInt(String(value ?? ''), 10); + return Number.isFinite(n) && n > 0 ? n : undefined; +}; + /** - * Two-person wagon-transfer queue. Requester (transfer_request perm) files a - * count-only request; OCC (transfer_fulfill perm) picks the wagons and executes - * the move. Separate top-level path so it never collides with `wagons/:id`. + * The wagon-transfer desk. A requester (transfer_request) files a count-only + * request; OCC (transfer_fulfill) moves wagons against it in as many + * instalments as the source yard allows, and closes it short + * (transfer_close_short) when the yard has no more to give. Separate top-level + * path so it never collides with `wagons/:id`. */ @ApiTags('wagon-transfer-requests') @Controller('wagon-transfer-requests') -@FleetView(FREIGHT_PERMS.wagons.view) +@WagonTransferView() export class WagonTransferRequestsController { constructor(private readonly service: WagonTransferRequestsService) {} @@ -47,10 +56,12 @@ export class WagonTransferRequestsController { } @Get() - @ApiQuery({ name: 'status', required: false, enum: WagonTransferRequestStatus }) - @ApiOperation({ summary: 'List transfer requests (OCC queue: status=PENDING)' }) - list(@Query('status') status?: WagonTransferRequestStatus) { - return this.service.listRequests(status); + @ApiOperation({ + summary: + 'Transfer desk list — paginated, filterable by status (comma-separated), yards and wagon type', + }) + list(@Query() query: ListTransferRequestsQueryDto) { + return this.service.listRequests(query); } // NOTE: static routes (`history`, `bulk-fulfill`) MUST stay above `@Get(':id')` @@ -73,24 +84,48 @@ export class WagonTransferRequestsController { // matches in declaration order, so `/history` would otherwise be captured by // the `:id` param route (and rejected by ParseUUIDPipe). @Get('history') + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'pageSize', required: false }) @ApiOperation({ summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)", }) - myHistory(@CurrentUser() user: TCurrentUser) { + myHistory( + @CurrentUser() user: TCurrentUser, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { // Never fall through to the all-staff view: getHistory(undefined) means // "everyone", so a missing caller id must return empty, not leak scope. - if (!user?.id) return { requests: [], movements: [] }; - return this.service.getHistory(user.id); + if (!user?.id) { + return { + requests: [], + movements: [], + meta: { + page: 1, + pageSize: 20, + requestsTotal: 0, + movementsTotal: 0, + totalPages: 1, + }, + }; + } + return this.service.getHistory(user.id, toInt(page), toInt(pageSize)); } @Get('history/all') @WagonTransferHistoryAll() @ApiQuery({ name: 'userId', required: false }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'pageSize', required: false }) @ApiOperation({ summary: "Admin: any/all staff's transfer history (optional ?userId filter)", }) - allHistory(@Query('userId') userId?: string) { - return this.service.getHistory(userId); + allHistory( + @Query('userId') userId?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.service.getHistory(userId, toInt(page), toInt(pageSize)); } @Get(':id') @@ -110,9 +145,26 @@ export class WagonTransferRequestsController { return this.service.fulfillRequest(id, dto, user?.id); } + @Post(':id/close-short') + @WagonTransferCloseShort() + @ApiOperation({ + summary: + 'OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall', + }) + closeShort( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CloseShortTransferRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.closeShort(id, dto, user?.id); + } + @Post(':id/cancel') - @FleetManage(FREIGHT_PERMS.wagons.transferRequest) - @ApiOperation({ summary: 'Withdraw a pending transfer request' }) + @WagonTransferCancel() + @ApiOperation({ + summary: + 'Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)', + }) cancel(@Param('id', ParseUUIDPipe) id: string) { return this.service.cancelRequest(id); } diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts new file mode 100644 index 000000000..205d86450 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts @@ -0,0 +1,260 @@ +import { WagonTransferRequestStatus } from '@edr/types'; +import { ConflictException, BadRequestException } from '@nestjs/common'; + +import { WagonTransferRequestsService } from './wagon-transfer-requests.service'; +import type { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; + +/** + * Instalment fulfilment: a request for 50 wagons is met with whatever the source + * yard can spare, whenever it can spare it. It stays open until the full count + * lands or OCC closes it short — which is what tells the requester to go ask + * another yard. + */ +describe('WagonTransferRequestsService — partial fulfilment', () => { + const request = (over: Partial = {}): WagonTransferRequest => + ({ + id: 'req-1', + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 50, + fulfilledQuantity: 0, + status: WagonTransferRequestStatus.Pending, + requestedByUserId: 'user-1', + ...over, + }) as WagonTransferRequest; + + let requestRepo: { + findOne: jest.Mock; + find: jest.Mock; + save: jest.Mock; + create: jest.Mock; + createQueryBuilder: jest.Mock; + }; + let wagonRepo: { find: jest.Mock; count: jest.Mock }; + let wagonsService: { bulkTransfer: jest.Mock }; + let inbox: { notify: jest.Mock }; + let service: WagonTransferRequestsService; + let stored: WagonTransferRequest; + + const flush = () => new Promise((resolve) => setImmediate(resolve)); + + const build = (row: WagonTransferRequest) => { + stored = row; + requestRepo.findOne.mockImplementation(async () => stored); + requestRepo.save.mockImplementation(async (r: WagonTransferRequest) => { + stored = r; + return r; + }); + }; + + beforeEach(() => { + requestRepo = { + findOne: jest.fn(), + find: jest.fn().mockResolvedValue([]), + save: jest.fn(), + create: jest.fn((r) => r), + createQueryBuilder: jest.fn(), + }; + wagonRepo = { find: jest.fn().mockResolvedValue([]), count: jest.fn() }; + wagonsService = { bulkTransfer: jest.fn().mockResolvedValue(undefined) }; + inbox = { notify: jest.fn().mockResolvedValue(undefined) }; + service = new WagonTransferRequestsService( + requestRepo as never, + wagonRepo as never, + { find: jest.fn(), findAndCount: jest.fn() } as never, + wagonsService as never, + inbox as never, + ); + build(request()); + }); + + const availableWagons = (n: number) => + Array.from({ length: n }, (_, i) => ({ + id: `w-${i}`, + wagonNumber: `100${i}`, + currentYardId: 'yard-a', + wagonTypeId: 'type-1', + status: 'AVAILABLE', + })); + + describe('fulfillRequest', () => { + it('books an instalment and keeps the request open', async () => { + wagonRepo.find.mockResolvedValue(availableWagons(20)); + + await service.fulfillRequest('req-1', { + wagonIds: availableWagons(20).map((w) => w.id), + }); + + expect(stored.fulfilledQuantity).toBe(20); + expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled); + expect(wagonsService.bulkTransfer).toHaveBeenCalledTimes(1); + }); + + it('completes the request when the last instalment lands', async () => { + build(request({ fulfilledQuantity: 30, status: WagonTransferRequestStatus.PartiallyFulfilled })); + wagonRepo.find.mockResolvedValue(availableWagons(20)); + + await service.fulfillRequest('req-1', { + wagonIds: availableWagons(20).map((w) => w.id), + }); + + expect(stored.fulfilledQuantity).toBe(50); + expect(stored.status).toBe(WagonTransferRequestStatus.Fulfilled); + }); + + it('refuses to move more than is still owed', async () => { + build(request({ fulfilledQuantity: 45, status: WagonTransferRequestStatus.PartiallyFulfilled })); + wagonRepo.find.mockResolvedValue(availableWagons(10)); + + await expect( + service.fulfillRequest('req-1', { + wagonIds: availableWagons(10).map((w) => w.id), + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(wagonsService.bulkTransfer).not.toHaveBeenCalled(); + }); + + it('refuses to touch a request that is already closed', async () => { + build(request({ status: WagonTransferRequestStatus.ClosedShort, fulfilledQuantity: 20 })); + + await expect( + service.fulfillRequest('req-1', { wagonIds: ['w-0'] }), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('tells the requester what landed and what is still owed', async () => { + wagonRepo.find.mockResolvedValue(availableWagons(20)); + + await service.fulfillRequest('req-1', { + wagonIds: availableWagons(20).map((w) => w.id), + }); + await flush(); + + const sent = inbox.notify.mock.calls[0][0]; + expect(sent.recipients).toEqual({ userIds: ['user-1'] }); + expect(sent.body).toContain('20 wagon(s) have arrived'); + expect(sent.body).toContain('30 of 50 still to come'); + }); + }); + + describe('bulkFulfill', () => { + it('sends what the yard has instead of skipping a short request', async () => { + wagonRepo.find.mockResolvedValue(availableWagons(20)); + + const result = await service.bulkFulfill(['req-1']); + + expect(stored.fulfilledQuantity).toBe(20); + expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled); + expect(result.skipped).toHaveLength(0); + }); + + it('skips only when the yard has nothing to give', async () => { + wagonRepo.find.mockResolvedValue([]); + + const result = await service.bulkFulfill(['req-1']); + + expect(wagonsService.bulkTransfer).not.toHaveBeenCalled(); + expect(result.skipped[0].reason).toContain('No available wagons'); + }); + }); + + describe('closeShort', () => { + it('ends the request and tells the requester to ask another yard', async () => { + build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled })); + + await service.closeShort('req-1', { note: 'Yard is empty until Friday' }); + await flush(); + + expect(stored.status).toBe(WagonTransferRequestStatus.ClosedShort); + expect(stored.closedShortAt).toBeInstanceOf(Date); + const sent = inbox.notify.mock.calls[0][0]; + expect(sent.body).toContain('Only 20 of the 50'); + expect(sent.body).toContain('Yard is empty until Friday'); + expect(sent.body).toContain('Request the remaining 30'); + }); + + it('refuses when the request is already fully supplied', async () => { + build(request({ fulfilledQuantity: 50, status: WagonTransferRequestStatus.PartiallyFulfilled })); + + await expect(service.closeShort('req-1', {})).rejects.toBeInstanceOf( + ConflictException, + ); + }); + }); + + describe('cancelRequest', () => { + it('withdraws a request that never moved a wagon', async () => { + await service.cancelRequest('req-1'); + expect(stored.status).toBe(WagonTransferRequestStatus.Cancelled); + }); + + it('refuses once wagons have moved — close it short instead', async () => { + build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled })); + + await expect(service.cancelRequest('req-1')).rejects.toThrow( + /close it short/i, + ); + }); + }); + + describe('createRequest', () => { + it('accepts a count larger than what the yard holds today', async () => { + wagonRepo.count.mockResolvedValue(20); + + await service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 50, + reason: 'Grain campaign', + }, + 'user-1', + ); + + expect(requestRepo.save).toHaveBeenCalled(); + expect(stored.quantity).toBe(50); + }); + + it('still refuses a same-yard move', async () => { + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-a', + wagonTypeId: 'type-1', + quantity: 5, + reason: 'x', + }, + 'user-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('listRequests', () => { + // TypeORM paginates a joined query through a DISTINCT subquery and resolves + // every orderBy criterion against entity metadata — a DB column name there + // (`r.created_at`) makes it read `.databaseName` of undefined → 500. + it('sorts by the entity property path, not the DB column', async () => { + const qb = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + }; + requestRepo.createQueryBuilder.mockReturnValue(qb); + + await service.listRequests({ + status: 'PENDING,PARTIALLY_FULFILLED', + page: 1, + pageSize: 10, + }); + + expect(qb.orderBy).toHaveBeenCalledWith('r.createdAt', 'DESC'); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts index 068d4dc6d..8d4159726 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -1,15 +1,27 @@ -import { WagonStatus, WagonTransferRequestStatus } from '@edr/types'; +import { + NotificationAudience, + NotificationType, + OPEN_WAGON_TRANSFER_STATUSES, + PaginatedResponse, + WagonStatus, + WagonTransferRequestStatus, +} from '@edr/types'; import { BadRequestException, ConflictException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, IsNull, Not, Repository } from 'typeorm'; +import { paginateQuery } from '../../common/utils/pagination.util'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { CloseShortTransferRequestDto } from './dto/close-short-transfer-request.dto'; import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto'; +import { ListTransferRequestsQueryDto } from './dto/list-transfer-requests-query.dto'; import { Wagon } from './entities/wagon.entity'; import { WagonMovement } from './entities/wagon-movement.entity'; import { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; @@ -19,10 +31,21 @@ import { WagonsService } from './wagons.service'; export interface TransferHistory { requests: WagonTransferRequest[]; movements: WagonMovement[]; + /** + * One pager drives both lists (they are shown side by side), so it carries a + * total per list and the page count of the longer one. + */ + meta: { + page: number; + pageSize: number; + requestsTotal: number; + movementsTotal: number; + totalPages: number; + }; } -/** How many ledger rows the history returns at most (newest first). */ -const HISTORY_LIMIT = 500; +/** Hard ceiling on a single history page, whatever the client asks for. */ +const HISTORY_LIMIT = 100; const REQUEST_RELATIONS = { fromYard: true, @@ -38,6 +61,8 @@ const REQUEST_RELATIONS = { */ @Injectable() export class WagonTransferRequestsService { + private readonly logger = new Logger(WagonTransferRequestsService.name); + constructor( @InjectRepository(WagonTransferRequest) private readonly requestRepo: Repository, @@ -46,13 +71,14 @@ export class WagonTransferRequestsService { @InjectRepository(WagonMovement) private readonly movementRepo: Repository, private readonly wagonsService: WagonsService, + private readonly inbox: NotificationInboxService, ) {} /** - * Record a PENDING request. Count-only — no wagons are picked here, but the - * count is capped at the AVAILABLE wagons of that type currently sitting in - * the source yard: staff may only ask for wagons that are actually there to - * give. A reason is mandatory and is shown on the OCC queue. + * Record a PENDING request. Count-only — no wagons are picked here, and the + * count is NOT capped by what the source yard holds today: OCC fulfils in + * instalments, so asking for 50 while only 20 sit there is a normal, useful + * request. A reason is mandatory and is shown on the OCC queue. */ async createRequest( dto: CreateTransferRequestDto, @@ -63,14 +89,6 @@ export class WagonTransferRequestsService { 'Source and destination yard must be different', ); } - const available = await this.countAvailable(dto.fromYardId, dto.wagonTypeId); - if (available < dto.quantity) { - throw new BadRequestException( - available === 0 - ? 'No available wagons of this type in the source yard' - : `Only ${available} available wagon(s) of this type in the source yard — request at most ${available}`, - ); - } const request = this.requestRepo.create({ fromYardId: dto.fromYardId, toYardId: dto.toYardId, @@ -85,26 +103,68 @@ export class WagonTransferRequestsService { return this.findById(saved.id); } - /** AVAILABLE wagons of `wagonTypeId` currently in `yardId`. */ - private countAvailable(yardId: string, wagonTypeId: string): Promise { + /** + * AVAILABLE wagons of `wagonTypeId` currently in `yardId` — what OCC can move + * right now. Shown on the desk beside the outstanding count so staff see at a + * glance how much of a request the yard can cover today. + */ + countAvailable(yardId: string, wagonTypeId: string): Promise { return this.wagonRepo.count({ where: { currentYardId: yardId, wagonTypeId, status: WagonStatus.Available, + // Coupled to a built train = not movable; bulkTransfer rejects it too. + trainId: IsNull(), }, }); } - /** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */ + /** + * The transfer desk list: paginated, newest first, filterable by status (one + * or a comma-separated set — the "Open" tab asks for PENDING + + * PARTIALLY_FULFILLED), yards and wagon type. Search matches the reason text. + */ async listRequests( - status?: WagonTransferRequestStatus, - ): Promise { - return this.requestRepo.find({ - where: status ? { status } : {}, - relations: REQUEST_RELATIONS, - order: { createdAt: 'DESC' }, - }); + query: ListTransferRequestsQueryDto, + ): Promise> { + const qb = this.requestRepo + .createQueryBuilder('r') + .leftJoinAndSelect('r.fromYard', 'fromYard') + .leftJoinAndSelect('r.toYard', 'toYard') + .leftJoinAndSelect('r.wagonType', 'wagonType'); + + const statuses = (query.status ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (statuses.length) { + qb.andWhere('r.status IN (:...statuses)', { statuses }); + } + if (query.fromYardId) { + qb.andWhere('r.from_yard_id = :fromYardId', { fromYardId: query.fromYardId }); + } + if (query.toYardId) { + qb.andWhere('r.to_yard_id = :toYardId', { toYardId: query.toYardId }); + } + if (query.wagonTypeId) { + qb.andWhere('r.wagon_type_id = :wagonTypeId', { + wagonTypeId: query.wagonTypeId, + }); + } + if (query.search) { + qb.andWhere('r.reason ILIKE :search', { search: `%${query.search}%` }); + } + + const sortColumn = + query.sortBy === 'quantity' + ? 'r.quantity' + : query.sortBy === 'status' + ? 'r.status' + : 'r.createdAt'; + qb.orderBy(sortColumn, query.sortOrder ?? 'DESC'); + + return paginateQuery(qb, { page: query.page, pageSize: query.pageSize }); } async findById(id: string): Promise { @@ -116,11 +176,23 @@ export class WagonTransferRequestsService { return request; } + /** Wagons still owed on an open request. */ + private remainingOn(request: WagonTransferRequest): number { + return Math.max(0, request.quantity - (request.fulfilledQuantity ?? 0)); + } + + /** True while OCC can still move wagons against this request. */ + private isOpen(request: WagonTransferRequest): boolean { + return OPEN_WAGON_TRANSFER_STATUSES.includes(request.status); + } + /** - * OCC fulfils a PENDING request with hand-picked wagons. Every wagon must sit - * in the request's source yard, match its wagon type, and the count must equal - * the requested quantity — then the transfer runs and the request is marked - * FULFILLED. + * OCC moves hand-picked wagons against an open request. Any number from 1 up + * to whatever is still owed — the yard rarely has the whole ask at once, so a + * request for 50 can be met 20 now, 30 later. Every wagon must sit in the + * source yard, match the type and be available. The request completes on its + * own once the full count has moved; short of that it stays open as + * PARTIALLY_FULFILLED and the requester is told what landed. */ async fulfillRequest( id: string, @@ -128,16 +200,17 @@ export class WagonTransferRequestsService { userId?: string | null, ): Promise { const request = await this.findById(id); - if (request.status !== WagonTransferRequestStatus.Pending) { + if (!this.isOpen(request)) { throw new ConflictException( - `Request is already ${request.status.toLowerCase()}`, + `Request is already ${request.status.toLowerCase().replace(/_/g, ' ')}`, ); } const wagonIds = [...new Set(dto.wagonIds)]; - if (wagonIds.length !== request.quantity) { + const remaining = this.remainingOn(request); + if (wagonIds.length > remaining) { throw new BadRequestException( - `Select exactly ${request.quantity} wagon(s); you selected ${wagonIds.length}`, + `Only ${remaining} wagon(s) still owed on this request; you selected ${wagonIds.length}`, ); } @@ -178,21 +251,124 @@ export class WagonTransferRequestsService { { transferRequestId: request.id }, ); - request.status = WagonTransferRequestStatus.Fulfilled; - request.fulfilledByUserId = userId ?? null; - request.fulfilledAt = new Date(); - await this.requestRepo.save(request); + await this.recordDelivery(request, wagonIds.length, userId); return this.findById(id); } /** - * OCC accepts AND executes a subset of pending requests in one action. For - * each selected request the system auto-picks the required number of - * AVAILABLE wagons of the requested type from the source yard (lowest wagon - * number first) and runs the audited transfer. A request that cannot be - * executed — already decided, or not enough available wagons left after the - * ones processed before it — is SKIPPED and simply stays PENDING, visible to - * both teams; nothing is rolled back for the others. + * Book an instalment against a request: bump the delivered count, complete it + * when the full ask has landed, and tell the requester what moved. Shared by + * the hand-picked and auto-picked (bulk) fulfilment paths. + */ + private async recordDelivery( + request: WagonTransferRequest, + moved: number, + userId?: string | null, + ): Promise { + request.fulfilledQuantity = (request.fulfilledQuantity ?? 0) + moved; + request.status = + request.fulfilledQuantity >= request.quantity + ? WagonTransferRequestStatus.Fulfilled + : WagonTransferRequestStatus.PartiallyFulfilled; + request.fulfilledByUserId = userId ?? null; + request.fulfilledAt = new Date(); + await this.requestRepo.save(request); + this.notifyRequester(request, moved); + } + + /** + * Tell the requester what landed. Fire-and-forget: a notification failure must + * never undo a transfer that already moved wagons. + */ + private notifyRequester( + request: WagonTransferRequest, + moved: number, + closedShortNote?: string | null, + ): void { + if (!request.requestedByUserId) return; + const outstanding = this.remainingOn(request); + const complete = request.status === WagonTransferRequestStatus.Fulfilled; + const closedShort = + request.status === WagonTransferRequestStatus.ClosedShort; + + const title = complete + ? `All ${request.quantity} wagon(s) transferred` + : closedShort + ? `Transfer closed short — ${request.fulfilledQuantity} of ${request.quantity} wagon(s)` + : `${moved} of ${request.quantity} wagon(s) transferred`; + + const body = complete + ? `Your wagon transfer request is complete — all ${request.quantity} wagon(s) have arrived.` + : closedShort + ? `Only ${request.fulfilledQuantity} of the ${request.quantity} wagon(s) you asked for could be supplied` + + `${closedShortNote ? `: ${closedShortNote}` : '.'} ` + + `Request the remaining ${outstanding} from another yard.` + : `${moved} wagon(s) have arrived against your request. ` + + `${outstanding} of ${request.quantity} still to come.`; + + void this.inbox + .notify({ + recipients: { userIds: [request.requestedByUserId] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.GENERIC, + title, + body, + link: `/dashboard/wagon-transfers/${request.id}`, + data: { + transferRequestId: request.id, + delivered: request.fulfilledQuantity, + requested: request.quantity, + outstanding, + }, + }) + .catch((err) => + this.logger.warn( + `Transfer notification failed for ${request.id}: ${(err as Error).message}`, + ), + ); + } + + /** + * OCC ends a request with fewer wagons than asked for — the source yard has + * nothing more to give. What already moved stays moved; the requester is told + * the shortfall so they can raise it against another yard. Cancelling is for + * requests that never moved anything; this is the close for ones that did. + */ + async closeShort( + id: string, + dto: CloseShortTransferRequestDto, + userId?: string | null, + ): Promise { + const request = await this.findById(id); + if (!this.isOpen(request)) { + throw new ConflictException( + `Request is already ${request.status.toLowerCase().replace(/_/g, ' ')}`, + ); + } + if (this.remainingOn(request) === 0) { + throw new ConflictException( + 'Nothing outstanding — this request is already fully supplied', + ); + } + + request.status = WagonTransferRequestStatus.ClosedShort; + request.closedShortAt = new Date(); + request.closedShortByUserId = userId ?? null; + if (dto.note?.trim()) { + request.note = dto.note.trim(); + } + await this.requestRepo.save(request); + this.notifyRequester(request, 0, dto.note ?? null); + return this.findById(id); + } + + /** + * OCC executes a set of open requests in one action, auto-picking AVAILABLE + * wagons of the requested type from each source yard (lowest wagon number + * first). A yard that cannot cover the whole ask still sends what it has — + * the request stays open for the rest rather than being skipped, which is the + * whole point of instalments. Only a request with NOTHING available is + * skipped, and nothing is rolled back for the others. */ async bulkFulfill( requestIds: string[], @@ -212,26 +388,28 @@ export class WagonTransferRequestsService { skipped.push({ id, reason: 'Request not found' }); continue; } - if (request.status !== WagonTransferRequestStatus.Pending) { + if (!this.isOpen(request)) { skipped.push({ id, - reason: `Already ${request.status.toLowerCase()}`, + reason: `Already ${request.status.toLowerCase().replace(/_/g, ' ')}`, }); continue; } + const remaining = this.remainingOn(request); const wagons = await this.wagonRepo.find({ where: { currentYardId: request.fromYardId, wagonTypeId: request.wagonTypeId, status: WagonStatus.Available, + trainId: IsNull(), }, order: { wagonNumber: 'ASC' }, - take: request.quantity, + take: remaining, }); - if (wagons.length < request.quantity) { + if (wagons.length === 0) { skipped.push({ id, - reason: `Only ${wagons.length} of ${request.quantity} wagon(s) available in the source yard — left pending`, + reason: 'No available wagons of this type in the source yard — left open', }); continue; } @@ -240,10 +418,7 @@ export class WagonTransferRequestsService { userId, { transferRequestId: request.id }, ); - request.status = WagonTransferRequestStatus.Fulfilled; - request.fulfilledByUserId = userId ?? null; - request.fulfilledAt = new Date(); - await this.requestRepo.save(request); + await this.recordDelivery(request, wagons.length, userId); fulfilled.push(await this.findById(id)); } @@ -258,17 +433,25 @@ export class WagonTransferRequestsService { * (the controller passes the caller's id unless they hold the history-all * permission) — this method trusts its argument. */ - async getHistory(userId?: string | null): Promise { - const requests = await this.requestRepo.find({ + async getHistory( + userId?: string | null, + page?: number, + pageSize?: number, + ): Promise { + const take = Math.min(pageSize ?? 20, HISTORY_LIMIT); + const skip = ((page ?? 1) - 1) * take; + + const [requests, requestsTotal] = await this.requestRepo.findAndCount({ where: userId ? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }] : {}, relations: REQUEST_RELATIONS, order: { createdAt: 'DESC' }, - take: HISTORY_LIMIT, + skip, + take, }); - const movements = await this.movementRepo.find({ + const [movements, movementsTotal] = await this.movementRepo.findAndCount({ // Own view: moves I made. All view: every user-attributed move (skip the // system-written loaded/reposition legs that carry no mover). where: userId @@ -276,18 +459,39 @@ export class WagonTransferRequestsService { : { movedByUserId: Not(IsNull()) }, relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true }, order: { occurredAt: 'DESC' }, - take: HISTORY_LIMIT, + skip, + take, }); - return { requests, movements }; + return { + requests, + movements, + meta: { + page: page ?? 1, + pageSize: take, + requestsTotal, + movementsTotal, + // Whichever list is longer decides how far the pager can go. + totalPages: Math.max( + 1, + Math.ceil(Math.max(requestsTotal, movementsTotal) / take), + ), + }, + }; } - /** Withdraw a still-PENDING request. */ + /** + * Withdraw a request before anything moved. Once wagons have been delivered + * the request can only be completed or closed short — cancelling would erase + * the fact that a transfer happened. + */ async cancelRequest(id: string): Promise { const request = await this.findById(id); if (request.status !== WagonTransferRequestStatus.Pending) { throw new ConflictException( - `Only pending requests can be cancelled (this one is ${request.status.toLowerCase()})`, + request.status === WagonTransferRequestStatus.PartiallyFulfilled + ? 'Wagons have already moved against this request — close it short instead of cancelling' + : `Only pending requests can be cancelled (this one is ${request.status.toLowerCase().replace(/_/g, ' ')})`, ); } request.status = WagonTransferRequestStatus.Cancelled; diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index 2ef7f00a5..c792057d8 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -12,13 +12,12 @@ import { import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; -import { FleetManage, FleetView, StaffReference } from '../../common/booking-guards'; +import { FleetManage, StaffReference } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateWagonDto } from './dto/create-wagon.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto'; import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; -import { ReorderWagonsDto } from './dto/reorder-wagons.dto'; import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto'; import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto'; import { WagonsService } from './wagons.service'; @@ -40,7 +39,9 @@ export class WagonsController { @Get() @StaffReference() - @ApiOperation({ summary: 'List all wagons' }) + @ApiOperation({ + summary: 'List wagons, paginated ({items, meta}) — 10 per page by default', + }) findAll(@Query() query: ListWagonsQueryDto) { return this.wagonsService.findAll(query); } @@ -103,17 +104,3 @@ export class WagonsController { return this.wagonsService.bulkSetStatus(dto); } } - -// Separate controller for train‑specific reorder (registered in module) -@Controller('trains/:trainId/reorder-wagons') -@FleetView(FREIGHT_PERMS.trains.view) -export class TrainWagonsReorderController { - constructor(private readonly wagonsService: WagonsService) {} - - @Post() - @FleetManage(FREIGHT_PERMS.trains.assignWagons) - @ApiOperation({ summary: 'Reorder wagons of a train' }) - reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) { - return this.wagonsService.reorderWagons(trainId, dto); - } -} diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts index 107a31e7e..8c8a0d11f 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts @@ -5,7 +5,8 @@ import { WagonMovement } from './entities/wagon-movement.entity'; import { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; import { Train } from '../trains/entities/train.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; -import { WagonsController, TrainWagonsReorderController } from './wagons.controller'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { WagonsController } from './wagons.controller'; import { WagonTransferRequestsController } from './wagon-transfer-requests.controller'; import { WagonsService } from './wagons.service'; import { WagonTransferRequestsService } from './wagon-transfer-requests.service'; @@ -19,10 +20,11 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service' Train, Yard, ]), + // The transfer desk notifies the requester as instalments land. + NotificationInboxModule, ], controllers: [ WagonsController, - TrainWagonsReorderController, WagonTransferRequestsController, ], providers: [WagonsService, WagonTransferRequestsService], diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 188bf1783..f51846e2d 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -1,4 +1,4 @@ -import { Freight, WagonMovementKind, WagonStatus } from '@edr/types'; +import { Freight, PaginatedResponse, WagonMovementKind, WagonStatus } from '@edr/types'; import { BadRequestException, Injectable, @@ -6,12 +6,12 @@ import { ConflictException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, DataSource, In } from 'typeorm'; +import { Repository, DataSource, In, SelectQueryBuilder } from 'typeorm'; +import { paginateQuery } from '../../common/utils/pagination.util'; import { CreateWagonDto } from './dto/create-wagon.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto'; import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; -import { ReorderWagonsDto } from './dto/reorder-wagons.dto'; import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto'; import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto'; import { Wagon } from './entities/wagon.entity'; @@ -43,7 +43,8 @@ export class WagonsService { return this.wagonRepo.save(wagon); } - async findAll(query: ListWagonsQueryDto = {}): Promise { + /** Shared filter/sort builder behind `findAll` (array) and `findAllPaged` (envelope). */ + private buildListQuery(query: ListWagonsQueryDto): SelectQueryBuilder { const search = query.search?.trim(); const trainId = query.trainId?.trim(); const wagonTypeId = query.wagonTypeId?.trim(); @@ -62,6 +63,9 @@ export class WagonsService { if (query.currentYardId) qb.andWhere('w.currentYardId = :currentYardId', { currentYardId: query.currentYardId }); if (trainId) qb.andWhere('w.trainId = :trainId', { trainId }); + // Pickers (train-builder, transfer fulfilment) can only take a wagon that is + // not already coupled to a built train — never offer one the API will reject. + if (query.unassigned) qb.andWhere('w.trainId IS NULL'); if (wagonTypeId) qb.andWhere('w.wagonTypeId = :wagonTypeId', { wagonTypeId }); // Filter by run: the odd export run identifies the pair, so match either @@ -73,6 +77,18 @@ export class WagonsService { ); } + // Registration-day range, both ends inclusive (the UI picks whole days). + if (query.createdFrom) { + qb.andWhere('w.createdAt >= CAST(:createdFrom AS date)', { + createdFrom: query.createdFrom, + }); + } + if (query.createdTo) { + qb.andWhere("w.createdAt < CAST(:createdTo AS date) + INTERVAL '1 day'", { + createdTo: query.createdTo, + }); + } + // Search matches the wagon number or either run number. if (search) { qb.andWhere( @@ -96,12 +112,16 @@ export class WagonsService { const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; qb.orderBy(`w.${sortBy}`, sortOrder); - if (query.page && query.limit) { - qb.skip((Number(query.page) - 1) * Number(query.limit)); - } - if (query.limit) qb.take(Number(query.limit)); + return qb; + } - return qb.getMany(); + /** + * The wagon list is always a page. Callers that genuinely need every row + * (yard workspace, coupling pickers) walk the pages client-side — see + * `wagonService.listAll` in the backoffice. + */ + findAll(query: ListWagonsQueryDto = {}): Promise> { + return paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 }); } async findById(id: string): Promise { @@ -392,20 +412,4 @@ export class WagonsService { } } - async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - try { - for (let i = 0; i < dto.wagonIds.length; i++) { - await queryRunner.manager.update(Wagon, dto.wagonIds[i], { sequenceNumber: i + 1 }); - } - await queryRunner.commitTransaction(); - } catch (err) { - await queryRunner.rollbackTransaction(); - throw err; - } finally { - await queryRunner.release(); - } - } } diff --git a/apps/edr-freight-api/src/modules/warehouses/double-handling-gate.spec.ts b/apps/edr-freight-api/src/modules/warehouses/double-handling-gate.spec.ts new file mode 100644 index 000000000..836a3bc4c --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/double-handling-gate.spec.ts @@ -0,0 +1,61 @@ +import { WarehouseFeeService } from './warehouse-fee.service'; + +/** + * Double handling bills ONLY when warehouse staff answered Yes after + * unloading. Undecided (null) or No must produce a zero charge even when a + * matching DOUBLE_HANDLING_FEE rule exists. + */ +type Item = Parameters extends unknown + ? Record + : never; + +const svc = Object.create(WarehouseFeeService.prototype) as { + computeDoubleHandling: ( + rule: Record | null, + item: Item, + now: Date, + billingCurrency: string, + ) => Promise<{ amount: number; billableUnits: number }>; + normalizeCurrency: (c?: string | null) => string; + convertAmount: (a: number, from: string, to: string) => Promise; + resolveBulkQuantity: (item: Item) => { quantity: number; unitLabel: string }; +}; +// No exchange service on a bare prototype — bill in the rule's own currency. +svc.normalizeCurrency = (c) => (c ? String(c).toUpperCase() : 'USD'); +svc.convertAmount = async (a) => a; + +const rule = { basis: 'PER_CONTAINER', ratePerDay: 100, currency: 'USD', id: 'r1', name: 'DH' }; +const item = (doubleHandling: boolean | null) => ({ + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + inventoryQuantity: 2, + bookingContainerCount: 3, + inventoryWeight: 10, + cargoUnitOfMeasure: 'PER_TON', + doubleHandling, +}) as unknown as Item; + +describe('double handling gate', () => { + it('bills rate x containers when the booking is flagged Yes', async () => { + const out = await svc.computeDoubleHandling(rule, item(true), new Date(), 'USD'); + expect(out.billableUnits).toBe(3); + expect(out.amount).toBe(300); + }); + + it('charges nothing when the answer is No', async () => { + const out = await svc.computeDoubleHandling(rule, item(false), new Date(), 'USD'); + expect(out.billableUnits).toBe(0); + expect(out.amount).toBe(0); + }); + + it('charges nothing while the answer is undecided', async () => { + const out = await svc.computeDoubleHandling(rule, item(null), new Date(), 'USD'); + expect(out.amount).toBe(0); + }); + + it('charges nothing for export even when flagged Yes', async () => { + const exportItem = { ...(item(true) as Record), tradeDirection: 'EXPORT' } as Item; + const out = await svc.computeDoubleHandling(rule, exportItem, new Date(), 'USD'); + expect(out.amount).toBe(0); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts index 56b9d0810..39a90ef8e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts @@ -1,7 +1,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsArray, IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; -import { WAREHOUSE_YARD_TYPES, WarehouseYardType } from '../entities/warehouse-yard.entity'; +import { + WAREHOUSE_YARD_DIRECTIONS, + WAREHOUSE_YARD_TYPES, + WarehouseYardDirection, + WarehouseYardType, +} from '../entities/warehouse-yard.entity'; export class CreateWarehouseYardDto { @ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' }) @@ -46,4 +51,22 @@ export class CreateWarehouseYardDto { @IsNumber() @Min(0) maxVolume?: number; + + @ApiPropertyOptional({ + enum: WAREHOUSE_YARD_DIRECTIONS, + description: 'Trade direction this yard serves. Only meaningful for CONTAINER_YARD — omit/BOTH for everything else.', + }) + @IsOptional() + @IsEnum(WAREHOUSE_YARD_DIRECTIONS) + direction?: WarehouseYardDirection; + + @ApiPropertyOptional({ + type: [String], + format: 'uuid', + description: 'Cargo types this yard accepts. Empty/omitted = open to any cargo type of this yard\'s structural type.', + }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + cargoTypeIds?: string[]; } diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/double-handling.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/double-handling.dto.ts new file mode 100644 index 000000000..21e54589f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/double-handling.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean } from 'class-validator'; + +/** + * Warehouse staff's post-unloading answer: did these goods have to be + * re-handled? Only `true` makes the DOUBLE_HANDLING_FEE rule bill the booking. + */ +export class SetDoubleHandlingDto { + @ApiProperty({ + description: 'Yes (true) applies the double-handling fee rule; No (false) does not.', + }) + @IsBoolean() + doubleHandling!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts index 6e3c93292..5169f486a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts @@ -1,6 +1,7 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm'; +import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; import { Warehouse } from './warehouse.entity'; import { WarehouseZone } from './warehouse-zone.entity'; @@ -16,6 +17,15 @@ export type WarehouseYardType = (typeof WAREHOUSE_YARD_TYPES)[number]; export const WAREHOUSE_YARD_STATUSES = ['ACTIVE', 'INACTIVE'] as const; export type WarehouseYardStatus = (typeof WAREHOUSE_YARD_STATUSES)[number]; +/** + * Which trade direction this yard serves. Only meaningful for CONTAINER_YARD, + * where import and export stacks are physically separate areas (e.g. Indode's + * Yard 5 for import vs Yard 6 for export) — every other yard type takes cargo + * either way, so BOTH/null is the right default there. + */ +export const WAREHOUSE_YARD_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; +export type WarehouseYardDirection = (typeof WAREHOUSE_YARD_DIRECTIONS)[number]; + @Entity({ schema: 'freight', name: 'warehouse_yards' }) @Index(['warehouseId']) @Index(['type']) @@ -64,6 +74,25 @@ export class WarehouseYard extends BaseEntity { @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; + /** Null = BOTH (no direction restriction). Only relevant for CONTAINER_YARD. */ + @Column({ name: 'direction', type: 'varchar', length: 10, nullable: true }) + direction?: WarehouseYardDirection | null; + + /** + * Cargo types this yard accepts — e.g. Yard 3 (Ro-Ro) takes Automobile/Truck, + * Yard 9 (Coffee and Tea) takes only those two. Empty/no rows = open to any + * cargo type of the yard's structural `type` (the pre-existing behavior), + * so this is additive and never blocks a yard that hasn't been configured. + */ + @ManyToMany(() => CargoType) + @JoinTable({ + name: 'warehouse_yard_cargo_types', + schema: 'freight', + joinColumn: { name: 'yard_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'cargo_type_id', referencedColumnName: 'id' }, + }) + cargoTypes?: CargoType[]; + @OneToMany(() => WarehouseZone, (zone) => zone.yard) zones?: WarehouseZone[]; } diff --git a/apps/edr-freight-api/src/modules/warehouses/per-truck-detention.spec.ts b/apps/edr-freight-api/src/modules/warehouses/per-truck-detention.spec.ts new file mode 100644 index 000000000..471b82965 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/per-truck-detention.spec.ts @@ -0,0 +1,82 @@ +import { WarehouseFeeService } from './warehouse-fee.service'; + +/** + * Detention is per truck: two trucks on the same delivery with different + * windows must produce different chargeable days and amounts (the old + * leg-level clock billed them identically). + */ +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +const svc = Object.create(WarehouseFeeService.prototype) as { + computeTruckDetention: ( + rule: Record | null, + row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number }, + now: Date, + billingCurrency: string, + ) => Promise<{ chargeableDays: number; billableUnits: number; amount: number; endIsOpen: boolean }>; + normalizeCurrency: (c?: string | null) => string; + convertAmount: (a: number, from: string, to: string) => Promise; + calculateTieredAmount: unknown; +}; +svc.normalizeCurrency = (c) => (c ? String(c).toUpperCase() : 'USD'); +svc.convertAmount = async (a) => a; + +// 3h grace, 50/truck/day, no tiers. +const rule = { freeHours: 3, ratePerDay: 50, currency: 'USD', id: 'r1', name: 'Detention', tiers: [] }; +const now = new Date('2026-07-25T12:00:00Z'); + +describe('per-truck detention', () => { + it('bills each truck on its own window', async () => { + // Truck A: out ~1 day past grace. Truck B: out ~3 days past grace. + const a = await svc.computeTruckDetention( + rule, + { + arrivedAt: new Date(now.getTime() - DAY - 4 * HOUR), + deliveredAt: now, + truckCount: 1, + }, + now, + 'USD', + ); + const b = await svc.computeTruckDetention( + rule, + { + arrivedAt: new Date(now.getTime() - 3 * DAY - 4 * HOUR), + deliveredAt: now, + truckCount: 1, + }, + now, + 'USD', + ); + + expect(a.chargeableDays).toBe(2); + expect(b.chargeableDays).toBe(4); + expect(a.amount).toBe(100); + expect(b.amount).toBe(200); + // The whole point: same delivery, different bills. + expect(a.amount).not.toBe(b.amount); + }); + + it('charges nothing inside the grace window', async () => { + const out = await svc.computeTruckDetention( + rule, + { arrivedAt: new Date(now.getTime() - 2 * HOUR), deliveredAt: now, truckCount: 1 }, + now, + 'USD', + ); + expect(out.chargeableDays).toBe(0); + expect(out.amount).toBe(0); + }); + + it('keeps accruing against now when a truck has not returned', async () => { + const out = await svc.computeTruckDetention( + rule, + { arrivedAt: new Date(now.getTime() - 2 * DAY), deliveredAt: null, truckCount: 1 }, + now, + 'USD', + ); + expect(out.endIsOpen).toBe(true); + expect(out.chargeableDays).toBe(2); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index fd967cc8c..59f4b5478 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -17,6 +17,8 @@ export interface ImportTrainRow { route: string | null; origin: string | null; destination: string | null; + /** freight.yards.id the train is heading to — lets the frontend restrict the unload warehouse picker to the warehouse actually at this station, instead of listing every warehouse. */ + destinationStationId: string | null; arrivalTime: string | null; totalBookings: number; totalContainers: number; @@ -38,6 +40,8 @@ export interface ImportTrainItemRow { freightType: string | null; containerNumber: string | null; cargoType: string | null; + /** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */ + cargoTypeCode: string | null; weight: number | null; arrivalTime: string | null; currentStatus: string | null; @@ -205,6 +209,7 @@ export class SchedulingReadFacade { ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", + dy.id AS "destinationStationId", oy.country AS "originCountry", dy.country AS "destinationCountry", COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", @@ -280,6 +285,7 @@ export class SchedulingReadFacade { WHERE c.booking_id = b.id AND c.deleted_at IS NULL ORDER BY c.container_number LIMIT 1) AS "containerNumber", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", + cgt.code AS "cargoTypeCode", b.cargo_total_weight_vgm AS "weight", COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", COALESCE(inv.status, b.status) AS "currentStatus", @@ -376,6 +382,7 @@ export class SchedulingReadFacade { ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", + dy.id AS "destinationStationId", dy.label AS "destinationName", oy.country AS "originCountry", dy.country AS "destinationCountry", diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts new file mode 100644 index 000000000..e23c6d1f8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts @@ -0,0 +1,201 @@ +import { WarehouseFeeService } from './warehouse-fee.service'; +import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; + +// Bulk storage/demurrage used to bill a flat rate per day regardless of cargo +// quantity. It now scales by the cargo type's own unit of measure — tons for +// PER_TON cargo, item count for PER_ITEM cargo (Machinery, Truck, Automobile, +// Livestock…) — read from THIS inventory row, not the whole booking's total. +describe('WarehouseFeeService bulk quantity billing', () => { + const makeService = () => + // compute() only touches its own arguments plus this.convertAmount, which + // short-circuits when rule.currency === billingCurrency — none of the + // constructor deps are exercised. + new WarehouseFeeService({} as any, {} as any, {} as any, {} as any); + + const rule = (overrides: Partial = {}): WarehouseFeeRule => + ({ + id: 'rule-1', + name: 'Bulk storage', + ruleType: 'STORAGE_FEE', + freeDays: 0, + ratePerDay: 10, + currency: 'USD', + tiers: [], + ...overrides, + }) as WarehouseFeeRule; + + const baseItem = (overrides: Record = {}) => ({ + arrivedAt: new Date('2026-01-01T00:00:00Z'), + gateClearedAt: null, + releaseDate: null, + freightType: 'BULK', + tradeDirection: 'IMPORT', + cargoTypeCode: 'WHEAT', + containerTypeCode: null, + vehicleType: null, + inventoryQuantity: 3, + inventoryWeight: 25, + bookingContainerCount: 0, + cargoUnitOfMeasure: null, + // Double handling now bills only when staff answered Yes after unloading; + // these quantity-basis cases assume that answer (the gate itself is covered + // in double-handling-gate.spec.ts). + doubleHandling: true, + facilityId: null, + warehouseId: null, + yardId: null, + zoneId: null, + ...overrides, + }); + + // 5 elapsed days, 0 free days -> 5 chargeable days throughout. + const now = new Date('2026-01-06T00:00:00Z'); + + it('bills PER_TON bulk cargo by this row\'s weight, not a flat day rate', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'STORAGE_FEE', + rule(), + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 25 }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('ton'); + expect(preview.containerCount).toBe(25); + expect(preview.billableUnits).toBe(5 * 25); + expect(preview.amount).toBe(5 * 25 * 10); + }); + + it('bills PER_ITEM bulk cargo (Machinery/Truck/Automobile/Livestock) by unit count', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'STORAGE_FEE', + rule(), + baseItem({ cargoUnitOfMeasure: 'PER_ITEM', inventoryQuantity: 3, cargoTypeCode: 'MACHINERY' }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('item'); + expect(preview.containerCount).toBe(3); + expect(preview.billableUnits).toBe(5 * 3); + expect(preview.amount).toBe(5 * 3 * 10); + }); + + it('defaults to PER_TON when the cargo type has no unit of measure set', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'STORAGE_FEE', + rule(), + baseItem({ cargoUnitOfMeasure: null, inventoryWeight: 12 }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('ton'); + expect(preview.containerCount).toBe(12); + }); + + it('charges nothing yet when the row has not been weighed/counted (0 is legitimate, not floored to 1)', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'STORAGE_FEE', + rule(), + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 0 }), + now, + 'USD', + ); + expect(preview.containerCount).toBe(0); + expect(preview.billableUnits).toBe(0); + expect(preview.amount).toBe(0); + }); + + it('leaves CONTAINER freight billing untouched by the new bulk fields', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'DEMURRAGE_FEE', + rule({ ruleType: 'DEMURRAGE_FEE' }), + baseItem({ + freightType: 'CONTAINER', + bookingContainerCount: 4, + cargoUnitOfMeasure: 'PER_ITEM', // must be ignored for container freight + inventoryWeight: 999, + }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('container'); + expect(preview.containerCount).toBe(4); + expect(preview.billableUnits).toBe(5 * 4); + }); + + // Double handling is a flat one-time charge, but previewForInventory() calls + // it once per warehouse_inventory ROW. Before this fix it read the whole + // booking's total on every row, so a booking split across N rows was billed + // N times against its full quantity. Reading each row's own weight/count + // fixes that: summing the rows now reproduces the booking total exactly once. + describe('double handling (row-level, not booking-wide)', () => { + const doubleHandlingRule = (basis: 'PER_CONTAINER' | 'PER_TON' | 'PER_ITEM') => + rule({ ruleType: 'DOUBLE_HANDLING_FEE', basis, ratePerDay: 20 }); + + it('bills PER_TON by this row\'s own weight', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + doubleHandlingRule('PER_TON'), + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 10 }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('ton'); + expect(preview.billableUnits).toBe(10); + expect(preview.amount).toBe(10 * 20); + }); + + it('bills PER_ITEM by this row\'s own unit count', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + doubleHandlingRule('PER_ITEM'), + baseItem({ cargoUnitOfMeasure: 'PER_ITEM', inventoryQuantity: 2, cargoTypeCode: 'TRUCK' }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('item'); + expect(preview.billableUnits).toBe(2); + expect(preview.amount).toBe(2 * 20); + }); + + it('two rows of one booking sum to the booking total exactly once (no N-times overcount)', async () => { + const service = makeService(); + const ruleDef = doubleHandlingRule('PER_TON'); + const rowA = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + ruleDef, + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 6 }), + now, + 'USD', + ); + const rowB = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + ruleDef, + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 4 }), + now, + 'USD', + ); + // Booking total is 10 tons across the two rows — billed once in total, + // not 10 tons charged against EACH row (which the old booking-wide read did). + expect(rowA.amount + rowB.amount).toBe(10 * 20); + }); + + it('no charge for export/domestic regardless of basis', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + doubleHandlingRule('PER_TON'), + baseItem({ tradeDirection: 'EXPORT', cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 10 }), + now, + 'USD', + ); + expect(preview.amount).toBe(0); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 3b61d7ff0..aa30b31e7 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -20,9 +20,13 @@ interface ItemAttributes { /** Vehicle type of the truck (truck detention scoping); null otherwise. */ vehicleType: string | null; inventoryQuantity: number; + /** This inventory row's own net weight (tonnes) — bulk STORAGE/DEMURRAGE for PER_TON cargo bills against this, not the booking-wide total. */ + inventoryWeight: number; bookingContainerCount: number; - /** Booking cargo total in the cargo's unit of measure: tonnes (PER_TON) or item count (PER_ITEM). */ - cargoQuantity: number; + /** This item's cargo type unit of measure (PER_TON | PER_ITEM); null defaults to PER_TON. Decides whether bulk day-based fees bill by weight or item count. */ + cargoUnitOfMeasure: string | null; + /** Booking-level Yes/No recorded after unloading; only true bills double handling (null = undecided). */ + doubleHandling: boolean | null; facilityId: string | null; warehouseId: string | null; yardId: string | null; @@ -60,7 +64,7 @@ export interface AccrualDashboardRow { export interface FeePreview { ruleType: FeeRuleType; - /** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */ + /** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_ITEM); null otherwise. */ basis: FeeRuleBasis | null; ruleId: string | null; ruleName: string | null; @@ -75,6 +79,8 @@ export interface FeePreview { elapsedDays: number; chargeableDays: number; containerCount: number; + /** What `containerCount`/`billableUnits` are counted in — 'container' | 'truck' | 'ton' | 'item'. Bulk cargo bills by weight (ton) or item count depending on the cargo type's unit of measure. */ + unitLabel: string; billableUnits: number; amount: number; tiers: Array<{ @@ -86,10 +92,20 @@ export interface FeePreview { ratePerDay: number; amount: number; }>; - /** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */ + /** + * Truck detention: one row PER TRUCK — each truck has its own detention + * window (it arrives and is released at its own time) and its own matching + * rule by truck type, so days and amount differ between trucks. + */ groups?: Array<{ + assignmentId: string | null; + vehicleId: string | null; + plateNumber: string | null; vehicleType: string | null; truckCount: number; + startDate: string | null; + endDate: string | null; + endIsOpen: boolean; chargeableDays: number; ratePerDay: number; amount: number; @@ -242,16 +258,18 @@ export class WarehouseFeeService { inv.gate_cleared_at AS "gateClearedAt", inv.release_date AS "releaseDate", inv.quantity AS "inventoryQuantity", + inv.weight AS "inventoryWeight", inv.warehouse_id AS "warehouseId", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", w.facility_id AS "facilityId", b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", + b.double_handling AS "doubleHandling", COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode", COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode", COALESCE(container_lines.container_count, 0) AS "bookingContainerCount", - COALESCE(b.cargo_total_weight_vgm, 0) AS "cargoQuantity" + COALESCE(cgt.unit_of_measure, booking_cgt.unit_of_measure) AS "cargoUnitOfMeasure" FROM freight.warehouse_inventory inv LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.bookings b ON b.id = inv.booking_id @@ -470,6 +488,22 @@ export class WarehouseFeeService { }; } + /** + * Bulk's own billing quantity for THIS inventory row — weight (tons) for + * PER_TON cargo, unit count for PER_ITEM cargo (Machinery, Truck, Automobile, + * Livestock…). Shared by every cargo-scoped fee type (storage, demurrage, + * double handling) so a booking split across several rows is never billed + * more than once against its full total. 0 is a legitimate charge (nothing + * weighed/counted yet), so no forced floor. + */ + private resolveBulkQuantity(item: ItemAttributes): { quantity: number; unitLabel: string } { + const cargoUnit = (item.cargoUnitOfMeasure ?? 'PER_TON').toUpperCase(); + if (cargoUnit === 'PER_ITEM') { + return { quantity: Math.max(0, Number(item.inventoryQuantity) || 0), unitLabel: 'item' }; + } + return { quantity: Math.max(0, Number(item.inventoryWeight) || 0), unitLabel: 'ton' }; + } + private async compute( ruleType: FeeRuleType, rule: WarehouseFeeRule | null, @@ -490,9 +524,11 @@ export class WarehouseFeeService { const targetCurrency = this.normalizeCurrency(billingCurrency); const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1)); + const bulk = this.resolveBulkQuantity(item); const containerCount = isContainer ? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity)) - : 1; + : bulk.quantity; + const unitLabel = isContainer ? 'container' : bulk.unitLabel; const elapsedDays = start ? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY)) @@ -533,6 +569,7 @@ export class WarehouseFeeService { elapsedDays, chargeableDays, containerCount, + unitLabel, billableUnits, amount, tiers: hasTiers ? convertedTiers : [], @@ -560,12 +597,19 @@ export class WarehouseFeeService { const containerCount = isContainer ? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity)) : 1; - // PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total, - // which is stored in the cargo's own unit of measure. - const cargoQuantity = Math.max(0, Number(item.cargoQuantity) || 0); - // Double handling applies to IMPORT only — no charge for export/domestic. + // PER_TON (tonnes) and PER_ITEM (piece count) both read THIS row's own + // weight/count — never the whole booking's total. previewForInventory() + // computes double handling once per inventory row, so a booking-wide total + // would double- (or triple-) bill a booking split across several rows. + const bulk = this.resolveBulkQuantity(item); + // Double handling applies to IMPORT only — no charge for export/domestic — + // AND only when warehouse staff recorded that the goods were actually + // re-handled (booking flag = Yes after unloading). Undecided (null) or No + // means no charge, so the rule can exist without billing every import. const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT'; - const quantity = !isImport ? 0 : basis === 'PER_CONTAINER' ? containerCount : cargoQuantity; + const applies = isImport && item.doubleHandling === true; + const quantity = !applies ? 0 : basis === 'PER_CONTAINER' ? containerCount : bulk.quantity; + const unitLabel = basis === 'PER_CONTAINER' ? 'container' : bulk.unitLabel; const sourceAmount = Math.round(rate * quantity * 100) / 100; const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0; const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0; @@ -586,6 +630,7 @@ export class WarehouseFeeService { elapsedDays: 0, chargeableDays: 0, containerCount, + unitLabel, billableUnits: quantity, amount, tiers: [], @@ -771,6 +816,7 @@ export class WarehouseFeeService { return { ruleType: 'TRUCK_DETENTION_FEE', basis: null, + unitLabel: 'truck', ruleId: null, ruleName: null, freeDays: 0, @@ -791,18 +837,48 @@ export class WarehouseFeeService { }; } - // Group the leg's vehicles by type so each truck type is billed by its own - // matching rule (rates differ by truck type). Falls back to one untyped group. - const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> = - await this.dataSource.query( - `SELECT v.vehicle_type AS "vehicleType", count(*)::int AS "truckCount" - FROM freight.last_mile_vehicle_assignments va - JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL - WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL - GROUP BY v.vehicle_type`, - [lastMileId], - ); - const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }]; + // One row PER TRUCK: each truck has its own detention window (it reaches the + // destination and is released at its own time) and resolves its own rule by + // CANONICAL truck type — the truck_types FK is the source of truth, with the + // normalized legacy vehicle_type code as fallback so FK-less vehicles keep + // billing. Per-truck timestamps fall back to the leg-level pair for legacy + // legs recorded before per-truck tracking. + const truckRows: Array<{ + assignmentId: string; + vehicleId: string; + plateNumber: string | null; + vehicleType: string | null; + startAt: Date | string | null; + endAt: Date | string | null; + }> = await this.dataSource.query( + `SELECT va.id AS "assignmentId", + va.vehicle_id AS "vehicleId", + COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber", + COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType", + COALESCE(va.destination_arrived_at, $2::timestamptz) AS "startAt", + COALESCE(va.returned_at, $3::timestamptz) AS "endAt" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL + LEFT JOIN freight.truck_types t + ON t.id = v.truck_type_id AND t.deleted_at IS NULL + WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL + ORDER BY va.created_at ASC`, + [lastMileId, leg.arrivedAt ?? null, leg.deliveredAt ?? null], + ); + // No trucks assigned yet: keep the leg-level single-truck estimate so the + // preview still tells the operator what detention would cost. + const trucks = truckRows.length + ? truckRows + : [ + { + assignmentId: null as string | null, + vehicleId: null as string | null, + plateNumber: null as string | null, + vehicleType: null as string | null, + startAt: leg.arrivedAt ?? null, + endAt: leg.deliveredAt ?? null, + }, + ]; const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'); @@ -810,7 +886,7 @@ export class WarehouseFeeService { const targetCurrency = this.normalizeCurrency(billingCurrency); const computed = await Promise.all( - groups.map(async (g) => { + trucks.map(async (t) => { const item: ItemAttributes = { arrivedAt: null, gateClearedAt: null, @@ -819,46 +895,60 @@ export class WarehouseFeeService { tradeDirection: leg.tradeDirection ?? null, cargoTypeCode: null, containerTypeCode: null, - vehicleType: g.vehicleType ?? null, + vehicleType: t.vehicleType ?? null, inventoryQuantity: 1, + inventoryWeight: 0, bookingContainerCount: 1, - cargoQuantity: 0, + cargoUnitOfMeasure: null, + // Irrelevant to detention (truck-time based, never double handling). + doubleHandling: null, facilityId: null, warehouseId: null, yardId: null, zoneId: null, }; const rule = this.bestRule(detentionRules, item); + // truckCount 1 — this row IS one truck. const c = await this.computeTruckDetention( rule, - { arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount }, + { arrivedAt: t.startAt, deliveredAt: t.endAt, truckCount: 1 }, now, billingCurrency, ); - return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c }; + return { ...t, c }; }), ); const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100; - const totalTrucks = computed.reduce((s, x) => s + x.truckCount, 0); + const totalTrucks = computed.length; const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0); - const chargeableDays = computed[0]?.c.chargeableDays ?? 0; + // Header days: the worst truck — a single number can't represent per-truck + // windows, and the longest detention is the one operations must act on. + const chargeableDays = computed.reduce((m, x) => Math.max(m, x.c.chargeableDays), 0); const single = computed.length === 1 ? computed[0].c : null; const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null; + const earliestStart = computed + .map((x) => (x.startAt ? new Date(x.startAt).getTime() : null)) + .filter((n): n is number => n != null) + .sort((a, b) => a - b)[0]; + const anyOpen = computed.some((x) => x.c.endIsOpen); return { ruleType: 'TRUCK_DETENTION_FEE', basis: null, + unitLabel: 'truck', ruleId: single?.ruleId ?? null, - ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName, + ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck rules' : anyRuleName, freeDays: 0, ratePerDay: single?.ratePerDay ?? 0, currency: targetCurrency, ruleCurrency: single?.ruleCurrency ?? null, billingCurrency: targetCurrency, - startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null, - endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(), - endIsOpen: !leg.deliveredAt, + startDate: earliestStart != null ? new Date(earliestStart).toISOString() : null, + endDate: (anyOpen ? now : new Date(Math.max( + ...computed.map((x) => (x.endAt ? new Date(x.endAt).getTime() : now.getTime())), + ))).toISOString(), + endIsOpen: anyOpen, elapsedDays: chargeableDays, chargeableDays, containerCount: totalTrucks, @@ -866,8 +956,14 @@ export class WarehouseFeeService { amount: totalAmount, tiers: single ? single.tiers : [], groups: computed.map((x) => ({ + assignmentId: x.assignmentId, + vehicleId: x.vehicleId, + plateNumber: x.plateNumber, vehicleType: x.vehicleType, - truckCount: x.truckCount, + truckCount: 1, + startDate: x.startAt ? new Date(x.startAt).toISOString() : null, + endDate: x.c.endDate, + endIsOpen: x.c.endIsOpen, chargeableDays: x.c.chargeableDays, ratePerDay: x.c.ratePerDay, amount: x.c.amount, @@ -920,6 +1016,7 @@ export class WarehouseFeeService { return { ruleType: 'TRUCK_DETENTION_FEE', basis: null, + unitLabel: 'truck', ruleId: rule?.id ?? null, ruleName: rule?.name ?? null, freeDays: 0, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts index 7bd56e593..cdf34cd66 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts @@ -20,8 +20,13 @@ import { WarehouseInspectionService } from './warehouse-inspection.service'; @ApiTags('warehouse-inspection') @ApiBearerAuth() +// Baseline read: inspection reports are opened from inventory screens too — +// either view permission grants reads; writes stack their own per route. @Controller() -@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.view) +@BookingStaff([ + FREIGHT_PERMS.warehouseInspectionReports.view, + FREIGHT_PERMS.warehouseInventory.view, +]) export class WarehouseInspectionController { constructor(private readonly inspectionService: WarehouseInspectionService) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 0174ef3e9..b78d6f6f9 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -5,7 +5,7 @@ import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { actorLabel } from './current-actor.util'; -import { BookingStaff } from '../../common/booking-guards'; +import { BookingStaff, StaffReference } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { BulkReceiveDto } from './dto/bulk-receive.dto'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; @@ -17,6 +17,7 @@ import { MoveInventoryDto } from './dto/move-inventory.dto'; import { StoreInventoryDto } from './dto/store-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; import { ApproveDeliveryDto } from './dto/approve-delivery.dto'; +import { SetDoubleHandlingDto } from './dto/double-handling.dto'; import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; @@ -456,6 +457,7 @@ export class WarehouseInventoryController { } @Get(':id/handover-document') + @StaffReference() @ApiOperation({ summary: 'View import goods handover document PDF' }) async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { const { filename, buffer } = await this.inventoryService.handoverDocument(id); @@ -466,6 +468,7 @@ export class WarehouseInventoryController { } @Post('bookings/:bookingId/approve-delivery') + @StaffReference() @ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" }) approveDeliveryForBooking( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -481,12 +484,14 @@ export class WarehouseInventoryController { } @Get('bookings/:bookingId/handovers') + @StaffReference() @ApiOperation({ summary: 'Handover records for a booking (per-booking or per-truck)' }) bookingHandovers(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.handoverService.list(bookingId); } @Post('handovers/:handoverId/sign') + @StaffReference() @ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' }) signHandover( @Param('handoverId', ParseUUIDPipe) handoverId: string, @@ -502,12 +507,14 @@ export class WarehouseInventoryController { } @Post('bookings/:bookingId/request-handover-signature') + @StaffReference() @ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' }) requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.handoverService.requestSignature(bookingId); } @Get('bookings/:bookingId/grn-document') + @StaffReference() @ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' }) async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(bookingId); @@ -518,6 +525,7 @@ export class WarehouseInventoryController { } @Get('bookings/:bookingId/release-document') + @StaffReference() @ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' }) async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(bookingId); @@ -528,6 +536,7 @@ export class WarehouseInventoryController { } @Get('bookings/:bookingId/handover-document') + @StaffReference() @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' }) async bookingHandoverDocument( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -544,19 +553,39 @@ export class WarehouseInventoryController { return res.send(buffer); } + @Patch('bookings/:bookingId/double-handling') + @BookingStaff([FREIGHT_PERMS.warehouseInventory.unload, FREIGHT_PERMS.warehouseInventory.inspect]) + @ApiOperation({ + summary: 'Record Yes/No double handling after unloading (Yes applies the double-handling fee rule)', + }) + setDoubleHandling( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: SetDoubleHandlingDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.setDoubleHandling( + bookingId, + dto.doubleHandling, + actorLabel(user), + ); + } + @Get('bookings/:bookingId/container-items') + @StaffReference() @ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' }) containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.inventoryService.containerItems(bookingId); } @Get('bookings/:bookingId/container-weights') + @StaffReference() @ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" }) containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.inventoryService.bookingContainerWeights(bookingId); } @Get('bookings/:bookingId/location') + @StaffReference() @ApiOperation({ summary: "Warehouse location of a booking's inventory (customer portal)" }) bookingLocation(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.inventoryService.bookingLocation(bookingId); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 582face93..c0b3cc060 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -382,6 +382,8 @@ export interface ImportUnloadedRow { customerTruckContainerNumber: string | null; customerTruckAssignedAt: string | null; hasAssignedTruck: boolean; + /** Post-unloading Yes/No; null = not recorded yet (no double-handling charge). */ + doubleHandling: boolean | null; currentStatus: string; releaseDate: string | null; releaseOrderReference: string | null; @@ -1139,17 +1141,31 @@ export class WarehouseInventoryService { async autoUnloadArrived(): Promise { const arrived: { id: string; + /** Goods owner (company) — the GRN number is mapped to it. */ + customer: string | null; weight: string | null; freightType: string | null; tradeDirection: string | null; cargoTypeCode: string | null; }[] = await this.dataSource.query( - `SELECT b.id, b.cargo_total_weight_vgm AS weight, + `SELECT b.id, + -- Received weight must land on the inventory row: a booking with no + -- declared VGM still has per-container VGM to record. + COALESCE( + NULLIF(b.cargo_total_weight_vgm, 0), + (SELECT SUM(bcu.vgm_tons) + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc2 + ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL + WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL) + ) AS weight, b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", - cgt.code AS "cargoTypeCode" + cgt.code AS "cargoTypeCode", + company.name AS customer FROM freight.bookings b LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.companies company ON company.id = b.company_id WHERE b.status = ANY($1) AND b.deleted_at IS NULL AND inv.id IS NULL`, [this.ARRIVED_BOOKING_STATUSES], ); @@ -1186,7 +1202,7 @@ export class WarehouseInventoryService { status: 'RECEIVED', arrivedAt: new Date(), ...(booking.tradeDirection === 'EXPORT' - ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) } + ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date(), booking.customer) } : {}), notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue', }); @@ -1211,12 +1227,19 @@ export class WarehouseInventoryService { // A GRN is the receipt for cargo entering the warehouse, so every booking // gets one on unload — import as well as export. The direction only decides // the GRN prefix, not whether one is issued. - const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query( - `SELECT trade_direction AS "tradeDirection" - FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, - [bookingId], - ); + // The GRN is mapped to the goods owner (the booking's company), so pull it + // alongside the direction rather than issuing an owner-less number. + const [bookingRow]: Array<{ tradeDirection: string | null; ownerName: string | null }> = + await this.dataSource.query( + `SELECT b.trade_direction AS "tradeDirection", + company.name AS "ownerName" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + WHERE b.id = $1 AND b.deleted_at IS NULL`, + [bookingId], + ); const grnDirection = bookingRow?.tradeDirection ?? 'WH'; + const ownerName = bookingRow?.ownerName ?? null; let location: DefaultLocation | null = dto.warehouseId && dto.yardId && dto.zoneId @@ -1240,7 +1263,7 @@ export class WarehouseInventoryService { // Keep an already-issued GRN rather than reissuing; mint one otherwise. ...(existing[0].grnNumber ? {} - : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }), + : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName) }), notes: dto.notes ?? existing[0].notes ?? 'Unloaded', }); return this.findById(existing[0].id); @@ -1255,7 +1278,7 @@ export class WarehouseInventoryService { weight: 0, status: 'RECEIVED', arrivedAt, - grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt), + grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName), notes: dto.notes ?? 'Unloaded', }); return this.findById(saved.id); @@ -1565,7 +1588,7 @@ export class WarehouseInventoryService { } const now = new Date(); - const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now); + const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer); const truckEntrance = dto.truckEntrance ? this.mergeSystemTruckEntrance(dto.truckEntrance, booking) : undefined; @@ -1981,6 +2004,7 @@ export class WarehouseInventoryService { WHERE lm.booking_id = b.id AND lm.vehicle_id IS NOT NULL AND lm.deleted_at IS NULL)) AS "hasAssignedTruck", + b.double_handling AS "doubleHandling", inv.status AS "currentStatus", inv.release_date AS "releaseDate", inv.release_order_reference AS "releaseOrderReference", @@ -2130,6 +2154,8 @@ export class WarehouseInventoryService { const bookings: { id: string; status: string; + /** Goods owner (company) — the GRN number is mapped to it. */ + customer: string | null; weight: string | null; freightType: string | null; tradeDirection: string | null; @@ -2140,12 +2166,24 @@ export class WarehouseInventoryService { // at an intermediate yard was already unloaded there by the checkpoint // auto-unload; without this filter it would be mis-located into the final // yard's inventory too. - `SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight, + `SELECT b.id, b.status, + -- Same fallback as autoUnloadArrived: never land a 0 t receipt when + -- the booking's containers carry a VGM. + COALESCE( + NULLIF(b.cargo_total_weight_vgm, 0), + (SELECT SUM(bcu.vgm_tons) + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc2 + ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL + WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL) + ) AS weight, b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", - cgt.code AS "cargoTypeCode" + cgt.code AS "cargoTypeCode", + company.name AS customer FROM freight.train_schedule_bookings tsb JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.companies company ON company.id = b.company_id WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL AND b.destination_yard_id = $2`, [scheduleId, schedule.destinationStationId], @@ -2203,11 +2241,16 @@ export class WarehouseInventoryService { zoneId: unloadLocation.zoneId, } : {}), + // Record the received weight on a row that never carried one — the + // GRN prints this, and an existing non-zero weight is left alone. + ...(Number(existing.weight) > 0 || !(Number(booking.weight) > 0) + ? {} + : { weight: Number(booking.weight) }), status: 'UNLOADED', unloadedAt: now, arrivedAt: existing.arrivedAt ?? now, // Import GRN is issued automatically at train unload. - ...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now) }), + ...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer) }), }); await this.activityLog.record({ activityType: 'INVENTORY_UNLOADED', @@ -2216,6 +2259,22 @@ export class WarehouseInventoryService { description: 'Unloaded from arrived import train', performedBy, }); + // Capacity follows the recorded weight: deliver() decrements by the + // item's weight, so a weight written here must be counted here too. + const addedWeight = Number(booking.weight) - Number(existing.weight ?? 0); + if (addedWeight > 0) { + await this.applyCapacityDelta( + this.dataSource.manager, + { + warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId, + yardId: unloadLocation?.yardId ?? existing.yardId, + zoneId: unloadLocation?.zoneId ?? existing.zoneId, + }, + addedWeight, + 0, + 0, + ); + } result.unloadedCount += 1; result.results.push({ bookingId: booking.id, inventoryId: existing.id, status: 'UNLOADED' }); continue; @@ -2241,7 +2300,7 @@ export class WarehouseInventoryService { quantity: 1, weight: Number(booking.weight) || 0, status: 'UNLOADED', - grnNumber: this.generateGrnNumber('IMPORT', booking.id, now), + grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer), arrivedAt: now, unloadedAt: now, notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train', @@ -2253,6 +2312,11 @@ export class WarehouseInventoryService { description: 'Unloaded from arrived import train', performedBy, }); + // New goods physically in the warehouse — count them, or deliver() would + // later free capacity that was never taken. + if (Number(saved.weight) > 0) { + await this.applyCapacityDelta(this.dataSource.manager, location, Number(saved.weight), 0, 0); + } result.unloadedCount += 1; result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'UNLOADED' }); } catch (error) { @@ -2670,7 +2734,12 @@ export class WarehouseInventoryService { this.assertCapacity('Zone', zone, weight, volume, containerCount); const now = new Date(); - const grnNumber = this.generateGrnNumber(bookingDirection ?? 'WH', dto.bookingId ?? 'MANUAL', now); + const grnNumber = this.generateGrnNumber( + bookingDirection ?? 'WH', + dto.bookingId ?? 'MANUAL', + now, + truckEntrance?.ownerName ?? bookingSource?.customer, + ); const receiveNote = this.buildReceiveNote({ grnNumber, notes: dto.notes?.trim() || 'Single booking received', @@ -3944,7 +4013,10 @@ export class WarehouseInventoryService { COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", COALESCE(inv.arrived_at, inv.created_at) AS "receivedAt", inv.quantity, - inv.weight, + -- An unweighed item still reports the cargo weight it holds: fall + -- back to the item's container VGM, then the booking's declared + -- weight, so a GRN never prints "0 t" for goods that are present. + COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight, inv.volume, inv.status, inv.notes, @@ -3992,6 +4064,16 @@ export class WarehouseInventoryService { WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL ) booking_container ON true + LEFT JOIN LATERAL ( + SELECT SUM(bcu.vgm_tons) AS tons + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc2 + ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL + WHERE bc2.booking_id = b.id + AND bcu.deleted_at IS NULL + AND (container.container_number IS NULL + OR bcu.container_number = container.container_number) + ) item_vgm ON true LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) WHERE inv.id = $1 AND inv.deleted_at IS NULL @@ -4379,6 +4461,81 @@ export class WarehouseInventoryService { }); } + /** + * Record whether a booking's goods needed double handling. Answered by + * warehouse staff once the goods are unloaded — only Yes bills the + * DOUBLE_HANDLING_FEE rule (see WarehouseFeeService.computeDoubleHandling). + * Locked once the fee has been invoiced, so a billed charge can't be + * retro-cancelled from the operations screen. + */ + async setDoubleHandling( + bookingId: string, + doubleHandling: boolean, + performedBy?: string, + ): Promise<{ bookingId: string; doubleHandling: boolean; setAt: string }> { + const [booking]: Array<{ id: string; tradeDirection: string | null; reference: string | null }> = + await this.dataSource.query( + `SELECT id, trade_direction AS "tradeDirection", reference + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + if ((booking.tradeDirection ?? '').toUpperCase() !== 'IMPORT') { + throw new BadRequestException('Double handling applies to import bookings only'); + } + + // Warehouse fees are billed per inventory row (invoices.source = 'warehouse', + // source_id = the inventory id), with the fee type on the line's charge_type. + const [invoiced]: Array<{ one: number }> = await this.dataSource.query( + `SELECT 1 AS one + FROM freight.invoices i + JOIN freight.invoice_lines il ON il.invoice_id = i.id AND il.deleted_at IS NULL + JOIN freight.warehouse_inventory inv + ON inv.id::text = i.source_id AND inv.deleted_at IS NULL + WHERE inv.booking_id = $1 + AND i.source = 'warehouse' + AND i.deleted_at IS NULL + AND i.status <> 'CANCELLED' + AND il.charge_type = 'DOUBLE_HANDLING' + LIMIT 1`, + [bookingId], + ); + if (invoiced) { + throw new BadRequestException( + 'Double handling has already been invoiced for this booking — cancel the invoice to change it', + ); + } + + const setAt = new Date(); + await this.dataSource.query( + `UPDATE freight.bookings + SET double_handling = $2, + double_handling_set_at = $3, + double_handling_set_by = $4, + updated_at = NOW() + WHERE id = $1`, + [bookingId, doubleHandling, setAt, performedBy ?? null], + ); + + // Audit on the booking's inventory rows so it shows in warehouse history. + const items: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query( + `SELECT id, warehouse_id AS "warehouseId" FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + for (const item of items) { + await this.activityLog.record({ + activityType: 'INVENTORY_STORED', + inventoryId: item.id, + warehouseId: item.warehouseId, + description: `Double handling set to ${doubleHandling ? 'YES — fee rule applies' : 'NO'}`, + performedBy, + }); + } + + return { bookingId, doubleHandling, setAt: setAt.toISOString() }; + } + /** Resolve the primary warehouse-inventory item for a booking (most recent). */ private async primaryInventoryIdForBooking(bookingId: string): Promise { const [inv]: Array<{ id: string }> = await this.dataSource.query( @@ -5213,7 +5370,9 @@ export class WarehouseInventoryService { }); const rows: Array<[string, unknown]> = [ ['Booking Reference', data.bookingReference], - ['Customer / Consignee', data.customerName], + // The GRN is mapped to the owner (import: consignee, export: shipper) — + // named explicitly so the note reads the same for both directions. + ["Owner's Name", data.customerName], ['Customer TIN', data.customerTin], ['Booking Status', data.bookingStatus], ['Service Type', data.serviceType], @@ -5844,8 +6003,13 @@ export class WarehouseInventoryService { } /** Shared with the facility handling flow — see common/grn.util.ts. */ - private generateGrnNumber(direction: string, referenceId: string, date: Date): string { - return generateGrnNumber(direction, referenceId, date); + private generateGrnNumber( + direction: string, + referenceId: string, + date: Date, + ownerName?: string | null, + ): string { + return generateGrnNumber(direction, referenceId, date, ownerName); } private async generateReleaseReference(item: WarehouseInventory): Promise { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts index 4ad469ce0..c2f5cec9c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts @@ -5,7 +5,7 @@ import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { actorLabel } from './current-actor.util'; -import { BookingStaff } from '../../common/booking-guards'; +import { BookingStaff, StaffReference } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto'; import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto'; @@ -43,6 +43,7 @@ export class WarehouseInvoiceController { } @Get('bookings/:id/warehouse-fee-invoices') + @StaffReference() @ApiOperation({ summary: 'List warehouse fee invoices for a booking' }) listForBooking(@Param('id', ParseUUIDPipe) id: string) { return this.invoiceService.listForBooking(id); @@ -70,12 +71,14 @@ export class WarehouseInvoiceController { } @Get('warehouse-fee-invoices/:id') + @StaffReference() @ApiOperation({ summary: 'Get a warehouse fee invoice with items + payment history' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.invoiceService.findById(id); } @Get('warehouse-fee-invoices/:id/document') + @StaffReference() @ApiOperation({ summary: 'Download sealed warehouse fee invoice PDF' }) async document(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { const { filename, buffer } = await this.invoiceService.document(id); @@ -86,6 +89,7 @@ export class WarehouseInvoiceController { } @Get('warehouse-fee-invoices/:id/receipt') + @StaffReference() @ApiOperation({ summary: 'Download sealed warehouse fee payment receipt PDF' }) async receipt(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { const { filename, buffer } = await this.invoiceService.receipt(id); @@ -110,6 +114,7 @@ export class WarehouseInvoiceController { } @Post('warehouse-fee-invoices/:id/pay-online') + @StaffReference() @ApiOperation({ summary: 'Initiate Telebirr/Waafi payment for a warehouse fee invoice' }) payOnline(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GatewayPayInvoiceDto) { return this.invoiceService.initiatePayment(id, dto); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts index 5f4205815..7e658d9db 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { BookingStaff, StaffReference } from '../../common/booking-guards'; +import { BookingStaff } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; @@ -10,8 +10,8 @@ import { WarehouseZonesService } from './warehouse-zones.service'; @ApiTags('warehouse-yards') @ApiBearerAuth() -// No class-level guard: the two reference GETs are open to any signed-in -// staff (StaffReference), every other route carries its own permission. +// No class-level guard: every route carries its own permission (reads accept +// yard-view OR inventory-view so inventory flows can populate yard pickers). @Controller('warehouse-yards') export class WarehouseYardsController { constructor( @@ -20,14 +20,14 @@ export class WarehouseYardsController { ) {} @Get() - @StaffReference() + @BookingStaff([FREIGHT_PERMS.warehouseYards.view, FREIGHT_PERMS.warehouseInventory.view]) @ApiOperation({ summary: 'List all warehouse yards' }) findAll() { return this.yardsService.findAll(); } @Get(':id') - @StaffReference() + @BookingStaff([FREIGHT_PERMS.warehouseYards.view, FREIGHT_PERMS.warehouseInventory.view]) @ApiOperation({ summary: 'Get warehouse yard by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.yardsService.findById(id); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts index 99bbdd21f..41f6aacaf 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts @@ -1,8 +1,9 @@ import { BaseRepository } from '@edr/api-common'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DeepPartial, Repository } from 'typeorm'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { WarehouseYard } from './entities/warehouse-yard.entity'; @Injectable() @@ -10,4 +11,20 @@ export class WarehouseYardsRepository extends BaseRepository { constructor(@InjectRepository(WarehouseYard) repository: Repository) { super(repository); } + + /** The cargoTypes relation can't ride a column UPDATE — sync it via entity save, like the plain columns. */ + async update(id: string, data: DeepPartial): Promise { + const { cargoTypes, ...columns } = data; + if (Object.keys(columns).length) { + await this.repository.update(id, columns as never); + } + if (cargoTypes) { + const entity = await this.repository.findOne({ where: { id } as never }); + if (entity) { + entity.cargoTypes = cargoTypes as CargoType[]; + await this.repository.save(entity); + } + } + return this.findById(id); + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts index 5b5e2b227..874de75db 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; import { WarehouseYard } from './entities/warehouse-yard.entity'; @@ -15,7 +16,7 @@ export class WarehouseYardsService { findAll(): Promise { return this.yardsRepository.findAll({ - relations: { warehouse: true, zones: true }, + relations: { warehouse: true, zones: true, cargoTypes: true }, order: { code: 'ASC' }, }); } @@ -23,14 +24,14 @@ export class WarehouseYardsService { findByWarehouse(warehouseId: string): Promise { return this.yardsRepository.findAll({ where: { warehouseId }, - relations: { zones: true }, + relations: { zones: true, cargoTypes: true }, order: { code: 'ASC' }, }); } async findById(id: string): Promise { const yard = await this.yardsRepository.findById(id, { - relations: { warehouse: true, zones: true }, + relations: { warehouse: true, zones: true, cargoTypes: true }, }); if (!yard) { @@ -51,6 +52,7 @@ export class WarehouseYardsService { name: dto.name.trim(), code: dto.code.trim(), type: dto.type, + direction: dto.direction ?? null, capacityWeight: dto.capacityWeight ?? null, capacityContainers: dto.capacityContainers ?? null, maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, @@ -60,6 +62,8 @@ export class WarehouseYardsService { currentVolume: 0, status: 'ACTIVE', isActive: true, + // Join rows are written by the save (RESTRICT FK rejects unknown ids). + cargoTypes: (dto.cargoTypeIds ?? []).map((id) => ({ id }) as CargoType), }); } @@ -84,12 +88,16 @@ export class WarehouseYardsService { name: dto.name?.trim() ?? existing.name, code: dto.code?.trim() ?? existing.code, type: dto.type ?? existing.type, + direction: dto.direction ?? existing.direction, capacityWeight: newCapacityWeight, capacityContainers: newCapacityContainers, maxWeight: dto.maxWeight ?? existing.maxWeight, maxVolume: dto.maxVolume ?? existing.maxVolume, status, isActive: status === 'ACTIVE', + ...(dto.cargoTypeIds + ? { cargoTypes: dto.cargoTypeIds.map((cargoTypeId) => ({ id: cargoTypeId }) as CargoType) } + : {}), }); if (!updated) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts index b0371cbcc..594fd7a6f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -8,8 +8,11 @@ import { WarehouseZonesService } from './warehouse-zones.service'; @ApiTags('warehouse-zones') @ApiBearerAuth() +// Baseline read: zone reference data also serves inventory flows (allocation, +// receive/move pickers) — either view permission grants reads; writes stack +// their specific permission per route. @Controller('warehouse-zones') -@BookingStaff(FREIGHT_PERMS.warehouseZones.view) +@BookingStaff([FREIGHT_PERMS.warehouseZones.view, FREIGHT_PERMS.warehouseInventory.view]) export class WarehouseZonesController { constructor(private readonly zonesService: WarehouseZonesService) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts index 63c40de94..3ee381a8c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts @@ -13,8 +13,15 @@ import { WarehousesService } from './warehouses.service'; @ApiTags('warehouses') @ApiBearerAuth() +// Baseline read: warehouse reference data is consumed by inventory/dashboard +// flows too, so any of the three view permissions grants reads. Writes stack +// their specific create/update permission per route on top. @Controller('warehouses') -@BookingStaff(FREIGHT_PERMS.warehouses.view) +@BookingStaff([ + FREIGHT_PERMS.warehouses.view, + FREIGHT_PERMS.warehouseInventory.view, + FREIGHT_PERMS.warehouseDashboard.view, +]) export class WarehousesController { constructor( private readonly warehousesService: WarehousesService, diff --git a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts index 2c4e65ae1..01442db07 100644 --- a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts +++ b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts @@ -411,7 +411,7 @@ The governing law shall be the laws of the Federal Democratic Republic of Ethiop a( "effectiveness", "Contract Effectiveness", - `The contract shall come into full force and effect on the date when the contract is signed by the parties and witnesses.`, + `The contract shall come into full force and effect on the date when the contract is signed by both parties.`, ), ], }; @@ -550,7 +550,7 @@ Notwithstanding the above, the Service Provider may revise transport tariffs due a( "effectiveness", "Contract Effectiveness", - `The contract is valid once signed by both parties and witnesses.`, + `The contract is valid once signed by both parties.`, ), a( "duration", @@ -698,7 +698,7 @@ If terminated for cause, the terminating party must issue a 15-day written notic a( "effectiveness", "Contract Effectiveness", - `The contract is valid once signed by both parties and witnesses.`, + `The contract is valid once signed by both parties.`, ), a( "duration", @@ -838,7 +838,7 @@ Notwithstanding the above, the Service Provider may revise transport tariffs due a( "effectiveness", "Contract Effectiveness", - `The contract is valid once signed by both parties and witnesses.`, + `The contract is valid once signed by both parties.`, ), a( "duration", diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 03908b62b..fa74f9043 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -582,6 +582,19 @@ const INTERCITY_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ }, ]; +// ── Hazardous cargo documents ─────────────────────────────────────────────── +// Asked for in the contract wizard the moment the customer flags the cargo as +// hazardous (ONE_TIME contracts only). Fields start empty and are configured in +// the backoffice file-settings editor. +const HAZARDOUS_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ + { + code: "hazardous_documents", + label: "Hazardous cargo documents", + entity: CONTRACT_INTAKE_ENTITY, + fields: [], + }, +]; + @Injectable() export class FileUploadSettingsSeeder { private readonly logger = new Logger(FileUploadSettingsSeeder.name); @@ -633,6 +646,11 @@ export class FileUploadSettingsSeeder { description: "Documents uploaded against a driver profile (license, ID, contracts, etc.).", })), + ...HAZARDOUS_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: + "Documents required when a one-time contract's cargo is flagged hazardous.", + })), ...INTERCITY_DOCUMENT_SETTINGS.map((s) => ({ ...s, description: diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 9871f6980..eaf2f1c02 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -19,6 +19,9 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [ 'rates', 'approval-rules', 'yard-distances', + // Keep new slugs at the END: ruleEngineCrudId derives ids from list index, + // so a mid-list insert would shift ids already seeded for later slugs. + 'truck-types', ] as const; export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number]; @@ -57,11 +60,13 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ perm('a1000001-0001-4000-8000-000000000021', 'edr_freight_app:bookings:upload_clearance_output', 'Upload customs output documents'), perm('a1000001-0001-4000-8000-000000000022', 'edr_freight_app:bookings:finalize_clearance', 'Finalize document clearance'), perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'), - perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'), perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'), perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'), perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'), perm('a1000001-0001-4000-8000-000000000024', 'edr_freight_app:bookings:create', 'Create booking'), + // Header alarm for the document-review deadline: its own key so only the + // position types that actually decide operation requests are alerted. + perm('a1000001-0001-4000-8000-000000000025', 'edr_freight_app:bookings:doc_review_alert', 'See document-review deadline alarm'), ]; /** @@ -83,7 +88,10 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ perm('a3000001-0001-4000-8000-000000000006', 'edr_freight_app:contracts:approve_director', 'Approve contract as director'), perm('a3000001-0001-4000-8000-000000000007', 'edr_freight_app:contracts:approve_ceo', 'Approve contract as CEO'), perm('a3000001-0001-4000-8000-000000000008', 'edr_freight_app:contracts:generate_contract', 'Generate contract document'), - perm('a3000001-0001-4000-8000-000000000009', 'edr_freight_app:contracts:sign_staff', 'Staff contract signature'), + // Staff counter-signature is split per freight type too — fresh ids for the + // same reason as the intake keys above. + perm('a3000001-0001-4000-8000-000000000017', 'edr_freight_app:contracts:sign_staff:bulk', 'Staff contract signature: bulk'), + perm('a3000001-0001-4000-8000-000000000018', 'edr_freight_app:contracts:sign_staff:container', 'Staff contract signature: container'), perm('a3000001-0001-4000-8000-00000000000a', 'edr_freight_app:contracts:clearance_review', 'Review pre-booking clearance docs'), perm('a3000001-0001-4000-8000-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'), perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'), @@ -91,26 +99,52 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ perm('a3000001-0001-4000-8000-00000000000e', 'edr_freight_app:contracts:clearance_et_actions', 'GL Ethiopia phased clearance actions'), perm('a3000001-0001-4000-8000-00000000000f', 'edr_freight_app:contracts:clearance_dj_actions', 'GL Djibouti phased clearance actions'), perm('a3000001-0001-4000-8000-000000000010', 'edr_freight_app:contracts:clearance_duty_advise', 'Advise contract duty/tax'), + // Hazardous contracts get two extra approval steps ahead of the normal chain. + // Each has its own permission so the two desks are genuinely separate people. + perm('a3000001-0001-4000-8000-000000000019', 'edr_freight_app:contracts:hazardous_approval_one', 'Hazardous approval — first review'), + perm('a3000001-0001-4000-8000-00000000001a', 'edr_freight_app:contracts:hazardous_approval_two', 'Hazardous approval — second review'), + // Freeze/unfreeze a signed contract. One key covers both directions — whoever + // may suspend must be able to lift it again. + perm('a3000001-0001-4000-8000-00000000001b', 'edr_freight_app:contracts:suspend', 'Suspend / resume a signed contract'), ]; -const RULE_ENGINE_PERMISSION_IDS: Record = { - 'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' }, - 'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' }, - 'wagon-types': { view: 'b2000001-0001-4000-8000-000000000015', manage: 'b2000001-0001-4000-8000-000000000016' }, - 'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' }, - yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' }, - 'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' }, - 'weight-limit-rules': { view: 'b2000001-0001-4000-8000-00000000000b', manage: 'b2000001-0001-4000-8000-00000000000c' }, - 'priority-configs': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' }, - rates: { view: 'b2000001-0001-4000-8000-000000000011', manage: 'b2000001-0001-4000-8000-000000000012' }, - 'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' }, - 'yard-distances': { view: 'b2000001-0001-4000-8000-000000000018', manage: 'b2000001-0001-4000-8000-000000000019' }, +// Existing per-slug view ids are kept as-is: position-type grants reference +// them by id, so re-minting would orphan those rows. +const RULE_ENGINE_VIEW_IDS: Record = { + 'cargo-types': 'b2000001-0001-4000-8000-000000000001', + 'container-types': 'b2000001-0001-4000-8000-000000000003', + 'wagon-types': 'b2000001-0001-4000-8000-000000000015', + 'truck-types': 'b2000001-0001-4000-8000-00000000001a', + 'service-types': 'b2000001-0001-4000-8000-000000000005', + yards: 'b2000001-0001-4000-8000-000000000007', + 'shipping-lines': 'b2000001-0001-4000-8000-000000000009', + 'weight-limit-rules': 'b2000001-0001-4000-8000-00000000000b', + 'priority-configs': 'b2000001-0001-4000-8000-00000000000f', + rates: 'b2000001-0001-4000-8000-000000000011', + 'approval-rules': 'b2000001-0001-4000-8000-000000000013', + 'yard-distances': 'b2000001-0001-4000-8000-000000000018', +}; + +// CRUD replaces the retired coarse `:manage`. New ids live in a fresh block +// (b2000002-…) so a stale `:manage` grant can never silently confer a CRUD +// action — the migration re-grants create/update/delete explicitly. +const RULE_ENGINE_CRUD_ACTIONS = ['create', 'update', 'delete'] as const; +type RuleEngineCrudAction = (typeof RULE_ENGINE_CRUD_ACTIONS)[number]; +const ruleEngineCrudId = ( + slug: RuleEngineResourceSlug, + action: RuleEngineCrudAction, +): string => { + const n = + RULE_ENGINE_RESOURCE_SLUGS.indexOf(slug) * 3 + + RULE_ENGINE_CRUD_ACTIONS.indexOf(action) + + 1; // 1..36 + return `b2000002-0001-4000-8000-${n.toString(16).padStart(12, '0')}`; }; /** - * Slugs whose changes go through a separate approver. `manage` lets a staff - * member propose a change; only `approve` lets someone put it into effect. - * Only listed slugs get the permission — the rest are manage-only. + * Slugs whose changes go through a separate approver. CRUD lets a staff member + * propose a change; only `approve` lets someone put it into effect. Only listed + * slugs get the permission. */ const RULE_ENGINE_APPROVE_PERMISSION_IDS: Partial> = { rates: 'b2000001-0001-4000-8000-000000000017', @@ -121,11 +155,12 @@ export type RuleEngineApprovableSlug = 'rates'; export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap( (slug) => { const resource = slugToResourceKey(slug); - const ids = RULE_ENGINE_PERMISSION_IDS[slug]; const approveId = RULE_ENGINE_APPROVE_PERMISSION_IDS[slug]; return [ - perm(ids.view, `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`), - perm(ids.manage, `edr_freight_app:rule_engine:${resource}:manage`, `Manage ${slug}`), + perm(RULE_ENGINE_VIEW_IDS[slug], `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`), + perm(ruleEngineCrudId(slug, 'create'), `edr_freight_app:rule_engine:${resource}:create`, `Create ${slug}`), + perm(ruleEngineCrudId(slug, 'update'), `edr_freight_app:rule_engine:${resource}:update`, `Update ${slug}`), + perm(ruleEngineCrudId(slug, 'delete'), `edr_freight_app:rule_engine:${resource}:delete`, `Delete ${slug}`), ...(approveId ? [perm(approveId, `edr_freight_app:rule_engine:${resource}:approve`, `Approve ${slug} changes`)] : []), @@ -201,6 +236,12 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ perm('e1b00001-0001-4000-8000-000000000005', 'edr_freight_app:wagons:transfer_request', 'Request wagon transfer'), perm('e1b00001-0001-4000-8000-000000000006', 'edr_freight_app:wagons:transfer_fulfill', 'Fulfil wagon transfer (OCC)'), perm('e1b00001-0001-4000-8000-000000000007', 'edr_freight_app:wagons:transfer_history_all', "View all staff's transfer history"), + // The transfer desk is its own screen, so it carries its own per-action keys — + // seeing the queue, withdrawing a request and short-closing one are separate + // grants from filing or fulfilling. + perm('e1b00001-0001-4000-8000-000000000008', 'edr_freight_app:wagons:transfer_view', 'View wagon transfer requests'), + perm('e1b00001-0001-4000-8000-000000000009', 'edr_freight_app:wagons:transfer_cancel', 'Withdraw a wagon transfer request'), + perm('e1b00001-0001-4000-8000-00000000000a', 'edr_freight_app:wagons:transfer_close_short', 'Close a transfer request short of the requested count'), perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'), perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'), perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'), @@ -376,6 +417,7 @@ export const FREIGHT_PERMS = { reviewDocuments: 'edr_freight_app:bookings:review_documents', uploadClearanceOutput: 'edr_freight_app:bookings:upload_clearance_output', finalizeClearance: 'edr_freight_app:bookings:finalize_clearance', + docReviewAlert: 'edr_freight_app:bookings:doc_review_alert', }, contracts: { view: 'edr_freight_app:contracts:view', @@ -394,8 +436,13 @@ export const FREIGHT_PERMS = { approveLineStaff: 'edr_freight_app:contracts:approve_line_staff', approveDirector: 'edr_freight_app:contracts:approve_director', approveCeo: 'edr_freight_app:contracts:approve_ceo', + hazardousApprovalOne: 'edr_freight_app:contracts:hazardous_approval_one', + hazardousApprovalTwo: 'edr_freight_app:contracts:hazardous_approval_two', generateContract: 'edr_freight_app:contracts:generate_contract', - signStaff: 'edr_freight_app:contracts:sign_staff', + signStaff: { + bulk: 'edr_freight_app:contracts:sign_staff:bulk', + container: 'edr_freight_app:contracts:sign_staff:container', + }, clearanceReview: 'edr_freight_app:contracts:clearance_review', finalizeClearance: 'edr_freight_app:contracts:finalize_clearance', createBooking: 'edr_freight_app:contracts:create_booking', @@ -403,10 +450,10 @@ export const FREIGHT_PERMS = { clearanceEtActions: 'edr_freight_app:contracts:clearance_et_actions', clearanceDjActions: 'edr_freight_app:contracts:clearance_dj_actions', clearanceDutyAdvise: 'edr_freight_app:contracts:clearance_duty_advise', + suspend: 'edr_freight_app:contracts:suspend', }, trainScheduling: { view: 'edr_freight_app:train_scheduling:view', - manage: 'edr_freight_app:train_scheduling:manage', create: 'edr_freight_app:train_scheduling:create', update: 'edr_freight_app:train_scheduling:update', cancel: 'edr_freight_app:train_scheduling:cancel', @@ -421,8 +468,12 @@ export const FREIGHT_PERMS = { ruleEngine: { view: (slug: RuleEngineResourceSlug) => `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`, - manage: (slug: RuleEngineResourceSlug) => - `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`, + create: (slug: RuleEngineResourceSlug) => + `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:create`, + update: (slug: RuleEngineResourceSlug) => + `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:update`, + delete: (slug: RuleEngineResourceSlug) => + `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:delete`, approve: (slug: RuleEngineApprovableSlug) => `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`, }, @@ -483,6 +534,12 @@ export const FREIGHT_PERMS = { // executes the move). Distinct keys so OCC can hold fulfil without request. transferRequest: 'edr_freight_app:wagons:transfer_request', transferFulfill: 'edr_freight_app:wagons:transfer_fulfill', + /** Open the transfer-requests desk (list + detail). */ + transferView: 'edr_freight_app:wagons:transfer_view', + /** Withdraw a request that has not moved any wagon yet. */ + transferCancel: 'edr_freight_app:wagons:transfer_cancel', + /** End a request short — anyone who can fulfil may also do this. */ + transferCloseShort: 'edr_freight_app:wagons:transfer_close_short', // Admin: read every staffer's transfer history. Without it, a user only sees // their own (the /history endpoint uses the caller id, backend-enforced). transferHistoryAll: 'edr_freight_app:wagons:transfer_history_all', @@ -750,8 +807,15 @@ export const ROLE_PERMISSION_PRESETS = { operationsOfficer: [ FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.operations, + // They are the ones who accept/reject operation requests, so they are the + // ones the doc-review countdown is for. + FREIGHT_PERMS.bookings.docReviewAlert, FREIGHT_PERMS.trainScheduling.view, - FREIGHT_PERMS.trainScheduling.manage, + FREIGHT_PERMS.trainScheduling.create, + FREIGHT_PERMS.trainScheduling.update, + FREIGHT_PERMS.trainScheduling.cancel, + FREIGHT_PERMS.trainScheduling.reschedule, + FREIGHT_PERMS.trainScheduling.rulesManage, FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.fleet.manage, ...FLEET_GRANULAR_KEYS, @@ -836,7 +900,8 @@ export const ROLE_PERMISSION_PRESETS = { ...bothFreightTypes(FREIGHT_PERMS.contracts.reject), FREIGHT_PERMS.contracts.approveLineStaff, FREIGHT_PERMS.contracts.generateContract, - FREIGHT_PERMS.contracts.signStaff, + ...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff), + FREIGHT_PERMS.contracts.suspend, ], orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; @@ -857,6 +922,12 @@ export const POSITION_PERMISSION_PRESETS = { ...ROLE_PERMISSION_PRESETS.director, ...ROLE_PERMISSION_PRESETS.operationsOfficer, FREIGHT_PERMS.allocation.manage, + // Customer desk: onboarding intake lands on the chief — open the customer + // list and approve/suspend a submitted profile. Deliberately NOT granted: + // create, update and password reset, which stay with the customer admins. + FREIGHT_PERMS.customers.view, + FREIGHT_PERMS.customers.verify, + FREIGHT_PERMS.customers.deactivate, ]), director: dedupe([...ROLE_PERMISSION_PRESETS.director]), ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]), diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index bb4d024c8..77e67f210 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,4 +1,5 @@ import { + ArrowLeftRight, Boxes, Building2, Container, @@ -86,6 +87,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import WagonTransfersPage from "./pages/wagons/WagonTransfersPage"; import VehicleDetailPage from "./pages/fleet/VehicleDetailPage"; import DriverDetailPage from "./pages/fleet/DriverDetailPage"; import RoutesPage from "./pages/fleet/RoutesPage"; @@ -166,8 +168,8 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.bookings.view, }, - // Operations hub: clearance-document review for contracts WITHOUT - // customs clearing (contract-level for one-time, per-booking for general). + // Operations hub: per-shipment clearance-document review for services + // WITHOUT customs clearing (self-clearance) — bookings only. { label: "Clearance Documents", href: "/dashboard/contracts/clearance-documents", @@ -297,6 +299,15 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view], }, + { + label: "Wagon Transfers", + href: "/dashboard/wagon-transfers", + icon: , + permission: [ + FREIGHT_PERMS.wagons.transferView, + FREIGHT_PERMS.wagons.view, + ], + }, { label: "Vehicles", href: "/dashboard/vehicles", @@ -1180,6 +1191,19 @@ const App = () => { } /> + + + + } + /> { } /> + + + + } + /> void; + searchPlaceholder?: string; + /** `YYYY-MM-DD`, matching Mantine 9's date inputs. */ + dateFrom: string | null; + onDateFromChange: (value: string | null) => void; + dateTo: string | null; + onDateToChange: (value: string | null) => void; + /** Label above the range, naming the date being filtered (e.g. "Arrival date"). */ + dateLabel?: string; + hasFilters?: boolean; + onReset?: () => void; + /** Page-specific selects (status, warehouse…) rendered after the date range. */ + children?: ReactNode; + showSearch?: boolean; + showDateRange?: boolean; +} + +/** + * Search box + inclusive date range + clear, shared by every freight list so the + * controls sit in the same place and behave the same way on all of them. + * Pair with `useListControls`, which owns the state and does the filtering. + */ +const ListControls = ({ + search, + onSearchChange, + searchPlaceholder = "Search…", + dateFrom, + onDateFromChange, + dateTo, + onDateToChange, + dateLabel, + hasFilters, + onReset, + children, + showSearch = true, + showDateRange = true, +}: ListControlsProps) => ( + + {showSearch && ( + onSearchChange(e.currentTarget.value)} + leftSection={} + style={{ flex: "1 1 240px", minWidth: 200 }} + /> + )} + + {showDateRange && ( + <> + + + + )} + + {children} + + {hasFilters && onReset && ( + + )} + +); + +export default ListControls; diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ArticleBodyDiff.test.ts b/apps/edr-freight-web/backoffice/src/components/contracts/ArticleBodyDiff.test.ts new file mode 100644 index 000000000..64c817b6b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ArticleBodyDiff.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { diffWords } from "./ArticleBodyDiff"; + +/** Rebuild each side from the token stream — the diff must lose nothing. */ +const rebuild = ( + tokens: ReturnType, + side: "before" | "after", +): string => + tokens + .filter((t) => + side === "before" ? t.op !== "added" : t.op !== "removed", + ) + .map((t) => t.text) + .join(""); + +describe("diffWords", () => { + it("marks only the words that actually changed", () => { + const tokens = diffWords( + "The carrier shall deliver within 30 days.", + "The carrier shall deliver within 45 days.", + ); + + expect(tokens.filter((t) => t.op === "removed").map((t) => t.text)).toEqual([ + "30", + ]); + expect(tokens.filter((t) => t.op === "added").map((t) => t.text)).toEqual([ + "45", + ]); + }); + + it("reconstructs both sides losslessly, whitespace included", () => { + const before = "Payment is due\nwithin ten (10) working days."; + const after = "Payment is due\nwithin five (5) working days of invoice."; + const tokens = diffWords(before, after); + + expect(rebuild(tokens, "before")).toBe(before); + expect(rebuild(tokens, "after")).toBe(after); + }); + + it("reports nothing changed for identical text", () => { + const tokens = diffWords("Same clause.", "Same clause."); + expect(tokens.every((t) => t.op === "same")).toBe(true); + }); + + it("handles a body being emptied or written from scratch", () => { + expect(rebuild(diffWords("Some clause.", ""), "after")).toBe(""); + expect(rebuild(diffWords("", "Brand new clause."), "before")).toBe(""); + }); + + it("falls back to a whole-block replace on pathological input", () => { + // Past MAX_TOKENS the LCS table is skipped; the change must still be + // reported truthfully rather than silently dropped. + const before = Array.from({ length: 2000 }, (_, i) => `a${i}`).join(" "); + const after = Array.from({ length: 2000 }, (_, i) => `b${i}`).join(" "); + const tokens = diffWords(before, after); + + expect(rebuild(tokens, "before")).toBe(before); + expect(rebuild(tokens, "after")).toBe(after); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ArticleBodyDiff.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ArticleBodyDiff.tsx new file mode 100644 index 000000000..1f3145390 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ArticleBodyDiff.tsx @@ -0,0 +1,168 @@ +import { useMemo, useState } from "react"; +import { Box, Button, Group, Text } from "@mantine/core"; +import { ChevronDown, ChevronRight } from "lucide-react"; + +type Op = "same" | "added" | "removed"; +interface Token { + op: Op; + text: string; +} + +/** Split on whitespace but KEEP it, so rebuilt text preserves its spacing. */ +function tokenize(text: string): string[] { + return text.split(/(\s+)/).filter((t) => t !== ""); +} + +/** + * Word-level diff via the classic LCS table. + * + * ponytail: O(n·m) time and memory over word counts. Contract articles are + * paragraphs (hundreds of words), so this is microseconds; the guard below + * bails to a whole-block replace if an article ever gets pathological. Swap in + * a real diff library only if that guard starts firing. + */ +const MAX_TOKENS = 1200; + +export function diffWords(before: string, after: string): Token[] { + const a = tokenize(before); + const b = tokenize(after); + + if (a.length > MAX_TOKENS || b.length > MAX_TOKENS) { + return [ + { op: "removed", text: before }, + { op: "added", text: after }, + ]; + } + + // lcs[i][j] = length of the longest common subsequence of a[i:] and b[j:]. + const lcs: number[][] = Array.from({ length: a.length + 1 }, () => + new Array(b.length + 1).fill(0), + ); + for (let i = a.length - 1; i >= 0; i--) { + for (let j = b.length - 1; j >= 0; j--) { + lcs[i][j] = + a[i] === b[j] + ? lcs[i + 1][j + 1] + 1 + : Math.max(lcs[i + 1][j], lcs[i][j + 1]); + } + } + + const tokens: Token[] = []; + // Merge runs of the same op so the output is spans, not one node per word. + const push = (op: Op, text: string) => { + const last = tokens[tokens.length - 1]; + if (last && last.op === op) last.text += text; + else tokens.push({ op, text }); + }; + + let i = 0; + let j = 0; + while (i < a.length && j < b.length) { + if (a[i] === b[j]) { + push("same", a[i]); + i++; + j++; + } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { + push("removed", a[i]); + i++; + } else { + push("added", b[j]); + j++; + } + } + while (i < a.length) push("removed", a[i++]); + while (j < b.length) push("added", b[j++]); + + return tokens; +} + +const OP_STYLE: Record = { + same: {}, + added: { + background: "var(--mantine-color-teal-1)", + color: "var(--mantine-color-teal-9)", + borderRadius: 3, + }, + removed: { + background: "var(--mantine-color-red-1)", + color: "var(--mantine-color-red-9)", + borderRadius: 3, + textDecoration: "line-through", + }, +}; + +/** + * Inline before/after of an edited article body: removed words struck through + * in red, inserted words highlighted in green. Collapsed by default — a + * revision list stays scannable, and the full text is one click away. + */ +export function ArticleBodyDiff({ + fromBody, + toBody, +}: { + fromBody: string; + toBody: string; +}) { + const [open, setOpen] = useState(false); + const tokens = useMemo( + () => (open ? diffWords(fromBody, toBody) : []), + [open, fromBody, toBody], + ); + + return ( + + + + {open && ( + + + {tokens.map((token, index) => ( + + {token.text} + + ))} + + + + + + Removed + + + + + + Added + + + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx new file mode 100644 index 000000000..16b1ab602 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx @@ -0,0 +1,132 @@ +import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import { AlertTriangle, Send } from "lucide-react"; +import { useState } from "react"; +import { Link } from "react-router-dom"; +import toast from "react-hot-toast"; + +import { bookingsService } from "@/services/bookings.service"; + +export interface BookingChangesRequestedAlertProps { + bookingId: string; + reference?: string | null; + /** Operations' note — what has to change before this can go back to them. */ + note?: string | null; + /** Shipment day the booking currently holds; the resubmit default. */ + scheduledDate?: string | null; + /** GL Ethiopia owns customs bookings, so only they get the resubmit control. */ + canResubmit: boolean; + onResubmitted?: () => void; +} + +/** + * Operations sent a GL-created booking back for changes. + * + * The customer cannot act on this — GL created the booking on their behalf — so + * the note and the way out both live here, on the page GL works from. Resubmit + * re-requests operation on the chosen shipment day; the server re-checks the day + * has a departure that can carry the cargo and refuses with the reason if not. + */ +export function BookingChangesRequestedAlert({ + bookingId, + reference, + note, + scheduledDate, + canResubmit, + onResubmitted, +}: BookingChangesRequestedAlertProps) { + const [day, setDay] = useState( + scheduledDate ? new Date(scheduledDate) : null, + ); + const [sending, setSending] = useState(false); + + const resubmit = async () => { + if (!day) return; + setSending(true); + try { + await bookingsService.proceedToOperation(bookingId, day.toISOString()); + toast.success("Sent back to Operations for review"); + onResubmitted?.(); + } catch { + // The http interceptor already toasts the server's own reason (no + // departure that day, no wagon that can carry the cargo, export train + // full…) — a second toast here would just duplicate it. + } finally { + setSending(false); + } + }; + + return ( + } + title={`Operations returned booking ${reference ?? ""} for changes`.trim()} + > + + {note ? ( + + + What Operations asked for + + + {note} + + + ) : ( + + Operations returned this booking without a note — contact them for + the detail before resubmitting. + + )} + + + This booking was created by GL Ethiopia, so the customer cannot fix it. + Make the correction Operations asked for, then send it back for review.{" "} + + Open the booking → + + + + {canResubmit ? ( + + setDay(v ? new Date(v) : null)} + minDate={new Date()} + size="sm" + w={230} + /> + + + ) : null} + + + ); +} + +export default BookingChangesRequestedAlert; diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceDocumentVersionsModal.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceDocumentVersionsModal.tsx new file mode 100644 index 000000000..0015ea47b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceDocumentVersionsModal.tsx @@ -0,0 +1,237 @@ +import { + Badge, + Button, + Group, + Loader, + Modal, + Paper, + Stack, + Text, + Textarea, +} from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Download, Eye, History, Upload } from "lucide-react"; +import { useState } from "react"; +import toast from "react-hot-toast"; + +import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { contractsService } from "@/services/contracts.service"; +import { isViewable } from "@edr/ui-common"; + +import { downloadBookingFile, fetchViewableFile } from "@/services/files.service"; + +export interface ClearanceDocumentVersionsModalProps { + contractId: string; + /** The document being inspected; null closes the modal. */ + doc: { fileKey: string; label: string } | null; + onClose: () => void; + /** Hide the replace form (finalized clearance, read-only viewers). */ + canReplace?: boolean; + onReplaced?: () => void; + onView?: (file: { name: string; url: string }) => void; +} + +const fmt = (iso: string) => + new Date(iso).toLocaleString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + +/** + * Version history of one clearance document, and the way to add a version. + * + * Staff can correct a document without bouncing it back to the customer, but + * the customer's original is never overwritten — it drops down this list as a + * superseded version, stamped with who replaced it and why. The corrected file + * comes back unreviewed, so it still has to be approved before finalizing. + */ +export function ClearanceDocumentVersionsModal({ + contractId, + doc, + onClose, + canReplace = false, + onReplaced, + onView, +}: ClearanceDocumentVersionsModalProps) { + const queryClient = useQueryClient(); + const [file, setFile] = useState(null); + const [reason, setReason] = useState(""); + + const { data: versions = [], isLoading } = useQuery({ + queryKey: ["contracts", "clearance-doc-versions", contractId, doc?.fileKey], + queryFn: () => + contractsService.getClearanceDocumentVersions(contractId, doc!.fileKey), + enabled: Boolean(doc), + }); + + const replace = useMutation({ + mutationFn: () => + contractsService.replaceClearanceDocument( + contractId, + doc!.fileKey, + file!, + reason.trim(), + ), + onSuccess: async () => { + toast.success("Document replaced — the previous version is kept on file"); + setFile(null); + setReason(""); + await queryClient.invalidateQueries({ + queryKey: ["contracts", "clearance-doc-versions", contractId, doc?.fileKey], + }); + await queryClient.invalidateQueries({ + queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId), + }); + onReplaced?.(); + }, + }); + + const close = () => { + setFile(null); + setReason(""); + onClose(); + }; + + return ( + + + {doc?.label ?? "Document"} — version history + + } + > + + {isLoading ? ( + + + + ) : versions.length === 0 ? ( + + Nothing uploaded under this document yet. + + ) : ( + + {versions.map((v, index) => ( + + + + + + {v.name} + + {v.isCurrent ? ( + + Current + + ) : index === versions.length - 1 ? ( + + Original + + ) : ( + + Superseded + + )} + + + Uploaded {fmt(v.uploadedAt)} + {v.replacedAt ? ` · replaced ${fmt(v.replacedAt)}` : ""} + + {v.replaceReason ? ( + + Reason: {v.replaceReason} + + ) : null} + + + {isViewable({ name: v.name, url: "" }) && onView ? ( + + ) : null} + + + + + ))} + + )} + + {canReplace ? ( + + + + Replace this document + + + Use this for a correction you can make yourself. The customer's + copy stays in the history above, and the new file has to be + approved before clearance is finalized. + + +