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..d6a39bfed 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 @@ -84,6 +93,9 @@ FAYDA_PRIVATE_KEY_BASE64= FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete # OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset. FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback +# OAuth redirect_uri for the customer portal (its own origin — must also be +# registered with eSignet). Defaults to FAYDA_WEB_REDIRECT_URI when unset. +FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/callback CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer FAYDA_SCOPE=openid profile email phone address FAYDA_ACR_VALUES=mosip:idp:acr:generated-code diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index cd87dd2d3..b3ed1c802 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -27,6 +27,7 @@ "seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts", "seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts", "auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts", + "backfill:missing-unload-inventory": "ts-node -r tsconfig-paths/register src/scripts/backfill-missing-unload-inventory.ts", "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", "seed:dropdown-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-dropdown-settings.ts", "seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts", @@ -35,13 +36,13 @@ "iam:migration:run": "pnpm run iam:typeorm:cli migration:run", "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", - "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js", "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts", "migration:run": "node dist/scripts/migrate.js", "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" }, "dependencies": { "@edr/api-common": "workspace:*", + "@edr/iam-seed": "workspace:*", "@edr/payment-providers": "workspace:*", "@edr/types": "workspace:*", "@golevelup/nestjs-rabbitmq": "^5.5.0", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 9270dd0a1..a3c09df97 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -2,6 +2,7 @@ import { MiddlewareConsumer, Module, OnApplicationBootstrap, + RequestMethod, } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; @@ -12,6 +13,7 @@ import { ensurePostgresSchemas, APPLICATION_SEARCH_PATH, } from "./config/ensure-postgres-schemas"; +import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed"; import { IamModule } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; @@ -29,6 +31,8 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module"; // import { TrainsModule } from "./modules/trains/trains.module"; import { LocomotivesModule } from "./modules/locomotives/locomotives.module"; +import { TruckTypesModule } from "./modules/truck-types/truck-types.module"; +import { TransitAgentsModule } from "./modules/transit-agents/transit-agents.module"; import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module"; import { TrainSetsModule } from "./modules/train-sets/train-sets.module"; import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module"; @@ -99,6 +103,7 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; import { AiModule } from "./modules/ai/ai.module"; import { LoggerMiddleware } from "./logger.middleware"; +import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; @Module({ imports: [ @@ -152,12 +157,26 @@ import { LoggerMiddleware } from "./logger.middleware"; applications: [EDR_FREIGHT_APPLICATION], permissions: EDR_FREIGHT_PERMISSIONS, }), + // Replaces the package's DataSeeder. Shared with edr-passenger-api, which + // seeds the same `iam` schema — see packages/iam-seed. + IamSeedModule.forRoot({ + superAdmin: { + username: "superadmin", + name: { am: "ሱፐር አድሚን", en: "Super Admin" }, + roleKey: "super_admin", + organizationKey: "edr_freight", + unitKey: "edr_freight_app", + fallbackEmail: "superadmin@tria.com", + }, + }), BookingsModule, ContractsModule, SignaturesModule, FilesModule, ConsignmentsModule, LocomotivesModule, + TruckTypesModule, + TransitAgentsModule, WagonTypesModule, TrainSetsModule, TrainSchedulesModule, @@ -225,11 +244,12 @@ import { LoggerMiddleware } from "./logger.middleware"; // MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, PaidImportExportMileDemoSeeder, + LoginAudienceMiddleware, ], }) export class AppModule implements OnApplicationBootstrap { constructor( - // private readonly seeder: DataSeeder, + private readonly iamBaselineSeeder: IamBaselineSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, @@ -259,13 +279,22 @@ export class AppModule implements OnApplicationBootstrap { // Permissions foundation — keep enabled: // freightPermissionKeyMigration → renames legacy permission keys - // seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions // edrOrgSeeder → seeds org/unit + the Permission catalog + // iamBaselineSeeder → @edr/iam-seed: IAM app, roles, permissions, + // position types, organization types + + // default units, org/unit settings and the + // super-admin account. Replaces the package's + // DataSeeder, and is shared with + // edr-passenger-api so one writer owns the + // `iam` schema. Runs after edrOrgSeeder + // because the super admin attaches to the + // edr_freight org/unit. + // Writes nothing unless SEED_IAM_BASELINE=true. // freightPositionsSeeder → seeds Position + PositionPermission rows // (depends on edrOrgSeeder, must run after) await this.freightPermissionKeyMigrationSeeder.run(); - // await this.seeder.run(); await this.edrOrgSeeder.run(); + await this.iamBaselineSeeder.run(); await this.freightPositionsSeeder.run(); // File upload settings — keep enabled. @@ -304,5 +333,9 @@ export class AppModule implements OnApplicationBootstrap { configure(consumer: MiddlewareConsumer) { consumer.apply(LoggerMiddleware).forRoutes("*"); + consumer.apply(LoginAudienceMiddleware).forRoutes( + { path: "auth/login", method: RequestMethod.POST }, + { path: "auth/mfa-verify", method: RequestMethod.POST }, + ); } } 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/config/fayda.config.ts b/apps/edr-freight-api/src/config/fayda.config.ts index a25289159..30525dd01 100644 --- a/apps/edr-freight-api/src/config/fayda.config.ts +++ b/apps/edr-freight-api/src/config/fayda.config.ts @@ -15,7 +15,7 @@ export interface FaydaJwk { qi?: string; } -export type FaydaPlatform = 'WEB' | 'MOBILE'; +export type FaydaPlatform = 'WEB' | 'MOBILE' | 'PORTAL'; export interface FaydaConfig { enabled: boolean; @@ -25,8 +25,10 @@ export interface FaydaConfig { userInfoEndpoint: string; /** OAuth redirect_uri sent to eSignet for MOBILE clients. */ redirectUri: string; - /** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */ + /** OAuth redirect_uri sent to eSignet for WEB (backoffice) clients. Falls back to `redirectUri`. */ webRedirectUri: string; + /** OAuth redirect_uri sent to eSignet for the customer portal. Falls back to `webRedirectUri`. */ + portalRedirectUri: string; privateJwk: FaydaJwk; scope: string; acrValues: string; @@ -77,6 +79,7 @@ export default registerAs('fayda', (): FaydaConfig => { const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10); const redirectUri = process.env.FAYDA_REDIRECT_URI ?? ''; const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri; + const portalRedirectUri = process.env.FAYDA_PORTAL_REDIRECT_URI || webRedirectUri; if (!enabled) { return { enabled: false, @@ -86,6 +89,7 @@ export default registerAs('fayda', (): FaydaConfig => { userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '', redirectUri, webRedirectUri, + portalRedirectUri, privateJwk: { kty: 'RSA', n: '', e: '', d: '' }, scope, acrValues, @@ -117,6 +121,7 @@ export default registerAs('fayda', (): FaydaConfig => { userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!, redirectUri, webRedirectUri, + portalRedirectUri, privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!), scope, acrValues, 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/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index 8b8b92f09..07f4d25d3 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -157,10 +157,14 @@ export class ContractViewModelBuilder { pricing, rateSchedule, signatures, - canSignCustomer: - booking.status === 'CONTRACT_READY' && !hasCustomer, - canSignStaff: - booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff, + // Government contracts are generated at creation and signable at any + // time, in any order — no status gate, no customer-first sequencing. + canSignCustomer: booking.isGovernment + ? !hasCustomer + : booking.status === 'CONTRACT_READY' && !hasCustomer, + canSignStaff: booking.isGovernment + ? !hasStaff + : booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff, hasContractDocument: hasContractFile, hasCustomerSignature: hasCustomer, hasStaffSignature: hasStaff, 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..cf4c37b2d 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) @@ -28,6 +46,9 @@ async function bootstrap() { "Accept", "Authorization", "X-Requested-With", + // Which freight frontend is calling — /auth/login uses this to reject + // cross-audience credentials (EDRFREIGHT-415). + "X-Client-App", // IAM context headers required by @tria-plc/api-common's JwtGuard "organization-unit-id", "delegator-position-id", @@ -40,6 +61,13 @@ async function bootstrap() { "x-delegator-position-id", "x-current-project-id", "x-current-position-id", + // Headers sent by the freight-backoffice OKR/objective-service client + // (withHeaders.tsx, signatureAndTeeterService.ts, useIncomingReport.ts) + // under yet another naming convention — unprefixed "tenant-key"/"unit-id", + // and "x-delegated-position-id" (delegated, not delegator). + "tenant-key", + "unit-id", + "x-delegated-position-id", ], exposedHeaders: ["Content-Disposition"], maxAge: 86400, // cache preflight for 24h to cut chatter in dev 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/migrations/3010000000000-AddBookingTransitAssignee.ts b/apps/edr-freight-api/src/migrations/3010000000000-AddBookingTransitAssignee.ts new file mode 100644 index 000000000..50fb9db2b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3010000000000-AddBookingTransitAssignee.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Transit-assignee handshake on the SHIPMENT, not the contract. + * + * Clearance runs per booking now, so the ask GL Ethiopia raises before filing a + * customs declaration ("who handles this shipment in Djibouti?") and Djibouti's + * answer belong on the booking. The contract-cycle columns added by + * 2950000000000 stay for the legacy contract-level cycles. + */ +export class AddBookingTransitAssignee3010000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS transit_assignee_requested_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_request_note text NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_name text NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_assigned_at timestamptz NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS transit_assignee_requested_at, + DROP COLUMN IF EXISTS transit_assignee_request_note, + DROP COLUMN IF EXISTS transit_assignee_name, + DROP COLUMN IF EXISTS transit_assignee_assigned_at + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3020000000000-AddContractSubmittedAt.ts b/apps/edr-freight-api/src/migrations/3020000000000-AddContractSubmittedAt.ts new file mode 100644 index 000000000..f2c579f6b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3020000000000-AddContractSubmittedAt.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * A contract's `created_at` is the DRAFT row's insert time, not when the + * customer actually submitted it for review — a DRAFT can sit edited for days + * first. `submitted_at` is stamped by ContractTransitionService.submit / + * confirmSubmit so the history UI can show a real submission time. + */ +export class AddContractSubmittedAt3020000000000 implements MigrationInterface { + name = 'AddContractSubmittedAt3020000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS submitted_at timestamptz;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contracts DROP COLUMN IF EXISTS submitted_at;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3030000000000-AddGlExchangeDocumentFields.ts b/apps/edr-freight-api/src/migrations/3030000000000-AddGlExchangeDocumentFields.ts new file mode 100644 index 000000000..2958f5406 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3030000000000-AddGlExchangeDocumentFields.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * GL Ethiopia ↔ GL Djibouti document exchange. The documents are ordinary + * `freight.files` rows (resource `gl_exchange`), so they only need the metadata + * a free-form upload has and a catalog-driven one does not: the uploader's own + * title, who uploaded it (the only user allowed to change it afterwards) and + * whether the customer may see it in the portal. + */ +export class AddGlExchangeDocumentFields3030000000000 + implements MigrationInterface +{ + name = 'AddGlExchangeDocumentFields3030000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.files + ADD COLUMN IF NOT EXISTS title varchar(300), + ADD COLUMN IF NOT EXISTS visible_to_customer boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS uploaded_by_user_id uuid, + ADD COLUMN IF NOT EXISTS uploaded_by_name varchar(200);`, + ); + // Every read of a thread is "all files of one resource" — index the pair. + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_files_resource_lookup + ON freight.files (resource, resource_id);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_files_resource_lookup;`, + ); + await queryRunner.query( + `ALTER TABLE freight.files + DROP COLUMN IF EXISTS title, + DROP COLUMN IF EXISTS visible_to_customer, + DROP COLUMN IF EXISTS uploaded_by_user_id, + DROP COLUMN IF EXISTS uploaded_by_name;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3040000000000-AddTransitAgents.ts b/apps/edr-freight-api/src/migrations/3040000000000-AddTransitAgents.ts new file mode 100644 index 000000000..f587af9a7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3040000000000-AddTransitAgents.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddTransitAgents3040000000000 implements MigrationInterface { + name = "AddTransitAgents3040000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.transit_agents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name varchar(150) NOT NULL, + valid_from date NOT NULL, + valid_to date NOT NULL, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS ix_transit_agents_is_active + ON freight.transit_agents (is_active) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_agents`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3050000000000-MergeDuplicateSebetaYards.ts b/apps/edr-freight-api/src/migrations/3050000000000-MergeDuplicateSebetaYards.ts new file mode 100644 index 000000000..c8a2c5722 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3050000000000-MergeDuplicateSebetaYards.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Two active "Sebeta" yards existed (code LEGACY_DEST label "Sebeta", and code + * SEBETA label "sebeta") — rates and routes pointed at one or the other, so a + * rate configured against one never matched a contract routed via the other. + * Merge them: keep the row all rates/distances/facilities reference + * (LEGACY_DEST), repoint every yard reference from the duplicate to it, retire + * the duplicate, and give the survivor the clean SEBETA code. Then make + * duplicate active yard labels/codes impossible at the DB level. + */ +export class MergeDuplicateSebetaYards3050000000000 implements MigrationInterface { + name = "MergeDuplicateSebetaYards3050000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + DECLARE + survivor uuid; + dupe uuid; + col record; + BEGIN + SELECT id INTO survivor FROM freight.yards + WHERE code = 'LEGACY_DEST' AND lower(trim(label)) = 'sebeta' AND deleted_at IS NULL; + SELECT id INTO dupe FROM freight.yards + WHERE code = 'SEBETA' AND deleted_at IS NULL; + IF survivor IS NULL OR dupe IS NULL OR survivor = dupe THEN + RETURN; + END IF; + + -- Every yard-referencing column in the schema, so rows created between + -- authoring and running this migration are repointed too. + FOR col IN + SELECT table_name, column_name FROM information_schema.columns + WHERE table_schema = 'freight' + AND table_name <> 'yards' + AND (column_name LIKE '%yard_id%' OR column_name LIKE '%station_id%') + LOOP + EXECUTE format( + 'UPDATE freight.%I SET %I = $1 WHERE %I = $2', + col.table_name, col.column_name, col.column_name + ) USING survivor, dupe; + END LOOP; + + UPDATE freight.yards + SET code = 'SEBETA@merged', label = 'sebeta@merged', deleted_at = now() + WHERE id = dupe; + UPDATE freight.yards SET code = 'SEBETA', label = 'Sebeta' WHERE id = survivor; + END $$; + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yards_label_active" + ON freight.yards (lower(trim(label))) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yards_code_active" + ON freight.yards (lower(trim(code))) WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Data repair — not reversible. The uniqueness indexes are the new invariant. + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_yards_label_active"`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_yards_code_active"`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3060000000000-AddExportPaymentWindow.ts b/apps/edr-freight-api/src/migrations/3060000000000-AddExportPaymentWindow.ts new file mode 100644 index 000000000..c55745126 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3060000000000-AddExportPaymentWindow.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Export bookings get their own pay window, separately tunable from import: + * - global_rules.export_payment_window_minutes — global default for EXPORT + * (payment_window_minutes keeps governing IMPORT/DOMESTIC). + * - train_schedules.rule_payment_window_minutes — per-schedule override; until + * now the DTO accepted paymentWindowMinutes but only folded it into the + * reopen-delay sum, so the override never reached the actual pay window. + * - bookings.requested_train_schedule_id — the export train the customer picked + * at day-commit; pickExportSchedule honors it instead of earliest-first. + * - bookings.payment_reminder_sent_at — marks the one pre-deadline pay + * reminder so the 10s window tick doesn't re-send it. + */ +export class AddExportPaymentWindow3060000000000 implements MigrationInterface { + name = 'AddExportPaymentWindow3060000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.train_scheduling_global_rules ADD COLUMN IF NOT EXISTS export_payment_window_minutes int NOT NULL DEFAULT 60;`, + ); + await queryRunner.query( + `ALTER TABLE freight.train_schedules ADD COLUMN IF NOT EXISTS rule_payment_window_minutes int;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS requested_train_schedule_id uuid;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS payment_reminder_sent_at timestamptz;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS payment_reminder_sent_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS requested_train_schedule_id;`, + ); + await queryRunner.query( + `ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS rule_payment_window_minutes;`, + ); + await queryRunner.query( + `ALTER TABLE freight.train_scheduling_global_rules DROP COLUMN IF EXISTS export_payment_window_minutes;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3070000000000-AddBulkTotalWeightTons.ts b/apps/edr-freight-api/src/migrations/3070000000000-AddBulkTotalWeightTons.ts new file mode 100644 index 000000000..f2c50ec8d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3070000000000-AddBulkTotalWeightTons.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Break-bulk (PER_ITEM) bookings store their item count in + * cargo_total_weight_vgm, so the actual tonnage was never captured — wagon + * allocation divided an item COUNT by a tons capacity and under-allocated + * (400 machines ÷ 69T wagon read as 6 wagons instead of 12). New column holds + * the real total weight in tons for PER_ITEM cargo; null for PER_TON bulk and + * container bookings. + */ +export class AddBulkTotalWeightTons3070000000000 implements MigrationInterface { + name = 'AddBulkTotalWeightTons3070000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_total_weight_tons numeric(12,3);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_total_weight_tons;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3080000000000-BackfillDireDawaMilestone.ts b/apps/edr-freight-api/src/migrations/3080000000000-BackfillDireDawaMilestone.ts new file mode 100644 index 000000000..78c44c0ce --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3080000000000-BackfillDireDawaMilestone.ts @@ -0,0 +1,65 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Several DCT (DORALEH) → GMP (KALITY) routes are missing the Dire Dawa stop + * in their milestone list. The corridor budget builds its per-leg edges from + * route_milestones, so on those routes a DCT→Dire Dawa or Dire Dawa→GMP + * booking cannot resolve its own leg and conservatively occupies the WHOLE + * route — per-leg wagon reuse (a wagon freed at Dire Dawa reloading for GMP) + * silently degrades to train-wide accounting. + * + * Insert the Dire Dawa milestone at sequence 2 on every active DORALEH→KALITY + * route with a stop list that lacks it, shifting later stops down. Matched by + * yard CODE so the repair is portable across environments. Idempotent: routes + * already carrying Dire Dawa are untouched. + */ +export class BackfillDireDawaMilestone3080000000000 implements MigrationInterface { + name = "BackfillDireDawaMilestone3080000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + DECLARE + dire uuid; + r record; + BEGIN + SELECT id INTO dire FROM freight.yards + WHERE code = 'DIRE_DAWA' AND deleted_at IS NULL; + IF dire IS NULL THEN + RETURN; + END IF; + + FOR r IN + SELECT rt.id + FROM freight.routes rt + JOIN freight.yards o ON o.id = rt.origin_yard_id AND o.code = 'DORALEH' + JOIN freight.yards d ON d.id = rt.destination_yard_id AND d.code = 'KALITY' + WHERE rt.deleted_at IS NULL + AND EXISTS (SELECT 1 FROM freight.route_milestones m + WHERE m.route_id = rt.id AND m.deleted_at IS NULL) + AND NOT EXISTS (SELECT 1 FROM freight.route_milestones m + WHERE m.route_id = rt.id AND m.yard_id = dire + AND m.deleted_at IS NULL) + LOOP + -- Two-phase shift: uq_route_milestones_route_sequence isn't deferrable, + -- so a direct +1 UPDATE can collide mid-scan (seq 2 -> 3 while seq 3 still live). + -- Route through negative sequence_no first to avoid any interim collision. + -- Soft-deleted rows shift too: the constraint counts them, so a dead row + -- left at a target sequence would still collide. + UPDATE freight.route_milestones + SET sequence_no = -sequence_no + WHERE route_id = r.id AND sequence_no >= 2; + UPDATE freight.route_milestones + SET sequence_no = -sequence_no + 1 + WHERE route_id = r.id AND sequence_no < 0; + INSERT INTO freight.route_milestones (route_id, yard_id, sequence_no) + VALUES (r.id, dire, 2); + END LOOP; + END $$; + `); + } + + public async down(): Promise { + // Data repair — not reversible. + } +} diff --git a/apps/edr-freight-api/src/migrations/3090000000000-AddSavedSignatureStamp.ts b/apps/edr-freight-api/src/migrations/3090000000000-AddSavedSignatureStamp.ts new file mode 100644 index 000000000..3beafb6bf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3090000000000-AddSavedSignatureStamp.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSavedSignatureStamp3090000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.saved_signatures + ADD COLUMN IF NOT EXISTS stamp_file_id UUID NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.saved_signatures + DROP COLUMN IF EXISTS stamp_file_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3100000000000-PromoteDraftSchedulesToScheduled.ts b/apps/edr-freight-api/src/migrations/3100000000000-PromoteDraftSchedulesToScheduled.ts new file mode 100644 index 000000000..f5a916bd3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3100000000000-PromoteDraftSchedulesToScheduled.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * The draft/finalize phase is abolished: train schedules are created SCHEDULED + * and the Finalize button is gone from the backoffice. Promote every surviving + * DRAFT schedule so it stays reachable (dispatch requires SCHEDULED and there + * is no manual promotion path anymore). Idempotent; one-way — the original + * DRAFT set is not recorded, so down() cannot restore it. + */ +export class PromoteDraftSchedulesToScheduled3100000000000 implements MigrationInterface { + name = "PromoteDraftSchedulesToScheduled3100000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE freight.train_schedules + SET status = 'SCHEDULED' + WHERE status = 'DRAFT' + AND deleted_at IS NULL`, + ); + } + + public async down(): Promise { + // One-way data promotion — nothing to restore. + } +} diff --git a/apps/edr-freight-api/src/migrations/3110000000000-AddYardToScheduleWagonAdjustmentLogs.ts b/apps/edr-freight-api/src/migrations/3110000000000-AddYardToScheduleWagonAdjustmentLogs.ts new file mode 100644 index 000000000..0e1d13175 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3110000000000-AddYardToScheduleWagonAdjustmentLogs.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Consist adjustments can now happen mid-route (train standing at a stop), so + * each history row records WHERE it happened. Nullable — rows written before + * this column simply have no yard. + */ +export class AddYardToScheduleWagonAdjustmentLogs3110000000000 implements MigrationInterface { + name = "AddYardToScheduleWagonAdjustmentLogs3110000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.schedule_wagon_adjustment_logs + ADD COLUMN IF NOT EXISTS yard_id uuid`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.schedule_wagon_adjustment_logs + DROP COLUMN IF EXISTS yard_id`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3120000000000-AddApprovedAtToCompanies.ts b/apps/edr-freight-api/src/migrations/3120000000000-AddApprovedAtToCompanies.ts new file mode 100644 index 000000000..976aca798 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3120000000000-AddApprovedAtToCompanies.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Pending→Active is the only company-level approval event; `updatedAt` can't + * stand in for it since any field edit bumps that too. Nullable — existing + * companies (approved before this column existed) have no recorded moment. + */ +export class AddApprovedAtToCompanies3120000000000 implements MigrationInterface { + name = "AddApprovedAtToCompanies3120000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS approved_at timestamptz`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.companies + DROP COLUMN IF EXISTS approved_at`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3120000000000-AddCargoTypeItemsPerWagonMap.ts b/apps/edr-freight-api/src/migrations/3120000000000-AddCargoTypeItemsPerWagonMap.ts new file mode 100644 index 000000000..153928b04 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3120000000000-AddCargoTypeItemsPerWagonMap.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Break-bulk (PER_ITEM) cargo needs a physical items-fit per allowed wagon + * type (e.g. cars → NW5: 4, NW7: 6): a wagon runs out of floor space before it + * runs out of rated tonnage, so allocation must respect BOTH limits. Stored as + * a jsonb map { [wagonTypeId]: itemsFit } on cargo_types — keys mirror the + * cargo_type_wagon_types join rows, kept in sync by the cargo-types service. + */ +export class AddCargoTypeItemsPerWagonMap3120000000000 implements MigrationInterface { + name = 'AddCargoTypeItemsPerWagonMap3120000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."cargo_types" ADD COLUMN IF NOT EXISTS "items_per_wagon_map" jsonb`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."cargo_types" DROP COLUMN IF EXISTS "items_per_wagon_map"`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3130000000000-CreateCompanyRevisions.ts b/apps/edr-freight-api/src/migrations/3130000000000-CreateCompanyRevisions.ts new file mode 100644 index 000000000..01c1c6991 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3130000000000-CreateCompanyRevisions.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +/** + * Append-only audit of company edits made before the company reaches Active + * (the onboarding phase) — that write path has no approval gate and, until + * now, left no trace of what changed (e.g. a phone number or a document). + */ +export class CreateCompanyRevisions3130000000000 implements MigrationInterface { + name = 'CreateCompanyRevisions3130000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'company_revisions', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'company_id', type: 'uuid' }, + { name: 'actor_id', type: 'uuid', isNullable: true }, + { name: 'summary', type: 'varchar', length: '255' }, + { name: 'changes', type: 'jsonb', default: "'[]'::jsonb" }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['company_id'], + referencedSchema: 'freight', + referencedTableName: 'companies', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.company_revisions', + new TableIndex({ name: 'idx_company_revisions_company', columnNames: ['company_id'] }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.company_revisions', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/3140000000000-SyncBulkRateUnitsToCargoUom.ts b/apps/edr-freight-api/src/migrations/3140000000000-SyncBulkRateUnitsToCargoUom.ts new file mode 100644 index 000000000..b2a931542 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3140000000000-SyncBulkRateUnitsToCargoUom.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Rates created before their commodity's unit_of_measure was flipped kept the + * old bulk-quantity unit, so bookings of a PER_ITEM commodity (e.g. Machinery) + * quoted "per ton". PER_TON and PER_ITEM bill the same stored quantity — only + * the name differs — so renaming is safe. Going forward the cargo-types + * service syncs rates on every uom change; this backfills the drift. + */ +export class SyncBulkRateUnitsToCargoUom3140000000000 implements MigrationInterface { + name = 'SyncBulkRateUnitsToCargoUom3140000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE "freight"."rates" r + SET "rate_unit" = 'PER_ITEM' + FROM "freight"."cargo_types" ct + WHERE ct."id" = r."cargo_type_id" + AND ct."unit_of_measure" = 'PER_ITEM' + AND r."rate_unit" = 'PER_TON'`, + ); + await queryRunner.query( + `UPDATE "freight"."rates" r + SET "rate_unit" = 'PER_TON' + FROM "freight"."cargo_types" ct + WHERE ct."id" = r."cargo_type_id" + AND ct."unit_of_measure" = 'PER_TON' + AND r."rate_unit" = 'PER_ITEM'`, + ); + } + + public async down(): Promise { + // Irreversible rename-by-join: the pre-sync unit is not recorded. Both + // units bill identically, so rolling back the code needs no data change. + } +} diff --git a/apps/edr-freight-api/src/modules/auth/login-audience.middleware.spec.ts b/apps/edr-freight-api/src/modules/auth/login-audience.middleware.spec.ts new file mode 100644 index 000000000..2303c3ef2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/login-audience.middleware.spec.ts @@ -0,0 +1,92 @@ +import { ForbiddenException } from '@nestjs/common'; + +import { LoginAudienceMiddleware } from './login-audience.middleware'; + +/** + * Touches only the DataSource, so build off the prototype rather than + * standing up a full Nest module — same pattern as + * warehouses/receive-export-paid.spec.ts. + */ +function makeMiddleware(userType: string | undefined) { + const query = jest.fn().mockResolvedValue(userType ? [{ userType }] : []); + const middleware = Object.create( + LoginAudienceMiddleware.prototype, + ) as LoginAudienceMiddleware; + (middleware as unknown as { dataSource: unknown }).dataSource = { query }; + return middleware; +} + +function makeReq(clientApp: string | undefined, email = 'someone@example.com') { + return { + header: (name: string) => + name.toLowerCase() === 'x-client-app' ? clientApp : undefined, + body: { email }, + } as any; +} + +describe('LoginAudienceMiddleware', () => { + it('rejects when the client app header is missing', async () => { + const middleware = makeMiddleware('employee'); + const next = jest.fn(); + + await expect( + middleware.use(makeReq(undefined), {} as any, next), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(next).not.toHaveBeenCalled(); + }); + + it('rejects an unrecognized client app header', async () => { + const middleware = makeMiddleware('employee'); + const next = jest.fn(); + + await expect( + middleware.use(makeReq('mobile'), {} as any, next), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('rejects an employee account signing in through the portal client', async () => { + const middleware = makeMiddleware('employee'); + const next = jest.fn(); + + await expect( + middleware.use(makeReq('portal'), {} as any, next), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(next).not.toHaveBeenCalled(); + }); + + it('rejects a customer account signing in through the backoffice client', async () => { + const middleware = makeMiddleware('individual'); + const next = jest.fn(); + + await expect( + middleware.use(makeReq('backoffice'), {} as any, next), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('allows an employee account through the backoffice client', async () => { + const middleware = makeMiddleware('employee'); + const next = jest.fn(); + + await middleware.use(makeReq('backoffice'), {} as any, next); + + expect(next).toHaveBeenCalledTimes(1); + }); + + it('allows a customer account through the portal client', async () => { + const middleware = makeMiddleware('individual'); + const next = jest.fn(); + + await middleware.use(makeReq('portal'), {} as any, next); + + expect(next).toHaveBeenCalledTimes(1); + }); + + it('lets an unknown identifier fall through to the login handler', async () => { + const middleware = makeMiddleware(undefined); + const next = jest.fn(); + + await middleware.use(makeReq('portal'), {} as any, next); + + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/edr-freight-api/src/modules/auth/login-audience.middleware.ts b/apps/edr-freight-api/src/modules/auth/login-audience.middleware.ts new file mode 100644 index 000000000..3af58d02c --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/login-audience.middleware.ts @@ -0,0 +1,58 @@ +import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { NextFunction, Request, Response } from 'express'; + +export const CLIENT_APP_HEADER = 'x-client-app'; + +// EUserType values from @tria-plc/api-common, duplicated here to avoid +// pulling in the full enum just for this string comparison. +const ALLOWED_USER_TYPES_BY_CLIENT: Record = { + backoffice: ['employee'], + portal: ['individual', 'external_organization'], +}; + +/** + * Blocks EDRFREIGHT-415: /auth/login and /auth/mfa-verify match credentials + * against email/username/phone_number only (see vendor + * findUserForLogin), with no check that the account's userType belongs on + * the app that's asking. A backoffice (employee) client presenting a + * customer's credentials — or vice versa — must not get a session. + */ +@Injectable() +export class LoginAudienceMiddleware implements NestMiddleware { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + async use(req: Request, _res: Response, next: NextFunction) { + const clientApp = req.header(CLIENT_APP_HEADER); + const allowedUserTypes = clientApp + ? ALLOWED_USER_TYPES_BY_CLIENT[clientApp] + : undefined; + if (!allowedUserTypes) { + throw new ForbiddenException( + `Missing or unrecognized ${CLIENT_APP_HEADER} header`, + ); + } + + const identifier: unknown = req.body?.email; + if (typeof identifier !== 'string' || !identifier) { + // No identifier to look up — the vendor DTO validation rejects the + // request on its own. + return next(); + } + + const [user] = await this.dataSource.query( + `SELECT user_type AS "userType" FROM iam.users + WHERE email = $1 OR username = $1 OR phone_number = $1 LIMIT 1`, + [identifier], + ); + + if (user && !allowedUserTypes.includes(user.userType)) { + throw new ForbiddenException( + `This account cannot sign in through the ${clientApp} application`, + ); + } + + next(); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index e7682879b..dbf2bd4fd 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -470,3 +470,78 @@ describe("BillingService.issuePayable", () => { expect(manager.update).not.toHaveBeenCalled(); }); }); + +describe("BillingService — CAC Bank (OTP debit)", () => { + const openInvoice = { + id: "inv-1", + status: Freight.InvoiceStatus.Pending, + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + type: "PREPAID", + invoiceNumber: "INV-20260101-00001", + currency: "USD", + balanceAmount: 500, + totalAmount: 500, + paymentId: "intent-1", + dueAt: null, + }; + + const build = (payment: Record) => { + const repo = { + findOne: jest.fn().mockResolvedValue(openInvoice), + update: jest.fn().mockResolvedValue(undefined), + }; + const service = new BillingService( + { getRepository: () => repo } as never, + {} as never, + {} as never, + makeEvents() as never, + payment as never, + {} as never, + {} as never, + ); + return { service, repo }; + }; + + it("rejects a CAC Bank charge with no payer mobile before calling the gateway", async () => { + const initiate = jest.fn(); + const { service } = build({ initiate }); + + await expect( + service.payInvoice("inv-1", { method: "CAC_BANK" }), + ).rejects.toThrow(/payerAccount/); + expect(initiate).not.toHaveBeenCalled(); + }); + + it("does not settle an OTP intent at initiate — the payer still has to confirm", async () => { + const handlePaymentEvent = jest.fn(); + const { service } = build({ + initiate: jest.fn().mockResolvedValue({ + intentId: "intent-1", + immediateSuccess: false, + response: { + intentId: "intent-1", + status: "REQUIRES_ACTION", + clientAction: { type: "COLLECT_OTP", providerOrderId: "cac-1" }, + }, + }), + handlePaymentEvent, + }); + + await service.payInvoice("inv-1", { + method: "CAC_BANK", + payerAccount: "77123456", + }); + + expect(handlePaymentEvent).not.toHaveBeenCalled(); + }); + + it("confirms the OTP against the intent stamped on the invoice", async () => { + const confirmOtp = jest.fn().mockResolvedValue({ status: "SUCCEEDED" }); + const { service } = build({ confirmOtp }); + + await service.confirmInvoiceOtp("inv-1", "123456"); + + expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 2bdc6ee16..525ccfefc 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -12,7 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm"; import { CompaniesService } from "../companies/companies.service"; import { PaymentService } from "../payment/payment.service"; -import { InitiateResponseDto } from "../payment/payments.dto"; +import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto"; import { InvoiceDocumentModel, InvoiceDocumentService, @@ -352,6 +352,34 @@ export class BillingService { return this.payInvoice(id, opts); } + /** + * Submit the CAC Bank OTP for one of the customer's own invoices + * (ownership-checked). Settlement of the invoice happens inside the payment + * service when the OTP succeeds. + */ + async confirmInvoiceOtpForUser( + id: string, + userId: string, + otp: string, + ): Promise { + await this.findByIdForUser(id, userId); + return this.confirmInvoiceOtp(id, otp); + } + + /** OTP confirmation by invoice id — the intent is the one stamped at initiate. */ + async confirmInvoiceOtp( + invoiceId: string, + otp: string, + ): Promise { + const invoice = await this.dataSource + .getRepository(Invoice) + .findOne({ where: { id: invoiceId } }); + if (!invoice?.paymentId) { + throw new NotFoundException("No payment to confirm for this invoice"); + } + return this.payment.confirmOtp(invoice.paymentId, otp); + } + /** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */ async documentForUser( id: string, @@ -983,6 +1011,17 @@ export class BillingService { * never fire before the link exists. Throws when the invoice is not found or * not in an open/payable status. */ + /** + * Settlement check before expiring a payable order (reconcile-before-expire): + * live-queries the gateway for any settled intent on the source order. Kept + * on billing so the domain never talks to the payment service directly. + */ + reconcilePayable( + sourceId: string, + ): Promise<{ paid: boolean; unverifiable: boolean }> { + return this.payment.reconcileShipment(sourceId); + } + async payInvoice( invoiceId: string, opts: { @@ -1002,11 +1041,39 @@ export class BillingService { ); } + // A booking's PREPAID invoice is only payable inside its pay window — + // `dueAt` mirrors booking.paymentDeadline (issuePayable at reserve time). + // Blocking INITIATION here is what makes the deadline real: a payment + // STARTED before this gate but settling late is still honored by the + // expire-time gateway reconcile. Other invoice types keep dueAt display-only. + if ( + invoice.source === Freight.InvoiceSource.Booking && + invoice.type === "PREPAID" && + invoice.dueAt && + invoice.dueAt.getTime() <= Date.now() + ) { + throw new BadRequestException( + "The payment window for this booking has closed — the reserved wagons " + + "were released. Please book again.", + ); + } + const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount); if (!(amountDue > 0)) { throw new BadRequestException("Invoice has no outstanding balance."); } + // CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is + // required up front (the payment service rejects it otherwise, as a 502 here). + if ( + (opts.method ?? "").toUpperCase() === "CAC_BANK" && + !opts.payerAccount?.trim() + ) { + throw new BadRequestException( + "payerAccount (mobile number) is required for CAC Bank", + ); + } + const result = await this.payment.initiate({ referenceId: invoice.sourceId, source: invoice.source, @@ -1034,7 +1101,12 @@ export class BillingService { // Settlement is driven by the payment API (webhook/outbox → payment.succeeded); // billing must not simulate it. Kept commented for local demos only. - if (!result.immediateSuccess) { + // An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the + // code — so the demo shortcut must never fire for it. + if ( + !result.immediateSuccess && + result.response.clientAction?.type !== "COLLECT_OTP" + ) { await this.payment.handlePaymentEvent({ eventType: "payment.succeeded", eventId: `demo-${result.intentId}`, diff --git a/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts index c29160ab7..354dd004e 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts @@ -1,5 +1,13 @@ -import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsIn, IsOptional, IsString } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsNotEmpty, IsOptional, IsString } from "class-validator"; + +/** OTP submitted for a COLLECT_OTP provider (CAC Bank). */ +export class ConfirmOtpDto { + @ApiProperty({ description: "One-time password SMSed by the bank." }) + @IsString() + @IsNotEmpty() + otp!: string; +} /** Gateway options for paying an invoice from the customer portal. */ export class PayInvoiceDto { diff --git a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts index 94e917754..981233df0 100644 --- a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts @@ -18,7 +18,7 @@ import { } from "../../common/resolve-auth-user-id"; import { sendPdf } from "./billing.controller"; import { BillingService } from "./billing.service"; -import { PayInvoiceDto } from "./dto/pay-invoice.dto"; +import { ConfirmOtpDto, PayInvoiceDto } from "./dto/pay-invoice.dto"; /** * Customer-facing billing endpoints. Unlike {@link BillingController} (admin, @@ -96,4 +96,20 @@ export class PortalBillingController { failureUrl: dto.failureUrl, }); } + + @Post("my-invoices/:id/confirm") + @ApiOperation({ + summary: "Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices", + }) + confirmOtp( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Body() dto: ConfirmOtpDto, + ) { + return this.billingService.confirmInvoiceOtpForUser( + id, + resolveAuthUserId(user), + dto.otp, + ); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts index 5297f3d57..095928bb3 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts @@ -112,15 +112,9 @@ export class BookingContractService { const templateKey = this.templateResolver.resolve(booking); const summary = this.buildContractSummary(booking); - // PDF rendering (Puppeteer/Chromium) is best-effort and must NOT block the contract - // from becoming ready — the document is (re)rendered lazily on view/download. - try { - await this.upsertContractPdf(bookingId, booking.reference, templateKey); - } catch (err) { - this.logger.warn( - `Contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download once Chromium is available.`, - ); - } + // No eager PDF render here: streamContract re-renders the document on every + // view/download, so rendering now only adds a Chromium launch (seconds, or a + // 60s asset-load hang) inside the staff-accept request. const now = new Date(); const updated = await this.bookingsRepository.update(bookingId, { @@ -132,6 +126,31 @@ export class BookingContractService { return updated!; } + /** + * Government bookings skip the whole customer contract flow (approve → + * CONTRACT_READY → sign chain): their contract is stamped server-side at + * creation/expedite WITHOUT touching booking status — the booking is already + * PAID/allocatable and the contract can be signed at any time. Idempotent. + */ + async generateContractForGovernment(bookingId: string): Promise { + const booking = await this.requireBooking(bookingId); + if (!booking.isGovernment || booking.contractGeneratedAt) return; + const templateKey = this.templateResolver.resolve(booking); + await this.bookingsRepository.update(bookingId, { + contractSummary: this.buildContractSummary(booking), + contractTemplateKey: templateKey, + contractGeneratedAt: new Date(), + } as never); + // Render the PDF eagerly but NEVER block creation on it — Chromium can take + // seconds (or hang on assets); the document re-renders on view/download. + void this.upsertContractPdf(bookingId, booking.reference, templateKey).catch( + (err) => + this.logger.warn( + `Government contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`, + ), + ); + } + async streamContract(bookingId: string) { const booking = await this.requireBooking(bookingId); const templateKey = @@ -152,8 +171,13 @@ export class BookingContractService { const booking = await this.requireBooking(bookingId); const role = dto.role as ContractSignerRole; + // Government contracts are order-free and status-free: either party may + // sign at any time (each once) — the booking is already expedited past the + // customer contract flow, so no status gate applies. if (role === 'CUSTOMER') { - assertBookingStatus(booking, ['CONTRACT_READY']); + if (!booking.isGovernment) { + assertBookingStatus(booking, ['CONTRACT_READY']); + } const existing = await this.bookingsRepository.findContractSignature( bookingId, 'CUSTOMER', @@ -162,7 +186,9 @@ export class BookingContractService { throw new BadRequestException('Customer has already signed this contract'); } } else { - assertBookingStatus(booking, ['SIGNED_CUSTOMER']); + if (!booking.isGovernment) { + assertBookingStatus(booking, ['SIGNED_CUSTOMER']); + } const existing = await this.bookingsRepository.findContractSignature( bookingId, 'STAFF', @@ -235,20 +261,30 @@ export class BookingContractService { ); if (role === 'CUSTOMER') { - updates.status = 'SIGNED_CUSTOMER'; updates.customerSignedAt = now; + // Government bookings keep their operational status (PAID) — a signature + // must never pull them back into the customer workflow. + if (!booking.isGovernment) updates.status = 'SIGNED_CUSTOMER'; } else { updates.fullyExecutedAt = now; updates.marketingApprovedAt = now; updates.marketingApprovedById = options.signerUserId ?? null; - updates.lockedAt = now; - updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED'; + if (!booking.isGovernment) { + updates.lockedAt = now; + updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED'; + } } const updated = await this.bookingsRepository.update(bookingId, updates as never); // Only the non-clearance (legacy/domestic) path enters the batch pipeline now; - // clearance bookings enter operations after the GL document gate. - if (role === 'STAFF' && !clearanceCode && updated?.trainScheduleId) { + // clearance bookings enter operations after the GL document gate. Government + // bookings are already in the pool from expedite — signing changes nothing. + if ( + role === 'STAFF' && + !booking.isGovernment && + !clearanceCode && + updated?.trainScheduleId + ) { this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId); } try { 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..6245195eb 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.`; @@ -218,6 +263,36 @@ export class BookingLifecycleNotifierService { this.inApp(b, 'Booking cancelled', msg); } + /** + * GL Ethiopia asked Djibouti to name the transit officer. Staff-only, and + * deep-linked to the Djibouti clearance page where the name is entered — the + * customs declaration is blocked until they answer. + */ + transitAssigneeRequested(b: Booking, note: string | null): void { + const msg = + `GL Ethiopia needs a transit assignee for shipment ${b.reference} before ` + + `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; + this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`); + this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/gl-djibouti/clearance/${b.id}`, + }); + } + + /** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */ + transitAssigneeAssigned(b: Booking, assignee: string, previous: string | null): void { + const msg = previous + ? `GL Djibouti changed the transit assignee for shipment ${b.reference} from ` + + `"${previous}" to "${assignee}".` + : `GL Djibouti assigned ${assignee} to handle shipment ${b.reference} in transit. ` + + `The customs declaration can now be filed.`; + this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`); + this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/bookings/${b.id}/clearance`, + }); + } + // ── Clearance milestones needing customer action ────────────────────────── /** GL advised duty & tax — the customer must pay and upload the slip. */ @@ -242,11 +317,11 @@ export class BookingLifecycleNotifierService { }); } - /** GL raised the final (post-offload) invoice — customer pays + uploads slip. */ + /** GL raised the final (post-offload) invoice — customer approves, pays, uploads slip. */ finalInvoiceCreated(b: Booking, amount: number, currency: string): void { const msg = - `A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` + - `Please pay and upload the payment slip from the portal.`; + `A final invoice of ${amount} ${currency} has been raised for booking ${b.reference}. ` + + `Please review and approve it in the portal, then pay and upload the payment slip.`; void this.notifyContact(b, msg, 'FINAL INVOICE'); this.inApp(b, 'Final invoice issued', msg, { type: NotificationType.INVOICE_ISSUED, @@ -286,6 +361,15 @@ export class BookingLifecycleNotifierService { ); } + /** Customer approved the GL Djibouti final invoice — payment slip can follow. */ + finalInvoiceApprovedToStaff(b: Booking): void { + this.inAppStaff( + b, + 'Final invoice approved', + `The customer approved the final invoice for booking ${this.ref(b)} — awaiting payment slip.`, + ); + } + /** Customer signed the booking contract. */ customerSignedToStaff(b: Booking): void { this.inAppStaff( @@ -317,6 +401,32 @@ export class BookingLifecycleNotifierService { ); } + /** GL Ethiopia sent a draft customs declaration — the customer must accept or request a change. */ + draftDeclarationReady(b: Booking, price: number, currency: string): void { + const msg = + `A draft customs declaration for booking ${b.reference} is ready for your review — ` + + `estimated price ${price} ${currency}. Please accept it or request a change from the portal.`; + void this.notifyContact(b, msg, 'DRAFT DECLARATION READY'); + this.inApp(b, 'Draft declaration ready for review', msg, { + type: NotificationType.DOCUMENT_ACTION, + }); + } + + /** + * The customer asked for a change on the draft declaration. This goes to + * STAFF, not the customer: GL Ethiopia is the one who has to send a + * corrected draft, and the clearance page is where they do it. + */ + draftDeclarationChangeRequested(b: Booking, note: string): void { + const msg = + `The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` + + `"${note}". Send a corrected draft from the clearance page.`; + this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/bookings/${b.id}/clearance`, + }); + } + /** Customer uploaded a duty/tax payment slip — GL verifies it. */ dutySlipUploadedToStaff(b: Booking, round: 'first' | 'second' | 'final'): void { const label = 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..b44c1c5e0 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 { @@ -15,6 +16,7 @@ import { containersPerWagonForSize, wagonsPerUnitForSize, } from '../rule-engine/container-type.util'; +import { bulkItemWagonsForAllowedTypes } from '../train-scheduling/train-capacity.util'; import { BookingsRepository } from './bookings.repository'; import { wagonRemainder } from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; @@ -199,7 +201,7 @@ export class BookingPricingService { // route's container freight, never a frozen OVERWEIGHT_PER_TON value. const frozen = isDerived ? null - : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency); + : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, usdToEtb); const unitAmount = frozen ? Number(frozen.unitPrice) : isEtbBooking @@ -529,8 +531,6 @@ export class BookingPricingService { const usedRatesMap = new Map(); const warnings: string[] = []; const blocked: string[] = []; - const wagonCount = await this.resolveWagonCount(booking); - for (const container of evalInput.containers) { const rate = this.pickRate( liveRates, @@ -540,14 +540,15 @@ export class BookingPricingService { booking.originYardId, booking.destinationYardId, ); - // H15: frozen contract rate for this container size, when present — its - // unitPrice is already in the booking currency (no USD→currency convert). - // It also stands on its own: a contract line prices off the agreed rate - // even when nobody configured a live rate for this leg + type yet. + // H15: frozen contract rate for this container size, when present — + // converted into the booking currency by frozenRateForContainer. It also + // stands on its own: a contract line prices off the agreed rate even when + // nobody configured a live rate for this leg + type yet. const frozen = await this.frozenRateForContainer( frozenRates, container.containerTypeId, paymentCurrency, + usdToEtb, ); const label = await this.containerTypeLabel(container.containerTypeId); if (!rate && !frozen) { @@ -565,6 +566,10 @@ export class BookingPricingService { } const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER'; + // A PER_WAGON line bills the wagons THIS line occupies (two 20ft share + // one), never the booking-wide count — otherwise a booking with a 20ft + // and a 40ft line charges each line for the other's wagons too. + const lineWagons = await this.lineWagonCount(container); let amount: number; let unitAmount: number; if (frozen) { @@ -573,11 +578,11 @@ export class BookingPricingService { rateUnit, unitAmount, container.quantity, - wagonCount, + lineWagons, ); } else { const unitUsd = Number(rate!.rateValue); - const usdAmount = this.amountForRate(rate!, container.quantity, wagonCount); + const usdAmount = this.amountForRate(rate!, container.quantity, lineWagons); amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; } @@ -588,7 +593,7 @@ export class BookingPricingService { amount, unitAmount, unit: rateUnit, - quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount), + quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, lineWagons), currency: paymentCurrency, }); } @@ -600,7 +605,10 @@ export class BookingPricingService { // container type above or stay unpriced with a warning — falling back to // a corridor rate of a DIFFERENT container type billed once (qty 1) is // how a 38-container booking was invoiced 40 USD instead of 1900. - const fallback = liveRates.find( + // Within the leg, the rate scoped to the booking's own commodity wins over + // the commodity-wide catch-all — a per-item machinery rate must never + // price a per-ton wheat booking (or the reverse). + const onLeg = liveRates.filter( (r) => r.rateType === rateType && r.currency === 'USD' && @@ -608,15 +616,29 @@ export class BookingPricingService { r.originYardId === booking.originYardId && r.destinationYardId === booking.destinationYardId, ); + const fallback = + (booking.cargoTypeId + ? onLeg.find((r) => r.cargoTypeId === booking.cargoTypeId) + : undefined) ?? onLeg.find((r) => !r.cargoTypeId); if (fallback) { usedRatesMap.set(fallback.id, fallback); - const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0); + // Bulk has no container lines to count wagons from, so a PER_WAGON bulk + // rate bills the tonnage-derived estimate for the WHOLE booking (there + // is only ever this one line). + const wagonCount = isBulk + ? Number(evalInput.bulkWagons ?? 0) || + (await this.bulkWagonCount(booking)) || + 0 + : await this.resolveWagonCount(booking); + // Bulk quantity is stored in the commodity's own unit — tonnes for a + // PER_TON commodity, item count for a PER_ITEM one. + const bulkQuantity = Number(booking.cargoTotalWeightVgm ?? 0); const quantity = - isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1; + isBulk && isBulkQuantityUnit(fallback.rateUnit) ? Math.max(bulkQuantity, 0) : 1; const unitUsd = Number(fallback.rateValue); // H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present. const frozen = isBulk - ? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency) + ? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, usdToEtb) : null; let amount: number; let unitAmount: number; @@ -719,6 +741,7 @@ export class BookingPricingService { quantity = containerCount; break; case 'PER_TON': + case 'PER_ITEM': quantity = bulkTons; break; case 'FLAT': @@ -727,12 +750,13 @@ export class BookingPricingService { break; } - // H15: frozen mile rate (already in booking currency) when the contract - // has one; else the live USD rate converted as before. + // H15: frozen mile rate (converted into the booking currency) when the + // contract has one; else the live USD rate converted as before. const frozen = this.frozenRateByCode( frozenRates, leg.rateType, paymentCurrency, + usdToEtb, ); let amount: number; let unitAmount: number; @@ -764,6 +788,34 @@ export class BookingPricingService { return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; } + /** + * Wagons ONE container line occupies: two 20ft share a wagon, a 40ft takes a + * whole one. This — not the booking-wide total — is what a PER_WAGON base + * freight line bills, so a booking of 4×20ft + 1×40ft charges the 20ft line + * for 2 wagons and the 40ft line for its own 1, instead of billing each line + * for all 3. + */ + private async lineWagonCount(container: { + containerTypeId: string; + quantity: number; + wagonsPerUnit?: number; + }): Promise { + let perUnit = container.wagonsPerUnit; + if (perUnit == null) { + // Preview bookings build their eval input without the fraction — read it + // off the container type instead of assuming one wagon per box. + try { + const ct = await this.containerTypesService.findById( + container.containerTypeId, + ); + perUnit = wagonsPerUnitForSize(Number(ct.sizeFt)); + } catch { + perUnit = 1; // unknown type: never under-bill + } + } + return Math.max(1, Math.ceil(container.quantity * perUnit)); + } + /** * Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate; * an unsaved preview booking (no id) sums the wagonsRequired already computed @@ -804,6 +856,7 @@ export class BookingPricingService { return 1; case 'PER_CONTAINER': case 'PER_TON': + case 'PER_ITEM': default: return quantity; } @@ -860,6 +913,7 @@ export class BookingPricingService { case 'PER_WAGON': return unitValue * wagonCount; case 'PER_TON': + case 'PER_ITEM': return unitValue * quantity; case 'FLAT': return unitValue; @@ -889,20 +943,45 @@ export class BookingPricingService { } /** - * The frozen snapshot for a rate code, or null when there is none, its price - * is negative, or it is in a different currency than the booking (in which - * case the live-rate path is safer than a mis-converted frozen price). + * The frozen snapshot for a rate code, expressed in the BOOKING's currency. + * + * A contract quotes in USD and freezes USD unit prices; the customer chooses + * the billing currency per booking. So a currency mismatch is the normal case + * now, not an error — the snapshot is converted rather than discarded. (It + * previously returned null on mismatch, which silently dropped the agreed + * contract price and re-priced the booking at whatever the live rate had + * drifted to.) Grandfathered ETB contracts convert the other way for the same + * reason. + * + * Returns null only when there is no snapshot or its price is unusable. */ private frozenRateByCode( frozenRates: Map | null, code: string, bookingCurrency: string, + usdToEtb: number, ): ContractRateSnapshot | null { const snap = frozenRates?.get(code); if (!snap) return null; - if (snap.currency !== bookingCurrency) return null; - if (!(Number(snap.unitPrice) >= 0)) return null; - return snap; + const unitPrice = Number(snap.unitPrice); + if (!(unitPrice >= 0)) return null; + if (snap.currency === bookingCurrency) return snap; + + // Only USD <-> ETB exist; a rate of 0/NaN would silently zero the price. + if (!(usdToEtb > 0)) return null; + const converted = + snap.currency === 'USD' && bookingCurrency === 'ETB' + ? Math.round(unitPrice * usdToEtb) + : snap.currency === 'ETB' && bookingCurrency === 'USD' + ? unitPrice / usdToEtb + : null; + if (converted == null) return null; + + // A copy — the snapshot rows are shared across the pricing pass. + return Object.assign(Object.create(Object.getPrototypeOf(snap)), snap, { + unitPrice: converted, + currency: bookingCurrency, + }) as ContractRateSnapshot; } /** @@ -914,6 +993,7 @@ export class BookingPricingService { frozenRates: Map | null, containerTypeId: string, bookingCurrency: string, + usdToEtb: number, ): Promise { if (!frozenRates) return null; let sizeFt: number | null = null; @@ -923,7 +1003,7 @@ export class BookingPricingService { return null; } if (!sizeFt) return null; - return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency); + return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, usdToEtb); } /** @@ -966,7 +1046,7 @@ export class BookingPricingService { const hasPerSizeSnapshot = frozenRates?.has('CUSTOMS_CLEARANCE_20FT') || frozenRates?.has('CUSTOMS_CLEARANCE_40FT'); - const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency); + const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb); if (legacyFlat && !hasPerSizeSnapshot) { const amount = Number(legacyFlat.unitPrice); if (amount > 0) { @@ -995,7 +1075,7 @@ export class BookingPricingService { // unknown type — falls through to the live per-type lookup below } const frozen = sizeFt - ? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency) + ? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency, usdToEtb) : null; const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId); if (!frozen && !live) { @@ -1030,7 +1110,7 @@ export class BookingPricingService { // flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee. // Live lookup: the rate scoped to the booking's commodity wins; a // commodity-less rate (legacy) is the catch-all fallback. - const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency); + const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb); const live = (booking.cargoTypeId ? onLeg.find( @@ -1044,7 +1124,7 @@ export class BookingPricingService { const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit; const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue)); let billedQty = 1; - if (unit === 'PER_TON') { + if (isBulkQuantityUnit(unit)) { billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0)); } else if (unit === 'PER_WAGON') { const wagons = await this.bulkWagonCount(booking); @@ -1081,6 +1161,8 @@ export class BookingPricingService { return 'PER_WAGON'; case 'per_ton': return 'PER_TON'; + case 'per_item': + return 'PER_ITEM'; case 'per_container': return 'PER_CONTAINER'; default: @@ -1105,6 +1187,11 @@ export class BookingPricingService { ...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0), ); if (!(capacity > 0)) return null; + // Break-bulk (PER_ITEM): `tons` above is the item count; size by + // indivisible items instead of pretending the count is tonnage. Best + // count across allowed wagon types, each capped by its items-fit. + const byItems = bulkItemWagonsForAllowedTypes(booking, cargo, capacity); + if (byItems > 0) return byItems; return Math.max(1, Math.ceil(tons / capacity)); } catch { return null; 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..b67366431 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 @@ -14,10 +14,10 @@ describe('BookingTransitionService — finalizeClearance gate', () => { serviceType: { includesCustoms: false }, // no output set → only the input gate }; - // Input set has two required docs. Non-customs bookings resolve to the - // ONE_TIME self-clearance document set. + // Input set has two required docs. Non-customs bookings resolve to their + // own without-customs document set. const inputSetting = { - code: 'contract_clearance_selfclear_import_container', + code: 'clearance_import_container_without_customs', fields: [ { fileKey: 'commercial_invoice', isRequired: true }, { fileKey: 'packing_list', isRequired: true }, @@ -46,7 +46,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => { fileUploadSettingsService as never, {} as never, // bookingBatchService bookingsService as never, - { isPhasedGeneralCustomsBooking: () => false } as never, + { isPhasedCustomsBooking: () => false } as never, {} as never, // workflowService {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, @@ -68,6 +68,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => { clearanceDocsUploadedToStaff: jest.fn(), dutySlipUploadedToStaff: jest.fn(), } as never, // notifier + { emit: jest.fn() } as never, // events ); return { service, bookingsRepository }; } @@ -149,7 +150,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( fileUploadSettingsService as never, {} as never, // bookingBatchService bookingsService as never, - { isPhasedGeneralCustomsBooking: () => false } as never, + { isPhasedCustomsBooking: () => false } as never, {} as never, // workflowService {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, @@ -171,6 +172,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( clearanceDocsUploadedToStaff: jest.fn(), dutySlipUploadedToStaff: jest.fn(), } as never, // notifier + { emit: jest.fn() } as never, // events ); return { service, bookingsRepository }; } @@ -199,7 +201,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( */ describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => { const inputSetting = { - code: 'contract_clearance_selfclear_import_container', + code: 'clearance_import_container_without_customs', fields: [ { fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true }, { fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true }, @@ -238,7 +240,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields fileUploadSettingsService as never, {} as never, // bookingBatchService bookingsService as never, - { isPhasedGeneralCustomsBooking: () => false } as never, + { isPhasedCustomsBooking: () => false } as never, {} as never, // workflowService {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, @@ -260,6 +262,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields clearanceDocsUploadedToStaff: jest.fn(), dutySlipUploadedToStaff: jest.fn(), } as never, // notifier + { emit: jest.fn() } as never, // events ); return { service, bookingsRepository, filesService }; } 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..0726445d5 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 @@ -26,6 +26,7 @@ describe('BookingTransitionService — operation review', () => { }; const bookingsService = { findById: jest.fn().mockResolvedValue(booking), + assertNoUnpaidHold: jest.fn().mockResolvedValue(undefined), }; const bookingBatchService = { enqueueRouteDayProcessing: jest.fn(), @@ -48,7 +49,7 @@ describe('BookingTransitionService — operation review', () => { {} as never, // fileUploadSettingsService bookingBatchService as never, bookingsService as never, - { isPhasedGeneralCustomsBooking: () => false } as never, + { isPhasedCustomsBooking: () => false } as never, {} as never, // workflowService invoiceService as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, @@ -70,6 +71,7 @@ describe('BookingTransitionService — operation review', () => { clearanceDocsUploadedToStaff: jest.fn(), dutySlipUploadedToStaff: jest.fn(), } as never, // notifier + { emit: jest.fn() } as never, // events ); return { service, bookingsRepository, bookingBatchService, invoiceService }; } @@ -143,6 +145,7 @@ describe('BookingTransitionService — requestOperation export space gate', () = }; const bookingsService = { findById: jest.fn().mockResolvedValue(booking), + assertNoUnpaidHold: jest.fn().mockResolvedValue(undefined), checkDayCompatibilityForBooking: jest .fn() .mockResolvedValue({ hasDeparture: true, hasCompatible: true }), @@ -164,11 +167,12 @@ describe('BookingTransitionService — requestOperation export space gate', () = {} as never, // fileUploadSettingsService bookingBatchService as never, bookingsService as never, - { isPhasedGeneralCustomsBooking: () => false } as never, + { isPhasedCustomsBooking: () => false } as never, {} as never, // workflowService {} as never, // invoiceService { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, notifier as never, + { emit: jest.fn() } as never, // events ); return { service, bookingsRepository, bookingBatchService }; } 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..149875e6e 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,9 +7,12 @@ import { Logger, Optional, } from "@nestjs/common"; -import { OnEvent } from "@nestjs/event-emitter"; +import { EventEmitter2, OnEvent } from "@nestjs/event-emitter"; -import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { + BookingBatchService, + type ExportTrainOption, +} from '../train-scheduling/booking-batch.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { isRoadService } from './road.util'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; @@ -56,11 +59,12 @@ export class BookingTransitionService { private readonly invoiceService: BookingInvoiceService, private readonly containerValidationService: ContainerValidationService, private readonly notifier: BookingLifecycleNotifierService, + private readonly events: EventEmitter2, @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} - private isPhasedGeneralCustoms(booking: Booking): boolean { - return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking); + private isPhasedCustoms(booking: Booking): boolean { + return this.bookingClearanceService.isPhasedCustomsBooking(booking); } /** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */ @@ -376,6 +380,8 @@ export class BookingTransitionService { } as never); const fresh = await this.bookingsService.findById(updated!.id); this.notifier.completed(fresh); + // A ONE_TIME contract closes on its single shipment being delivered. + this.events.emit('booking.completed', { bookingId }); // Customer tracking: close out the tail milestones so a finished shipment // never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are // implied by delivery; a storage invoice that was never raised is skipped @@ -398,6 +404,31 @@ export class BookingTransitionService { return fresh; } + /** + * Customer cancels their own unpaid hold (SELECTED_FOR_BATCH): the wagons + * release immediately instead of tying up the train until the pay window + * lapses. Ends CANCELLED; the freed capacity tops up from the waiting list. + */ + async cancelHold(bookingId: string, reason?: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]); + if (booking.consolidationPartnerId) { + throw new BadRequestException( + "This booking shares a consolidated wagon with another booking — " + + "contact support to cancel it.", + ); + } + await this.bookingsRepository.createReviewNote( + bookingId, + reason ?? "Customer cancelled before payment", + "REJECTION", + ); + await this.bookingBatchService.cancelReservation(bookingId); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.cancelled(fresh, reason ?? "Cancelled before payment"); + return fresh; + } + async cancel(bookingId: string, reason: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ @@ -491,7 +522,7 @@ export class BookingTransitionService { operationReady?: boolean; }> { const booking = await this.bookingsService.findById(bookingId); - if (this.isPhasedGeneralCustoms(booking)) { + if (this.isPhasedCustoms(booking)) { return this.bookingClearanceService.getClearanceView(bookingId); } const { inputCode, outputCode, includesCustoms } = @@ -650,7 +681,7 @@ export class BookingTransitionService { status: "DOCUMENTS_UNDER_REVIEW", } as never); - if (this.isPhasedGeneralCustoms(booking)) { + if (this.isPhasedCustoms(booking)) { await this.workflowService.onCustomerDocsUploadedForBooking( bookingId, booking.tradeDirection ?? 'IMPORT', @@ -732,7 +763,7 @@ export class BookingTransitionService { } if ( status === 'QUERIED' && - this.isPhasedGeneralCustoms(booking) && + this.isPhasedCustoms(booking) && booking.preClearanceFinalizedAt ) { throw new BadRequestException( @@ -755,7 +786,7 @@ export class BookingTransitionService { "CHANGES_REQUESTED", staffId, ); - if (this.isPhasedGeneralCustoms(booking)) { + if (this.isPhasedCustoms(booking)) { await this.workflowService.onDocumentReviewReopenedForBooking(bookingId); await this.bookingsRepository.update(bookingId, { clearanceCurrentPhase: ContractDocPhase.GlEtReview, @@ -767,7 +798,7 @@ export class BookingTransitionService { if (status === "QUERIED") { this.notifier.documentQueried(updated, fileKey, note ?? ''); } - if (this.isPhasedGeneralCustoms(updated)) { + if (this.isPhasedCustoms(updated)) { const allApproved = await this.isClearanceFullyApproved(updated); if (allApproved) { await this.workflowService.onAllDocsApprovedForBooking(bookingId); @@ -817,7 +848,7 @@ export class BookingTransitionService { */ async finalizeClearance(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - if (this.isPhasedGeneralCustoms(booking)) { + if (this.isPhasedCustoms(booking)) { throw new BadRequestException( 'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.', ); @@ -888,6 +919,7 @@ export class BookingTransitionService { async requestOperation( bookingId: string, scheduledDate: string, + requestedTrainScheduleId?: string | null, ): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ @@ -895,6 +927,13 @@ export class BookingTransitionService { "OPERATION_CHANGES_REQUESTED", ]); + // A company sitting on another unpaid hold commits nothing new — this is + // the moment export capacity locks, so the lock applies here too. + // Government bookings allocate without paying and are exempt. + if (!booking.isGovernment) { + await this.bookingsService.assertNoUnpaidHold(booking.companyId); + } + // A bare initiated instance (clearance-first flow) carries no cargo or // price — it must go through the contract completion endpoint, which // persists cargo, prices, invoices and only then lands here itself. @@ -939,10 +978,18 @@ export class BookingTransitionService { // largest bookable leftover ("reduce to N wagons or pick another day"). // Import/domestic bookings are batched + splittable, so they are NOT gated // here — they get an advisory count below and the batch engine sizes them. - const scheduledBooking = { ...booking, scheduledDate: date } as Booking; const isExportTrain = booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType); + // The customer's train pick only exists for export rail; it rides the + // booking through the space checks below AND is persisted so the accept / + // reserve path locks onto that train (pickExportSchedule honors it). + const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null; + const scheduledBooking = { + ...booking, + scheduledDate: date, + requestedTrainScheduleId: requestedId, + } as Booking; if (isExportTrain) { // With export split ON the booking no longer has to ride ONE train whole: // the largest fitting part is offered and the leftover rebooks on the next @@ -955,9 +1002,14 @@ export class BookingTransitionService { eatDay(date), "EXPORT", ); - if (!fitting.length) { + const fitsRequest = requestedId + ? fitting.some((f) => f.scheduleId === requestedId) + : fitting.length > 0; + if (!fitsRequest) { throw new ConflictException( - "No export train on this day has space left — pick another shipment day.", + requestedId + ? "The selected train has no space left — pick another train or day." + : "No export train on this day has space left — pick another shipment day.", ); } } else { @@ -968,6 +1020,7 @@ export class BookingTransitionService { await this.bookingsRepository.update(bookingId, { status: "OPERATION_REQUEST_PENDING", scheduledDate: date, + requestedTrainScheduleId: requestedId, } as never); const fresh = await this.bookingsService.findById(bookingId); this.notifier.operationRequestedToStaff(fresh); @@ -985,6 +1038,43 @@ export class BookingTransitionService { * total covers the booking. `trainsForDay` is false when no departure carries * the leg — the day is unbookable regardless of space. */ + /** + * Export train picker data for a shipment day the customer is choosing: + * each export train on the booking's corridor with per-wagon-type free + * space. Export rail bookings only — nothing else picks a train. + */ + async exportTrainsForBooking( + bookingId: string, + scheduledDate: string, + overrides?: { + containerTypeIds?: string[]; + containerSizes?: string[]; + cargoTypeId?: string; + cargoTypeCode?: string; + wagons?: number; + }, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + const date = new Date(scheduledDate); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException("A valid schedule date is required"); + } + if ( + booking.tradeDirection !== "EXPORT" || + isRoadService(booking.serviceType) + ) { + throw new BadRequestException( + "Train selection is only available for export rail bookings", + ); + } + const scheduledBooking = { ...booking, scheduledDate: date } as Booking; + return this.bookingBatchService.exportTrainOptionsForDay( + scheduledBooking, + eatDay(date), + overrides, + ); + } + async dayAvailabilityForBooking( bookingId: string, scheduledDate: string, 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..7b1f34f36 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -464,6 +464,26 @@ export class BookingsController { res.send(buffer); } + @Get(':id/carriage-acceptance-sheet') + @ApiOperation({ + summary: + 'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)', + }) + async carriageAcceptanceSheet( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + const { filename, buffer } = await this.bookingsService.carriageAcceptanceSheet(id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(buffer); + } + @Get(':id/customer-trucks') @ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' }) async listCustomerTrucks( @@ -751,10 +771,42 @@ export class BookingsController { const booking = await this.transitionService.requestOperation( id, dto.scheduledDate, + dto.trainScheduleId ?? null, ); return this.transitionService.enrichBookingResponse(booking); } + @Get(":id/export-trains") + @ApiOperation({ + summary: + "Export train picker: the day's export trains on the booking's corridor " + + "with per-wagon-type free space (export rail bookings only)", + }) + async exportTrainsForBooking( + @Param("id", ParseUUIDPipe) id: string, + @Query("date") date: string, + // Bare contract instances carry no cargo yet — the completion form sends + // what the customer is entering so per-type space reflects THEIR cargo. + @Query("containerTypeIds") containerTypeIds?: string, + @Query("containerSizes") containerSizes?: string, + @Query("cargoTypeId") cargoTypeId?: string, + @Query("cargoTypeCode") cargoTypeCode?: string, + @Query("wagons") wagons?: string, + ) { + const parsedWagons = Number(wagons); + return this.transitionService.exportTrainsForBooking(id, date, { + containerTypeIds: containerTypeIds + ? containerTypeIds.split(",").filter(Boolean) + : undefined, + containerSizes: containerSizes + ? containerSizes.split(",").filter(Boolean) + : undefined, + cargoTypeId: cargoTypeId || undefined, + cargoTypeCode: cargoTypeCode || undefined, + wagons: Number.isFinite(parsedWagons) && parsedWagons > 0 ? parsedWagons : undefined, + }); + } + @Post(":id/operation/review") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ @@ -823,6 +875,34 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(':id/clearance/transit-assignee/request') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ + summary: + 'GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration', + }) + async requestBookingTransitAssignee( + @Param('id', ParseUUIDPipe) id: string, + @Body('note') note: string | undefined, + ) { + const booking = await this.bookingClearanceService.requestTransitAssignee(id, note); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/transit-assignee/assign') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ + summary: + 'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns', + }) + async assignBookingTransitAssignee( + @Param('id', ParseUUIDPipe) id: string, + @Body('transitAgentId', ParseUUIDPipe) transitAgentId: string, + ) { + const booking = await this.bookingClearanceService.assignTransitAssignee(id, transitAgentId); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(':id/clearance/declaration') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) @@ -872,6 +952,59 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(':id/clearance/draft-declaration') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: + 'GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review', + }) + async uploadBookingDraftDeclaration( + @Param('id', ParseUUIDPipe) id: string, + @Body('price') priceRaw: string, + @Body('currency') currency: string | undefined, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.uploadDraftDeclaration( + id, + files ?? [], + Number(priceRaw), + currency ?? 'ETB', + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/draft-declaration/accept') + @ApiOperation({ + summary: + 'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia', + }) + async acceptBookingDraftDeclaration(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.bookingClearanceService.acceptDraftDeclaration(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/draft-declaration/change') + @ApiOperation({ + summary: + 'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)', + }) + async requestBookingDraftDeclarationChange( + @Param('id', ParseUUIDPipe) id: string, + @Body('note') note: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.requestDraftDeclarationChange( + id, + note, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(':id/clearance/finalize-pre-clearance') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' }) @@ -916,14 +1049,15 @@ export class BookingsController { async uploadBookingDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, - @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, + @Body('vesselArrivalDate') vesselArrivalDate: string | undefined, + @Body('doCollectedDate') doCollectedDate: string | undefined, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.uploadDeliveryOrder( id, file, resolveAuthUserId(user), - vesselDepartureDate, + { vesselArrivalDate, doCollectedDate }, ); return this.transitionService.enrichBookingResponse(booking); } @@ -1189,6 +1323,20 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(":id/cancel-hold") + @ApiOperation({ + summary: + "Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); " + + "reserved wagons release immediately", + }) + async cancelHold( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CancelBookingDto, + ) { + const booking = await this.transitionService.cancelHold(id, dto.reason); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(":id/consolidation") @ApiOperation({ summary: "Request freight consolidation" }) requestConsolidation(@Param("id", ParseUUIDPipe) id: string) { 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..b16febe2e 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'; @@ -15,6 +23,7 @@ import { DocumentReviewStatus, } from './entities/booking-document-review.entity'; import { BookingContainer } from './entities/booking-container.entity'; +import { BookingContainerUnit } from './entities/booking-container-unit.entity'; import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; @@ -26,6 +35,22 @@ import { import { FileRecord } from '../files/entities/file.entity'; import { ContainerWeightResult } from '../rule-engine/rule-engine.service'; +/** A booking is ready for a batch: commercial = signed, government = approved/paid. */ +const BATCH_POOL_READY = `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') + OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`; + +/** + * Suspending a contract freezes its bookings, so they drop out of every + * scheduling pool. Filtering here (rather than letting the write guard throw) + * keeps the batch crons quiet — a frozen contract simply stops being a + * candidate until the suspension is lifted. + */ +const NOT_ON_SUSPENDED_CONTRACT = `(booking.contract_id IS NULL + OR NOT EXISTS ( + SELECT 1 FROM freight.contracts c + WHERE c.id = booking.contract_id AND c.status = 'SUSPENDED' + ))`; + export interface BookingListFilterOptions { statuses?: string[]; status?: string; @@ -68,6 +93,42 @@ export class BookingsRepository extends BaseRepository { 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 +184,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(); @@ -139,10 +202,12 @@ export class BookingsRepository extends BaseRepository { vgmPerUnitTons: number; hazardousQuantity?: number; reeferQuantity?: number; + containerNumbers?: string[]; weightResult: ContainerWeightResult; }>, ): Promise { const containerRepo = this.dataSource.getRepository(BookingContainer); + const unitRepo = this.dataSource.getRepository(BookingContainerUnit); const typeRepo = this.dataSource.getRepository(ContainerType); const saved: BookingContainer[] = []; @@ -168,7 +233,26 @@ export class BookingsRepository extends BaseRepository { isOverweight: item.weightResult.isOverweight, overweightExcessTons: item.weightResult.overweightExcessTons, }); - saved.push(await containerRepo.save(row)); + const savedRow = await containerRepo.save(row); + saved.push(savedRow); + + // Physical container numbers, one unit row each (capped to the line + // quantity; blanks skipped). Optional — units can also be entered later. + const numbers = (item.containerNumbers ?? []) + .map((n) => n.trim()) + .filter(Boolean) + .slice(0, item.quantity); + let sortOrder = 0; + for (const containerNumber of numbers) { + await unitRepo.save( + unitRepo.create({ + bookingContainerId: savedRow.id, + containerNumber, + vgmTons: item.vgmPerUnitTons, + sortOrder: sortOrder++, + }), + ); + } } return saved; @@ -549,6 +633,17 @@ export class BookingsRepository extends BaseRepository { ); } + /** Review notes of one type, newest first — the duty advice/dispute rounds. */ + async findReviewNotes( + bookingId: string, + type: ReviewNoteType, + ): Promise { + return this.dataSource.getRepository(BookingReviewNote).find({ + where: { bookingId, type }, + order: { createdAt: 'DESC' }, + }); + } + async findLatestReviewNote( bookingId: string, type?: ReviewNoteType, @@ -1015,6 +1110,15 @@ export class BookingsRepository extends BaseRepository { * the whole (route, day) pool rather than bookings pre-targeted to one train. */ day?: string; + /** + * The schedule's ordered route stops. When given, the corridor filter + * replaces the exact origin/destination match: any booking whose BOTH yards + * lie on the route qualifies (sub-corridor bookings like Dire→DCT on a + * GMT→Dire→DCT train — the caller still checks stop ORDER). Dateless + * DOMESTIC (intercity) bookings also join the pool: they ride any train on + * their corridor. + */ + corridorYardIds?: string[]; }): Promise { const qb = this.repository .createQueryBuilder('booking') @@ -1030,15 +1134,19 @@ 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 // single-schedule filter only when no day is supplied (e.g. a staff-pinned // booking that still carries train_schedule_id). if (options.day) { + // Dateless DOMESTIC (intercity) bookings ride any train on their corridor + // — no scheduled_date to match, so the day filter must not hide them. qb.andWhere( - `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, + `(DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day + OR (booking.trade_direction = 'DOMESTIC' AND booking.scheduled_date IS NULL))`, { day: options.day }, ); } else if (options.trainScheduleId) { @@ -1051,15 +1159,23 @@ export class BookingsRepository extends BaseRepository { qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType }); } - if (options.originStationId) { - qb.andWhere('booking.originYardId = :originStationId', { - originStationId: options.originStationId, - }); - } - if (options.destinationStationId) { - qb.andWhere('booking.destinationYardId = :destinationStationId', { - destinationStationId: options.destinationStationId, + if (options.corridorYardIds?.length) { + qb.andWhere('booking.originYardId IN (:...corridorYardIds)', { + corridorYardIds: options.corridorYardIds, + }).andWhere('booking.destinationYardId IN (:...corridorYardIds)', { + corridorYardIds: options.corridorYardIds, }); + } else { + if (options.originStationId) { + qb.andWhere('booking.originYardId = :originStationId', { + originStationId: options.originStationId, + }); + } + if (options.destinationStationId) { + qb.andWhere('booking.destinationYardId = :destinationStationId', { + destinationStationId: options.destinationStationId, + }); + } } if (options.schedulingStatus) { qb.andWhere('booking.scheduling_status = :schedulingStatus', { @@ -1089,10 +1205,8 @@ export class BookingsRepository extends BaseRepository { .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .andWhere('sb.id IS NULL') - .andWhere( - `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') - OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`, - ) + .andWhere(BATCH_POOL_READY) + .andWhere(NOT_ON_SUSPENDED_CONTRACT) .orderBy('booking.is_government', 'DESC') .addOrderBy('booking.priority_score', 'DESC') .addOrderBy('booking.fully_executed_at', 'ASC') @@ -1128,10 +1242,8 @@ export class BookingsRepository extends BaseRepository { { day }, ) .andWhere('sb.id IS NULL') - .andWhere( - `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') - OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`, - ) + .andWhere(BATCH_POOL_READY) + .andWhere(NOT_ON_SUSPENDED_CONTRACT) .orderBy('booking.is_government', 'DESC') .addOrderBy('booking.priority_score', 'DESC') .addOrderBy('booking.fully_executed_at', 'ASC') @@ -1168,10 +1280,8 @@ export class BookingsRepository extends BaseRepository { { day }, ) .andWhere('sb.id IS NULL') - .andWhere( - `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') - OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`, - ) + .andWhere(BATCH_POOL_READY) + .andWhere(NOT_ON_SUSPENDED_CONTRACT) .orderBy('booking.is_government', 'DESC') .addOrderBy('booking.priority_score', 'DESC') .addOrderBy('booking.fully_executed_at', 'ASC') @@ -1248,6 +1358,16 @@ export class BookingsRepository extends BaseRepository { .getMany(); } + /** Open unpaid holds (wagons reserved, pay window running) for a company. */ + countUnpaidHoldsForCompany(companyId: string): Promise { + return this.repository.count({ + where: { + companyId, + status: In(['SELECTED_FOR_BATCH', 'AWAITING_PAYMENT']), + }, + }); + } + /** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */ findReservedForSchedule(scheduleId: string): Promise { return this.repository diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index d40433f4e..b8f73dfc8 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -32,6 +32,8 @@ import { Yard } from '../rule-engine/entities/yard.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { Contract } from '../contracts/entities/contract.entity'; +import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { BookingContractService } from './booking-contract.service'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; import { VehiclesService } from '../vehicles/vehicles.service'; @@ -68,6 +70,29 @@ export interface PaginatedBookings { }; } +/** One wagon line on the carriage acceptance sheet (raw SQL projection). */ +interface CarriageAcceptanceWagonRow { + sequenceNo: number; + wagonType: string | null; + wagonNumber: string | null; + tareWeightTons: string | null; + equatedLength: string | null; + loadCapacityTons: string | null; + allocatedWeightTons: string | null; + trainNumber: string | null; + departureAt: Date | null; + marshalledAt: string | null; + arrivalAt: string | null; + containerNumbers: string | null; + sealNumbers: string | null; +} + +/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */ +interface CarriageAcceptanceReceivedRow { + allocatedWeightTons: string | null; + containerNumbers: string | null; +} + const URGENT_PRIORITY_THRESHOLD = 1000; const NEEDS_ACTION_STATUSES = [ 'SUBMITTED', @@ -103,6 +128,10 @@ export class BookingsService { private readonly vehiclesService: VehiclesService, private readonly pdfRender: PdfRenderService, private readonly events: EventEmitter2, + @Inject(forwardRef(() => BookingContractService)) + private readonly bookingContractService: BookingContractService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService: BookingBatchService, ) {} async assignCustomerTruck( @@ -202,6 +231,284 @@ export class BookingsService { }; } + /** + * Carriage acceptance sheet — one per booking, listing every wagon the booking + * occupies. Handed to the customer when EDR accepts the cargo (export) and when + * the wagons are allocated before marshalling (import), so it is only available + * once the booking has wagon allocations. + */ + async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { + const booking = await this.findById(bookingId); + let wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query( + `SELECT tsw.sequence_no AS "sequenceNo", + COALESCE(wt.code, wt.name) AS "wagonType", + w.wagon_number AS "wagonNumber", + wt.tare_weight_tons AS "tareWeightTons", + tsw.length_meters AS "equatedLength", + tsw.capacity_tons AS "loadCapacityTons", + a.allocated_weight_tons AS "allocatedWeightTons", + s.train_number AS "trainNumber", + s.scheduled_departure_date AS "departureAt", + so.label AS "marshalledAt", + sd.label AS "arrivalAt", + string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers", + string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers" + FROM freight.wagon_booking_allocations a + JOIN freight.train_set_wagons tsw + ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL + LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id + LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + LEFT JOIN freight.train_schedules s + ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL + LEFT JOIN freight.yards so ON so.id = s.origin_station_id + LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id + LEFT JOIN freight.wagon_allocation_container_items ci + ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL + WHERE a.booking_id = $1 AND a.deleted_at IS NULL + GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons, + s.train_number, s.scheduled_departure_date, so.label, sd.label + ORDER BY tsw.sequence_no`, + [bookingId], + ); + // Export acceptance happens at the warehouse gate, not at marshalling: EDR + // takes custody of the cargo when it receives it, and the customer is handed + // this sheet then — before the booking is put on a train. So a received + // export booking gets its sheet off the received cargo, wagon columns blank + // until the consist exists. Import keeps the allocation gate: nothing is + // accepted from the customer before the wagons carry it. + // + // Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork + // and never appears on this sheet — it is only the signal that EDR has taken + // the cargo, which is what the customer's sheet attests to. + const pendingWagons = wagons.length === 0; + if (pendingWagons) { + const receivedLines: CarriageAcceptanceReceivedRow[] = + booking.tradeDirection === 'EXPORT' + ? await this.dataSource.query( + `SELECT inv.weight AS "allocatedWeightTons", + c.container_number AS "containerNumbers" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.containers c + ON c.id = inv.container_id AND c.deleted_at IS NULL + WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL + AND COALESCE(NULLIF(TRIM(inv.grn_number), ''), '') <> '' + ORDER BY inv.created_at`, + [bookingId], + ) + : []; + if (receivedLines.length === 0) { + throw new BadRequestException( + booking.tradeDirection === 'EXPORT' + ? 'This export booking has no GRN yet — receive the cargo at the warehouse before issuing the carriage acceptance sheet' + : 'No wagons are allocated to this booking yet — the carriage acceptance sheet is issued after wagon allocation', + ); + } + wagons = receivedLines.map((row, index) => ({ + sequenceNo: index + 1, + wagonType: null, + wagonNumber: null, + tareWeightTons: null, + equatedLength: null, + loadCapacityTons: null, + allocatedWeightTons: row.allocatedWeightTons, + trainNumber: null, + departureAt: null, + marshalledAt: null, + arrivalAt: null, + containerNumbers: row.containerNumbers, + sealNumbers: null, + })); + } + + const html = this.buildCarriageAcceptanceSheetHtml(booking, wagons, { pendingWagons }); + const buffer = await this.pdfRender.htmlToPdfBuffer(html, { + label: 'carriage acceptance sheet', + fallback: (prepared) => buildTabularFallbackPdf(prepared), + }); + return { + filename: `carriage-acceptance-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer, + }; + } + + /** + * Split the booking amount across its wagons, proportional to allocated weight + * (equal shares when no weights are recorded). The last row absorbs the rounding + * remainder so the Price column always sums to the Total Amount on the sheet. + */ + private splitAmountAcrossWagons(total: number, weights: number[]): number[] { + const sum = weights.reduce((acc, w) => acc + w, 0); + const shares = weights.map((w) => + Math.round((sum > 0 ? (total * w) / sum : total / weights.length) * 100) / 100, + ); + const drift = Math.round((total - shares.reduce((a, b) => a + b, 0)) * 100) / 100; + shares[shares.length - 1] = Math.round((shares[shares.length - 1] + drift) * 100) / 100; + return shares; + } + + private buildCarriageAcceptanceSheetHtml( + booking: Booking, + wagons: CarriageAcceptanceWagonRow[], + { pendingWagons }: { pendingWagons: boolean }, + ): string { + const esc = (v: unknown) => this.escapeHtml(String(v ?? '-')); + const num = (v: unknown, digits = 3) => (Number(v) || 0).toFixed(digits); + const money = (v: number) => + v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + + const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-'; + const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'; + const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-'; + const currency = booking.paymentCurrency ?? 'ETB'; + const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0; + const prices = this.splitAmountAcrossWagons( + totalAmount, + wagons.map((w) => Number(w.allocatedWeightTons) || 0), + ); + const header = wagons[0]; + const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date(); + + const totals = wagons.reduce( + (acc, w) => ({ + tare: acc.tare + (Number(w.tareWeightTons) || 0), + capacity: acc.capacity + (Number(w.loadCapacityTons) || 0), + load: acc.load + (Number(w.allocatedWeightTons) || 0), + length: acc.length + (Number(w.equatedLength) || 0), + }), + { tare: 0, capacity: 0, load: 0, length: 0 }, + ); + // A wagon carrying no weight and no container is running empty under this booking. + const fullWagons = wagons.filter( + (w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers), + ).length; + + const rows = wagons + .map( + (w, i) => ` + ${i + 1} + ${esc(w.wagonType)} + ${esc(w.wagonNumber)} + ${num(w.tareWeightTons, 2)} + ${num(w.equatedLength)} + ${num(w.loadCapacityTons)} + ${esc(arrivalStation)} + ${esc(cargoName)} + ${esc(departureStation)} + ${esc(w.containerNumbers)} + ${esc(w.sealNumbers)} + ${money(prices[i])} + `, + ) + .join(''); + + return ` + + + + Carriage Acceptance Sheet + + + +
+
+
Ethio-Djibouti Railway S.C.
+

Carriage Acceptance Sheet

+
Booking ${esc(booking.reference)} — ${esc(booking.tradeDirection)}
+
+
+ Sheet No. + CAS-${esc(booking.reference)} + Generated: ${esc(new Date().toLocaleString('en-GB'))} +
+
+ +
+
Marshalled at${esc(header.marshalledAt ?? departureStation)}
+
Arrival at${esc(header.arrivalAt ?? arrivalStation)}
+
Date and time${esc(sheetDate.toLocaleString('en-GB'))}
+
Train No.${esc(header.trainNumber)}
+
Customer${esc(booking.company?.name)}
+
Cargo${esc(cargoName)}
+
+ + + + + + + + + + + + + + + + + + + + ${rows} + + + + + + + + + + + +
SNType of WagonWagon No.Tare WeightEquated LengthLoad CapacityArrival StationCargo NameDeparture StationContainer No.Seal No.Price (${esc(currency)})
${ + pendingWagons + ? `Received lines: ${wagons.length} — wagons pending marshalling` + : `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})` + }${num(totals.tare, 2)}${num(totals.length)}${num(totals.capacity)}Gross weight (tare + load): ${num(totals.tare + totals.load)} T${money(totalAmount)}
+ +
+ ${ + pendingWagons + ? `The cargo listed above is accepted for carriage under booking ${esc(booking.reference)}. + Wagon identity and seal numbers are filled in when the booking is marshalled onto a train.` + : `The wagons listed above are accepted for carriage under booking ${esc(booking.reference)}. + Wagon identity, container and seal numbers must be verified against the physical consist + before the sheet is signed.` + } +
+ +
+
Signed by — EDR operations / date
+
Signed by — customer or agent / date
+
Signed by — marshalling yard / date
+
+ +`; + } + /** Resolve trade direction from yard countries; reject client mismatch. */ /** * An intercity corridor is valid when both yards are Ethiopian and at least @@ -602,6 +909,24 @@ export class BookingsService { return result.booking; } + /** + * A company with an open unpaid hold (SELECTED_FOR_BATCH — wagons reserved, + * pay window running) may not take more capacity until it pays or the hold + * dies: otherwise one customer can lock a train's wagons over and over + * without ever paying. EXPIRED / CANCELLED holds free the lock. + */ + async assertNoUnpaidHold(companyId?: string | null): Promise { + if (!companyId) return; + const holds = + await this.bookingsRepository.countUnpaidHoldsForCompany(companyId); + if (holds > 0) { + throw new ConflictException( + 'You already have a booking waiting for payment. Pay it or cancel it ' + + 'before making a new booking.', + ); + } + } + /** Create a new freight booking. */ async create( dto: CreateBookingDto, @@ -664,6 +989,10 @@ export class BookingsService { companyId = company.id; } + // Government bookings allocate without paying, so the unpaid-hold lock + // only applies to commercial companies. + if (!isGovernment) await this.assertNoUnpaidHold(companyId); + if (dto.trainScheduleId) { // Staff manual pin: the schedule must be OPEN and on the same route. const schedule = await this.dataSource @@ -857,6 +1186,9 @@ export class BookingsService { cargoFreeText: dto.cargoFreeText, shippingLineId: dto.shippingLineId, cargoTotalWeightVgm: dto.cargoTotalWeightVgm, + // Break-bulk actual tonnage (PER_ITEM cargo); meaningless outside BULK. + bulkTotalWeightTons: + dto.freightType === 'BULK' ? (dto.bulkTotalWeightTons ?? null) : null, isHazardous: dto.isHazardous ?? false, // Bulk reefer is the customer's toggle; container reefer is derived from // the container type at pricing time, so the booking-level flag stays off @@ -903,6 +1235,7 @@ export class BookingsService { vgmPerUnitTons: c.vgmPerUnitTons, hazardousQuantity: c.hazardousQuantity, reeferQuantity: c.reeferQuantity, + containerNumbers: c.containerNumbers, weightResult: ruleResult.containerWeightResults[i], })), ); @@ -957,6 +1290,21 @@ export class BookingsService { warnings.push(...consolidation.messages); } + // Government bookings pass every customer step at creation: the server + // expedites them to PAID/Eligible, generates the contract (signable at any + // time) and queues priority placement. Best-effort — the booking row is + // already inserted, so a late failure must not 500 the whole create; the + // idempotent expedite endpoint remains the retry path. + if (isGovernment) { + try { + full = await this.governmentExpedite(booking.id, userId ?? 'system'); + } catch (err) { + warnings.push( + `Government expedite incomplete — retry via the expedite action: ${(err as Error).message}`, + ); + } + } + return { booking: full, warnings }; } @@ -1048,6 +1396,11 @@ export class BookingsService { ...dto, freightType, cargoTypeId: freightType === 'BULK' ? cargoTypeId : null, + // Break-bulk actual tonnage; cleared when the booking leaves BULK. + bulkTotalWeightTons: + freightType === 'BULK' + ? (dto.bulkTotalWeightTons ?? existing.bulkTotalWeightTons ?? null) + : null, // Booking-level reefer is only meaningful for bulk; container reefer is // derived from the container type at pricing time. isReefer: @@ -1800,13 +2153,22 @@ export class BookingsService { return false; } - /** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */ + /** + * Expedite a government booking past every customer step: PAID + Eligible + * (no commercial hold, no payment), contract generated server-side (signable + * at any time), and the (route, day) fill kicked immediately so it grabs a + * seat on any open train — government-first, preempting commercial cargo if + * the day is full. Runs automatically at creation; the endpoint remains as a + * no-op-safe retry for older bookings. + */ async governmentExpedite(id: string, staffUserId: string): Promise { const booking = await this.findById(id); if (!booking.isGovernment) { throw new BadRequestException('Only government bookings can be expedited'); } - const blocked = ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED']; + // Idempotent: create() already expedites — a repeat call changes nothing. + if (booking.status === 'PAID') return booking; + const blocked = ['IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED']; if (blocked.includes(booking.status)) { throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`); } @@ -1818,12 +2180,22 @@ export class BookingsService { holdStartedAt: null, holdExpiresAt: null, }); + await this.bookingContractService.generateContractForGovernment(id); await this.bookingsRepository.createReviewNote( id, `Government booking expedited to PAID by staff (${staffUserId})`, 'STAFF_NOTE', staffUserId, ); + // Priority placement: run the day-level fill now instead of waiting for a + // batch tick — the pool sorts government first and preempts if needed. + if (booking.scheduledDate) { + this.bookingBatchService.enqueueRouteDayProcessing( + booking.originYardId, + booking.destinationYardId, + eatDay(booking.scheduledDate), + ); + } return this.findById(id); } 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/carriage-acceptance-price-split.spec.ts b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts new file mode 100644 index 000000000..195e0cab0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts @@ -0,0 +1,26 @@ +import { BookingsService } from './bookings.service'; + +// The split is a pure helper on the prototype (never touches `this`), so it can be +// exercised without constructing the service and its dependency graph. +const split = (total: number, weights: number[]): number[] => + ( + BookingsService.prototype as unknown as { + splitAmountAcrossWagons(total: number, weights: number[]): number[]; + } + ).splitAmountAcrossWagons(total, weights); + +describe('carriage acceptance sheet — price split', () => { + it('splits proportionally to allocated weight', () => { + expect(split(100, [30, 10])).toEqual([75, 25]); + }); + + it('splits equally when no weights are recorded', () => { + expect(split(90, [0, 0, 0])).toEqual([30, 30, 30]); + }); + + it('always sums back to the booking total despite rounding', () => { + const shares = split(100, [1, 1, 1]); + expect(shares.reduce((a, b) => a + b, 0)).toBe(100); + expect(shares).toEqual([33.33, 33.33, 33.34]); + }); +}); 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..e38715ae9 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 @@ -11,10 +11,8 @@ describe('clearance.util — clearanceSettingCode', () => { expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe( 'clearance_import_container_with_customs', ); - // Non-customs bookings self-clear with the same document set a ONE_TIME - // self-clear contract uses. expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe( - 'contract_clearance_selfclear_import_container', + 'clearance_import_container_without_customs', ); }); @@ -23,7 +21,7 @@ describe('clearance.util — clearanceSettingCode', () => { 'clearance_export_bulk_with_customs', ); expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe( - 'contract_clearance_selfclear_export_bulk', + 'clearance_export_bulk_without_customs', ); }); @@ -62,13 +60,15 @@ describe('clearance.util — clearanceCodesForBooking (intercity)', () => { expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE); }); - it('ONE_TIME contract drawdowns skip the per-booking set (contract collected it)', () => { + it('ONE_TIME contract shipments carry the same per-booking set', () => { + // Contracts no longer collect clearance documents — every shipment does, + // whatever kind of contract it draws on. const drawdown = clearanceCodesForBooking({ ...base, contractId: 'c1', contractKind: 'ONE_TIME', } as unknown as Booking); - expect(drawdown.inputCode).toBeNull(); + expect(drawdown.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE); expect(drawdown.outputCode).toBeNull(); }); }); 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..e28917715 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'; @@ -40,12 +39,11 @@ export function clearanceSettingCode( const op = operationFor(tradeDirection); if (!op) return null; const freight = freightFor(freightType); - // Non-customs (Path A) bookings self-clear: the customer proves his own - // clearance with the SAME smaller document set a ONE_TIME self-clear - // contract uses (customs declaration, release permit, …) — not the - // GL-oriented booking sets. + // 4 import + 4 export cases (bulk/container × with/without customs) — each + // booking resolves to its own clearance_{op}_{freight}_{with|without}_customs + // set, independent of any contract-level clearance codes. if (!includesCustoms) { - return `contract_clearance_selfclear_${op}_${freight}`; + return `clearance_${op}_${freight}_without_customs`; } return `clearance_${op}_${freight}_with_customs`; } @@ -77,16 +75,6 @@ export function clearanceCodesForBooking(booking: Booking): { const includesCustoms = Boolean(booking.serviceType?.includesCustoms) || Boolean(booking.customsClearingEnabled); - // Intercity drawdowns under a ONE_TIME contract already cleared the intercity - // document set on the CONTRACT (post-signature); only GENERAL drawdowns and - // direct (contract-less) bookings carry the per-booking set. - if ( - booking.tradeDirection === 'DOMESTIC' && - booking.contractId && - booking.contractKind === 'ONE_TIME' - ) { - return { inputCode: null, outputCode: null, includesCustoms: false }; - } return { inputCode: clearanceSettingCode( booking.tradeDirection, 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/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts index 919652af6..0dceadf43 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts @@ -20,6 +20,9 @@ export class SavedSignatureViewDto { @ApiPropertyOptional() signatureImageUrl?: string | null; + + @ApiPropertyOptional() + stampImageUrl?: string | null; } export class ContractViewDto { diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index c4d971f51..a9aca53dd 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -74,6 +74,17 @@ export class CreateBookingContainerDto { @Min(0) @Transform(({ value }) => Number(value ?? 0)) reeferQuantity?: number; + + @ApiPropertyOptional({ + description: + 'Physical container numbers for this line (each becomes a booking_container_unit; extras beyond `quantity` are ignored)', + type: [String], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + @MaxLength(64, { each: true }) + containerNumbers?: string[]; } /** @@ -325,6 +336,21 @@ export class CreateBookingDto { @Transform(({ value }) => Number(value)) cargoTotalWeightVgm!: number; + /** + * Break-bulk only: actual total cargo weight in tons when the bulk cargo + * type is PER_ITEM — `cargoTotalWeightVgm` then carries the item count. + * Omit for PER_TON bulk and container freight. + */ + @ApiPropertyOptional({ + minimum: 0, + description: 'Break-bulk (PER_ITEM) total weight in tons; cargoTotalWeightVgm holds the item count', + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => (value == null ? undefined : Number(value))) + bulkTotalWeightTons?: number; + @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index f27b375bb..63ad5b9f4 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -5,6 +5,7 @@ import { IsInt, IsOptional, IsString, + IsUUID, Max, Min, MinLength, @@ -93,6 +94,17 @@ export class RequestOperationDto { }) @IsDateString() scheduledDate!: string; + + @ApiPropertyOptional({ + description: + 'EXPORT rail only: the specific train (schedule id) the customer picked ' + + 'from GET /bookings/:id/export-trains. The reserve path locks onto this ' + + 'train instead of earliest-first; 409 if it no longer fits. Ignored for ' + + 'import/domestic/road bookings.', + }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; } export class OperationReviewDto { diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts index 91171a793..969098196 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts @@ -2,7 +2,16 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; import { Booking } from './booking.entity'; -export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION', 'STAFF_NOTE'] as const; +export const REVIEW_NOTE_TYPES = [ + 'CHANGES_REQUESTED', + 'REJECTION', + 'STAFF_NOTE', + /** + * The customer asked GL Ethiopia to correct the draft customs declaration + * (price/files). One row per round — the draft/change-request loop can repeat. + */ + 'DRAFT_DECL_CHANGE_REQUEST', +] as const; export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number]; @Entity({ schema: 'freight', name: 'booking_review_note' }) 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..3d339fa19 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; @@ -351,6 +365,15 @@ export class Booking extends BaseEntity { @Column({ name: 'cargo_total_weight_vgm', type: 'numeric', precision: 12, scale: 3 }) cargoTotalWeightVgm!: number; + /** + * Break-bulk only: actual total cargo weight in tons when the bulk cargo + * type is PER_ITEM (`cargoTotalWeightVgm` then carries the item COUNT). + * Null for PER_TON bulk and all CONTAINER bookings. Wagon allocation uses + * weight ÷ count to size indivisible items per wagon. + */ + @Column({ name: 'bulk_total_weight_tons', type: 'numeric', precision: 12, scale: 3, nullable: true }) + bulkTotalWeightTons?: number | null; + @Column({ name: 'is_hazardous', type: 'boolean', default: false }) isHazardous!: boolean; @@ -485,6 +508,18 @@ export class Booking extends BaseEntity { @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) trainScheduleId?: string | null; + /** + * EXPORT only: the specific train the customer picked at day-commit. + * pickExportSchedule reserves on this train (409 if it no longer fits) + * instead of falling back to earliest-departure-first. NULL = no preference. + */ + @Column({ name: 'requested_train_schedule_id', type: 'uuid', nullable: true }) + requestedTrainScheduleId?: string | null; + + /** Stamped when the one pre-deadline pay reminder went out (tick dedup). */ + @Column({ name: 'payment_reminder_sent_at', type: 'timestamptz', nullable: true }) + paymentReminderSentAt?: Date | null; + // ── Per-booking journey (segment corridor bookings) ──────────────────────── // A booking rides only its own origin→destination leg of the train's route, // so dispatch/arrival are per-booking facts, not train facts. Clearance gates @@ -526,6 +561,14 @@ export class Booking extends BaseEntity { @Column({ name: 'vessel_departure_date', type: 'date', nullable: true }) vesselDepartureDate?: string | null; + /** Import DO: when the vessel arrived in Djibouti. Required on DO upload. */ + @Column({ name: 'vessel_arrival_date', type: 'date', nullable: true }) + vesselArrivalDate?: string | null; + + /** Import DO: when GL Djibouti collected the DO. Required on DO upload. */ + @Column({ name: 'do_collected_date', type: 'date', nullable: true }) + doCollectedDate?: string | null; + @Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true }) roAmendmentRequestedAt?: Date | null; @@ -535,6 +578,23 @@ export class Booking extends BaseEntity { @Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true }) preClearanceFinalizedAt?: Date | null; + /** + * Pre-declaration handshake: GL Ethiopia asks GL Djibouti who will handle this + * shipment in transit, Djibouti answers with a name (free text — the officer is + * not a platform user). The import declaration is blocked until `name` is set. + */ + @Column({ name: 'transit_assignee_requested_at', type: 'timestamptz', nullable: true }) + transitAssigneeRequestedAt?: Date | null; + + @Column({ name: 'transit_assignee_request_note', type: 'text', nullable: true }) + transitAssigneeRequestNote?: string | null; + + @Column({ name: 'transit_assignee_name', type: 'text', nullable: true }) + transitAssigneeName?: string | null; + + @Column({ name: 'transit_assignee_assigned_at', type: 'timestamptz', nullable: true }) + transitAssigneeAssignedAt?: Date | null; + /** GL staff user bound to this shipment by the station manager. */ @Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true }) glAssignedStaffId?: string | null; 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.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 8e3be4d1a..7665d82eb 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -35,6 +35,10 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; +import { + CompanyIdentityStateDto, + CompleteIdentityVerificationDto, +} from "./dto/complete-identity-verification.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; import { StartOnboardingDto } from "./dto/start-onboarding.dto"; import { DashboardQueryDto } from "./dto/dashboard-query.dto"; @@ -58,6 +62,7 @@ import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-stat import { RejectChangeRequestDto } from "./dto/reject-change-request.dto"; import { RequestDocumentChangeDto } from "./dto/request-document-change.dto"; import { ChangeRequestResponseDto } from "./dto/change-request-response.dto"; +import { CompanyRevisionResponseDto } from "./dto/company-revision-response.dto"; import { FetchETradeDto } from "./dto/fetch-etrade.dto"; import { ETradeResponseDto } from "./dto/etrade-response.dto"; @@ -188,9 +193,20 @@ export class CompaniesController { @Post("fetch-etrade-info") @ApiOperation({ summary: "Fetch company info from eTrade by TIN" }) async fetchETradeInfo( + @CurrentUser() user: CurrentIamUser, @Body() dto: FetchETradeDto, ): Promise { - const data = await this.companiesService.fetchETradeData(dto.tin); + // Best-effort: a first-run onboarding draft may not exist yet, in which + // case there is no company to exclude and `tinTaken` checks every row — + // the correct behaviour for a brand-new lookup. + const companyId = await this.companiesService + .getCompanyInfoByUserId(user.id) + .then(({ company }) => company.id) + .catch(() => undefined); + const data = await this.companiesService.fetchETradeData( + dto.tin, + companyId, + ); return new ETradeResponseDto(data); } @@ -378,6 +394,32 @@ export class CompaniesController { return this.companiesService.removePoaDelegationLetter(user.id, fileId); } + @Post("identity/fayda/complete") + @ApiOperation({ + summary: + "Bind a completed Fayda verification to the company's owner or Power of Attorney. " + + "Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " + + "The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.", + }) + async completeIdentityVerification( + @CurrentUser() user: CurrentIamUser, + @Body() dto: CompleteIdentityVerificationDto, + ): Promise { + return this.companiesService.completeIdentityVerification(user.id, dto); + } + + @Delete("identity/fayda/poa") + @ApiOperation({ + summary: + "Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " + + "Refused while the company holds a freight forwarder role, which cannot operate without a representative.", + }) + async removePoaIdentity( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.removePoaIdentity(user.id); + } + @Patch("onboarding-step") @ApiOperation({ summary: "Persist the user's current onboarding wizard step" }) @HttpCode(HttpStatus.NO_CONTENT) @@ -644,6 +686,17 @@ export class CompaniesController { return requests.map((r) => new ChangeRequestResponseDto(r)); } + @Get(":companyId/revisions") + @BookingStaff(FREIGHT_PERMS.customers.view) + @ApiOperation({ summary: "Onboarding-phase edit history (version history)" }) + async listCompanyRevisions( + @Param("companyId", ParseUUIDPipe) companyId: string, + ): Promise { + const revisions = + await this.companiesService.listCompanyRevisions(companyId); + return revisions.map((r) => new CompanyRevisionResponseDto(r)); + } + @Post("change-requests/:id/approve") @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ @@ -678,6 +731,25 @@ export class CompaniesController { return new ChangeRequestResponseDto(request); } + @Post("change-requests/:id/request-changes") + @BookingStaff(FREIGHT_PERMS.customers.verify) + @ApiOperation({ + summary: + "Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)", + }) + async requestChangeRequestChanges( + @CurrentUser() user: CurrentIamUser, + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: RejectChangeRequestDto, + ): Promise { + const request = await this.companiesService.requestChangeRequestChanges( + id, + dto.note, + user.id, + ); + return new ChangeRequestResponseDto(request); + } + @Post(":companyId/profiles") @BookingStaff(FREIGHT_PERMS.customers.update) @ApiOperation({ summary: "Add a profile (employee) to a company" }) diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts new file mode 100644 index 000000000..511c3aa25 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -0,0 +1,466 @@ +import { BadRequestException } from "@nestjs/common"; + +import { CompaniesService } from "./companies.service"; +import { CompanyNationality, CompanyStatus } from "./entities/company.entity"; +import { ProfileType } from "./entities/company-profile.entity"; +import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants"; + +/** + * A person's identity is proved through Fayda: name, email, phone and address + * come from the verified payload, not typed. Fayda's userinfo carries no + * national ID number, so none is collected or derived here. + * + * Only the OWNER's credential varies by nationality: + * - Ethiopian company: the owner is verified through Fayda. + * - Foreign company: Fayda is an Ethiopian national ID, so the owner instead + * supplies a typed passport number — required on its own, whether or not the + * owner also completes a (purely optional) Fayda verification. + * + * The PoA does not vary. A representative acts for the company inside Ethiopia + * whoever owns it, so a PoA is always an Ethiopian holding a Fayda ID: once one + * is named, both nationalities must verify them, and their details come from + * the verified payload rather than the form. + * + * The owner is NOT the general manager — GM is a separate, plain typed role + * the portal offers a "same as owner" copy for, but it is never itself + * Fayda-verified or gated on. + */ + +interface Ctx { + attributes: Record; + files: { id: string; code: string; reviewStatus?: string | null }[]; + profileTypes: ProfileType[]; + status: CompanyStatus; + nationality: CompanyNationality; + verification: Record; +} + +const OWNER_VERIFIED = { + ownerFaydaSub: "owner-sub", + ownerFaydaVerifiedAt: "2026-07-01T00:00:00.000Z", + ownerName: "Abebe Bikila", +}; + +const POA_VERIFIED = { + poaFaydaSub: "poa-sub", + poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z", + poaName: "Tirunesh Dibaba", + poaEmail: "tirunesh@example.com", + poaPhone: "+251911000000", +}; + +const paper = () => ({ + id: "file-1", + code: POA_DELEGATION_FILE_KEY, + reviewStatus: null, +}); + +function makeService(overrides: Partial = {}) { + const ctx: Ctx = { + attributes: {}, + files: [], + profileTypes: [ProfileType.importer], + status: CompanyStatus.Pending, + nationality: CompanyNationality.Ethiopian, + verification: { + purpose: "VERIFY", + verified: true, + sub: "new-sub", + fullName: "Haile Gebrselassie", + email: "haile@example.com", + phoneNumber: "+251922000000", + address: "Addis Ababa", + birthdate: "1973-04-18", + gender: "Male", + }, + ...overrides, + }; + + const company = () => ({ + id: "company-1", + status: ctx.status, + nationality: ctx.nationality, + attributes: ctx.attributes, + companyProfiles: ctx.profileTypes.map((type, i) => ({ + id: `profile-${i}`, + type, + })), + type: "customer", + }); + + const deps = { + companiesRepo: { + findById: jest.fn(async () => company()), + update: jest.fn(async (_id: string, patch: Record) => { + if (patch.attributes) + ctx.attributes = patch.attributes as Record; + return company(); + }), + findByTin: jest.fn(async () => null), + }, + companyProfilesRepo: { + findByCompanyId: jest.fn(async () => + ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })), + ), + findByType: jest.fn(async (_id: string, type: ProfileType) => + ctx.profileTypes.includes(type) ? { id: "existing", type } : null, + ), + create: jest.fn(async (row: Record) => ({ + id: "new", + ...row, + })), + }, + changeRequestRepo: { + findPendingByCompanyId: jest.fn(async () => null), + findLatestOpenByCompanyId: jest.fn(async () => null), + findByCompanyId: jest.fn(async () => []), + create: jest.fn(async (row: Record) => ({ + id: "cr-1", + ...row, + })), + update: jest.fn(async () => ({ id: "cr-1" })), + }, + revisionRepo: { + create: jest.fn(async (row: Record) => ({ + id: "rev-1", + ...row, + })), + findByCompanyId: jest.fn(async () => []), + }, + profilesRepo: { + findByCompanyId: jest.fn(async () => []), + findByUserId: jest.fn(async () => ({ + id: "external-1", + companyId: "company-1", + company: company(), + onboardingCompleted: false, + })), + }, + filesService: { + findByResource: jest.fn(async () => ctx.files), + findById: jest.fn(async () => null), + remove: jest.fn(async () => undefined), + }, + companyNotifier: { changeRequestSubmitted: jest.fn() }, + verifayda: { + completeVerification: jest.fn(async () => ctx.verification), + }, + }; + + const service = new CompaniesService( + deps.companiesRepo as never, + deps.companyProfilesRepo as never, + deps.changeRequestRepo as never, + deps.revisionRepo as never, + deps.profilesRepo as never, + {} as never, + deps.filesService as never, + {} as never, + {} as never, + deps.companyNotifier as never, + {} as never, + deps.verifayda as never, + ); + + jest + .spyOn(service, "getCompanyInfoByUserId") + .mockImplementation( + async () => + ({ profile: { id: "external-1" }, company: company() }) as never, + ); + + return { service, ctx, deps, company }; +} + +describe("Fayda identity verification binds a person to the company", () => { + it("writes the verified identity", async () => { + const { service, ctx } = makeService(); + + const state = await service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }); + + expect(ctx.attributes.ownerFaydaSub).toBe("new-sub"); + expect(ctx.attributes.ownerName).toBe("Haile Gebrselassie"); + expect(state.owner.verified).toBe(true); + }); + + it("fills every PoA detail from the payload, address included", async () => { + const { service, ctx } = makeService(); + + await service.completeIdentityVerification("user-1", { + subject: "poa", + code: "c", + state: "s", + }); + + expect(ctx.attributes.poaName).toBe("Haile Gebrselassie"); + expect(ctx.attributes.poaEmail).toBe("haile@example.com"); + expect(ctx.attributes.poaPhone).toBe("+251922000000"); + expect(ctx.attributes.poaAddress).toBe("Addis Ababa"); + }); + + it("verifies successfully even though Fayda returns no national ID number", async () => { + // Fayda's userinfo carries no FAN/FIN claim at all — this must be the + // normal, successful path, not an error. + const { service } = makeService({ + verification: { + purpose: "VERIFY", + verified: true, + sub: "x", + fullName: "No Fan Here", + }, + }); + + const state = await service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }); + + expect(state.owner.verified).toBe(true); + }); + + it("refuses to make one identity both owner and PoA", async () => { + const { service } = makeService({ + attributes: { ownerFaydaSub: "same-person" }, + verification: { + purpose: "VERIFY", + verified: true, + sub: "same-person", + fullName: "Abebe Bikila", + }, + }); + + await expect( + service.completeIdentityVerification("user-1", { + subject: "poa", + code: "c", + state: "s", + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("stages an owner re-verification for review on an approved company", async () => { + // The owner is the live company's identity proof, so re-verifying one is + // exactly what the backoffice review exists for: it must not rewrite the + // row directly. + const { service, ctx, deps } = makeService({ + status: CompanyStatus.Active, + }); + + await service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }); + + expect(deps.changeRequestRepo.create).toHaveBeenCalled(); + expect(ctx.attributes.ownerFaydaSub).toBeUndefined(); + }); + + it("applies a PoA verification live on an approved company", async () => { + // The PoA is personnel the company names for itself — the delegation paper + // is what a reviewer actually judges — so it does not go to review. + const { service, ctx, deps } = makeService({ + status: CompanyStatus.Active, + }); + + await service.completeIdentityVerification("user-1", { + subject: "poa", + code: "c", + state: "s", + }); + + expect(deps.changeRequestRepo.create).not.toHaveBeenCalled(); + expect(ctx.attributes.poaFaydaSub).toBe("new-sub"); + }); + + it("refuses to rename a verified person by hand", async () => { + const { service } = makeService({ + attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, + files: [paper()], + }); + + await expect( + service.updateProfile("user-1", { poaName: "Someone Else" } as never), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("never locks or gates the general manager — it is not the verified subject", async () => { + // GM is a plain typed role; the portal offers a "same as owner" copy, but + // the backend must not treat it as identity-owned or require it verified. + const { service } = makeService({ + attributes: { ...OWNER_VERIFIED }, + }); + + await expect( + service.updateProfile("user-1", { + generalManagerName: "Someone Else", + generalManagerEmail: "someone@example.com", + generalManagerPhone: "+251911223344", + } as never), + ).resolves.toBeDefined(); + }); +}); + +describe("Ethiopian companies verify with Fayda; foreign companies verify identity by passport", () => { + // The company is applying for the forwarder role, so it must not already + // hold it — createCompanyProfileForUser short-circuits on an existing profile + // and would never reach the gate. + const applyingForFf = { + profileTypes: [ProfileType.importer], + attributes: { ...POA_VERIFIED }, + files: [paper()], + }; + + it("blocks the forwarder role while the owner is unverified", async () => { + const { service } = makeService(applyingForFf); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("blocks the forwarder role while the PoA is unverified", async () => { + const { service } = makeService({ + profileTypes: [ProfileType.importer], + attributes: { + ...OWNER_VERIFIED, + poaName: "Tirunesh Dibaba", + poaEmail: "t@example.com", + poaPhone: "+251911000000", + }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("grants the forwarder role once owner and PoA are both verified", async () => { + const { service } = makeService({ + profileTypes: [ProfileType.importer], + attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).resolves.toBeDefined(); + }); + + it("never asks a foreign company for Fayda, verified or not", async () => { + const { service } = makeService({ + nationality: CompanyNationality.Foreign, + }); + + const state = await service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }); + + // Still lets the owner verify — a foreign owner verifying is allowed, just + // never required — but the passport is the thing that actually gates it. + expect(state.owner.verified).toBe(true); + expect(state.faydaRequired).toBe(false); + expect(state.passportRequired).toBe(true); + }); + + it("blocks the forwarder role for a foreign company with no owner passport", async () => { + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Foreign, + attributes: { + poaName: "Jean Dupont", + poaEmail: "jean@example.com", + poaPhone: "+33100000000", + }, + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("grants the forwarder role to a foreign company whose owner has a passport and whose PoA is Fayda-verified", async () => { + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Foreign, + attributes: { + ownerPassportNumber: "P1234567", + ...POA_VERIFIED, + }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).resolves.toBeDefined(); + }); + + it("still requires a Fayda-verified PoA from a foreign company", async () => { + // The owner's credential is nationality-specific; the representative's is + // not. A PoA acts for the company inside Ethiopia whoever owns it, so a + // typed foreign name is not a representative the platform can accept. + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Foreign, + attributes: { + ownerPassportNumber: "P1234567", + poaName: "Jean Dupont", + poaEmail: "jean@example.com", + poaPhone: "+33100000000", + }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => { + // Verifying is optional for a foreign owner, but it does not waive the + // passport requirement — the two are independent credentials. + const { service } = makeService({ + profileTypes: [ProfileType.importer], + nationality: CompanyNationality.Foreign, + attributes: { + ...OWNER_VERIFIED, + poaName: "Jean Dupont", + poaEmail: "jean@example.com", + poaPhone: "+33100000000", + }, + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 73826689a..f0a443ba8 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -15,11 +15,14 @@ import { Company } from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; import { CompanyProfile } from "./entities/company-profile.entity"; import { CompanyChangeRequest } from "./entities/company-change-request.entity"; +import { CompanyRevision } from "./entities/company-revision.entity"; import { Booking } from "../bookings/entities/booking.entity"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; +import { CompanyRevisionRepository } from "./company-revision.repository"; import { ETradeService } from "./services/etrade.service"; import { CompanyNotifierService } from "./company-notifier.service"; +import { VerifaydaModule } from "../verifayda/verifayda.module"; @Module({ imports: [ @@ -28,6 +31,7 @@ import { CompanyNotifierService } from "./company-notifier.service"; ExternalProfile, CompanyProfile, CompanyChangeRequest, + CompanyRevision, Booking, ]), HttpModule, @@ -38,6 +42,8 @@ import { CompanyNotifierService } from "./company-notifier.service"; // imports this module back for portal recipient targeting, hence forwardRef. NotificationsModule, forwardRef(() => NotificationInboxModule), + // Fayda identity verification for the company's owner and PoA. + VerifaydaModule, ], controllers: [CompaniesController], providers: [ @@ -46,6 +52,7 @@ import { CompanyNotifierService } from "./company-notifier.service"; ExternalProfileRepository, CompanyProfileRepository, CompanyChangeRequestRepository, + CompanyRevisionRepository, CompanyDashboardRepository, ETradeService, CompanyNotifierService, diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts new file mode 100644 index 000000000..365bd4639 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts @@ -0,0 +1,253 @@ +import { BadRequestException } from "@nestjs/common"; + +import { CompaniesService } from "./companies.service"; +import { CompanyStatus } from "./entities/company.entity"; +import { ProfileType } from "./entities/company-profile.entity"; +import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants"; + +/** + * EDRFREIGHT-358: a company that names a Power of Attorney must have the DARS + * delegation paper on file. The rule used to live only in the onboarding + * wizard's completion check, so every other write that could break the pairing + * — saving PoA details, deleting the paper, picking up the forwarder role — + * went unguarded. These cover those writes. + */ + +interface Ctx { + attributes: Record; + files: { id: string; code: string; reviewStatus?: string | null }[]; + profileTypes: ProfileType[]; + status: CompanyStatus; + pendingSnapshot: Record | null; +} + +const POA = { poaName: "Abebe", poaEmail: "a@b.com", poaPhone: "+251911000000" }; + +/** + * The forwarder role is gated on Fayda-verified identities as well as on the + * delegation paper. These tests are about the paper, so they run against a + * company whose identities are already verified — the identity rule itself is + * covered in companies.fayda-identity.spec.ts. + */ +const VERIFIED_IDENTITIES = { + ownerFaydaSub: "owner-sub", + poaFaydaSub: "poa-sub", +}; + +function makeService(overrides: Partial = {}) { + const ctx: Ctx = { + attributes: {}, + files: [], + profileTypes: [ProfileType.importer], + status: CompanyStatus.Pending, + pendingSnapshot: null, + ...overrides, + }; + + const company = () => ({ + id: "company-1", + status: ctx.status, + attributes: ctx.attributes, + companyProfiles: ctx.profileTypes.map((type, i) => ({ + id: `profile-${i}`, + type, + })), + type: "customer", + }); + + const deps = { + companiesRepo: { + findById: jest.fn(async () => company()), + update: jest.fn(async (_id: string, patch: Record) => { + ctx.attributes = (patch.attributes ?? + ctx.attributes) as Record; + return company(); + }), + findByTin: jest.fn(async () => null), + }, + companyProfilesRepo: { + findByCompanyId: jest.fn(async () => + ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })), + ), + findByType: jest.fn(async (_id: string, type: ProfileType) => + ctx.profileTypes.includes(type) ? { id: "existing", type } : null, + ), + create: jest.fn(async (row: Record) => ({ + id: "new", + ...row, + })), + }, + changeRequestRepo: { + findPendingByCompanyId: jest.fn(async () => + ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null, + ), + findLatestOpenByCompanyId: jest.fn(async () => + ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null, + ), + findByCompanyId: jest.fn(async () => []), + create: jest.fn(async (row: Record) => ({ + id: "cr-1", + ...row, + })), + update: jest.fn(async () => ({ id: "cr-1" })), + }, + revisionRepo: { + create: jest.fn(async (row: Record) => ({ + id: "rev-1", + ...row, + })), + findByCompanyId: jest.fn(async () => []), + }, + profilesRepo: { + findByCompanyId: jest.fn(async () => []), + findByUserId: jest.fn(async () => ({ + id: "external-1", + companyId: "company-1", + company: company(), + onboardingCompleted: false, + })), + }, + filesService: { + findByResource: jest.fn(async () => ctx.files), + findById: jest.fn(async (id: string) => + ctx.files.find((f) => f.id === id) + ? { + ...ctx.files.find((f) => f.id === id), + resource: "companies", + resourceId: "company-1", + name: "dars.pdf", + } + : null, + ), + remove: jest.fn(async () => undefined), + }, + companyNotifier: { changeRequestSubmitted: jest.fn() }, + }; + + const service = new CompaniesService( + deps.companiesRepo as never, + deps.companyProfilesRepo as never, + deps.changeRequestRepo as never, + deps.revisionRepo as never, + deps.profilesRepo as never, + {} as never, + deps.filesService as never, + {} as never, + {} as never, + deps.companyNotifier as never, + {} as never, + {} as never, + ); + + // getCompanyInfoByUserId does its own lookups; the stubs above are enough for + // the PoA paths, so short-circuit it rather than mock the whole graph. + jest + .spyOn(service, "getCompanyInfoByUserId") + .mockImplementation( + async () => + ({ profile: { id: "external-1" }, company: company() }) as never, + ); + + return { service, ctx, deps }; +} + +const paper = (reviewStatus: string | null = null) => ({ + id: "file-1", + code: POA_DELEGATION_FILE_KEY, + reviewStatus, +}); + +describe("PoA delegation paper is enforced wherever PoA state changes", () => { + it("rejects PoA details saved with no paper on file", async () => { + const { service } = makeService(); + + await expect( + service.updateProfile("user-1", POA as never), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("accepts PoA details once the paper is on file", async () => { + const { service } = makeService({ files: [paper()] }); + + await expect( + service.updateProfile("user-1", POA as never), + ).resolves.toBeDefined(); + }); + + it("rejects a paper the reviewer sent back for correction", async () => { + const { service } = makeService({ files: [paper("change_requested")] }); + + await expect( + service.updateProfile("user-1", POA as never), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("leaves edits that don't touch the PoA alone", async () => { + // A company carrying legacy details must not be locked out of every other + // field until it produces a paper. + const { service } = makeService({ attributes: { ...POA }, files: [] }); + + await expect( + service.updateProfile("user-1", { companyEmail: "x@y.com" } as never), + ).resolves.toBeDefined(); + }); + + it("refuses to remove the paper while the PoA is still named", async () => { + const { service } = makeService({ + attributes: { ...POA }, + files: [paper()], + }); + + await expect( + service.removePoaDelegationLetter("user-1", "file-1"), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("allows removing the paper once the PoA has been cleared", async () => { + const { service } = makeService({ attributes: {}, files: [paper()] }); + + await expect( + service.removePoaDelegationLetter("user-1", "file-1"), + ).resolves.toBeDefined(); + }); + + it("judges the removal against a staged clear, not the live row", async () => { + // An Active company's edits are staged for review rather than written, so + // the live attributes still carry the PoA the customer just cleared. + const { service } = makeService({ + status: CompanyStatus.Active, + attributes: { ...POA }, + pendingSnapshot: { poaName: "", poaEmail: "", poaPhone: "" }, + files: [paper()], + }); + + await expect( + service.removePoaDelegationLetter("user-1", "file-1"), + ).resolves.toBeDefined(); + }); + + it("refuses the forwarder role to a company with no PoA", async () => { + const { service } = makeService({ attributes: { ...VERIFIED_IDENTITIES } }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("grants the forwarder role once PoA details and paper are both in place", async () => { + const { service } = makeService({ + attributes: { ...POA, ...VERIFIED_IDENTITIES }, + files: [paper()], + }); + + await expect( + service.createCompanyProfileForUser( + "user-1", + ProfileType.freightForwarder, + ), + ).resolves.toBeDefined(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index db8db0d2e..092ebc2c4 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -75,8 +75,14 @@ export class CompaniesRepository extends BaseRepository { .getMany(); } - async existsByTin(tin: string): Promise { - const count = await this.repository.count({ where: { tin } as any }); + async existsByTin(tin: string, excludeCompanyId?: string): Promise { + const qb = this.repository + .createQueryBuilder('company') + .where('company.tin = :tin', { tin }); + if (excludeCompanyId) { + qb.andWhere('company.id != :excludeCompanyId', { excludeCompanyId }); + } + const count = await qb.getCount(); return count > 0; } diff --git a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts new file mode 100644 index 000000000..5961be90f --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts @@ -0,0 +1,114 @@ +import { CompaniesService } from "./companies.service"; +import { CompanyType } from "./entities/company.entity"; +import { ProfileStatus, ProfileType } from "./entities/company-profile.entity"; + +/** + * EDRFREIGHT-416: onboarding asked for a deselected role's documents. + * + * Re-running role selection used to only ADD operational profiles, so a role + * the user unticked on the way back left its company_profile row behind — and + * every role-driven requirement (business license, forwarder PoA) is derived + * from those rows. startOnboarding now reconciles both directions. + */ + +interface ExistingProfile { + id: string; + type: ProfileType; + status: ProfileStatus; +} + +function makeService(existing: ExistingProfile[]) { + const companyProfilesRepo = { + findByCompanyId: jest.fn(async () => existing), + create: jest.fn(async (row: Record) => ({ + id: "new", + ...row, + })), + softDelete: jest.fn(async () => undefined), + }; + const companiesRepo = { update: jest.fn(async () => null) }; + const profilesRepo = { + findByUserId: jest.fn(async () => ({ + id: "external-1", + companyId: "company-1", + company: { id: "company-1" }, + })), + }; + + const service = new CompaniesService( + companiesRepo as never, + companyProfilesRepo as never, + {} as never, + {} as never, + profilesRepo as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + + jest + .spyOn(service, "getCompanyInfoByUserId") + .mockImplementation( + async () => + ({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never, + ); + + return { service, companyProfilesRepo }; +} + +const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" }; + +const start = (service: CompaniesService, roles: ProfileType[]) => + service.startOnboarding(identity as never, CompanyType.Customer, roles); + +describe("re-running role selection reconciles the operational profiles", () => { + it("drops the profile for a role the user deselected", async () => { + const { service, companyProfilesRepo } = makeService([ + { id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending }, + { + id: "p-ff", + type: ProfileType.freightForwarder, + status: ProfileStatus.Pending, + }, + ]); + + await start(service, [ProfileType.importer]); + + expect(companyProfilesRepo.softDelete).toHaveBeenCalledWith("p-ff"); + expect(companyProfilesRepo.softDelete).toHaveBeenCalledTimes(1); + expect(companyProfilesRepo.create).not.toHaveBeenCalled(); + }); + + it("keeps an already-approved profile even when it is unticked", async () => { + const { service, companyProfilesRepo } = makeService([ + { id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending }, + { + id: "p-exp", + type: ProfileType.exporter, + status: ProfileStatus.Active, + }, + ]); + + await start(service, [ProfileType.importer]); + + expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled(); + }); + + it("still adds a newly-picked role", async () => { + const { service, companyProfilesRepo } = makeService([ + { id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending }, + ]); + + await start(service, [ProfileType.importer, ProfileType.exporter]); + + expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled(); + expect(companyProfilesRepo.create).toHaveBeenCalledTimes(1); + expect(companyProfilesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ type: ProfileType.exporter }), + ); + }); +}); 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..d9798af5d 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -1,14 +1,20 @@ import { Injectable, + Logger, NotFoundException, ConflictException, 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"; +import { CompanyRevisionRepository } from "./company-revision.repository"; +import { + diffCompanyUpdate, + summarizeCompanyChanges, +} from "./company-revision-diff.util"; import { ExternalProfileRepository } from "./external-profile.repository"; import { CompanyDashboardRepository, @@ -17,10 +23,23 @@ import { import { FilesService } from "../files/files.service"; import { FileRecord } from "../files/entities/file.entity"; import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; +import { + POA_DELEGATION_FILE_KEY, + POA_DELEGATION_LABEL, + POA_DELEGATION_PENDING_CODE, +} from "../file-upload-settings/poa-delegation.constants"; +import { VerifaydaService } from "../verifayda/verifayda.service"; +import { + buildCompanyIdentityState, + CompanyIdentityStateDto, + CompleteIdentityVerificationDto, + IdentitySubject, +} from "./dto/complete-identity-verification.dto"; import { ETradeService } from "./services/etrade.service"; import { CompanyNotifierService } from "./company-notifier.service"; import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; +import type { CompanyRegistrationData } from "@edr/types"; import { CreateCompanyDto } from "./dto/create-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto"; import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; @@ -51,6 +70,10 @@ import { DocumentChangeIntent, LicenseChangeIntent, } from "./entities/company-change-request.entity"; +import { + CompanyRevision, + CompanyRevisionChange, +} from "./entities/company-revision.entity"; /** FileRecord `resource` + `code` slots for business-license documents. */ const LICENSE_RESOURCE = "company_profiles"; @@ -58,10 +81,6 @@ const LICENSE_CODE = "business_license"; /** Code for a license file staged in an open change request (not yet live). */ const LICENSE_PENDING_CODE = "business_license_pending"; -/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */ -const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; -/** Code for a PoA letter staged in an open change request (not yet live). */ -const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending"; /** FileRecord resource that company-level documents are stored under. */ const COMPANY_RESOURCE = "companies"; /** company.attributes keys that together mean "a PoA was entered". */ @@ -72,6 +91,28 @@ const POA_ATTRIBUTES = [ "poaLocation", "poaAddress", ] as const; +/** + * Personnel an approved company maintains itself: its contact person, its + * general manager and its Power of Attorney. These name who to talk to, not + * what the company is allowed to do, so freezing the settings page until a + * reviewer gets to a new phone number costs more than it protects. They write + * straight to the live row even for an active company. + * + * The PoA's *delegation letter* is deliberately not here — the paper is the + * thing that actually evidences the delegation, so it still goes through + * review (see `uploadPoaDelegationLetter`), as does the owner's own identity. + */ +const SELF_SERVICE_ATTRIBUTES: readonly string[] = [ + "contactPersonName", + "contactPersonPosition", + "contactPersonEmail", + "contactPersonPhone", + "contactVerifiedPhone", + "generalManagerName", + "generalManagerEmail", + "generalManagerPhone", + ...POA_ATTRIBUTES, +]; /** Mandatory once the company operates as a freight forwarder. */ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ { key: "poaName", label: "PoA name" }, @@ -79,6 +120,62 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ { key: "poaPhone", label: "PoA phone" }, ]; +/** + * `attributes` key prefix per verifiable person. The owner is NOT the general + * manager — GM is a plain typed role (the portal offers a "same as owner" copy + * once the owner is verified), while the owner is who this verification + * actually proves. They're very often the same human; that's what the copy is + * for. + */ +const IDENTITY_PREFIX: Record = { + owner: "owner", + poa: "poa", +}; + +const IDENTITY_LABEL: Record = { + owner: "owner", + poa: "Power of Attorney", +}; + +/** + * Identity fields a Fayda verification owns outright, per person. Once verified + * these can no longer be typed — the government IdP is the source, so an edit + * that disagrees with it is either a mistake or an attempt to launder the + * guarantee away. The GM fields are deliberately absent: GM is never itself + * Fayda-verified, so it stays freely editable regardless of the owner's state. + */ +const IDENTITY_OWNED_FIELDS: Record = { + owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"], + poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"], +}; + +/** + * `UpdateProfileDto` fields eTrade is the sole source of truth for. A request + * touching any of these must be re-checked against a fresh eTrade lookup — + * see `assertEtradeFieldsAuthentic`. + */ +const ETRADE_SOURCED_FIELDS = [ + "companyName", + "tin", + "licenceNumber", + "statusDescription", + "dateRegistered", + "renewedFrom", + "renewalDate", + "renewedTo", + "region", + "zone", + "woreda", + "kebele", + "houseNo", + "etradePhone", +] as const satisfies readonly (keyof UpdateProfileDto)[]; + +/** The attributes a verification writes, for one person. */ +interface VerifiedIdentityAttributes { + [key: string]: unknown; +} + export interface UserIdentity { userId: string; firstName: string; @@ -89,10 +186,13 @@ export interface UserIdentity { @Injectable() export class CompaniesService { + private readonly logger = new Logger(CompaniesService.name); + constructor( private readonly companiesRepo: CompaniesRepository, private readonly companyProfilesRepo: CompanyProfileRepository, private readonly changeRequestRepo: CompanyChangeRequestRepository, + private readonly revisionRepo: CompanyRevisionRepository, private readonly profilesRepo: ExternalProfileRepository, private readonly dashboardRepo: CompanyDashboardRepository, private readonly filesService: FilesService, @@ -100,6 +200,7 @@ export class CompaniesService { private readonly etradeService: ETradeService, private readonly companyNotifier: CompanyNotifierService, private readonly dataSource: DataSource, + private readonly verifaydaService: VerifaydaService, ) { } /** @@ -255,8 +356,9 @@ export class CompaniesService { * chosen operational role(s) up front, so every subsequent wizard step can * save incrementally (PATCH /profile, /onboarding-step) against existing rows. * - * Idempotent: if the user already has a profile, returns it unchanged (only - * adding any newly-chosen roles). The draft company carries a placeholder TIN + * Idempotent: if the user already has a profile, returns it unchanged, with + * the operational profiles reconciled against the roles just chosen (added + * and — for still-pending ones — removed). The draft company carries a placeholder TIN * (the real one is filled on the Company Information step) and stays * status=pending / onboardingCompleted=false until the wizard finishes. */ @@ -271,7 +373,7 @@ export class CompaniesService { const existing = await this.profilesRepo.findByUserId(identity.userId); if (existing) { const companyId = existing.company?.id ?? existing.companyId; - await this.ensureCompanyProfiles(companyId, companyType, roles); + await this.syncCompanyProfiles(companyId, companyType, roles); if (nationality) { await this.companiesRepo.update(companyId, { nationality }); } @@ -302,25 +404,44 @@ export class CompaniesService { onboardingCompleted: false, }); - await this.ensureCompanyProfiles(company.id, companyType, chosenTypes); + await this.syncCompanyProfiles(company.id, companyType, chosenTypes); return this.getCompanyInfoByUserId(identity.userId); } - /** Create any of the requested operational profiles that don't exist yet. */ - private async ensureCompanyProfiles( + /** + * Reconcile the company's operational profiles with the roles the user has + * selected: create the missing ones, drop the ones they deselected. + * + * Dropping matters because every role-driven onboarding requirement — the + * per-profile business license, the freight-forwarder PoA rule, the license + * cards in the wizard — is derived from these rows. A row left behind after + * the user went back and unticked a role keeps asking for that role's + * documents (EDRFREIGHT-416). Only still-pending profiles are removed: an + * approved one is live (it can carry bookings and contracts) and re-running + * role selection must never delete it. + */ + private async syncCompanyProfiles( companyId: string, companyType: CompanyType, roles: ProfileType[], ): Promise { const allowedTypes = this.getProfileTypeForCompanyType(companyType); - for (const type of roles) { - if (!allowedTypes.includes(type)) continue; - const existing = await this.companyProfilesRepo.findByType( - companyId, - type, - ); - if (existing) continue; + const chosen = roles.filter((t) => allowedTypes.includes(t)); + const existing = await this.companyProfilesRepo.findByCompanyId(companyId); + + for (const profile of existing) { + if (chosen.includes(profile.type)) continue; + if (profile.status !== ProfileStatus.Pending) continue; + // The license files uploaded against this profile go with it: they are + // only ever read per company_profile id, so a soft-deleted profile + // leaves nothing behind to prompt for. Re-picking the role creates a + // fresh profile the user uploads against again. + await this.companyProfilesRepo.softDelete(profile.id); + } + + for (const type of chosen) { + if (existing.some((p) => p.type === type)) continue; // No reference yet — minted on backoffice approval (setCompanyProfileStatus). await this.companyProfilesRepo.create({ companyId, @@ -574,7 +695,16 @@ export class CompaniesService { async updateCompany(id: string, dto: UpdateCompanyDto): Promise { const before = await this.findCompanyById(id); - const updated = await this.companiesRepo.update(id, dto); + const patch: UpdateCompanyDto & { approvedAt?: Date } = { ...dto }; + // Staff can also promote Pending -> Active directly through this generic + // endpoint (not just via the first-profile-approval path), so stamp it here too. + if ( + dto.status === CompanyStatus.Active && + before.status !== CompanyStatus.Active + ) { + patch.approvedAt = new Date(); + } + const updated = await this.companiesRepo.update(id, patch); if (!updated) throw new NotFoundException(`Company ${id} not found`); // Suspending or blacklisting locks the customer out, so they must be told. @@ -599,7 +729,9 @@ export class CompaniesService { */ private mapProfileDtoToCompanyUpdates( company: Company, - dto: Partial, + dto: Partial & { + faydaIdentity?: VerifiedIdentityAttributes; + }, ): Record { const companyUpdates: Record = {}; const attrUpdates: Record = { ...(company.attributes ?? {}) }; @@ -617,7 +749,6 @@ export class CompaniesService { if (dto.tin !== undefined && dto.tin !== company.tin) companyUpdates.tin = dto.tin; if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; - if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber; if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName; @@ -661,10 +792,104 @@ export class CompaniesService { if (dto.etradePhone !== undefined) companyUpdates.etradePhone = normalizeE164(dto.etradePhone); + // A plain typed field — never Fayda-verified, so no lock ever applies to + // it. Independent of the owner's verification: still required for a + // foreign company even if the owner also verifies with Fayda. + if (dto.ownerPassportNumber !== undefined) + attrUpdates.ownerPassportNumber = dto.ownerPassportNumber; + + // A verified identity overwrites the person's details. `faydaIdentity` + // never comes off the wire — the global validation pipe runs with + // forbidNonWhitelisted, so a client that sends it is rejected outright; it + // only reaches here from completeIdentityVerification, directly or through + // a staged snapshot. + if (dto.faydaIdentity) { + Object.assign(attrUpdates, dto.faydaIdentity); + } + + // companyEmail/companyPhone are the Company-column mirrors of the owner's + // verified contact details (the portal derives and submits them, it never + // lets the customer type them once verified) — lock them the same way + // ownerEmail/ownerPhone themselves are locked below, once there is a + // verified owner to lock them to. + if (attrUpdates.ownerFaydaSub) { + if ( + dto.companyEmail !== undefined && + dto.companyEmail !== attrUpdates.ownerEmail + ) { + throw new BadRequestException( + "companyEmail is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.", + ); + } + if ( + dto.companyPhone !== undefined && + normalizeE164(dto.companyPhone) !== + normalizeE164(String(attrUpdates.ownerPhone ?? "")) + ) { + throw new BadRequestException( + "companyPhone is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.", + ); + } + } + + // Renaming a Fayda-verified person by hand would launder the guarantee + // away, so the fields the verification owns are refused once it exists. + for (const subject of ["owner", "poa"] as IdentitySubject[]) { + if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue; + for (const field of IDENTITY_OWNED_FIELDS[subject]) { + const incoming = (dto as Record)[field]; + if (incoming === undefined) continue; + // The verification itself is allowed to write them; anything else is + // compared against what is already stored, not against the value this + // same call just copied into the patch. Phones are compared normalized: + // a form that re-renders +251911000000 as 0911000000 is echoing the + // stored value back, not trying to change it. + if (dto.faydaIdentity && field in dto.faydaIdentity) continue; + const stored = company.attributes?.[field]; + const same = field.endsWith("Phone") + ? normalizeE164(String(incoming)) === + normalizeE164(String(stored ?? "")) + : incoming === stored; + if (!same) { + throw new BadRequestException( + `${field} is set by the Fayda verification of this company's ${IDENTITY_LABEL[subject]} and cannot be edited. Re-verify to change it.`, + ); + } + } + } + companyUpdates.attributes = attrUpdates; return companyUpdates; } + /** + * Append a version-history entry for an onboarding-phase edit (the company + * is not yet Active, so the change went straight to the live row with no + * approval gate to carry a record of it). Best-effort: a no-op patch or a + * failure to write history must never break the edit that triggered it. + */ + private async recordCompanyRevision( + before: Company, + patch: Record, + actorId?: string | null, + extraChanges: CompanyRevisionChange[] = [], + ): Promise { + try { + const changes = [...diffCompanyUpdate(before, patch), ...extraChanges]; + if (changes.length === 0) return; + await this.revisionRepo.create({ + companyId: before.id, + actorId: actorId ?? null, + summary: summarizeCompanyChanges(changes), + changes, + }); + } catch (err) { + this.logger.error( + `Failed to record company revision for ${before.id}: ${String(err)}`, + ); + } + } + /** Reject a TIN already registered to a *different* company. */ private async assertTinAvailable( company: Company, @@ -691,10 +916,11 @@ export class CompaniesService { * * - Company not yet approved (onboarding) → write straight to the Company row, * as before. The company/role pending→approve gate already covers first-run. - * - Company already `active` → do NOT touch the live Company. Stage the edit in - * a pending change request (merging into any open one) so a backoffice - * reviewer can approve (apply) or reject (with a note). This locks the - * customer until the review resolves. + * - Company already `active` → personnel details (`SELF_SERVICE_ATTRIBUTES`) + * still write straight through; everything else does NOT touch the live + * Company but is staged in a pending change request (merging into any open + * one) so a backoffice reviewer can approve (apply) or reject (with a + * note). Only the staged half locks the customer until the review resolves. */ async updateProfile( userId: string, @@ -702,6 +928,21 @@ export class CompaniesService { ): Promise { const { profile, company } = await this.getCompanyInfoByUserId(userId); + await this.assertEtradeFieldsAuthentic(company, dto); + + // Naming (or renaming) a Power of Attorney is one of the writes that can + // leave the company with a representative and nothing evidencing them, so + // it is gated here. Edits that don't touch the PoA are left alone — a + // company carrying legacy details must not be locked out of every other + // field until it produces a paper. + if (POA_ATTRIBUTES.some((k) => dto[k] !== undefined)) { + const attributes = this.mapProfileDtoToCompanyUpdates(company, dto) + .attributes as Record; + await this.assertPoaDelegationSatisfied(company.id, attributes, { + requirePoa: await this.isFreightForwarder(company.id), + }); + } + if (company.status !== CompanyStatus.Active) { await this.assertTinAvailable(company, dto.tin); const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto); @@ -711,12 +952,41 @@ export class CompaniesService { ); if (!updated) throw new NotFoundException(`Company ${company.id} not found`); + await this.recordCompanyRevision(company, companyUpdates, userId); return new ProfileResponseDto(profile, updated); } - // Approved company: stage the change for review, leaving the live row intact. + // Approved company: personnel details apply immediately, the rest is staged + // for review with the live row left intact. await this.assertTinAvailable(company, dto.tin); const fields = this.pickDefined(dto); + const selfService: Record = {}; + const staged: Record = {}; + for (const [key, value] of Object.entries(fields)) { + if (SELF_SERVICE_ATTRIBUTES.includes(key)) selfService[key] = value; + else staged[key] = value; + } + + let live = company; + if (Object.keys(selfService).length > 0) { + live = + (await this.companiesRepo.update( + company.id, + this.mapProfileDtoToCompanyUpdates(company, selfService), + )) ?? company; + live.companyProfiles = company.companyProfiles; + } + + if (Object.keys(staged).length === 0) { + // Nothing a reviewer needs to see. Any request already open (a document + // upload, an owner verification) still surfaces so its banner survives — + // it just no longer gains fields it was never asked to review. + return new ProfileResponseDto( + profile, + live, + await this.changeRequestRepo.findLatestOpenByCompanyId(company.id), + ); + } const existing = await this.changeRequestRepo.findPendingByCompanyId( company.id, @@ -726,10 +996,14 @@ export class CompaniesService { if (existing) { request = (await this.changeRequestRepo.update(existing.id, { - snapshot: { ...(existing.snapshot ?? {}), ...fields }, + // Note is left untouched: if this request was ChangesRequested, the + // reviewer's ask stays visible on the resubmitted (Pending) row — + // clearing it here would hide what was asked for right when the + // reviewer comes back to check whether it was actually addressed. + snapshot: { ...(existing.snapshot ?? {}), ...staged }, submittedBy: userId, submittedAt: now, - note: null, + status: ChangeRequestStatus.Pending, })) ?? existing; this.companyNotifier.changeRequestSubmitted(company, request.id, false); } else { @@ -742,7 +1016,7 @@ export class CompaniesService { ); request = await this.changeRequestRepo.create({ companyId: company.id, - snapshot: fields, + snapshot: staged, status: ChangeRequestStatus.Pending, submittedBy: userId, submittedAt: now, @@ -754,8 +1028,9 @@ export class CompaniesService { ); } - // Live company is unchanged; surface the pending state for the settings page. - return new ProfileResponseDto(profile, company, request); + // Only the personnel half (if any) landed; surface the pending state for + // the settings page. + return new ProfileResponseDto(profile, live, request); } /** List a company's change requests, newest first (backoffice review). */ @@ -766,6 +1041,53 @@ export class CompaniesService { return this.changeRequestRepo.findByCompanyId(companyId); } + /** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */ + async listCompanyRevisions(companyId: string): Promise { + await this.findCompanyById(companyId); + return this.revisionRepo.findByCompanyId(companyId); + } + + /** + * Pair adjacent remove-then-add intents into one before/after revision + * change — that's exactly how a "replace" is staged (see + * `replaceProfileLicenseFile`: `[{op:'remove',...}, {op:'add',...}]` + * pushed together, and later merges only ever append after that pair, so + * adjacency is preserved). A remove or add with no adjacent partner (a pure + * add, or a pure removal) stands alone. + */ + private pairReplaceIntents< + T extends { op: "add" | "remove"; fileId: string; fileName?: string }, + >(intents: T[], labelFor: (intent: T) => string): CompanyRevisionChange[] { + const changes: CompanyRevisionChange[] = []; + let i = 0; + while (i < intents.length) { + const current = intents[i]; + const next = intents[i + 1]; + if (current.op === "remove" && next?.op === "add") { + changes.push({ + field: `document:${current.fileId}`, + label: labelFor(next), + from: current.fileName ?? null, + to: next.fileName ?? null, + fromFileId: current.fileId, + toFileId: next.fileId, + }); + i += 2; + continue; + } + changes.push({ + field: `document:${current.fileId}`, + label: labelFor(current), + from: current.op === "remove" ? (current.fileName ?? null) : null, + to: current.op === "add" ? (current.fileName ?? null) : null, + fromFileId: current.op === "remove" ? current.fileId : null, + toFileId: current.op === "add" ? current.fileId : null, + }); + i += 1; + } + return changes; + } + /** * Approve a pending change request: apply its snapshot to the live Company and * mark the request approved. Any staged documents are already attached to the @@ -795,6 +1117,30 @@ export class CompaniesService { await this.applyLicenseChanges(request); await this.applyDocumentChanges(request); + // This is the ONLY place post-approval FIELD/license/PoA-document changes + // land on the live row — without this call, everything the #419 + // change-request flow does to those is invisible in Version History. + // `documentFileIds` (the general bulk company-documents upload) is + // deliberately NOT re-recorded here — those documents go live immediately + // at upload time and are already recorded there (see + // `uploadCompanyDocuments`); redoing it here would double the entry. + const documentChanges: CompanyRevisionChange[] = [ + ...this.pairReplaceIntents( + request.documents?.licenseChanges ?? [], + () => "Business license", + ), + ...this.pairReplaceIntents( + request.documents?.documentChanges ?? [], + (intent) => intent.code, + ), + ]; + await this.recordCompanyRevision( + company, + companyUpdates, + reviewerId, + documentChanges, + ); + return ( (await this.changeRequestRepo.update(id, { status: ChangeRequestStatus.Approved, @@ -805,12 +1151,62 @@ export class CompaniesService { ); } + /** + * A fresh upload under a single-file document slot (`isMultiple: false`) + * replaces whatever was there, not adds to it — soft-delete the prior live + * file(s) for that code, and describe each replacement (plus each genuinely + * new upload) as a revision change carrying both file ids, so the reviewer + * can open the previous and current file. Multi-file slots are left alone + * (genuinely additive, no single "the" document to diff against). Unrecognised + * codes (no matching field in the nationality's document setting) are also + * left alone — safer to under-clean than to guess wrong. Independent of the + * change-request review outcome: nothing else in this flow ever retires a + * superseded document, on approve OR reject — these documents go live the + * moment they're uploaded. + */ + private async replaceSingleFileCompanyDocuments( + company: Company, + before: FileRecord[], + uploaded: FileRecord[], + ): Promise { + const setting = await this.fileUploadSettingsService + .getByCode(this.documentSettingCodeFor(company.nationality)) + .catch(() => null); + const fields = setting?.fields ?? []; + const singleFileCodes = new Set( + fields.filter((f) => !f.isMultiple).map((f) => f.fileKey), + ); + const labelByCode = new Map(fields.map((f) => [f.fileKey, f.fileLabel])); + const uploadedIds = new Set(uploaded.map((f) => f.id)); + + const changes: CompanyRevisionChange[] = []; + const toRemove: FileRecord[] = []; + for (const file of uploaded) { + if (!singleFileCodes.has(file.code)) continue; + const prior = before.find( + (f) => f.code === file.code && !uploadedIds.has(f.id), + ); + changes.push({ + field: `document:${file.code}`, + label: labelByCode.get(file.code) ?? file.code, + from: prior?.name ?? null, + to: file.name, + fromFileId: prior?.id ?? null, + toFileId: file.id, + }); + if (prior) toRemove.push(prior); + } + await Promise.all(toRemove.map((f) => this.filesService.remove(f.id))); + return changes; + } + /** * Upload company documents. For an approved company this also opens/updates a * pending change request (recording the uploaded file ids) so the upload is * reviewed and the customer is locked until it clears — consistent with the * field-edit review. During onboarding (company not yet active) it's a plain - * upload with no review. + * upload with no review. Either way the documents go live immediately, so + * the revision history is recorded right away too, not gated on a decision. */ async uploadCompanyDocuments( companyId: string, @@ -818,11 +1214,20 @@ export class CompaniesService { submittedBy?: string, ): Promise { const company = await this.findCompanyById(companyId); + const before = await this.filesService.findByResource( + companyId, + "companies", + ); const uploaded = await this.filesService.uploadMany( companyId, "companies", files, ); + const documentChanges = await this.replaceSingleFileCompanyDocuments( + company, + before, + uploaded, + ); await this.resolveDocumentChangeRequests( companyId, "companies", @@ -836,6 +1241,9 @@ export class CompaniesService { submittedBy, ); } + if (documentChanges.length > 0) { + await this.recordCompanyRevision(company, {}, submittedBy, documentChanges); + } return uploaded; } @@ -950,7 +1358,8 @@ export class CompaniesService { }, submittedBy: submittedBy ?? existing.submittedBy ?? null, submittedAt: now, - note: null, + // Note left untouched — see the comment in updateProfile's merge branch. + status: ChangeRequestStatus.Pending, }); if (company) { this.companyNotifier.changeRequestSubmitted(company, existing.id, false); @@ -1011,6 +1420,37 @@ export class CompaniesService { ); } + /** + * Ask for specific fixes without rejecting outright: unlike + * {@link rejectChangeRequest}, staged license/document intents are kept (the + * row stays open), so the customer's next edit is appended to this SAME + * request — via the merge branches in `updateProfile`/`stageDocumentChange`/ + * `stageLicenseChange`/`stageDocumentIntent`/`stageIdentityChange` — instead + * of starting a fresh cycle. + */ + async requestChangeRequestChanges( + id: string, + note: string, + reviewerId?: string, + ): Promise { + const request = await this.changeRequestRepo.findById(id); + if (!request) + throw new NotFoundException(`Change request ${id} not found`); + if (request.status !== ChangeRequestStatus.Pending) { + throw new BadRequestException( + `Change request ${id} is already ${request.status}`, + ); + } + return ( + (await this.changeRequestRepo.update(id, { + status: ChangeRequestStatus.ChangesRequested, + note, + reviewedBy: reviewerId ?? null, + reviewedAt: new Date(), + })) ?? request + ); + } + async deleteCompany(id: string): Promise { await this.findCompanyById(id); await this.companiesRepo.softDelete(id); @@ -1111,9 +1551,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 @@ -1125,11 +1567,24 @@ export class CompaniesService { // blacklist skip all this — staff must always be able to act against a bad // account. return this.dataSource.transaction(async (manager) => { - await manager.findOne(Company, { + const company = await manager.findOne(Company, { where: { id: existing.companyId }, lock: { mode: "pessimistic_write" }, }); + // Putting a forwarder into service without a Power of Attorney backed by + // a DARS paper is the thing EDRFREIGHT-358 forbids, so the approval is + // the last place it has to be checked — the role may have been applied + // for before the paper was withdrawn. + if (company && existing.type === ProfileType.freightForwarder) { + this.assertIdentityVerified(company, { requirePoa: true }); + await this.assertPoaDelegationSatisfied( + company.id, + company.attributes, + { requirePoa: true }, + ); + } + const [companyDocs, profileDocs] = await Promise.all([ this.filesService.findWithOpenChangeRequest( [existing.companyId], @@ -1149,7 +1604,7 @@ export class CompaniesService { ); } - return this.applyProfileStatus(existing, status, note, reviewerId); + return this.applyProfileStatus(manager, existing, status, note, reviewerId); }); } @@ -1160,11 +1615,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 +1655,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 +1674,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,8 +1690,9 @@ export class CompaniesService { status === ProfileStatus.Active && company.status === CompanyStatus.Pending ) { - await this.companiesRepo.update(updated.companyId, { + await companyRepo.update(updated.companyId, { status: CompanyStatus.Active, + approvedAt: new Date(), }); this.companyNotifier.companyApproved(company); } @@ -1367,6 +1836,18 @@ export class CompaniesService { ); if (existing) continue; + // A forwarder signs on other companies' behalf, so it cannot be taken on + // without a Power of Attorney and its DARS paper — checked here so the + // customer is told at the point of asking, not at review. + if (type === ProfileType.freightForwarder) { + this.assertIdentityVerified(company, { requirePoa: true }); + await this.assertPoaDelegationSatisfied( + companyId, + await this.effectivePoaAttributes(company), + { requirePoa: true }, + ); + } + // Self-service role adds start Pending and carry no reference — a reference // is minted only when a backoffice reviewer approves the role. await this.companyProfilesRepo.create({ @@ -1404,6 +1885,14 @@ export class CompaniesService { } let created = await this.companyProfilesRepo.findByType(companyId, type); + if (!created && type === ProfileType.freightForwarder) { + this.assertIdentityVerified(company, { requirePoa: true }); + await this.assertPoaDelegationSatisfied( + companyId, + await this.effectivePoaAttributes(company), + { requirePoa: true }, + ); + } if (!created) { // New self-service roles start Pending (awaiting backoffice approval) and // carry no reference until approved. @@ -1438,11 +1927,17 @@ export class CompaniesService { userId: string, ): Promise { const { profile, company } = await this.getCompanyInfoByUserId(userId); + const identity = this.getCompanyIdentityState(company); - // 1. Required company-information fields. - const missingInfo = this.REQUIRED_COMPANY_INFO.filter( - (f) => !f.get(company), - ).map((f) => ({ key: f.key, label: f.label })); + // 1. Required company-information fields. The FAN is never one of them — + // Fayda verification doesn't produce a FAN, so it's never collected as + // part of onboarding at all (see the identity block below). + const requiredInfo = this.REQUIRED_COMPANY_INFO.filter( + (f) => f.key !== "fanNumber", + ); + const missingInfo = requiredInfo + .filter((f) => !f.get(company)) + .map((f) => ({ key: f.key, label: f.label })); // 2. Nationality-based company documents + which are already uploaded. const documentSettingCode = this.documentSettingCodeFor(company.nationality); @@ -1489,26 +1984,26 @@ export class CompaniesService { // 4. Power of Attorney. Optional in general, but a freight forwarder acts on // other companies' behalf so its PoA is mandatory. Either way, a PoA that - // has been entered must be evidenced by the delegation letter. + // has been entered must be evidenced by the DARS delegation paper — a legal + // requirement, so unlike the documents above it does not depend on the + // upload set carrying a field for it (see poa-delegation.constants.ts). const poaRequired = (company.companyProfiles ?? []).some( (p) => p.type === ProfileType.freightForwarder, ); const poaProvided = POA_ATTRIBUTES.some((k) => (company.attributes?.[k] as string | undefined)?.trim(), ); - const missingPoaFields = poaRequired - ? REQUIRED_POA_FIELDS.filter( - (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), - ) - : []; - // Only gate on the letter once the document set actually carries the field. - const delegationField = (setting?.fields ?? []).find( - (f) => f.fileKey === POA_DELEGATION_FILE_KEY, - ); - const missingDelegation = - Boolean(delegationField) && - (poaRequired || poaProvided) && - !uploadedCodes.has(POA_DELEGATION_FILE_KEY); + // No company types its PoA details — they arrive from the Fayda + // verification whatever the nationality — so reporting them as missing + // fields would ask for something no form offers. The identity block below + // reports "verify your PoA" instead. + const missingPoaFields: typeof REQUIRED_POA_FIELDS = []; + const delegation = await this.getPoaDelegationState(company.id); + const delegationDue = poaRequired || poaProvided; + const missingDelegation = delegationDue && !delegation.onFile; + // A paper the reviewer sent back is not evidence — the customer has to + // replace it before the application counts as complete. + const flaggedDelegation = delegationDue && delegation.flagged; const outstanding = [ ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), @@ -1519,29 +2014,54 @@ export class CompaniesService { ), ...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`), ...(missingDelegation - ? ["Upload the delegation letter for your Power of Attorney"] + ? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`] + : []), + ...(flaggedDelegation + ? [`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`] + : []), + ...(identity.faydaRequired && !identity.owner.verified + ? ["Verify the company owner's identity with Fayda"] + : []), + ...((poaRequired || poaProvided) && !identity.poa.verified + ? ["Verify your Power of Attorney's identity with Fayda"] + : []), + ...(identity.passportRequired && !identity.owner.passportNumber + ? ["Add the company owner's passport number"] : []), ]; // Progress spans every required item the user has to satisfy: company-info // fields, required documents, one license per operational profile, and the - // PoA details/letter whenever those are mandatory. + // PoA details/paper whenever those are mandatory. const requiredDocCount = documents.filter((d) => d.isRequired).length; - const poaItemCount = - (poaRequired ? REQUIRED_POA_FIELDS.length : 0) + - (delegationField && (poaRequired || poaProvided) ? 1 : 0); + const poaItemCount = delegationDue ? 1 : 0; + // One item per identity credential the company has to prove: the owner + // always (Fayda for Ethiopian, passport for foreign), plus the PoA once + // there is one — that one is Fayda whatever the nationality. + const ownerCredentialDue = + identity.faydaRequired || identity.passportRequired; + const ownerCredentialProven = identity.faydaRequired + ? identity.owner.verified + : Boolean(identity.owner.passportNumber); + const identityItemCount = + (ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0); + const missingIdentityCount = + (ownerCredentialDue && !ownerCredentialProven ? 1 : 0) + + (delegationDue && !identity.poa.verified ? 1 : 0); const total = - this.REQUIRED_COMPANY_INFO.length + + requiredInfo.length + requiredDocCount + licenseProfiles.length + - poaItemCount; + poaItemCount + + identityItemCount; const completed = total - (missingInfo.length + missingDocs.length + missingLicenses.length + missingPoaFields.length + - (missingDelegation ? 1 : 0)); + (missingDelegation || flaggedDelegation ? 1 : 0) + + missingIdentityCount); return new OnboardingRequirementsResponseDto({ documentSettingCode, @@ -1552,10 +2072,15 @@ export class CompaniesService { poa: { required: poaRequired, provided: poaProvided, - delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY), + delegationLetterUploaded: delegation.onFile, + delegationLetterFlagged: delegation.flagged, missingFields: missingPoaFields, - complete: missingPoaFields.length === 0 && !missingDelegation, + complete: + missingPoaFields.length === 0 && + !missingDelegation && + !flaggedDelegation, }, + identity, progress: { completed, total }, isComplete: outstanding.length === 0, onboardingCompleted: profile.onboardingCompleted, @@ -1960,7 +2485,8 @@ export class CompaniesService { }, submittedBy: submittedBy ?? existing.submittedBy ?? null, submittedAt: now, - note: null, + // Note left untouched — see the comment in updateProfile's merge branch. + status: ChangeRequestStatus.Pending, }); } else { await this.changeRequestRepo.create({ @@ -2029,15 +2555,353 @@ export class CompaniesService { } // --------------------------------------------------------------------------- - // Power of Attorney delegation letter + // Power of Attorney delegation paper (DARS) // // A company-level document that follows the same staged-review model as the // business license: on an approved (Active) company an upload lands under the - // pending code and the live letter is flagged for removal, so the reviewer + // pending code and the live paper is flagged for removal, so the reviewer // sees both and approval swaps them atomically. During onboarding it goes live. // --------------------------------------------------------------------------- - /** The company's PoA letter(s), with each file's review status resolved. */ + /** + * What the company has on file towards its DARS delegation paper. A paper + * staged for review counts as "on file" — it is the customer's whole + * obligation discharged; whether it is good enough is the reviewer's call, + * recorded as `flagged`. + */ + private async getPoaDelegationState( + companyId: string, + ignoreFileIds: string[] = [], + ): Promise<{ onFile: boolean; flagged: boolean }> { + const records = ( + await this.filesService.findByResource(companyId, COMPANY_RESOURCE) + ).filter( + (r) => + (r.code === POA_DELEGATION_FILE_KEY || + r.code === POA_DELEGATION_PENDING_CODE) && + !ignoreFileIds.includes(r.id), + ); + return { + onFile: records.length > 0, + flagged: records.some((r) => r.reviewStatus === "change_requested"), + }; + } + + /** + * The rule behind EDRFREIGHT-358: a company that names a Power of Attorney + * must evidence it with a DARS delegation paper, and a freight forwarder — + * which signs on other companies' behalf — must have both, verified. + * + * This is enforced at every write that can break the pairing (PoA details + * saved, paper removed, forwarder role applied for or approved) rather than + * only at onboarding submission, which is what let a company that finished + * onboarding as an importer pick up the forwarder role with neither. + * + * `attributes` is the state being written, which is not always the state on + * the row yet — a staged change request carries it, and a removal has to be + * judged against the files that would survive it (`ignoreFileIds`). + */ + private async assertPoaDelegationSatisfied( + companyId: string, + attributes: Record | null | undefined, + opts: { requirePoa: boolean; ignoreFileIds?: string[] }, + ): Promise { + const read = (key: string) => + (attributes?.[key] as string | undefined)?.trim(); + const poaProvided = POA_ATTRIBUTES.some((k) => read(k)); + if (!opts.requirePoa && !poaProvided) return; + + if (opts.requirePoa) { + const missing = REQUIRED_POA_FIELDS.filter((f) => !read(f.key)); + if (missing.length > 0) { + throw new BadRequestException( + `A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` + + `Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`, + ); + } + } + + const { onFile, flagged } = await this.getPoaDelegationState( + companyId, + opts.ignoreFileIds, + ); + if (!onFile) { + throw new BadRequestException( + `Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` + + (opts.requirePoa ? " — it is required for freight forwarders." : "."), + ); + } + if (flagged) { + throw new BadRequestException( + `The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` + + `Re-upload it before continuing.`, + ); + } + } + + /** Does this company operate as a freight forwarder? */ + private async isFreightForwarder(companyId: string): Promise { + const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); + return profiles.some((p) => p.type === ProfileType.freightForwarder); + } + + // --------------------------------------------------------------------------- + // Fayda identity verification (owner / PoA) + // + // A completed VeriFayda verification proves a person's name, phone, email + // and address — Fayda's userinfo carries no national ID number, so none of + // that is collected here. For an Ethiopian company both the owner and its + // PoA (once named) must be verified before the company can trade. Fayda is + // an Ethiopian national ID system, so a foreign company's owner proves + // identity with a typed passport number instead — required on its own + // terms, not waived by an owner who happens to verify with Fayda too. + // --------------------------------------------------------------------------- + + /** + * Verification state for both people, plus whether it is mandatory here. + * `complete` answers the gate question directly so the portal, the onboarding + * requirements and the assertions below all read the same verdict — the + * derivation itself is shared with ProfileResponseDto. + */ + getCompanyIdentityState(company: Company): CompanyIdentityStateDto { + return buildCompanyIdentityState(company); + } + + /** + * Complete a Fayda verification and bind the identity to one of the company's + * people. The portal starts the flow through the shared + * `POST /fayda/verification/start` and only tells us which person it was for + * here, at completion — so the verifayda module stays generic and its session + * table needs no company-specific column. + */ + async completeIdentityVerification( + userId: string, + dto: CompleteIdentityVerificationDto, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const prefix = IDENTITY_PREFIX[dto.subject]; + + const result = await this.verifaydaService.completeVerification({ + code: dto.code, + state: dto.state, + }); + if (!result.verified || !result.sub) { + throw new BadRequestException( + "Fayda could not verify this identity. Start the verification again.", + ); + } + + // The owner delegating power of attorney to themselves is not a + // delegation — it would let one identity satisfy both halves of the check. + const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa"; + const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`]; + if (otherSub && otherSub === result.sub) { + throw new BadRequestException( + `This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`, + ); + } + + const now = new Date().toISOString(); + const identity: VerifiedIdentityAttributes = { + [`${prefix}FaydaSub`]: result.sub, + [`${prefix}FaydaVerifiedAt`]: now, + [`${prefix}Birthdate`]: result.birthdate ?? null, + [`${prefix}Gender`]: result.gender ?? null, + // The verified payload owns the person's details from here on. + ...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}), + ...(result.email ? { [`${prefix}Email`]: result.email } : {}), + ...(result.phoneNumber ? { [`${prefix}Phone`]: result.phoneNumber } : {}), + ...(result.address ? { [`${prefix}Address`]: result.address } : {}), + }; + + // An approved company's *owner* is its identity proof, so re-verifying one + // is staged for backoffice review rather than quietly rewriting a live + // record. The PoA is personnel — the company names its own representative, + // and the delegation letter backing them is what the reviewer sees — so a + // PoA verification lands live, matching the typed PoA fields in + // `SELF_SERVICE_ATTRIBUTES`. + if (company.status === CompanyStatus.Active && dto.subject !== "poa") { + await this.stageIdentityChange(company, userId, identity); + return this.getCompanyIdentityState(company); + } + + const updated = await this.companiesRepo.update(company.id, { + attributes: { ...(company.attributes ?? {}), ...identity }, + }); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + updated.companyProfiles = company.companyProfiles; + return this.getCompanyIdentityState(updated); + } + + /** + * Drop the Power of Attorney entirely — the verified identity, the details it + * wrote and the delegation paper together. + * + * Only the PoA can go: a company always has an owner, and a freight forwarder + * always has a representative. Once a PoA is Fayda-verified its + * fields are locked, so blanking the form is no longer a way out — without + * this the customer would be stuck with a representative they cannot remove. + */ + async removePoaIdentity(userId: string): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + if ( + (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ) + ) { + throw new BadRequestException( + "A freight forwarder must have a Power of Attorney. Remove the freight forwarder role first.", + ); + } + + const cleared: Record = {}; + for (const key of [ + ...POA_ATTRIBUTES, + "poaFaydaSub", + "poaFaydaVerifiedAt", + "poaBirthdate", + "poaGender", + ]) { + cleared[key] = null; + } + const attributes = { ...(company.attributes ?? {}), ...cleared }; + + // The paper evidences a representative who no longer exists. + const records = await this.filesService.findByResource( + company.id, + COMPANY_RESOURCE, + ); + for (const r of records) { + if ( + r.code === POA_DELEGATION_FILE_KEY || + r.code === POA_DELEGATION_PENDING_CODE + ) { + await this.filesService.remove(r.id); + await this.withdrawDocumentIntent(company.id, r.id); + } + } + + const updated = await this.companiesRepo.update(company.id, { attributes }); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + updated.companyProfiles = company.companyProfiles; + return this.getCompanyIdentityState(updated); + } + + /** Stage a verified identity onto the company's pending change request. */ + private async stageIdentityChange( + company: Company, + userId: string, + identity: VerifiedIdentityAttributes, + ): Promise { + const existing = await this.changeRequestRepo.findPendingByCompanyId( + company.id, + ); + const now = new Date(); + const snapshot = { + ...(existing?.snapshot ?? {}), + faydaIdentity: { + ...(((existing?.snapshot ?? {}) as Record) + .faydaIdentity ?? {}), + ...identity, + }, + }; + if (existing) { + await this.changeRequestRepo.update(existing.id, { + snapshot, + submittedBy: userId, + submittedAt: now, + // Note left untouched — see the comment in updateProfile's merge branch. + status: ChangeRequestStatus.Pending, + }); + this.companyNotifier.changeRequestSubmitted(company, existing.id, false); + return; + } + const history = await this.changeRequestRepo.findByCompanyId(company.id); + const resubmitted = history.some( + (r) => r.status === ChangeRequestStatus.Rejected, + ); + const request = await this.changeRequestRepo.create({ + companyId: company.id, + snapshot, + status: ChangeRequestStatus.Pending, + submittedBy: userId, + submittedAt: now, + }); + this.companyNotifier.changeRequestSubmitted( + company, + request.id, + resubmitted, + ); + } + + /** + * The gate: an Ethiopian company's owner must be Fayda-verified, and so must + * its Power of Attorney once it has one; a foreign company's owner must carry + * a passport number instead. Called from the same places as + * `assertPoaDelegationSatisfied` — the two rules describe the same moment + * (who may act for this company, and on what evidence) and drifting them + * apart is how one of them ends up unenforced. + */ + private assertIdentityVerified( + company: Company, + opts: { requirePoa: boolean }, + ): void { + const state = buildCompanyIdentityState(company); + + // Only the owner's credential is nationality-specific: Fayda for an + // Ethiopian company, a typed passport number for a foreign one. + if (state.passportRequired) { + if (!state.owner.passportNumber) { + throw new BadRequestException( + "Add the company owner's passport number before continuing.", + ); + } + } else if (!state.owner.verified) { + throw new BadRequestException( + "Verify the company owner's identity with Fayda before continuing.", + ); + } + + // The representative is not. A PoA acts for the company inside Ethiopia + // whoever owns it, so they are always an Ethiopian holding a Fayda ID — + // a foreign company nominates one rather than typing a name. + const poaNamed = POA_ATTRIBUTES.some((k) => + (company.attributes?.[k] as string | undefined)?.trim(), + ); + if (!opts.requirePoa && !poaNamed) return; + + if (!state.poa.verified) { + throw new BadRequestException( + opts.requirePoa + ? "Verify your Power of Attorney with Fayda — a freight forwarder cannot operate without one." + : "Verify the Power of Attorney you named with Fayda, or remove the representative.", + ); + } + } + + /** + * The PoA details the company is heading for: its live attributes with any + * pending change-request snapshot laid over them. An Active company's edits + * are staged rather than written, so the live row on its own would judge the + * customer against details they have already asked to change. + */ + private async effectivePoaAttributes( + company: Company, + ): Promise> { + const pending = await this.changeRequestRepo.findPendingByCompanyId( + company.id, + ); + const snapshot = (pending?.snapshot ?? {}) as Record; + const staged: Record = {}; + for (const key of POA_ATTRIBUTES) { + if (key in snapshot) staged[key] = snapshot[key]; + } + return { ...(company.attributes ?? {}), ...staged }; + } + + /** The company's PoA paper(s), with each file's review status resolved. */ async listPoaDelegationFiles( userId: string, ): Promise { @@ -2134,6 +2998,18 @@ export class CompaniesService { throw new NotFoundException(`Delegation letter ${fileId} not found`); } + // Taking the paper away is the other half of the pairing: allowed only once + // the representative it evidences is gone too (which, for an Active + // company, means the clearing edit is already staged). + await this.assertPoaDelegationSatisfied( + company.id, + await this.effectivePoaAttributes(company), + { + requirePoa: await this.isFreightForwarder(company.id), + ignoreFileIds: [fileId], + }, + ); + if (record.code === POA_DELEGATION_PENDING_CODE) { await this.filesService.remove(fileId); await this.withdrawDocumentIntent(company.id, fileId); @@ -2217,7 +3093,8 @@ export class CompaniesService { }, submittedBy: submittedBy ?? existing.submittedBy ?? null, submittedAt: now, - note: null, + // Note left untouched — see the comment in updateProfile's merge branch. + status: ChangeRequestStatus.Pending, }); } else { await this.changeRequestRepo.create({ @@ -2313,7 +3190,10 @@ export class CompaniesService { return match?.id ?? null; } - async fetchETradeData(tin: string) { + /** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */ + private async resolveEtradeRegistration( + tin: string, + ): Promise { const { businessInfo, companyInfo } = await this.etradeService.resolveCompanyData(tin); if (!businessInfo) { @@ -2321,11 +3201,71 @@ export class CompaniesService { "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", ); } - const registrationData = this.etradeService.extractRegistrationData( - businessInfo, - companyInfo, + return this.etradeService.extractRegistrationData(businessInfo, companyInfo); + } + + async fetchETradeData(tin: string, excludeCompanyId?: string) { + const registrationData = await this.resolveEtradeRegistration(tin); + const tinTaken = await this.companiesRepo.existsByTin( + tin, + excludeCompanyId, ); - const tinTaken = await this.companiesRepo.existsByTin(tin); return { ...registrationData, tinTaken }; } + + /** + * An eTrade-sourced field can only ever hold what a fresh eTrade lookup for + * this TIN actually returns — the portal never lets the customer type these + * once eTrade has supplied them, so a mismatch here means either stale + * client state or a hand-crafted request, and either way the write is + * refused rather than silently trusting it. + */ + private async assertEtradeFieldsAuthentic( + company: Company, + dto: UpdateProfileDto, + ): Promise { + const touched = ETRADE_SOURCED_FIELDS.some( + (key) => dto[key] !== undefined, + ); + if (!touched) return; + + const tin = dto.tin ?? company.tin; + const registration = await this.resolveEtradeRegistration(tin); + const expected: Partial> = { + companyName: registration.companyName, + licenceNumber: registration.licenceNumber, + statusDescription: registration.statusDescription, + dateRegistered: registration.dateRegistered, + renewedFrom: registration.renewedFrom, + renewalDate: registration.renewalDate, + renewedTo: registration.renewedTo, + region: registration.region, + zone: registration.zone, + woreda: registration.woreda, + kebele: registration.kebele, + houseNo: registration.houseNo, + etradePhone: + registration.managerPhone || + registration.regularPhone || + registration.mobilePhone, + }; + + for (const key of ETRADE_SOURCED_FIELDS) { + const submitted = dto[key]; + if (submitted === undefined) continue; + const source = expected[key]; + // eTrade left this field blank — the onboarding/settings card falls back + // to letting the customer type it directly, so nothing to check against. + if (!source) continue; + const same = + key === "etradePhone" + ? normalizeE164(String(submitted)) === normalizeE164(source) + : submitted === source; + if (!same) { + throw new BadRequestException( + `${key} doesn't match eTrade's current record for this TIN. Re-verify with eTrade to pick up the latest details.`, + ); + } + } + } } diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts index 73271e0af..1dc4b5a84 100644 --- a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.spec.ts @@ -1,4 +1,4 @@ -import { Repository } from "typeorm"; +import { FindOperator, Repository } from "typeorm"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { @@ -10,6 +10,16 @@ type Row = Pick & { createdAt: Date }; const COMPANY_ID = "company-1"; +/** Matches a row's status against either a plain value or an `In([...])` operator. */ +function statusMatches( + rowStatus: ChangeRequestStatus, + where: ChangeRequestStatus | FindOperator | undefined, +): boolean { + if (where === undefined) return true; + if (where instanceof FindOperator) return where.value.includes(rowStatus); + return rowStatus === where; +} + /** * Stands in for the TypeORM repository over a fixed set of rows, honouring the * `where.status` filter and the `createdAt DESC` ordering findOne relies on. @@ -17,13 +27,17 @@ const COMPANY_ID = "company-1"; function mockRepositoryOver(rows: Row[]) { return { findOne: jest.fn( - ({ where }: { where: Partial & { companyId: string } }) => + ({ + where, + }: { + where: { companyId: string; status?: Row["status"] | FindOperator }; + }) => Promise.resolve( rows .filter( (row) => where.companyId === COMPANY_ID && - (where.status === undefined || row.status === where.status), + statusMatches(row.status, where.status), ) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ?? null, @@ -57,6 +71,21 @@ describe("CompanyChangeRequestRepository.findLatestOpenByCompanyId", () => { expect(result?.id).toBe("pending"); }); + it("treats a changes-requested request as open, same as pending", async () => { + const changesRequested: Row = { + id: "changes-requested", + status: ChangeRequestStatus.ChangesRequested, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + }; + + const result = await subject([ + rejected, + changesRequested, + ]).findLatestOpenByCompanyId(COMPANY_ID); + + expect(result?.id).toBe("changes-requested"); + }); + it("returns the latest rejected request when nothing is pending", async () => { const result = await subject([rejected]).findLatestOpenByCompanyId( COMPANY_ID, diff --git a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts index eb44d56cb..8fb81e6f5 100644 --- a/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-change-request.repository.ts @@ -1,12 +1,18 @@ import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { In, Repository } from "typeorm"; import { BaseRepository } from "@edr/api-common"; import { ChangeRequestStatus, CompanyChangeRequest, } from "./entities/company-change-request.entity"; +/** Statuses that mean "still open, awaiting the customer's next edit" — Pending and ChangesRequested behave identically here, they just carry a note or not. */ +const OPEN_FOR_EDIT_STATUSES = [ + ChangeRequestStatus.Pending, + ChangeRequestStatus.ChangesRequested, +]; + @Injectable() export class CompanyChangeRequestRepository extends BaseRepository { constructor( @@ -16,12 +22,12 @@ export class CompanyChangeRequestRepository extends BaseRepository { return this.repository.findOne({ - where: { companyId, status: ChangeRequestStatus.Pending }, + where: { companyId, status: In(OPEN_FOR_EDIT_STATUSES) }, order: { createdAt: "DESC" }, }); } diff --git a/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts b/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts new file mode 100644 index 000000000..83fa539e3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts @@ -0,0 +1,90 @@ +import type { Company } from "./entities/company.entity"; +import type { CompanyRevisionChange } from "./entities/company-revision.entity"; + +/** Human label per audited company field — anything not listed here is skipped (internal/lock fields like `*FaydaSub`). */ +export const COMPANY_FIELD_LABELS: Record = { + name: "Company name", + phone: "Phone", + email: "Email", + address: "Address", + country: "Country", + tin: "TIN", + vatNumber: "VAT number", + fanNumber: "FAN number", + nationality: "Nationality", + website: "Website", + licenceNumber: "Licence number", + region: "Region", + zone: "Zone", + woreda: "Woreda", + kebele: "Kebele", + houseNo: "House No", + contactPersonName: "Contact person name", + contactPersonPhone: "Contact person phone", + contactPersonEmail: "Contact person email", + contactPersonPosition: "Contact person position", + generalManagerName: "General manager name", + generalManagerPhone: "General manager phone", + generalManagerEmail: "General manager email", + poaName: "PoA name", + poaPhone: "PoA phone", + poaEmail: "PoA email", + poaLocation: "PoA location", + poaAddress: "PoA address", + documents: "Document", +}; + +function displayValue(value: unknown): string | null { + if (value === null || value === undefined || value === "") return null; + if (typeof value === "boolean") return value ? "Yes" : "No"; + return String(value); +} + +/** + * Compare the company row before a write against the patch about to be + * applied (the same shape `mapProfileDtoToCompanyUpdates` returns: scalar + * columns plus a merged `attributes` blob). Only fields with a known label + * are reported, so identity-lock bookkeeping (`ownerFaydaSub`, etc.) never + * shows up as noise. + */ +export function diffCompanyUpdate( + before: Company, + patch: Record, +): CompanyRevisionChange[] { + const changes: CompanyRevisionChange[] = []; + const { attributes: attrPatch, ...columnPatch } = patch; + + for (const [field, nextRaw] of Object.entries(columnPatch)) { + const label = COMPANY_FIELD_LABELS[field]; + if (!label) continue; + const next = displayValue(nextRaw); + const previous = displayValue((before as unknown as Record)[field]); + if (next === previous) continue; + changes.push({ field, label, from: previous, to: next }); + } + + if (attrPatch) { + const beforeAttrs = before.attributes ?? {}; + for (const [field, nextRaw] of Object.entries(attrPatch)) { + const label = COMPANY_FIELD_LABELS[field]; + if (!label) continue; + const next = displayValue(nextRaw); + const previous = displayValue(beforeAttrs[field]); + if (next === previous) continue; + changes.push({ field, label, from: previous, to: next }); + } + } + + return changes; +} + +/** Short human summary of a change set, e.g. "phone, address changed". */ +export function summarizeCompanyChanges( + changes: CompanyRevisionChange[], +): string { + if (changes.length === 0) return "No changes"; + const labels = changes.map((c) => c.label.toLowerCase()); + return labels.length <= 3 + ? `${labels.join(", ")} changed` + : `${labels.length} fields changed`; +} diff --git a/apps/edr-freight-api/src/modules/companies/company-revision.repository.ts b/apps/edr-freight-api/src/modules/companies/company-revision.repository.ts new file mode 100644 index 000000000..b04bb35de --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-revision.repository.ts @@ -0,0 +1,23 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { CompanyRevision } from "./entities/company-revision.entity"; + +@Injectable() +export class CompanyRevisionRepository extends BaseRepository { + constructor( + @InjectRepository(CompanyRevision) + repo: Repository, + ) { + super(repo); + } + + /** Revision history for a company, newest first. */ + async findByCompanyId(companyId: string): Promise { + return this.repository.find({ + where: { companyId }, + order: { createdAt: "DESC" }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts index 2634e0943..34071e813 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -13,9 +13,12 @@ export class CompanyInfoResponseDto { /** * Open profile-edit review, if any. Drives the portal-wide lock (pending → * settings + new-contract/booking creation disabled) and the reapply banner. + * `changes_requested` is the soft variant of `rejected`: same edit-and-resubmit + * call to action, but the customer's edit appends to this SAME request + * instead of starting a fresh one. */ review: { - status: 'pending' | 'rejected'; + status: 'pending' | 'rejected' | 'changes_requested'; note: string | null; } | null; @@ -30,12 +33,13 @@ export class CompanyInfoResponseDto { const open = changeRequest && (changeRequest.status === ChangeRequestStatus.Pending || - changeRequest.status === ChangeRequestStatus.Rejected) + changeRequest.status === ChangeRequestStatus.Rejected || + changeRequest.status === ChangeRequestStatus.ChangesRequested) ? changeRequest : null; this.review = open ? { - status: open.status as 'pending' | 'rejected', + status: open.status as 'pending' | 'rejected' | 'changes_requested', note: open.note ?? null, } : null; diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts new file mode 100644 index 000000000..c93c7387c --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/company-revision-response.dto.ts @@ -0,0 +1,23 @@ +import { + CompanyRevision, + CompanyRevisionChange, +} from "../entities/company-revision.entity"; + +/** One version-history entry, shown on the backoffice customer detail page. */ +export class CompanyRevisionResponseDto { + id: string; + companyId: string; + actorId: string | null; + summary: string; + changes: CompanyRevisionChange[]; + createdAt: Date; + + constructor(revision: CompanyRevision) { + this.id = revision.id; + this.companyId = revision.companyId; + this.actorId = revision.actorId ?? null; + this.summary = revision.summary; + this.changes = revision.changes ?? []; + this.createdAt = revision.createdAt; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts new file mode 100644 index 000000000..a9988cd28 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts @@ -0,0 +1,157 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsIn, IsString, IsNotEmpty } from "class-validator"; + +import { Company, CompanyNationality } from "../entities/company.entity"; +import { ProfileType } from "../entities/company-profile.entity"; + +/** + * The two people a company is verified through — its owner and its Power of + * Attorney. "Owner" is not the same as the General Manager: a company's GM is + * a plain typed role (with a "same as owner" copy the portal offers), while + * the owner is the person this verification proves. They're very often the + * same human, which is exactly what the copy is for. + */ +export const IDENTITY_SUBJECTS = ["owner", "poa"] as const; +export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number]; + +export class CompleteIdentityVerificationDto { + @ApiProperty({ + enum: IDENTITY_SUBJECTS, + description: "Which of the company's people this verification is for.", + }) + @IsIn(IDENTITY_SUBJECTS) + subject!: IdentitySubject; + + @ApiProperty({ description: "Authorization code from the Fayda redirect." }) + @IsString() + @IsNotEmpty() + code!: string; + + @ApiProperty({ description: "CSRF state from the Fayda redirect." }) + @IsString() + @IsNotEmpty() + state!: string; +} + +/** One person's verification state, as reported back to the portal. */ +export class IdentityVerificationStateDto { + @ApiProperty() verified!: boolean; + @ApiProperty({ nullable: true }) name!: string | null; + @ApiProperty({ nullable: true }) phone!: string | null; + @ApiProperty({ nullable: true }) email!: string | null; + @ApiProperty({ nullable: true }) address!: string | null; + @ApiProperty({ nullable: true }) verifiedAt!: string | null; + @ApiProperty({ nullable: true }) birthdate!: string | null; + @ApiProperty({ nullable: true }) gender!: string | null; +} + +export class OwnerIdentityStateDto extends IdentityVerificationStateDto { + @ApiProperty({ + nullable: true, + description: + "Typed passport number — the foreign-company identity credential. Independent of Fayda: never written by a verification, and still required even if the owner also verifies.", + }) + passportNumber!: string | null; +} + +export class CompanyIdentityStateDto { + @ApiProperty({ + description: + "True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.", + }) + faydaRequired!: boolean; + + @ApiProperty({ + description: + "True when the owner's passport number is mandatory — foreign companies only. Independent of faydaRequired: a foreign owner may verify with Fayda too, but the passport is still required.", + }) + passportRequired!: boolean; + + @ApiProperty({ type: OwnerIdentityStateDto }) + owner!: OwnerIdentityStateDto; + + @ApiProperty({ type: IdentityVerificationStateDto }) + poa!: IdentityVerificationStateDto; + + @ApiProperty({ + description: + "False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.", + }) + complete!: boolean; +} + +/** `attributes` key prefix per person. */ +const PREFIX: Record = { + owner: "owner", + poa: "poa", +}; + +/** company.attributes keys that together mean "a PoA was entered". */ +const POA_KEYS = [ + "poaName", + "poaPhone", + "poaEmail", + "poaLocation", + "poaAddress", +] as const; + +function stateFor( + attrs: Record, + subject: IdentitySubject, +): IdentityVerificationStateDto { + const p = PREFIX[subject]; + const read = (key: string) => (attrs[key] as string | undefined) ?? null; + return { + verified: Boolean(read(`${p}FaydaSub`)), + name: read(`${p}Name`), + phone: read(`${p}Phone`), + email: read(`${p}Email`), + address: read(`${p}Address`), + verifiedAt: read(`${p}FaydaVerifiedAt`), + birthdate: read(`${p}Birthdate`), + gender: read(`${p}Gender`), + }; +} + +/** + * Derive both people's verification state from the company row. + * + * Pure and shared: `CompaniesService` gates on it and `ProfileResponseDto` + * renders from it, so the settings page and the onboarding wizard can never + * disagree with the rule the API actually enforces. + */ +export function buildCompanyIdentityState( + company: Company, +): CompanyIdentityStateDto { + const attrs = company.attributes ?? {}; + const read = (key: string) => (attrs[key] as string | undefined) ?? null; + + // Fayda is an Ethiopian national ID — a foreign company's owner may not hold + // one, so a typed passport number is the mandatory credential there instead. + // The two are mutually exclusive by nationality but independently tracked, + // since a foreign owner verifying with Fayda doesn't waive the passport. + const foreign = company.nationality === CompanyNationality.Foreign; + const faydaRequired = !foreign; + const passportRequired = foreign; + + const owner: OwnerIdentityStateDto = { + ...stateFor(attrs, "owner"), + passportNumber: read("ownerPassportNumber"), + }; + const poa = stateFor(attrs, "poa"); + const poaDue = + (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim()); + + // Only the *owner's* credential is nationality-specific. A Power of Attorney + // acts for the company inside Ethiopia whoever owns it, so the PoA is always + // proven with Fayda — a foreign company nominates a representative who holds + // one rather than typing a name nothing backs. + const ownerProven = faydaRequired + ? owner.verified + : !passportRequired || Boolean(owner.passportNumber); + const complete = ownerProven && (!poaDue || poa.verified); + + return { faydaRequired, passportRequired, owner, poa, complete }; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index da908a177..a8d2f24a2 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -8,6 +8,8 @@ * truth the wizard uses to auto-finish. */ +import { CompanyIdentityStateDto } from "./complete-identity-verification.dto"; + export interface OnboardingInfoField { key: string; label: string; @@ -40,11 +42,13 @@ export interface OnboardingPoaState { required: boolean; /** True once any PoA detail has been entered. */ provided: boolean; - /** True when the delegation letter is stored for the company. */ + /** True when the DARS delegation paper is stored for the company. */ delegationLetterUploaded: boolean; + /** True when a reviewer sent the paper back for correction. */ + delegationLetterFlagged: boolean; /** PoA details still missing (only populated when `required`). */ missingFields: OnboardingInfoField[]; - /** False while the PoA step still owes details or a delegation letter. */ + /** False while the PoA step still owes details or an uncorrected paper. */ complete: boolean; } @@ -68,6 +72,13 @@ export class OnboardingRequirementsResponseDto { /** Power of Attorney state, so the wizard needn't re-derive the rule. */ poa: OnboardingPoaState; + /** + * Fayda verification state for the company's people. `required` is false for + * a foreign company, which is never gated on it — the portal renders the + * typed personnel forms in that case and the verify panels otherwise. + */ + identity: CompanyIdentityStateDto; + /** Overall setup progress across fields + documents + licenses. */ progress: { completed: number; total: number }; @@ -87,6 +98,7 @@ export class OnboardingRequirementsResponseDto { this.documents = init.documents; this.licenseProfiles = init.licenseProfiles; this.poa = init.poa; + this.identity = init.identity; this.progress = init.progress; this.isComplete = init.isComplete; this.onboardingCompleted = init.onboardingCompleted; diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 89ab954e7..f0a19dad7 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -1,3 +1,7 @@ +import { + buildCompanyIdentityState, + CompanyIdentityStateDto, +} from "./complete-identity-verification.dto"; import { Company } from '../entities/company.entity'; import { ExternalProfile } from '../entities/external-profile.entity'; import { @@ -53,11 +57,23 @@ export class ProfileResponseDto { profileId: string; /** - * Open profile-edit review, if any. `reviewStatus === "pending"` locks the - * settings page; `"rejected"` surfaces the note and prefills the (declined) - * proposed values from `pendingChanges` so the customer can amend & resubmit. + * Fayda verification state for the company's owner and PoA — not the general + * manager, which is a separate typed role. The settings tabs and the + * onboarding wizard render from `identity.faydaRequired` / + * `identity.passportRequired`: an Ethiopian company verifies the owner (and + * PoA) instead of typing their details; a foreign one requires a typed + * passport number instead. */ - reviewStatus: "pending" | "rejected" | null; + identity: CompanyIdentityStateDto; + + /** + * Open profile-edit review, if any. `reviewStatus === "pending"` locks the + * settings page; `"rejected"`/`"changes_requested"` both surface the note and + * prefill the proposed values from `pendingChanges` so the customer can amend + * & resubmit — `"changes_requested"` just appends the edit to this same + * request instead of starting a fresh one. + */ + reviewStatus: "pending" | "rejected" | "changes_requested" | null; reviewNote: string | null; pendingChanges: Record | null; @@ -113,7 +129,8 @@ export class ProfileResponseDto { const openReview = changeRequest && (changeRequest.status === ChangeRequestStatus.Pending || - changeRequest.status === ChangeRequestStatus.Rejected) + changeRequest.status === ChangeRequestStatus.Rejected || + changeRequest.status === ChangeRequestStatus.ChangesRequested) ? changeRequest : null; this.reviewStatus = @@ -121,8 +138,11 @@ export class ProfileResponseDto { ? "pending" : openReview?.status === ChangeRequestStatus.Rejected ? "rejected" - : null; + : openReview?.status === ChangeRequestStatus.ChangesRequested + ? "changes_requested" + : null; this.reviewNote = openReview?.note ?? null; this.pendingChanges = openReview?.snapshot ?? null; + this.identity = buildCompanyIdentityState(company); } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index a05812558..d705e7323 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -9,6 +9,10 @@ import { ProfileLicenseFileView, } from '../entities/company-profile.entity'; import { ResponseExternalProfileDto } from './response-external-profile.dto'; +import { + buildCompanyIdentityState, + CompanyIdentityStateDto, +} from './complete-identity-verification.dto'; export class ResponseCompanyProfileDto { id: string; @@ -69,8 +73,31 @@ export class ResponseCompanyDto { * external profiles weren't loaded. */ onboardingCompleted?: boolean; + + // eTrade-sourced registration record — populated by the onboarding TIN + // lookup, locked/read-only on the portal from the moment it's fetched. + licenceNumber?: string | null; + statusDescription?: string | null; + dateRegistered?: string | null; + renewedFrom?: string | null; + renewalDate?: string | null; + renewedTo?: string | null; + region?: string | null; + zone?: string | null; + woreda?: string | null; + kebele?: string | null; + houseNo?: string | null; + + /** + * Owner/PoA Fayda verification state, shared with the portal + * (`buildCompanyIdentityState`) so backoffice never re-derives — or + * disagrees with — the rule the API actually enforces. + */ + identity: CompanyIdentityStateDto; + createdAt: Date; updatedAt: Date; + approvedAt: Date | null; constructor(company: Company) { this.id = company.id; @@ -95,7 +122,20 @@ export class ResponseCompanyDto { ? company.profiles.length === 0 || company.profiles.some((p) => p.onboardingCompleted) : undefined; + this.licenceNumber = company.licenceNumber; + this.statusDescription = company.statusDescription; + this.dateRegistered = company.dateRegistered; + this.renewedFrom = company.renewedFrom; + this.renewalDate = company.renewalDate; + this.renewedTo = company.renewedTo; + this.region = company.region; + this.zone = company.zone; + this.woreda = company.woreda; + this.kebele = company.kebele; + this.houseNo = company.houseNo; + this.identity = buildCompanyIdentityState(company); this.createdAt = company.createdAt; this.updatedAt = company.updatedAt; + this.approvedAt = company.approvedAt ?? null; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index ba3e27aeb..9f7d1ed39 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -44,10 +44,11 @@ export class UpdateProfileDto { @MaxLength(50) vatNumber?: string; - @IsOptional() - @IsString() - @MaxLength(16) - fanNumber?: string; + // `fanNumber` is deliberately absent: the FAN is the Fayda number of the + // company's PoA (or its general manager), so it is derived from a completed + // Fayda verification rather than typed. The global validation pipe runs with + // forbidNonWhitelisted, so a client that still sends it gets a 400 telling it + // so — see CompaniesService.completeIdentityVerification. @IsOptional() @IsString() @@ -110,6 +111,16 @@ export class UpdateProfileDto { @IsString() poaAddress?: string; + /** + * The owner's passport number — the identity credential for a foreign + * company, since Fayda is an Ethiopian national ID. Plain typed field, never + * written or locked by a Fayda verification: still required even if the + * owner also verifies. + */ + @IsOptional() + @IsString() + ownerPassportNumber?: string; + @IsOptional() @IsString() @MaxLength(100) diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts index 5ee6739de..2ba39ecad 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-change-request.entity.ts @@ -5,14 +5,18 @@ import { Company } from "./company.entity"; /** * Lifecycle of a customer's proposed profile change. Edits made on the portal * settings page by an already-approved company are staged here (not written to - * the live Company row) until a backoffice reviewer approves — at which point - * the snapshot is applied — or rejects with a note, after which the customer can - * amend and resubmit. + * the live Company row) until a backoffice reviewer resolves it: + * - Approved — the snapshot is applied to the live Company row. + * - Rejected — terminal for this row; the customer's next edit starts a fresh one. + * - ChangesRequested — soft: the row stays open with the reviewer's note attached, + * so the customer's next edit is appended (merged) into this SAME row instead + * of starting a new cycle. */ export enum ChangeRequestStatus { Pending = "pending", Approved = "approved", Rejected = "rejected", + ChangesRequested = "changes_requested", } /** diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts new file mode 100644 index 000000000..222a8364f --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/company-revision.entity.ts @@ -0,0 +1,46 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import { Company } from "./company.entity"; + +/** + * One recorded field/document change, as shown on the customer's version + * history. A document change carries `fromFileId`/`toFileId` alongside the + * display names, so the reviewer can open the previous and current file — + * not just read that "a document changed." + */ +export interface CompanyRevisionChange { + field: string; + label: string; + from: string | null; + to: string | null; + fromFileId?: string | null; + toFileId?: string | null; +} + +/** + * Append-only audit of edits made to a company record BEFORE it reaches + * `Active` (the onboarding phase), where {@link CompaniesService.updateProfile} + * and {@link CompaniesService.uploadCompanyDocuments} write straight to the + * live row with no approval gate — and, until this entity, no trace at all. + * Post-approval edits already get history via `CompanyChangeRequest`; this + * covers the gap before that gate exists. + */ +@Entity({ schema: "freight", name: "company_revisions" }) +@Index(["companyId"]) +export class CompanyRevision extends BaseEntity { + @Column({ name: "company_id", type: "uuid" }) + companyId!: string; + + @ManyToOne(() => Company, { onDelete: "CASCADE" }) + @JoinColumn({ name: "company_id" }) + company?: Company; + + @Column({ name: "actor_id", type: "uuid", nullable: true }) + actorId?: string | null; + + @Column({ name: "summary", type: "varchar", length: 255 }) + summary!: string; + + @Column({ name: "changes", type: "jsonb", default: () => `'[]'::jsonb` }) + changes!: CompanyRevisionChange[]; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index 5fe3a3f67..f254e0121 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -61,6 +61,10 @@ export class Company extends BaseEntity { }) status!: CompanyStatus; + /** Set when the company is first promoted Pending → Active. Null for companies approved before this column existed. */ + @Column({ name: "approved_at", type: "timestamptz", nullable: true }) + approvedAt?: Date | null; + @Column({ name: "tin", type: "varchar", length: 10, unique: true }) tin!: string; diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index 7b5cb77d6..df55493de 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -15,6 +15,9 @@ const generalImportBooking = { dutyRequired: true, roHoldReason: null, vesselDepartureDate: null, + // Djibouti already named the transit officer — the declaration gate is open. + transitAssigneeRequestedAt: new Date('2026-01-01T00:00:00Z'), + transitAssigneeName: 'Ahmed Bourhan', } as Booking; const generalExportBooking = { @@ -27,6 +30,8 @@ const generalExportBooking = { function makeService(overrides?: { booking?: Booking; workflowThrows?: boolean; + /** Resolve the input doc set with no required fields → every doc counts approved. */ + docsApproved?: boolean; }) { const booking = overrides?.booking ?? generalImportBooking; const bookingsRepository = { @@ -38,10 +43,14 @@ function makeService(overrides?: { }; const filesService = { upsertByCode: jest.fn().mockResolvedValue({}), + upload: jest.fn().mockResolvedValue({}), + deleteByCode: jest.fn().mockResolvedValue(undefined), findByResource: jest.fn().mockResolvedValue([]), }; const fileUploadSettingsService = { - getByCode: jest.fn().mockRejectedValue(new Error('no setting')), + getByCode: overrides?.docsApproved + ? jest.fn().mockResolvedValue({ fields: [] }) + : jest.fn().mockRejectedValue(new Error('no setting')), }; const workflowService = { assertPriorCompleteForBooking: overrides?.workflowThrows @@ -49,6 +58,7 @@ function makeService(overrides?: { : jest.fn().mockResolvedValue(undefined), completeMilestoneForBooking: jest.fn().mockResolvedValue(undefined), onDeclarationUploadedForBooking: jest.fn().mockResolvedValue(undefined), + onAllDocsApprovedForBooking: jest.fn().mockResolvedValue(undefined), onDutySkippedForBooking: jest.fn().mockResolvedValue(undefined), listMilestonesForBooking: jest.fn().mockResolvedValue([]), resolvePhaseForBooking: jest.fn().mockReturnValue(null), @@ -91,7 +101,15 @@ function makeService(overrides?: { documentQueried: jest.fn(), dutySlipUploadedToStaff: jest.fn(), clearanceDocsUploadedToStaff: jest.fn(), + transitAssigneeRequested: jest.fn(), + transitAssigneeAssigned: jest.fn(), } as never, // notifier + { listVisibleToCustomer: jest.fn().mockResolvedValue([]) } as never, // GL exchange + { + getAssignable: jest + .fn() + .mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }), + } as never, // transit agents ); return { @@ -186,6 +204,82 @@ describe('BookingClearanceService', () => { service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]), ).rejects.toBeInstanceOf(BadRequestException); }); + + it('rejects an import declaration before Djibouti names the transit officer', async () => { + const { service, workflowService } = makeService({ + docsApproved: true, + booking: { + ...generalImportBooking, + transitAssigneeRequestedAt: null, + transitAssigneeName: null, + } as Booking, + }); + + await expect( + service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]), + ).rejects.toThrow(/Request a transit assignee/i); + expect(workflowService.onDeclarationUploadedForBooking).not.toHaveBeenCalled(); + }); + + it('lets the export declaration through without a transit assignee', async () => { + const { service, workflowService } = makeService({ + docsApproved: true, + booking: { + ...generalExportBooking, + transitAssigneeRequestedAt: null, + transitAssigneeName: null, + } as Booking, + }); + + await service.uploadDeclaration('b-export', [ + { fieldname: 'decl' } as Express.Multer.File, + ]); + + expect(workflowService.onDeclarationUploadedForBooking).toHaveBeenCalled(); + }); + }); + + describe('transit assignee handshake', () => { + it('refuses an assignment GL Ethiopia never asked for', async () => { + const { service } = makeService({ + booking: { + ...generalImportBooking, + transitAssigneeRequestedAt: null, + transitAssigneeName: null, + } as Booking, + }); + + await expect( + service.assignTransitAssignee('b-general', 'Ahmed Bourhan'), + ).rejects.toThrow(/has not requested a transit assignee/i); + }); + + it('stamps the ask and then the name', async () => { + const { service, bookingsRepository } = makeService({ + booking: { + ...generalImportBooking, + transitAssigneeName: null, + } as Booking, + }); + + await service.requestTransitAssignee('b-general', ' night shift '); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ + transitAssigneeRequestedAt: expect.any(Date), + transitAssigneeRequestNote: 'night shift', + }), + ); + + await service.assignTransitAssignee('b-general', ' Ahmed Bourhan '); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ + transitAssigneeName: 'Ahmed Bourhan', + transitAssigneeAssignedAt: expect.any(Date), + }), + ); + }); }); describe('uploadReleaseOrder', () => { 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..c9c15f8b0 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 @@ -1,10 +1,13 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { ContractDocPhase, + isDraftDeclarationFileCode, type ClearanceFinalInvoiceSummary, + type ClearanceOffloadState, type ClearanceSecondDuty, type ClearanceT1State, type ClearanceTrainState, + type GlExchangeDocument, } from '@edr/types'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; @@ -19,11 +22,14 @@ 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'; +import { GlExchangeService } from './gl-exchange.service'; +import { TransitAgentsService } from '../transit-agents/transit-agents.service'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; -import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDraftDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; @@ -64,15 +70,50 @@ 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; + /** + * Pre-declaration handshake with GL Djibouti: who handles this shipment in + * transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot + * file the import customs declaration before it is set. + */ + transitAssignee?: { + requestedAt: string | null; + requestNote: string | null; + name: string | null; + assignedAt: string | null; + } | null; dutyAdvice?: { amount: number; currency: string; declarationSerial?: string | null; noticeFile?: { id: string; name: string; url: string } | null; } | null; + /** + * Import only: the draft customs declaration GL Ethiopia sends before filing + * the real one. Present once a draft has been uploaded, regardless of + * accept state — `accepted` tells the caller which. + */ + draftDeclaration?: { + price: number; + currency: string; + files: Array<{ id: string; name: string; url: string }>; + accepted: boolean; + } | null; + /** + * The customer's open change request on the current draft declaration. + * Present only until GL sends a corrected draft; `rounds` counts how many + * times it has been sent back. + */ + draftDeclarationChangeRequest?: { + note: string; + raisedAt: string; + rounds: number; + } | null; workflowFiles?: ReturnType; /** Import post-allocation T1 transit document state (null until wagon allocation). */ t1?: ClearanceT1State | null; @@ -83,6 +124,8 @@ export interface BookingClearanceView { t1Closed?: boolean; t1ClosedAt?: string | null; offloaded?: boolean; + /** Offload stats for this booking (what came off the train, and where). */ + offload?: ClearanceOffloadState | null; /** GL Djibouti post-offload final invoice (export). */ finalInvoice?: ClearanceFinalInvoiceSummary | null; /** Customs risk level assigned by GL ET (import; visible to the customer). */ @@ -93,6 +136,8 @@ export interface BookingClearanceView { /** Post-arrival additional duty/tax round (import). */ secondDuty?: ClearanceSecondDuty | null; importReleaseGranted?: boolean; + /** GL-shared documents this booking's uploader marked visible to the customer. */ + exchangeDocuments?: GlExchangeDocument[]; } @Injectable() @@ -107,15 +152,14 @@ export class BookingClearanceService { private readonly dropdownSettingsService: DropdownSettingsService, private readonly glOperationsService: GlOperationsService, private readonly notifier: BookingLifecycleNotifierService, + private readonly glExchangeService: GlExchangeService, + private readonly transitAgentsService: TransitAgentsService, ) {} - 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 +167,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; } @@ -210,6 +254,11 @@ export class BookingClearanceService { booking.tradeDirection ?? 'IMPORT', ); const dutyAdvice = this.buildDutyAdvice(files, milestones); + const draftDeclaration = this.buildDraftDeclaration(files, milestones); + const draftDeclarationChangeRequest = await this.buildDraftDeclarationChangeRequest( + bookingId, + milestones, + ); const workflowFiles = buildWorkflowFiles( files, booking.tradeDirection ?? 'IMPORT', @@ -237,6 +286,12 @@ export class BookingClearanceService { const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); const riskMilestone = bookingMilestone('RISK_ASSIGNED'); const secondDuty = this.glOperationsService.secondDutyState(milestones, files); + // GL↔GL exchange documents shared with the customer. The two desks may work + // the thread on the booking (per-booking customs) or on its contract + // (pre-booking clearance), so the customer's view spans both. + const exchangeDocuments = await this.glExchangeService.listVisibleToCustomer( + [bookingId, booking.contractId ?? ''], + ); return { bookingId, @@ -261,13 +316,28 @@ 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, operationReady: boundary, preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt), + transitAssignee: { + requestedAt: booking.transitAssigneeRequestedAt + ? booking.transitAssigneeRequestedAt.toISOString() + : null, + requestNote: booking.transitAssigneeRequestNote ?? null, + name: booking.transitAssigneeName ?? null, + assignedAt: booking.transitAssigneeAssignedAt + ? booking.transitAssigneeAssignedAt.toISOString() + : null, + }, dutyAdvice, + draftDeclaration, + draftDeclarationChangeRequest, workflowFiles, + exchangeDocuments, t1, train, gatepassGranted: gatepass.granted, @@ -278,6 +348,7 @@ export class BookingClearanceService { ? t1ClosedMilestone.triggeredAt.toISOString() : null, offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', + offload: await this.glOperationsService.offloadState(bookingId, milestones), finalInvoice, riskLevel: riskMilestone?.status === 'COMPLETED' @@ -324,6 +395,46 @@ export class BookingClearanceService { }; } + private buildDraftDeclaration( + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + milestones: ClearanceMilestone[], + ): BookingClearanceView['draftDeclaration'] { + const uploaded = milestones.find( + (m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED' && m.status === 'COMPLETED', + ); + if (!uploaded?.metadata) return null; + const price = uploaded.metadata.draftDeclarationPrice; + const currency = uploaded.metadata.draftDeclarationCurrency; + if (typeof price !== 'number' || typeof currency !== 'string') return null; + const draftFiles = files + .filter((f) => f.code && isDraftDeclarationFileCode(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')) + .map((f) => ({ id: f.id, name: f.name, url: f.url })); + const accepted = + milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_ACCEPTED')?.status === + 'COMPLETED'; + return { price, currency, files: draftFiles, accepted }; + } + + private async buildDraftDeclarationChangeRequest( + bookingId: string, + milestones: ClearanceMilestone[], + ): Promise { + const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED'); + if (!uploaded || uploaded.status === 'COMPLETED') return null; + const notes = await this.bookingsRepository.findReviewNotes( + bookingId, + 'DRAFT_DECL_CHANGE_REQUEST', + ); + const latest = notes[0]; + if (!latest) return null; + return { + note: latest.note, + raisedAt: latest.createdAt.toISOString(), + rounds: notes.length, + }; + } + private async isClearanceFullyApproved(booking: Booking): Promise { const { inputCode } = clearanceCodesForBooking(booking); if (!inputCode) return true; @@ -346,14 +457,59 @@ 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) ); } + /** + * GL Ethiopia asks Djibouti to name the officer who will handle this shipment + * in transit. The import declaration is gated on the answer, so this is the + * first thing ET does once the customer documents are approved. Re-requesting + * is allowed (a nudge) and simply restamps the ask. + */ + async requestTransitAssignee( + bookingId: string, + note: string | undefined, + ): Promise { + const booking = await this.loadBooking(bookingId); + + await this.bookingsRepository.update(bookingId, { + transitAssigneeRequestedAt: new Date(), + transitAssigneeRequestNote: note?.trim() || null, + } as never); + + this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null); + return this.bookingsService.findById(bookingId); + } + + /** + * GL Djibouti picks the transit officer from the admin-managed roster — + * rejected unless the agent is active and inside its validity window. + * Answering unblocks the declaration for Ethiopia. A later call overwrites + * the name (reassignment) and re-notifies. + */ + async assignTransitAssignee(bookingId: string, transitAgentId: string): Promise { + const booking = await this.loadBooking(bookingId); + if (!booking.transitAssigneeRequestedAt) { + throw new BadRequestException( + 'GL Ethiopia has not requested a transit assignee for this shipment yet.', + ); + } + const agent = await this.transitAgentsService.getAssignable(transitAgentId); + + const previous = booking.transitAssigneeName ?? null; + await this.bookingsRepository.update(bookingId, { + transitAssigneeName: agent.name, + transitAssigneeAssignedAt: new Date(), + } as never); + + this.notifier.transitAssigneeAssigned(booking, agent.name, previous); + return this.bookingsService.findById(bookingId); + } + async uploadDeclaration( bookingId: string, files: Express.Multer.File[], @@ -367,6 +523,16 @@ export class BookingClearanceService { 'All required customer documents must be approved before uploading a declaration.', ); } + // Import only: the declaration is filed against whoever physically handles + // the shipment in Djibouti, so that name must be in first. Exports have no + // such handshake — their Djibouti steps come after the declaration. + if (tradeDirection === 'IMPORT' && !booking.transitAssigneeName) { + throw new BadRequestException( + booking.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.listMilestonesForBooking(bookingId); const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED'); if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') { @@ -392,12 +558,6 @@ export class BookingClearanceService { : ContractDocPhase.CustomerDuty, } as never); - // Export: the declaration is the last GL ET pre-operation action — release - // immediately so the customer can proceed without a separate confirm click. - if (tradeDirection === 'EXPORT') { - await this.workflowService.onExportReleasedForBooking(bookingId, userId); - } - return this.bookingsService.findById(bookingId); } @@ -454,6 +614,124 @@ export class BookingClearanceService { return this.bookingsService.findById(bookingId); } + /** + * GL Ethiopia sends a draft customs declaration (estimated price + files) for + * the customer to review before the real declaration is filed. Repeatable — + * each call replaces the previous draft's files/price and re-arms the step, + * which is what a re-send after a change request needs. + */ + async uploadDraftDeclaration( + bookingId: string, + files: Express.Multer.File[], + price: number, + currency: string, + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Draft declaration applies only to import bookings.'); + } + if (files.length === 0) { + throw new BadRequestException('No draft declaration documents uploaded'); + } + if (!Number.isFinite(price) || price < 0) { + throw new BadRequestException('A valid estimated price is required.'); + } + // Backfills the two new milestone rows for bookings seeded before this step + // existed — a blind complete() 404s on a booking with no such row yet. + await this.milestoneService.ensureBookingMilestones(bookingId, 'IMPORT'); + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'IMPORT', + 'DRAFT_DECLARATION_UPLOADED', + ); + + await persistDraftDeclarationUploads(this.filesService, bookingId, 'bookings', files); + await this.milestoneService.completeWithMetadataForBooking( + bookingId, + 'DRAFT_DECLARATION_UPLOADED', + { draftDeclarationPrice: price, draftDeclarationCurrency: currency }, + userId, + ); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtOutput, + } as never); + + const updated = await this.bookingsService.findById(bookingId); + this.notifier.draftDeclarationReady(updated, price, currency); + return updated; + } + + /** + * The customer accepts the draft declaration — GL Ethiopia may now file the + * real customs declaration. + */ + async acceptDraftDeclaration(bookingId: string): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Draft declaration applies only to import bookings.'); + } + const milestones = await this.workflowService.listMilestonesForBooking(bookingId); + const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED'); + if (uploaded?.status !== 'COMPLETED') { + throw new BadRequestException('There is no draft declaration to accept yet.'); + } + + await this.workflowService.completeMilestoneForBooking(bookingId, 'DRAFT_DECLARATION_ACCEPTED'); + return this.bookingsService.findById(bookingId); + } + + /** + * The customer sends the draft declaration back with a reason. Nothing is + * filed; the upload milestone reopens so the step becomes actionable again + * for GL Ethiopia, with the customer's message shown beside it. GL re-sends + * (same endpoint as the first time), which closes the request — the loop may + * run as many rounds as it takes. + */ + async requestDraftDeclarationChange( + bookingId: string, + note: string, + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Draft declaration applies only to import bookings.'); + } + if (!note?.trim()) { + throw new BadRequestException( + 'Say what needs to change so GL can correct the draft.', + ); + } + + const milestones = await this.workflowService.listMilestonesForBooking(bookingId); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + if (byCode.get('DRAFT_DECLARATION_UPLOADED')?.status !== 'COMPLETED') { + throw new BadRequestException('There is no draft declaration to request a change on yet.'); + } + if (byCode.get('DRAFT_DECLARATION_ACCEPTED')?.status === 'COMPLETED') { + throw new BadRequestException( + 'The draft declaration has already been accepted — contact GL Ethiopia directly.', + ); + } + + await this.bookingsRepository.createReviewNote( + bookingId, + note.trim(), + 'DRAFT_DECL_CHANGE_REQUEST', + userId, + ); + // Back to GL: reopening the milestone is what re-arms the step (the + // stepper picks its active step from milestone completion). + await this.milestoneService.reopenForBooking(bookingId, 'DRAFT_DECLARATION_UPLOADED'); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtOutput, + } as never); + + const updated = await this.bookingsService.findById(bookingId); + this.notifier.draftDeclarationChangeRequested(updated, note.trim()); + return updated; + } + async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { @@ -547,7 +825,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 +834,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 +846,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); @@ -657,6 +936,10 @@ export class BookingClearanceService { 'RELEASE_ORDER_SECURED', userId, ); + // Release Order is now the last GL DJ pre-operation action (it follows the + // declaration) — release immediately so booking creation unlocks without a + // separate confirm click. + await this.workflowService.onExportReleasedForBooking(bookingId, userId); return { booking: await this.bookingsService.findById(bookingId), hold: false }; } @@ -713,7 +996,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 +1009,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/clearance-milestone.catalog.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts index 648d9666f..b3241d10c 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts @@ -18,6 +18,8 @@ const IMPORT_DEFS: Record> = { IMPORT_DOCS_UPLOADED: { label: 'Import Documents Uploaded', ownerRegion: 'CUST', triggeredByDoc: false }, PENDING_DOCUMENT_REVIEW: { label: 'Pending Document Review', ownerRegion: 'ET', triggeredByDoc: true }, DOCUMENTS_APPROVED: { label: 'Documents Approved', ownerRegion: 'ET', triggeredByDoc: false }, + DRAFT_DECLARATION_UPLOADED: { label: 'Draft Declaration Sent', ownerRegion: 'ET', triggeredByDoc: true }, + DRAFT_DECLARATION_ACCEPTED: { label: 'Draft Declaration Accepted', ownerRegion: 'CUST', triggeredByDoc: false }, UNDER_CUSTOMS_CLEARANCE: { label: 'Under Customs Clearance', ownerRegion: 'ET', triggeredByDoc: false }, DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true }, DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false }, diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts index 9b17a3e76..325987f0c 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts @@ -353,10 +353,10 @@ export class ClearanceWorkflowService { if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview; if (tradeDirection === 'EXPORT') { + if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; if (!isDone('RELEASE_ORDER_SECURED')) { return ContractDocPhase.GlDjCollection; } - if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance; return ContractDocPhase.GlEtPostClearance; } @@ -450,13 +450,6 @@ export class ClearanceWorkflowService { : 'Proceed to request operation'; if (tradeDirection === 'EXPORT') { - if (!isDone('RELEASE_ORDER_SECURED')) { - return { - actor: 'GL_DJ', - action: 'Upload Release Order and vessel departure date', - milestoneCode: 'RELEASE_ORDER_SECURED', - }; - } if (!isDone('DECLARED')) { return { actor: 'GL_ET', @@ -464,6 +457,13 @@ export class ClearanceWorkflowService { milestoneCode: 'DECLARED', }; } + if (!isDone('RELEASE_ORDER_SECURED')) { + return { + actor: 'GL_DJ', + action: 'Upload Release Order and vessel departure date', + milestoneCode: 'RELEASE_ORDER_SECURED', + }; + } if (!isDone(EXPORT_BOUNDARY)) { return { actor: 'GL_ET', 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..7eb87bf86 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, @@ -106,6 +109,23 @@ export class ContractBookingService { private readonly bookingTransitionService: BookingTransitionService, ) {} + /** + * Mirrors BookingsService.assertNoUnpaidHold for the contract booking paths: + * a company sitting on an unpaid hold (SELECTED_FOR_BATCH) books nothing new + * until it pays or the hold dies. + */ + private async assertNoUnpaidHold(companyId?: string | null): Promise { + if (!companyId) return; + const holds = + await this.bookingsRepository.countUnpaidHoldsForCompany(companyId); + if (holds > 0) { + throw new ConflictException( + 'You already have a booking waiting for payment. Pay it or cancel it ' + + 'before making a new booking.', + ); + } + } + async createUnderContract( contractId: string, dto: CreateBookingUnderContractDto, @@ -166,6 +186,8 @@ export class ContractBookingService { // remainder; the customer cannot start any other booking on the contract. // If the remainder splits again the same rule repeats until the cap is // exhausted and the contract completes. + await this.assertNoUnpaidHold(contract.companyId); + if (contract.contractKind === 'ONE_TIME') { if (await this.hasSplitBooking(contractId)) { await this.assertExactRemainder(contract, dto); @@ -187,21 +209,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 +233,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 +267,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 +276,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 +374,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 +400,7 @@ export class ContractBookingService { } } - await this.finalizeContractBooking( - booking.id, - contract, - generalCustoms, - ); + await this.finalizeContractBooking(booking.id, contract); await this.maybeCompleteContract(contract); @@ -417,13 +409,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 +435,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 +453,29 @@ 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.', + ); + } + } + await this.assertNoUnpaidHold(contract.companyId); + const route = await this.resolveRoute(contract, dto.contractRouteId); // Bare instance: no cargo, no date, no price. Draws no contract capacity @@ -481,7 +498,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 +520,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 +547,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 +587,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 +745,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 +774,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 +853,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 @@ -821,6 +866,7 @@ export class ContractBookingService { const completed = await this.bookingTransitionService.requestOperation( booking.id, dto.scheduledDate, + dto.trainScheduleId ?? null, ); return { booking: completed, warnings }; } @@ -874,33 +920,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 +951,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 +1000,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 +1015,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 +1058,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 +1469,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 +1702,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 +1949,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..2ebd8fc70 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,7 +1,13 @@ -import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { ContractDocPhase, type ClearanceFinalInvoiceSummary, + type ClearanceOffloadState, type ClearanceSecondDuty, type ClearanceT1State, type ClearanceTrainState, @@ -13,11 +19,15 @@ 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'; import { GlOperationsService } from './gl-operations.service'; +import { TransitAgentsService } from '../transit-agents/transit-agents.service'; import { ClearanceMilestone, type RiskAssignmentRecord, @@ -26,7 +36,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 +82,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 +108,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; @@ -100,6 +142,8 @@ export interface ContractClearanceView { t1Closed?: boolean; t1ClosedAt?: string | null; offloaded?: boolean; + /** Offload stats for the linked booking (null until one exists). */ + offload?: ClearanceOffloadState | null; /** GL Djibouti post-offload final invoice (export). */ finalInvoice?: ClearanceFinalInvoiceSummary | null; /** Customs risk level assigned by GL ET (import; visible to the customer). */ @@ -125,6 +169,7 @@ export class ContractClearanceService { private readonly dropdownSettingsService: DropdownSettingsService, private readonly glOperationsService: GlOperationsService, private readonly notifier: ContractNotifierService, + private readonly transitAgentsService: TransitAgentsService, ) {} private isPhasedCustoms(contract: Contract): boolean { @@ -239,6 +284,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 +359,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 +411,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 +422,11 @@ export class ContractClearanceService { linkedBookingId: cycle?.bookingId ?? null, linkedBookingReference, linkedBookingStatus, + linkedBookingReviewNote, + linkedBookingScheduledDate, dutyAdvice, + dutyDispute, + transitAssignee, workflowFiles, t1, train, @@ -361,6 +438,9 @@ export class ContractClearanceService { ? t1ClosedMilestone.triggeredAt.toISOString() : null, offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', + offload: cycle?.bookingId + ? await this.glOperationsService.offloadState(cycle.bookingId, bookingMilestones) + : null, finalInvoice, riskLevel: riskMilestone?.status === 'COMPLETED' @@ -406,6 +486,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 +737,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 +1068,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 +1097,67 @@ 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 picks the transit officer from the admin-managed roster — + * rejected unless the agent is active and inside its validity window. + * Answering unblocks the declaration for Ethiopia. A later call overwrites + * the name (reassignment) and re-notifies. + */ + async assignTransitAssignee( + contractId: string, + transitAgentId: string, + 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'); + if (!cycle.transitAssigneeRequestedAt) { + throw new BadRequestException( + 'GL Ethiopia has not requested a transit assignee for this clearance yet.', + ); + } + const agent = await this.transitAgentsService.getAssignable(transitAgentId); + + const previous = cycle.transitAssigneeName ?? null; + await this.contractsRepository.updateCycle(cycle.id, { + transitAssigneeName: agent.name, + transitAssigneeAssignedAt: new Date(), + transitAssigneeAssignedByUserId: userId ?? null, + }); + + const updated = await this.contractsService.findById(contractId); + this.notifier.transitAssigneeAssigned(updated, agent.name, previous); + return updated; + } + private async ensureDeclarationPrerequisites( contractId: string, contract: Contract, @@ -955,6 +1168,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') { @@ -970,6 +1193,15 @@ export class ContractClearanceService { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); await this.ensureDeclarationPrerequisites(contractId, contract); + // The draft-declaration accept/change-request loop only exists on the + // booking-scoped clearance page (portal customers never see contract-scoped + // clearance) — skip it here so it can never block the ONE_TIME pre-booking + // flow, which has no UI to complete it. Contracts seeded before this step + // existed have no such row to skip — ignore, `assertPriorComplete` below + // already tolerates a missing milestone as "not required". + await this.workflowService + .skipMilestones(contractId, ['DRAFT_DECLARATION_UPLOADED', 'DRAFT_DECLARATION_ACCEPTED']) + .catch(() => undefined); await this.workflowService.assertPriorComplete( contractId, contract.tradeDirection, @@ -999,12 +1231,6 @@ export class ContractClearanceService { }); } - // Export: the declaration is the last GL ET pre-booking action — release - // immediately so booking creation unlocks without a separate confirm click. - if (contract.tradeDirection === 'EXPORT') { - await this.workflowService.onExportReleased(contractId, userId); - } - return this.contractsService.findById(contractId); } @@ -1061,6 +1287,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 +1462,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 +1472,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 +1485,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) { @@ -1281,6 +1571,10 @@ export class ContractClearanceService { currentPhase: ContractDocPhase.GlEtOutput, }); await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId); + // Release Order is now the last GL DJ pre-booking action (it follows the + // declaration) — release immediately so booking creation unlocks without a + // separate confirm click. + await this.workflowService.onExportReleased(contractId, userId); return { contract: await this.contractsService.findById(contractId), hold: false }; } @@ -1373,80 +1667,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..e5dd120ee 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'; @@ -24,30 +26,22 @@ function freightFor(freightType: string): Freight { /** * The customer-input clearance setting code, or null when no gate applies. * - * - Path B (customs bundled): the customer uploads the documents GL needs to do - * the clearance work → `contract_clearance_{op}_{freight}`. - * - Path A (no customs): the customer clears the cargo himself and uploads his - * own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`, - * reviewed by Operations rather than GL. + * Contract-level IMPORT/EXPORT clearance has been removed — clearance is + * collected per booking instead (see bookings/clearance.util.ts), so this + * always returns null for IMPORT/EXPORT now. * * DOMESTIC/intercity has no border, but a ONE_TIME intercity contract still * collects the admin-configured intercity document set after both signatures - * (ops-reviewed, like Path A). GENERAL intercity contracts skip the contract - * gate and collect the same set per booking instead. + * (ops-reviewed). GENERAL intercity contracts skip the contract gate and + * collect the same set per booking instead. */ export function contractClearanceSettingCode( tradeDirection: string, - freightType: string, - includesCustoms: boolean, + _freightType: string, + _includesCustoms: boolean, ): string | null { if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE; - const op = operationFor(tradeDirection); - if (!op) return null; - const freight = freightFor(freightType); - if (!includesCustoms) { - return `contract_clearance_selfclear_${op}_${freight}`; - } - return `contract_clearance_${op}_${freight}`; + return null; } /** The GL-output (customs output) setting code, keyed on op + freight. */ @@ -84,3 +78,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..f5e2f694c --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-duplicate-guard.spec.ts @@ -0,0 +1,80 @@ +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, + { buildBreakdown: async () => ({ lineItems: [] }) } 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..b86b08444 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts @@ -0,0 +1,165 @@ +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, + {} as never, // transitAgentsService + ); + 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.lane-scope.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts new file mode 100644 index 000000000..96a8a26ac --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts @@ -0,0 +1,122 @@ +import { UnprocessableEntityException } from '@nestjs/common'; + +import { ContractPricingService } from './contract-pricing.service'; +import type { Contract } from './entities/contract.entity'; +import type { Rate } from '../rule-engine/entities/rate.entity'; + +const CT20 = 'ct-20'; +const CT40 = 'ct-40'; +const DCT = 'yard-dct'; +const SEBETA = 'yard-sebeta'; +const GMP = 'yard-gmp'; + +const rate = (over: Partial): Rate => + ({ + rateType: 'CONTAINER_IMPORT', + currency: 'USD', + rateValue: 1000, + rateUnit: 'PER_CONTAINER', + containerTypeId: null, + cargoTypeId: null, + originYardId: DCT, + destinationYardId: SEBETA, + ...over, + }) as Rate; + +const contract = (over: Partial): Contract => + ({ + freightType: 'CONTAINER', + tradeDirection: 'IMPORT', + paymentCurrency: 'USD', + customsClearingEnabled: false, + isHazardous: false, + isReefer: false, + routes: [{ originYardId: DCT, destinationYardId: SEBETA, sortOrder: 0 }], + cargoScope: [{ containerSize: '20ft' }], + ...over, + }) as Contract; + +const service = (liveRates: Rate[]): ContractPricingService => + new ContractPricingService( + {} as never, + { findLiveRates: async () => liveRates } as never, + { + findAll: async () => ({ + items: [ + { id: CT20, sizeFt: 20 }, + { id: CT40, sizeFt: 40 }, + ], + }), + } as never, + { getRate: async () => 1 } as never, + ); + +describe('contract base freight is priced on the contract lane only', () => { + it('prices from the contract route, never another lane (CTR-2026-00065)', async () => { + const breakdown = await service([ + // Same size, other lane — the leak that priced DCT → Sebeta at GMP rates. + rate({ containerTypeId: CT20, destinationYardId: GMP, rateValue: 1690 }), + rate({ containerTypeId: CT20, rateValue: 750 }), + ]).buildBreakdown(contract({})); + expect(breakdown.lineItems).toEqual([ + expect.objectContaining({ code: 'CONTAINER_20FT', unitPrice: 750 }), + ]); + }); + + it('blocks the contract when its lane has no container rate', async () => { + await expect( + service([ + rate({ containerTypeId: CT20, destinationYardId: GMP, rateValue: 1690 }), + ]).buildBreakdown(contract({})), + ).rejects.toThrow(UnprocessableEntityException); + }); + + it('freezes the OVERWEIGHT_PER_TON surcharge on export contracts only', async () => { + const overweight = rate({ + rateType: 'OVERWEIGHT_PER_TON', + trigger: 'OVERWEIGHT', + rateUnit: 'PER_TON', + rateValue: 25, + originYardId: null, + destinationYardId: null, + } as Partial); + + const exported = await service([ + rate({ rateType: 'CONTAINER_EXPORT', containerTypeId: CT20, rateValue: 900 }), + overweight, + ]).buildBreakdown(contract({ tradeDirection: 'EXPORT' })); + expect(exported.lineItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'OVERWEIGHT_PER_TON', unitPrice: 25 }), + ]), + ); + + // Import derives overweight from the route's base freight — never frozen. + const imported = await service([ + rate({ containerTypeId: CT20, rateValue: 750 }), + overweight, + ]).buildBreakdown(contract({})); + expect( + imported.lineItems.some((li) => li.code === 'OVERWEIGHT_PER_TON'), + ).toBe(false); + }); + + it('blocks bulk contracts too instead of borrowing an arbitrary rate', async () => { + const bulk = contract({ freightType: 'BULK', cargoScope: [] }); + await expect( + service([ + rate({ + rateType: 'BULK_IMPORT', + rateUnit: 'PER_TON', + destinationYardId: GMP, + }), + ]).buildBreakdown(bulk), + ).rejects.toThrow(UnprocessableEntityException); + const priced = await service([ + rate({ rateType: 'BULK_IMPORT', rateUnit: 'PER_TON', rateValue: 32 }), + ]).buildBreakdown(bulk); + expect(priced.lineItems).toEqual([ + expect.objectContaining({ code: 'BULK_FREIGHT', unitPrice: 32 }), + ]); + }); +}); 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..d54b0b544 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': @@ -82,6 +84,26 @@ export class ContractPricingService { const lineItems: ContractUnitRateLineItem[] = []; const baseType = this.baseRateType(contract); + // Base rail freight is quoted per route (CK_rates_yard_scope) — only rates + // on the contract's own lane may price it. Matching without the yard filter + // is how a DCT → Sebeta contract froze DCT → GMP (Indode) prices, and the + // frozen snapshot then bills bookings that the route-scoped booking lookup + // would have hard-blocked (CTR-2026-00065). + // ponytail: multi-route contracts price the first lane (same as customs + // clearance below); per-lane pricing needs per-route breakdowns. + const route = [...(contract.routes ?? [])].sort( + (a, b) => a.sortOrder - b.sortOrder, + )[0]; + const onLane = route + ? liveRates.filter( + (r) => + r.rateType === baseType && + r.currency === 'USD' && + r.originYardId === route.originYardId && + r.destinationYardId === route.destinationYardId, + ) + : []; + if (contract.freightType === 'CONTAINER') { const sizes = (contract.cargoScope ?? []) .map((c) => c.containerSize) @@ -95,17 +117,14 @@ export class ContractPricingService { const matchedTypes = containerTypes.filter((ct) => ct.sizeFt === sizeFt); const matchedIds = new Set(matchedTypes.map((ct) => ct.id)); const rate = - liveRates.find( - (r) => - r.rateType === baseType && - r.currency === 'USD' && - r.containerTypeId && - matchedIds.has(r.containerTypeId), - ) ?? - liveRates.find( - (r) => r.rateType === baseType && r.currency === 'USD' && !r.containerTypeId, + onLane.find( + (r) => r.containerTypeId && matchedIds.has(r.containerTypeId), + ) ?? onLane.find((r) => !r.containerTypeId); + if (!rate || Number(rate.rateValue) <= 0) { + throw new UnprocessableEntityException( + `No rail freight rate is configured for ${size} containers on this direction and route — the contract cannot be priced. Ask the rates team to set a live ${baseType} rate for this container type and origin → destination.`, ); - if (!rate) continue; + } lineItems.push({ code: `CONTAINER_${size.toUpperCase()}`, label: `${size} container`, @@ -115,18 +134,29 @@ export class ContractPricingService { }); } } else { - const bulkRate = - liveRates.find((r) => r.rateType === baseType && r.currency === 'USD') ?? null; const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId); - if (bulkRate) { - lineItems.push({ - code: 'BULK_FREIGHT', - label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo', - unit: toContractUnit(bulkRate.rateUnit), - unitPrice: convert(Number(bulkRate.rateValue)), - cargoTypeCode: cargoScope?.cargoType?.code ?? null, - }); + // 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. + // No arbitrary-rate fallback: another commodity's rate must never price + // this contract. + const bulkRate = + (cargoScope?.cargoTypeId + ? onLane.find((r) => r.cargoTypeId === cargoScope.cargoTypeId) + : undefined) ?? + onLane.find((r) => !r.cargoTypeId) ?? + null; + if (!bulkRate || Number(bulkRate.rateValue) <= 0) { + throw new UnprocessableEntityException( + 'No bulk rail freight rate is configured for this cargo type on this direction and route — the contract cannot be priced. Ask the rates team to set a live rate for this commodity and origin → destination.', + ); } + lineItems.push({ + code: 'BULK_FREIGHT', + label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo', + unit: toContractUnit(bulkRate.rateUnit), + unitPrice: convert(Number(bulkRate.rateValue)), + cargoTypeCode: cargoScope?.cargoType?.code ?? null, + }); } // First / last mile trucking unit rates — shown when the contract carries @@ -189,6 +219,26 @@ export class ContractPricingService { }); } } + // Overweight surcharge — EXPORT contracts freeze the OVERWEIGHT_PER_TON + // rate so booking pricing bills the contract's price on excess tons + // (frozenRateByCode wins over the live rate). Always included, no toggle: + // overweight is system-detected at booking, never customer-opted. IMPORT + // never reads this snapshot — its overweight price derives from the + // route's base container freight (see RuleEngineService). + if (contract.tradeDirection === 'EXPORT') { + const overweight = liveRates.find( + (r) => r.trigger === 'OVERWEIGHT' && r.currency === 'USD', + ); + if (overweight && Number(overweight.rateValue) > 0) { + lineItems.push({ + code: 'OVERWEIGHT_PER_TON', + label: 'Overweight surcharge (per excess ton)', + unit: toContractUnit(overweight.rateUnit), + unitPrice: convert(Number(overweight.rateValue)), + conditionalOn: 'is_overweight', + }); + } + } // Lashing / cargo securing — BULK only, shown when the contract's commodity // needs lashing (cargoType.hasLashing). The commodity-scoped rate for the // contract's direction wins over the commodity-wide catch-all; billed at @@ -229,9 +279,6 @@ export class ContractPricingService { // one display line per contract size that has a configured rate. A size // with no rate shows nothing here and hard-blocks at booking time. // ponytail: bookings bill the live route rate, not a frozen snapshot. - const route = [...(contract.routes ?? [])].sort( - (a, b) => a.sortOrder - b.sortOrder, - )[0]; const onLeg = route ? liveRates.filter( (r) => @@ -279,9 +326,6 @@ export class ContractPricingService { if (contract.customsClearingEnabled) { // Strict, no route-less fallback. // ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots. - const route = [...(contract.routes ?? [])].sort( - (a, b) => a.sortOrder - b.sortOrder, - )[0]; const onLeg = route ? liveRates.filter( (r) => 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..1a4de8245 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, @@ -211,6 +225,7 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'SUBMITTED', + submittedAt: new Date(), } as never); const updated = await this.contractsService.findById(contractId); this.notifier.submittedToStaff(updated); @@ -227,6 +242,7 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'SUBMITTED', + submittedAt: new Date(), } as never); const updated = await this.contractsService.findById(contractId); this.notifier.submittedToStaff(updated); @@ -243,6 +259,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 +276,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 +299,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 +570,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 +982,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 +1031,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 +1097,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 +1126,7 @@ export class ContractTransitionService { signerDisplayName, signedAt: new Date(), signatureFileId: fileRecord.id, + stampFileId: stampRecord?.id ?? null, consentText: dto.consentText ?? null, }); @@ -1053,7 +1165,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 +1199,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 +1256,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 +1277,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 +1296,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..8c004ade0 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 picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns', + }) + assignTransitAssignee( + @Param('id', ParseUUIDPipe) id: string, + @Body('transitAgentId', ParseUUIDPipe) transitAgentId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.assignTransitAssignee( + id, + transitAgentId, + 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, @@ -1148,6 +1294,20 @@ export class ContractsController { ); } + @Post('bookings/:bookingId/final-invoice/approve') + @ApiOperation({ + summary: 'Customer approves the drafted final invoice — unlocks the payment slip', + }) + approveFinalInvoice( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.approveFinalInvoice( + bookingId, + resolveAuthUserId(user), + ); + } + @Post('bookings/:bookingId/final-invoice-slip') @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @@ -1240,7 +1400,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..658acf39d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -17,10 +17,12 @@ import { NotificationInboxModule } from '../notification-inbox/notification-inbo import { BookingsModule } from '../bookings/bookings.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { ContractTemplatesModule } from '../contract-templates/contract-templates.module'; +import { TransitAgentsModule } from '../transit-agents/transit-agents.module'; 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'; @@ -30,6 +32,8 @@ import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ContractBookingService } from './contract-booking.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; +import { GlExchangeController } from './gl-exchange.controller'; +import { GlExchangeService } from './gl-exchange.service'; import { BookingRequestService } from './booking-request.service'; import { BookingRequestRepository } from './booking-request.repository'; @@ -88,6 +92,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum // Provides the admin-editable contract document templates consumed by // ContractDocumentViewModelBuilder when rendering contract PDFs. ContractTemplatesModule, + TransitAgentsModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). forwardRef(() => BookingsModule), @@ -101,10 +106,11 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum config.get('app.cbeExchange') ?? {}, }), ], - controllers: [ContractsController], + controllers: [ContractsController, GlExchangeController], providers: [ ContractsService, ContractsRepository, + ContractExpiryService, ContractPricingService, ContractNotifierService, ContractTransitionService, @@ -115,6 +121,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractBookingService, ClearanceMilestoneService, GlOperationsService, + GlExchangeService, BookingRequestService, BookingRequestRepository, // Contract PDF providers (template resolution + render + PDF) — stateless @@ -134,6 +141,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum BookingClearanceService, ContractBookingService, ClearanceMilestoneService, + GlExchangeService, ], }) export class ContractsModule {} 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..5c0b5e29e 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,10 @@ 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 { ContractPricingService } from './contract-pricing.service'; import { FileRecord } from '../files/entities/file.entity'; /** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */ @@ -40,6 +45,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 +113,8 @@ export class ContractsService { private readonly companiesService: CompaniesService, private readonly filesService: FilesService, private readonly minioService: MinioService, + private readonly documentHistory: ContractDocumentHistoryService, + private readonly pricingService: ContractPricingService, ) {} /** Generate a unique contract reference number (CTR-YYYY-NNNNN). */ @@ -155,6 +215,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 +291,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 @@ -225,6 +333,33 @@ export class ContractsService { ); } + // Price the contract BEFORE anything persists: a lane with no configured + // rate 422s here and the wizard shows its blocking modal — with no orphan + // DRAFT row left behind for the customer to trip over on retry. The probe + // carries exactly the fields buildBreakdown prices from; relation-only + // niceties (cargoType labels) are absent, which only affects display + // lines, never the missing-rate gates. + await this.pricingService.buildBreakdown({ + tradeDirection: dto.tradeDirection, + freightType: dto.freightType, + paymentCurrency: 'USD', + customsClearingEnabled: includesCustoms, + isHazardous: dto.isHazardous ?? false, + isReefer: dto.isReefer ?? false, + equipmentReturn: dto.equipmentReturn ?? null, + firstMilePickupAddress: dto.firstMilePickupAddress ?? null, + lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? null, + routes: (dto.routes ?? []).map((r, i) => ({ + originYardId: r.originYardId, + destinationYardId: r.destinationYardId, + sortOrder: r.sortOrder ?? i, + })), + cargoScope: (dto.cargoScope ?? []).map((c) => ({ + containerSize: c.containerSize ?? null, + cargoTypeId: c.cargoTypeId ?? null, + })), + } as unknown as Contract); + // An explicit reference is caller-chosen — a collision there is a real // conflict and should surface. Auto-generated references retry past a // concurrent insert that grabbed the same sequence number. @@ -291,7 +426,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 +441,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 +588,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 +615,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 +677,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 +875,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..8c1bf763d 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.', @@ -159,6 +175,16 @@ export class CreateBookingUnderContractDto { @IsDateString() scheduledDate?: string; + @ApiPropertyOptional({ + description: + 'EXPORT rail only: the specific train (schedule id) picked from ' + + 'GET /bookings/:id/export-trains for the shipment day. The reserve path ' + + 'locks onto this train; 409 when it no longer fits. Ignored otherwise.', + }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + @ApiPropertyOptional({ enum: SHIPMENT_EQUIPMENT_RETURNS, description: 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/clearance-milestone.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts index 14e3b86dd..57bcf4820 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts @@ -46,6 +46,9 @@ export interface MilestoneMetadata { declarationSerial?: string; /** When the gate pass was physically granted (GL DJ captures the time). */ gatepassAt?: string; + /** DRAFT_DECLARATION_UPLOADED → the estimated price GL sent the customer. */ + draftDeclarationPrice?: number; + draftDeclarationCurrency?: string; } /** 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..cdf6b4ecf 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; @@ -206,9 +216,21 @@ export class Contract extends BaseEntity { @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) expiresAt?: Date | null; + /** When the customer last submitted this contract (DRAFT/CHANGES_REQUESTED → SUBMITTED). */ + @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) + submittedAt?: Date | null; + @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 +357,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/final-invoice-approval.spec.ts b/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts new file mode 100644 index 000000000..4875d9e24 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts @@ -0,0 +1,102 @@ +import { BadRequestException } from '@nestjs/common'; +import { Freight } from '@edr/types'; + +import { GlOperationsService } from './gl-operations.service'; +import { Booking } from '../bookings/entities/booking.entity'; + +/** + * The GL Djibouti final invoice lands as a DRAFT: the customer must approve it + * (which issues it) before a payment slip is accepted. + */ +describe('GlOperationsService — final invoice approval', () => { + const invoice = (status: Freight.InvoiceStatus, issuedAt: Date | null = null) => ({ + id: 'inv-1', + invoiceNumber: 'INV-1', + status, + totalAmount: 1500, + currency: 'ETB', + issuedAt, + paidAt: null, + }); + + let billingService: { findInvoice: jest.Mock; updateStatus: jest.Mock }; + let filesService: { findByResource: jest.Mock; upsertByCode: jest.Mock }; + let notifier: { finalInvoiceApprovedToStaff: jest.Mock; dutySlipUploadedToStaff: jest.Mock }; + let service: GlOperationsService; + + beforeEach(() => { + billingService = { + findInvoice: jest.fn(), + updateStatus: jest.fn().mockResolvedValue(undefined), + }; + filesService = { + findByResource: jest.fn().mockResolvedValue([]), + upsertByCode: jest.fn().mockResolvedValue(undefined), + }; + notifier = { + finalInvoiceApprovedToStaff: jest.fn(), + dutySlipUploadedToStaff: jest.fn(), + }; + const dataSource = { + getRepository: (entity: unknown) => + entity === Booking + ? { findOne: jest.fn().mockResolvedValue({ id: 'bk-1', reference: 'BKG-1' }) } + : { findOne: jest.fn().mockResolvedValue({ description: 'Post-offload charges' }) }, + }; + + service = new GlOperationsService( + dataSource as never, + filesService as never, + {} as never, // milestoneService + billingService as never, + notifier as never, + ); + }); + + it('issues the draft on customer approval and reports approvedAt', async () => { + const issued = new Date('2026-07-28T09:00:00.000Z'); + billingService.findInvoice + .mockResolvedValueOnce(invoice(Freight.InvoiceStatus.Draft)) + .mockResolvedValueOnce(invoice(Freight.InvoiceStatus.Issued, issued)); + + const summary = await service.approveFinalInvoice('bk-1', 'user-1'); + + expect(billingService.updateStatus).toHaveBeenCalledWith( + 'inv-1', + Freight.InvoiceStatus.Issued, + ); + expect(notifier.finalInvoiceApprovedToStaff).toHaveBeenCalled(); + expect(summary.approvedAt).toBe(issued.toISOString()); + }); + + it('is a no-op when the invoice was already approved', async () => { + billingService.findInvoice.mockResolvedValue( + invoice(Freight.InvoiceStatus.Issued, new Date()), + ); + + await service.approveFinalInvoice('bk-1'); + + expect(billingService.updateStatus).not.toHaveBeenCalled(); + }); + + it('refuses a payment slip while the invoice is still a draft', async () => { + billingService.findInvoice.mockResolvedValue(invoice(Freight.InvoiceStatus.Draft)); + + await expect( + service.uploadFinalInvoiceSlip('bk-1', { originalname: 'slip.pdf' } as never), + ).rejects.toThrow(BadRequestException); + expect(filesService.upsertByCode).not.toHaveBeenCalled(); + }); + + it('accepts the payment slip once approved', async () => { + billingService.findInvoice.mockResolvedValue( + invoice(Freight.InvoiceStatus.Issued, new Date()), + ); + + await service.uploadFinalInvoiceSlip('bk-1', { originalname: 'slip.pdf' } as never); + + expect(filesService.upsertByCode).toHaveBeenCalledWith( + expect.objectContaining({ code: 'final_invoice_slip' }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/gl-exchange.controller.ts b/apps/edr-freight-api/src/modules/contracts/gl-exchange.controller.ts new file mode 100644 index 000000000..6ea0dacbe --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/gl-exchange.controller.ts @@ -0,0 +1,133 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + ParseUUIDPipe, + Patch, + Post, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { actorLabel } from '../warehouses/current-actor.util'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { hasFreightPermission } from '../../common/freight-permission.util'; +import { resolveAuthUserId } from '../../common/resolve-auth-user-id'; + +import { + GlExchangeService, + type GlExchangeActor, + type GlExchangeSide, +} from './gl-exchange.service'; + +/** Either GL desk may read and post; ownership decides who may edit. */ +const GL_EXCHANGE_PERMS = [ + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.clearanceDjActions, +]; + +/** Multipart bodies arrive as strings — "true"/"1" mean checked. */ +const asBool = (raw: string | boolean | undefined): boolean => + raw === true || raw === 'true' || raw === '1'; + +@ApiTags('gl-exchange') +@ApiBearerAuth() +@Controller('gl-exchange') +export class GlExchangeController { + constructor(private readonly exchangeService: GlExchangeService) {} + + @Get(':entityId') + @BookingStaff(GL_EXCHANGE_PERMS) + @ApiOperation({ + summary: 'GL ET ↔ GL DJ shared documents for a booking or contract', + }) + list( + @Param('entityId', ParseUUIDPipe) entityId: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.exchangeService.list(entityId, resolveAuthUserId(user)); + } + + @Post(':entityId') + @BookingStaff(GL_EXCHANGE_PERMS) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Share a document with the other GL desk' }) + upload( + @Param('entityId', ParseUUIDPipe) entityId: string, + @UploadedFile() file: Express.Multer.File | undefined, + @Body('title') title: string, + @Body('visibleToCustomer') visibleToCustomer: string | undefined, + @CurrentUser() user: TCurrentUser, + ) { + return this.exchangeService.upload( + entityId, + file, + { title, visibleToCustomer: asBool(visibleToCustomer) }, + this.actor(user), + ); + } + + @Patch('documents/:documentId') + @BookingStaff(GL_EXCHANGE_PERMS) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'Uploader edits a shared document (title, visibility, file)', + }) + update( + @Param('documentId', ParseUUIDPipe) documentId: string, + @UploadedFile() file: Express.Multer.File | undefined, + @Body('title') title: string | undefined, + @Body('visibleToCustomer') visibleToCustomer: string | undefined, + @CurrentUser() user: TCurrentUser, + ) { + return this.exchangeService.update( + documentId, + { + title, + visibleToCustomer: + visibleToCustomer == null ? undefined : asBool(visibleToCustomer), + }, + file, + resolveAuthUserId(user), + ); + } + + @Delete('documents/:documentId') + @BookingStaff(GL_EXCHANGE_PERMS) + @HttpCode(204) + @ApiOperation({ summary: 'Uploader removes a shared document' }) + async remove( + @Param('documentId', ParseUUIDPipe) documentId: string, + @CurrentUser() user: TCurrentUser, + ) { + await this.exchangeService.remove(documentId, resolveAuthUserId(user)); + } + + /** + * Which desk is posting. A user holding only the Djibouti actions permission + * is Djibouti; everyone else (GL Ethiopia, and super admins who hold both) + * posts as Ethiopia. + */ + private actor(user: TCurrentUser): GlExchangeActor { + const side: GlExchangeSide = + !hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) && + hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) + ? 'DJ' + : 'ET'; + return { + userId: resolveAuthUserId(user), + name: actorLabel(user) ?? null, + side, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/gl-exchange.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-exchange.service.ts new file mode 100644 index 000000000..1b0d88b2a --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/gl-exchange.service.ts @@ -0,0 +1,198 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { Freight } from '@edr/types'; + +import { FilesService } from '../files/files.service'; +import type { FileRecord } from '../files/entities/file.entity'; + +/** + * `files.resource` of the GL Ethiopia ↔ GL Djibouti document exchange. The + * thread is keyed by the entity the two desks are working on — a booking id on + * the per-booking clearance pages, a contract id on the pre-booking ones — so + * both desks opening the same record see the same documents. + */ +export const GL_EXCHANGE_RESOURCE = 'gl_exchange'; + +export type GlExchangeSide = 'ET' | 'DJ'; + +export interface GlExchangeActor { + userId: string; + name?: string | null; + side: GlExchangeSide; +} + +export interface GlExchangeUploadInput { + title: string; + visibleToCustomer: boolean; +} + +/** + * Free-form document exchange between the two Global Logistics desks. Anything + * either side needs the other to have (scans, correspondence, corrected forms) + * lands here under a title they choose, instead of a fixed clearance slot. + * + * Rules, all enforced here rather than in the UI: + * - both desks read every document in a thread, whoever uploaded it; + * - only the uploader may retitle, replace or remove one; + * - the customer sees only what its uploader marked visible. + */ +@Injectable() +export class GlExchangeService { + constructor(private readonly filesService: FilesService) {} + + /** Every document on one thread, newest first, from a GL desk's view. */ + async list( + entityId: string, + viewerId: string, + ): Promise { + const records = await this.filesService.findByResource( + entityId, + GL_EXCHANGE_RESOURCE, + ); + return this.sort(records.map((r) => this.toDto(r, viewerId))); + } + + /** + * The customer-facing slice across several threads (a booking and the + * contract it belongs to). Never exposes internal documents, and never marks + * anything editable — the customer is not a GL desk. + */ + async listVisibleToCustomer( + entityIds: string[], + ): Promise { + const ids = [...new Set(entityIds.filter(Boolean))]; + if (ids.length === 0) return []; + const grouped = await this.filesService.findByResourceIdsGrouped( + ids, + GL_EXCHANGE_RESOURCE, + ); + const visible = [...grouped.values()] + .flat() + .filter((r) => r.visibleToCustomer); + return this.sort(visible.map((r) => this.toDto(r, null))); + } + + async upload( + entityId: string, + file: Express.Multer.File | undefined, + input: GlExchangeUploadInput, + actor: GlExchangeActor, + ): Promise { + const title = input.title?.trim(); + if (!title) throw new BadRequestException('A document title is required.'); + if (!file) throw new BadRequestException('A file is required.'); + + const record = await this.filesService.upload({ + resourceId: entityId, + resource: GL_EXCHANGE_RESOURCE, + // No fixed slot exists for these — `code` carries the uploading desk, so + // a document's origin survives even if the uploader leaves the org. + code: actor.side, + file, + title, + visibleToCustomer: input.visibleToCustomer, + uploadedByUserId: actor.userId, + uploadedByName: actor.name ?? null, + }); + return this.toDto(record, actor.userId); + } + + /** + * Retitle, re-share or replace a document. Uploader only — the other desk + * reads it but never edits it. A replacement file supersedes the old record + * (soft-deleted, bytes kept) and carries its metadata forward. + */ + async update( + documentId: string, + patch: { title?: string; visibleToCustomer?: boolean }, + file: Express.Multer.File | undefined, + actorId: string, + ): Promise { + const record = await this.assertUploader(documentId, actorId); + const title = patch.title?.trim(); + if (patch.title != null && !title) { + throw new BadRequestException('A document title is required.'); + } + + if (file) { + const replacement = await this.filesService.upload({ + resourceId: record.resourceId, + resource: GL_EXCHANGE_RESOURCE, + code: record.code, + file, + title: title ?? record.title, + visibleToCustomer: patch.visibleToCustomer ?? record.visibleToCustomer, + uploadedByUserId: record.uploadedByUserId, + uploadedByName: record.uploadedByName, + }); + await this.filesService.remove(record.id); + return this.toDto(replacement, actorId); + } + + const updated = await this.filesService.updateMeta(record.id, { + ...(title ? { title } : {}), + ...(patch.visibleToCustomer != null + ? { visibleToCustomer: patch.visibleToCustomer } + : {}), + }); + return this.toDto(updated, actorId); + } + + /** Uploader-only removal (soft delete — the stored bytes are kept). */ + async remove(documentId: string, actorId: string): Promise { + const record = await this.assertUploader(documentId, actorId); + await this.filesService.remove(record.id); + } + + private async assertUploader( + documentId: string, + actorId: string, + ): Promise { + const record = await this.filesService.findById(documentId); + if (record.resource !== GL_EXCHANGE_RESOURCE) { + throw new NotFoundException(`Exchange document ${documentId} not found`); + } + if (record.uploadedByUserId !== actorId) { + throw new ForbiddenException( + 'Only the person who uploaded this document can change it.', + ); + } + return record; + } + + private sort( + docs: Freight.GlExchangeDocument[], + ): Freight.GlExchangeDocument[] { + return docs.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt)); + } + + private toDto( + record: FileRecord, + viewerId: string | null, + ): Freight.GlExchangeDocument { + return { + id: record.id, + entityId: record.resourceId, + // Pre-title rows (none in practice) fall back to the filename so a list + // never renders a blank row. + title: record.title ?? record.name, + side: record.code === 'DJ' ? 'DJ' : 'ET', + visibleToCustomer: record.visibleToCustomer, + uploadedById: record.uploadedByUserId, + uploadedByName: record.uploadedByName, + uploadedAt: record.createdAt.toISOString(), + file: { + id: record.id, + name: record.name, + url: record.url, + size: record.size, + mimeType: record.mimeType, + }, + canEdit: viewerId != null && record.uploadedByUserId === viewerId, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index 106acdb0b..c7be2372b 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -282,6 +282,85 @@ export class GlOperationsService { }; } + /** + * Offload facts for a booking, read-only: what came off the train at its + * destination (containers, wagons, tonnes) and where the goods went. Sourced + * from the booking's warehouse-inventory row — written by the auto-unload + * that runs on train arrival for both directions. + */ + async offloadState( + bookingId: string, + milestones: Array<{ milestoneCode: string; status: string; triggeredAt?: Date | null }>, + ): Promise { + const [row]: Array<{ + destination: string | null; + containers: number; + wagons: number; + bookedWeight: string | null; + inventoryStatus: string | null; + unloadedAt: Date | null; + grnNumber: string | null; + offloadedWeight: string | null; + warehouse: string | null; + warehouseYard: string | null; + zone: string | null; + }> = await this.dataSource.query( + `SELECT COALESCE(dy.label, dy.code) AS "destination", + (SELECT COUNT(*)::int + FROM freight.booking_container bc + JOIN freight.booking_container_units bcu + ON bcu.booking_container_id = bc.id AND bcu.deleted_at IS NULL + WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL) AS "containers", + (SELECT COUNT(*)::int + FROM freight.wagon_booking_allocations wba + WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL) AS "wagons", + b.cargo_total_weight_vgm AS "bookedWeight", + inv.status AS "inventoryStatus", + inv.unloaded_at AS "unloadedAt", + inv.grn_number AS "grnNumber", + inv.weight AS "offloadedWeight", + wh.name AS "warehouse", + wy.name AS "warehouseYard", + wz.name AS "zone" + FROM freight.bookings b + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN LATERAL ( + SELECT i.* + FROM freight.warehouse_inventory i + WHERE i.booking_id = b.id AND i.deleted_at IS NULL + ORDER BY i.unloaded_at DESC NULLS LAST, i.created_at DESC + LIMIT 1 + ) inv ON TRUE + LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id + LEFT JOIN freight.warehouse_yards wy ON wy.id = inv.yard_id + LEFT JOIN freight.warehouse_zones wz ON wz.id = inv.zone_id + WHERE b.id = $1 AND b.deleted_at IS NULL`, + [bookingId], + ); + + const milestone = milestones.find((m) => m.milestoneCode === 'OFFLOADED'); + const offloadedAt = + milestone?.status === 'COMPLETED' && milestone.triggeredAt + ? new Date(milestone.triggeredAt).toISOString() + : (row?.unloadedAt ? new Date(row.unloadedAt).toISOString() : null); + // The warehouse records the real offloaded tonnage; before it does, the + // booked VGM is the best number we have. + const weight = Number(row?.offloadedWeight ?? 0) || Number(row?.bookedWeight ?? 0); + const location = [row?.warehouse, row?.warehouseYard, row?.zone].filter(Boolean).join(' › '); + + return { + offloaded: milestone?.status === 'COMPLETED' || Boolean(row?.unloadedAt), + offloadedAt, + destination: row?.destination ?? null, + containers: row?.containers ?? 0, + wagons: row?.wagons ?? 0, + weightTons: weight || null, + grnNumber: row?.grnNumber ?? null, + location: location || null, + inventoryStatus: row?.inventoryStatus ?? null, + }; + } + /** * GL Djibouti uploads T1 transport documents (multi-file) once the gate pass * is secured on the train schedule (which itself follows wagon allocation). @@ -381,8 +460,9 @@ export class GlOperationsService { /** * GL Djibouti raises the post-offload final invoice (export): manual amount + - * attached invoice document. The customer pays offline and attaches a slip; - * GL (ET or DJ) then confirms to settle it. + * attached invoice document. It is issued as a DRAFT the customer must approve + * first; only then do they pay offline and attach a slip, and GL (ET or DJ) + * confirms to settle it. */ async createFinalInvoice( bookingId: string, @@ -445,7 +525,8 @@ export class GlOperationsService { amount: input.amount, }, ], - status: Freight.InvoiceStatus.Issued, + // DRAFT until the customer approves it — approveFinalInvoice issues it. + status: Freight.InvoiceStatus.Draft, }); await this.filesService.upsertByCode({ @@ -467,6 +548,40 @@ export class GlOperationsService { return summary; } + /** + * Customer approves the drafted final invoice — issues it, which is what + * unlocks the payment slip upload. Idempotent: approving twice is a no-op. + */ + async approveFinalInvoice( + bookingId: string, + userId?: string, + ): Promise { + const booking = await this.getBooking(bookingId); + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) { + throw new BadRequestException('No final invoice has been raised for this shipment.'); + } + if ( + invoice.status === Freight.InvoiceStatus.Cancelled || + invoice.status === Freight.InvoiceStatus.Expired + ) { + throw new BadRequestException('The final invoice is no longer payable.'); + } + if (invoice.status === Freight.InvoiceStatus.Draft) { + await this.billingService.updateStatus(invoice.id, Freight.InvoiceStatus.Issued); + this.notifier.finalInvoiceApprovedToStaff(booking); + } + + void userId; + const summary = await this.finalInvoiceSummary(bookingId); + if (!summary) throw new NotFoundException('Final invoice not found.'); + return summary; + } + /** Customer attaches the payment slip for the final invoice. */ async uploadFinalInvoiceSlip( bookingId: string, @@ -483,6 +598,11 @@ export class GlOperationsService { if (!invoice) { throw new BadRequestException('No final invoice has been issued for this shipment.'); } + if (invoice.status === Freight.InvoiceStatus.Draft) { + throw new BadRequestException( + 'Approve the final invoice before attaching a payment slip.', + ); + } if (invoice.status === Freight.InvoiceStatus.Paid) { throw new BadRequestException('The final invoice is already paid.'); } @@ -688,6 +808,8 @@ export class GlOperationsService { description: line?.description ?? null, invoiceFile: toRef('final_invoice'), slipFile: toRef('final_invoice_slip'), + // Issuing IS the customer approval (createFinalInvoice leaves it DRAFT). + approvedAt: invoice.issuedAt ? new Date(invoice.issuedAt).toISOString() : null, confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null, }; } diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts index f155a27be..78d0d6b47 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -2,7 +2,9 @@ import { BadRequestException } from '@nestjs/common'; import { catalogEntriesForTradeDirection, declarationFileLabel, + draftDeclarationFileLabel, isDeclarationFileCode, + isDraftDeclarationFileCode, isImportTransitPermitFileCode, isExportTransportFileCode, isT1TransportFileCode, @@ -72,6 +74,52 @@ export async function persistDeclarationUploads( ); } +/** Require at least one draft declaration file in the upload batch. */ +export function assertDraftDeclarationFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No draft declaration documents uploaded'); + } +} + +/** Assign stable `draft_declaration_*` codes so multi-file uploads always pass validation. */ +export function normalizeDraftDeclarationFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `draft_declaration_${index}`, + })); +} + +/** Replace all draft declaration files on a resource with a new multi-file upload batch. */ +export async function persistDraftDeclarationUploads( + store: DeclarationFileStore, + resourceId: string, + resource: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeDraftDeclarationFieldNames(files); + assertDraftDeclarationFiles(normalized); + + const existing = await store.findByResource(resourceId, resource); + await Promise.all( + existing + .filter((f) => f.code && isDraftDeclarationFileCode(f.code)) + .map((f) => store.deleteByCode(resourceId, resource, f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId, + resource, + code: `draft_declaration_${index}`, + file, + }), + ), + ); +} + /** Require at least one transit permit file in the upload batch. */ export function assertTransitPermitFiles(files: Express.Multer.File[]): void { if (files.length === 0) { @@ -341,6 +389,22 @@ export function buildWorkflowFiles( }); }); + const extraDraftDeclarations = files + .filter((f) => f.code && isDraftDeclarationFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraDraftDeclarations.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: draftDeclarationFileLabel(index), + uploadedBy: 'gl_et', + category: 'draft_declaration', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + if (tradeDirection === 'IMPORT') { const extraTransit = files .filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code)) 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..a6c8c8d6f --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts @@ -0,0 +1,189 @@ +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 transitAgentsService: { getAssignable: 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(), + }; + transitAgentsService = { + getAssignable: jest.fn().mockResolvedValue({ id: 'agent-1', name: 'Ahmed Bourhan' }), + }; + 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, + transitAgentsService 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', 'agent-1', 'dj-1'); + + expect(transitAgentsService.getAssignable).toHaveBeenCalledWith('agent-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', + }), + ); + transitAgentsService.getAssignable.mockResolvedValue({ + id: 'agent-2', + name: 'Fatouma Ali', + }); + + await service.assignTransitAssignee('ctr-1', 'agent-2', 'dj-1'); + + expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith( + expect.anything(), + 'Fatouma Ali', + 'Ahmed Bourhan', + ); + }); + + it('refuses a suspended or out-of-window agent', async () => { + repo.currentCycle.mockResolvedValue( + cycle({ transitAssigneeRequestedAt: new Date() }), + ); + transitAgentsService.getAssignable.mockRejectedValue( + new BadRequestException('suspended'), + ); + await expect( + service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('refuses before Ethiopia has asked', async () => { + await expect( + service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'), + ).rejects.toThrow(/not requested/i); + expect(transitAgentsService.getAssignable).not.toHaveBeenCalled(); + }); + }); + + 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/file-upload-settings/file-upload-settings.service.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts index 947bb5ffb..9651d4f37 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts @@ -15,6 +15,11 @@ import { FILE_UPLOAD_SETTINGS_REPOSITORY, IFileUploadSettingsRepository, } from "./interfaces/file-upload-settings.repository.interface"; +import { + COMPANY_ONBOARDING_CODE_PREFIX, + POA_DELEGATION_FILE_KEY, + poaDelegationField, +} from "./poa-delegation.constants"; @Injectable() export class FileUploadSettingsService { @@ -40,6 +45,22 @@ export class FileUploadSettingsService { async getByCode(code: string): Promise { const setting = await this.repository.findByCode(code); if (!setting) throw new NotFoundException(`Setting "${code}" not found`); + return this.withPoaDelegationField(setting); + } + + /** + * Company onboarding sets always carry the DARS delegation paper, whether or + * not anyone configured a row for it — see poa-delegation.constants.ts. Every + * consumer (the portal's PoA step, the onboarding gate) reads the set through + * here, so this is the single place the field can be guaranteed. + */ + private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting { + if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting; + const fields = setting.fields ?? []; + if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting; + + const lastOrder = fields.reduce((max, f) => Math.max(max, f.displayOrder), 0); + setting.fields = [...fields, poaDelegationField(lastOrder + 1)]; return setting; } diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts new file mode 100644 index 000000000..9085cc018 --- /dev/null +++ b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts @@ -0,0 +1,52 @@ +import { FileUploadField } from "./entities/file-upload-field.entity"; + +/** + * The DARS delegation paper — the document that evidences a company's Power of + * Attorney (EDRFREIGHT-358). + * + * Every other onboarding document is admin-managed: the rows in + * `file_upload_fields` are edited from the backoffice file-settings editor and + * the seeder deliberately inserts none. This one is different — a company that + * names a PoA must produce a delegation paper authenticated by the Documents + * Authentication and Registration Service, and that is a legal requirement + * rather than a configuration choice. So the field is defined here in code and + * injected into the company onboarding sets on read: no row to forget to seed, + * and deleting one in the editor cannot silently switch the requirement off. + */ + +/** FileRecord `code` (and upload field key) of the live delegation paper. */ +export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; + +/** Code for a delegation paper staged in an open change request (not yet live). */ +export const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending"; + +/** Customer-facing name of the document, used by the API and both web apps. */ +export const POA_DELEGATION_LABEL = "DARS Delegation Paper"; + +/** Prefix of the setting codes the field is injected into. */ +export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_"; + +const POA_DELEGATION_HELP = + "Delegation paper issued by the Documents Authentication and Registration " + + "Service (DARS) delegating the representative named above. Upload the " + + "authenticated copy — a plain letter is not accepted."; + +/** + * The field descriptor. `isRequired` stays false because the paper is only due + * once a PoA has actually been named (or the company operates as a freight + * forwarder) — a rule that spans form fields as well as files, so it is + * enforced in CompaniesService rather than by this flag. + */ +export function poaDelegationField(displayOrder: number): FileUploadField { + return { + fileKey: POA_DELEGATION_FILE_KEY, + fileLabel: POA_DELEGATION_LABEL, + helpText: POA_DELEGATION_HELP, + isRequired: false, + isMultiple: false, + maxFiles: 1, + allowedExtensions: ["pdf", "jpg", "jpeg", "png"], + maxSizeMb: 10, + displayOrder, + } as FileUploadField; +} 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..4e2d40f55 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,41 @@ 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; + + /** + * Free-text label chosen by the uploader, when the document has no fixed slot + * (`code`) to name it — the GL Ethiopia ↔ GL Djibouti exchange. Null for every + * catalog-driven upload, whose label comes from its code. + */ + @Column({ name: "title", type: "varchar", length: 300, nullable: true }) + title!: string | null; + + /** Uploader's choice to share the document with the customer's portal. */ + @Column({ name: "visible_to_customer", type: "boolean", default: false }) + visibleToCustomer!: boolean; + + /** Who uploaded it — the only user allowed to edit or remove it afterwards. */ + @Column({ name: "uploaded_by_user_id", type: "uuid", nullable: true }) + uploadedByUserId!: string | null; + + /** Uploader's display name, resolved once so lists need no IAM lookup. */ + @Column({ + name: "uploaded_by_name", + type: "varchar", + length: 200, + nullable: true, + }) + uploadedByName!: string | null; } diff --git a/apps/edr-freight-api/src/modules/files/files.controller.ts b/apps/edr-freight-api/src/modules/files/files.controller.ts index 6978ad446..fc58a731c 100644 --- a/apps/edr-freight-api/src/modules/files/files.controller.ts +++ b/apps/edr-freight-api/src/modules/files/files.controller.ts @@ -51,7 +51,11 @@ export class FilesController { @Query("download") download: string | undefined, @Res() res: Response, ) { - const record = await this.filesService.findById(fileId); + // Includes soft-deleted records: a superseded document (replaced via a + // single-file document slot, or resolved as part of a license/PoA swap) + // is only reachable by UUID through the change-request/version-history + // diff, where reviewers need to open the "previous" file to compare it. + const record = await this.filesService.findByIdIncludingDeleted(fileId); // Chat attachments are cross-tenant sensitive and this route has no // ownership check, so a leaked/guessed UUID would hand one company's file to @@ -63,7 +67,9 @@ export class FilesController { ); } - const { stream } = await this.filesService.streamById(fileId); + const { stream } = await this.filesService.streamById(fileId, { + includeDeleted: true, + }); const forceDownload = download === "1" || download === "true"; const disposition = forceDownload ? "attachment" : "inline"; 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..e76240704 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -15,6 +15,11 @@ export interface CreateFileInput { resource: string; code: string; file: Express.Multer.File; + /** Optional metadata for free-form uploads (GL exchange) — see FileRecord. */ + title?: string | null; + visibleToCustomer?: boolean; + uploadedByUserId?: string | null; + uploadedByName?: string | null; } /** @@ -101,16 +106,87 @@ export class FilesService { url, size: file.size, mimeType: file.mimetype, + title: input.title ?? null, + visibleToCustomer: input.visibleToCustomer ?? false, + uploadedByUserId: input.uploadedByUserId ?? null, + uploadedByName: input.uploadedByName ?? null, }); } - /** Replace existing file row for the same resource + code (e.g. contract PDF). */ - async upsertByCode(input: CreateFileInput): Promise { + /** + * Edit the uploader-authored metadata of a stored file (title, customer + * visibility). Bytes are untouched — callers replacing content upload a new + * record instead. + */ + async updateMeta( + id: string, + patch: { title?: string; visibleToCustomer?: boolean }, + ): Promise { + const updated = await this.filesRepository.update(id, patch); + if (!updated) throw new NotFoundException(`File ${id} not found`); + return updated; + } + + /** + * 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, @@ -169,6 +245,23 @@ export class FilesService { return record; } + /** + * Same as {@link findById}, but also matches a soft-deleted record — a + * superseded document (replaced via a single-file slot, or a resolved + * license/PoA swap) is exactly this: gone from every live listing, but its + * id is still handed to reviewers in the change-request/version-history + * diff so they can open the "previous" file for comparison. Only the + * preview/download route should use this; every other caller wants the + * default (soft-deleted = not found). + */ + async findByIdIncludingDeleted(id: string): Promise { + const record = await this.filesRepository.findById(id, { + withDeleted: true, + }); + if (!record) throw new NotFoundException(`File ${id} not found`); + return record; + } + /** * Record a reviewer verdict on one document. `change_requested` keeps the note * (the customer sees it verbatim); any other verdict clears it, so a stale @@ -284,8 +377,11 @@ export class FilesService { async streamById( id: string, + opts: { includeDeleted?: boolean } = {}, ): Promise<{ stream: Readable; record: FileRecord }> { - const record = await this.findById(id); + const record = opts.includeDeleted + ? await this.findByIdIncludingDeleted(id) + : await this.findById(id); const objectName = this.minioService.getObjectNameFromUrl(record.url); const stream = await this.minioService.getFileStream(objectName); return { stream, record }; 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/dto/set-warehouse-gate-times.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-warehouse-gate-times.dto.ts new file mode 100644 index 000000000..ba980dfdc --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-warehouse-gate-times.dto.ts @@ -0,0 +1,22 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator'; + +export class TruckWarehouseGateTimeInput { + @IsUUID() + vehicleId!: string; + + @IsOptional() + @IsDateString() + arrivedAt?: string | null; + + @IsOptional() + @IsDateString() + departedAt?: string | null; +} + +export class SetWarehouseGateTimesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TruckWarehouseGateTimeInput) + trucks!: TruckWarehouseGateTimeInput[]; +} 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..d7e2ba1ff 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,8 @@ 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 { SetWarehouseGateTimesDto } from './dto/set-warehouse-gate-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 +133,30 @@ 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/warehouse-gate-times') + @BookingStaff(FREIGHT_PERMS.lastMile.update) + @ApiOperation({ + summary: 'Set each truck\'s warehouse gate arrival/departure times', + }) + async setWarehouseGateTimes( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetWarehouseGateTimesDto, + ) { + return this.lastMileService.setWarehouseGateTimes(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..af6c920fc 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 @@ -327,6 +327,8 @@ export class LastMileService { */ async arrivalTrucksForBooking(bookingId: string): Promise< Array<{ + /** The last-mile leg this truck belongs to — lets a caller chain straight into truck-detention-preview without a separate lookup. */ + lastMileId: string; vehicleId: string; truckPlateNumber: string | null; trailerPlateNumber: string | null; @@ -359,6 +361,7 @@ export class LastMileService { : []; const out: Array<{ + lastMileId: string; vehicleId: string; truckPlateNumber: string | null; trailerPlateNumber: string | null; @@ -386,6 +389,7 @@ export class LastMileService { } } out.push({ + lastMileId: lm.id, vehicleId: vehicle.id, truckPlateNumber: vehicle.powerPlateNo || vehicle.plateNumber || null, trailerPlateNumber: vehicle.trailerPlateNo || null, @@ -869,6 +873,81 @@ 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 setWarehouseGateTimes( + id: string, + trucks: Array<{ + vehicleId: string; + arrivedAt?: string | null; + departedAt?: string | null; + }>, + ): Promise { + await this.findById(id); + + const invoices = await this.billing.findBySourceIds('last_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Warehouse gate times cannot be changed after the invoice is generated', + ); + } + + for (const t of trucks) { + const arrived = t.arrivedAt ? new Date(t.arrivedAt) : null; + const departed = t.departedAt ? new Date(t.departedAt) : null; + if (arrived && departed && departed.getTime() < arrived.getTime()) { + throw new BadRequestException( + 'A truck cannot depart before it arrived — check the warehouse gate times', + ); + } + await this.dataSource.manager.update( + LastMileVehicleAssignment, + { lastMileId: id, vehicleId: t.vehicleId }, + { arrivedAt: arrived, departedAt: departed }, + ); + } + + 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/payment/payment-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts index 4c2ebe971..26a0cf5ed 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts @@ -1,12 +1,17 @@ -import { BadGatewayException, Injectable, Logger } from "@nestjs/common"; +import { + BadGatewayException, + BadRequestException, + Injectable, + Logger, +} from "@nestjs/common"; import { HttpService } from "@nestjs/axios"; import { AxiosError } from "axios"; import { firstValueFrom } from "rxjs"; import { - InitiatePaymentRequest, - PaymentIntentSnapshot, - PaymentReferenceType, - PaymentService, + InitiatePaymentRequest, + PaymentIntentSnapshot, + PaymentReferenceType, + PaymentService, } from "@edr/types"; /** @@ -16,67 +21,125 @@ import { */ @Injectable() export class PaymentClientService { - private readonly logger = new Logger(PaymentClientService.name); - private readonly baseUrl = ( - // process.env.PAYMENT_API_URL ?? - "https://paymentcallback.triaplc.com" - // "http://localhost:3003" - ).replace(/\/$/, ""); - private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; + private readonly logger = new Logger(PaymentClientService.name); + private readonly baseUrl = ( + process.env.PAYMENT_API_URL ?? "https://paymentcallback.triaplc.com" + ) + // "http://localhost:3003" + .replace(/\/$/, ""); + private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; - constructor(private readonly http: HttpService) { } + constructor(private readonly http: HttpService) { } - /** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */ - async initiate(request: InitiatePaymentRequest): Promise { - return this.call("POST", "/payments/initiate", request); + /** POST /payments/initiate — idempotent per (service, referenceType, referenceId). */ + async initiate( + request: InitiatePaymentRequest, + ): Promise { + return this.call("POST", "/payments/initiate", request); + } + + /** + * POST /payments/reconcile — settlement check for a domain order + * (reconcile-before-cancel). Live-queries every non-failed intent at the + * provider and registers any late capture found (flips it to SUCCEEDED and + * emits payment.succeeded). `unverifiable: true` = could not confirm + * "not paid" — the caller must NOT cancel/expire the order. + */ + async reconcileReference( + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise<{ paid: boolean; unverifiable: boolean }> { + return this.call("POST", "/payments/reconcile", { + service: PaymentService.FREIGHT, + referenceType, + referenceId, + }); + } + + /** GET /payments/intents?… — active intent by domain reference; null when none exists. */ + async getIntentByReference( + referenceType: PaymentReferenceType, + referenceId: string, + ): Promise { + const query = new URLSearchParams({ + service: PaymentService.FREIGHT, + referenceType, + referenceId, + }); + try { + return await this.call("GET", `/payments/intents?${query.toString()}`); + } catch (err) { + if (err instanceof AxiosError && err.response?.status === 404) + return null; + throw err; } + } - /** GET /payments/intents?… — active intent by domain reference; null when none exists. */ - async getIntentByReference( - referenceType: PaymentReferenceType, - referenceId: string, - ): Promise { - const query = new URLSearchParams({ - service: PaymentService.FREIGHT, - referenceType, - referenceId, - }); - try { - return await this.call("GET", `/payments/intents?${query.toString()}`); - } catch (err) { - if (err instanceof AxiosError && err.response?.status === 404) return null; - throw err; - } + /** + * POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider + * (CAC Bank). A wrong/expired OTP comes back as 400 from the payment service; + * surface that as a BadRequest (retryable) rather than a 502, so the payer can + * re-enter the code. + */ + async confirmOtp( + intentId: string, + otp: string, + ): Promise { + try { + return await this.call( + "POST", + `/payments/intents/${intentId}/confirm`, + { otp }, + ); + } catch (err) { + // `call` re-throws raw 404s and masks every other 4xx as BadGateway; an + // unknown intent or a bad OTP is client-fixable, so translate both to 400. + if (err instanceof AxiosError && err.response?.status === 404) { + throw new BadRequestException("PaymentIntent not found"); + } + if (err instanceof BadGatewayException) { + const detail = err.message.replace(/^Payment service error: /, ""); + throw new BadRequestException(detail); + } + throw err; } + } - private async call(method: "GET" | "POST", path: string, body?: unknown): Promise { - const url = `${this.baseUrl}${path}`; - try { - const response = await firstValueFrom( - this.http.request({ - method, - url, - data: body, - headers: this.serviceToken - ? { "x-service-token": this.serviceToken } - : {}, - }), - ); - return response.data; - } catch (err) { - if (err instanceof AxiosError && err.response) { - if (err.response.status === 404) throw err; - const detail = - (err.response.data as { message?: string | string[] })?.message ?? - err.message; - this.logger.error( - `payment service ${method} ${path} → ${err.response.status}: ${detail}`, - ); - throw new BadGatewayException(`Payment service error: ${detail}`); - } - const message = err instanceof Error && err.message ? err.message : String(err); - this.logger.error(`payment service unreachable (${method} ${path}): ${message}`); - throw new BadGatewayException("Payment service unreachable"); - } + private async call( + method: "GET" | "POST", + path: string, + body?: unknown, + ): Promise { + const url = `${this.baseUrl}${path}`; + try { + const response = await firstValueFrom( + this.http.request({ + method, + url, + data: body, + headers: this.serviceToken + ? { "x-service-token": this.serviceToken } + : {}, + }), + ); + return response.data; + } catch (err) { + if (err instanceof AxiosError && err.response) { + if (err.response.status === 404) throw err; + const detail = + (err.response.data as { message?: string | string[] })?.message ?? + err.message; + this.logger.error( + `payment service ${method} ${path} → ${err.response.status}: ${detail}`, + ); + throw new BadGatewayException(`Payment service error: ${detail}`); + } + const message = + err instanceof Error && err.message ? err.message : String(err); + this.logger.error( + `payment service unreachable (${method} ${path}): ${message}`, + ); + throw new BadGatewayException("Payment service unreachable"); } + } } diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 05267746d..961aa32bd 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -56,7 +56,13 @@ function rabbitMQImport(): DynamicModule[] { @Module({ imports: [ - HttpModule.register({ timeout: 10_000 }), + // CAC Bank's initiate SMSes an OTP and routinely takes >10s, so the old + // 10s cap 502'd every CAC charge while the bank was still working — + // orphaning an intent the payer had already been texted about. Matches + // the passenger API's budget. + HttpModule.register({ + timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000, + }), ConfigModule, forwardRef(() => BillingModule), // forwardRef(() => TrainSchedulingModule), diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts new file mode 100644 index 000000000..ed0f8da58 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts @@ -0,0 +1,190 @@ +import { BadRequestException, NotFoundException } from "@nestjs/common"; +import { of, throwError } from "rxjs"; +import { AxiosError, AxiosHeaders } from "axios"; +import { PaymentReferenceType, ProviderPaymentStatus } from "@edr/types"; + +import { PaymentClientService } from "./payment-client.service"; +import { PaymentService } from "./payment.service"; + +/** Local intent projection row (the invoice's `paymentId` points at this). */ +function localIntent(overrides: Record = {}) { + return { + id: "intent-1", + refId: "booking-1", + referenceType: PaymentReferenceType.SHIPMENT, + status: "action-required", + method: "cac-bank", + merchantOrderId: "EDR_INV_1", + clientAction: { type: "COLLECT_OTP", providerOrderId: "471583397" }, + ...overrides, + }; +} + +function makeRepo(rows: Record[]) { + const store = [...rows]; + return { + findOneBy: jest.fn((where: Record) => + Promise.resolve( + store.find((r) => + Object.entries(where).every(([k, v]) => r[k] === v), + ) ?? null, + ), + ), + update: jest.fn((where: { id: string }, data: Record) => { + const row = store.find((r) => r.id === where.id); + if (row) Object.assign(row, data); + return Promise.resolve(undefined); + }), + }; +} + +describe("PaymentService.confirmOtp", () => { + const build = ( + client: Partial, + rows = [localIntent()], + ) => { + const repo = makeRepo(rows); + const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) }; + const service = new PaymentService( + repo as never, + client as never, + billing as never, + ); + return { service, repo, billing }; + }; + + it("settles the local intent and tells billing to settle the invoice on SUCCEEDED", async () => { + const paidAt = "2026-07-31T10:00:00.000Z"; + const { service, repo, billing } = build({ + getIntentByReference: jest + .fn() + .mockResolvedValue({ intentId: "gw-1", status: "REQUIRES_ACTION" }), + confirmOtp: jest.fn().mockResolvedValue({ + intentId: "gw-1", + status: ProviderPaymentStatus.SUCCEEDED, + providerTxnId: "11709363209530624", + paidAt, + }), + }); + + const result = await service.confirmOtp("intent-1", "8280"); + + expect(repo.update).toHaveBeenCalledWith( + { id: "intent-1" }, + expect.objectContaining({ + status: "success", + transactionId: "11709363209530624", + }), + ); + // Billing settles the invoice linked by this intent id. + expect(billing.settleByPaymentId).toHaveBeenCalledWith( + "intent-1", + "11709363209530624", + new Date(paidAt), + ); + expect(result.status).toBe(ProviderPaymentStatus.SUCCEEDED); + }); + + it("forwards the OTP against the GATEWAY intent id, not the local one", async () => { + const confirmOtp = jest + .fn() + .mockResolvedValue({ status: ProviderPaymentStatus.REQUIRES_ACTION }); + const { service } = build({ + getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }), + confirmOtp, + }); + + await service.confirmOtp("intent-1", "8280"); + + expect(confirmOtp).toHaveBeenCalledWith("gw-1", "8280"); + }); + + it("leaves the intent open and does not settle when the OTP is not accepted", async () => { + const { service, repo, billing } = build({ + getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }), + confirmOtp: jest.fn().mockResolvedValue({ + status: ProviderPaymentStatus.REQUIRES_ACTION, + failureMessage: "OTP confirmation failed", + }), + }); + + const result = await service.confirmOtp("intent-1", "0000"); + + expect(billing.settleByPaymentId).not.toHaveBeenCalled(); + expect(repo.update).toHaveBeenCalledWith( + { id: "intent-1" }, + expect.objectContaining({ status: "action-required" }), + ); + expect(result.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION); + }); + + it("404s when the gateway has no active intent for the reference", async () => { + const { service } = build({ + getIntentByReference: jest.fn().mockResolvedValue(null), + confirmOtp: jest.fn(), + }); + + await expect(service.confirmOtp("intent-1", "8280")).rejects.toBeInstanceOf( + NotFoundException, + ); + }); +}); + +describe("PaymentClientService.confirmOtp", () => { + const axiosErr = (status: number, message: string) => + new AxiosError( + `Request failed with status code ${status}`, + undefined, + undefined, + undefined, + { + status, + statusText: "", + data: { message }, + headers: new AxiosHeaders(), + config: { headers: new AxiosHeaders() }, + }, + ); + + const build = (request: jest.Mock) => + new PaymentClientService({ request } as never); + + it("posts the OTP to the payment service intent-confirm route", async () => { + const request = jest + .fn() + .mockReturnValue(of({ data: { intentId: "gw-1", status: "SUCCEEDED" } })); + + const result = await build(request).confirmOtp("gw-1", "8280"); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("/payments/intents/gw-1/confirm"), + data: { otp: "8280" }, + }), + ); + expect(result.status).toBe("SUCCEEDED"); + }); + + it("maps a rejected OTP (400) to BadRequest so the payer can retry", async () => { + const request = jest + .fn() + .mockReturnValue( + throwError(() => axiosErr(400, "OTP confirmation failed")), + ); + + await expect(build(request).confirmOtp("gw-1", "0000")).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it("maps an unknown intent (404) to BadRequest rather than a gateway error", async () => { + const request = jest + .fn() + .mockReturnValue(throwError(() => axiosErr(404, "PaymentIntent not found"))); + + await expect(build(request).confirmOtp("nope", "8280")).rejects.toBeInstanceOf( + BadRequestException, + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index d96bcf492..0c166d164 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -188,6 +188,30 @@ export class PaymentService { * marked paid WITHOUT emitting — the caller (billing) settles inline after it * has stored the intent id, avoiding a settle-before-correlation race. */ + /** + * Reconcile-before-cancel: ask the payment service whether ANY intent for + * this shipment actually settled at the provider (bank/gateway). A late + * capture found there is registered as SUCCEEDED and emits payment.succeeded, + * which drives the normal paid flow. A network/provider error reports + * `unverifiable` — the caller must not expire the order on unknown. + */ + async reconcileShipment( + referenceId: string, + ): Promise<{ paid: boolean; unverifiable: boolean }> { + try { + const result = await this.paymentClient.reconcileReference( + PaymentReferenceType.SHIPMENT, + referenceId, + ); + return { paid: result.paid, unverifiable: result.unverifiable }; + } catch (err) { + this.logger.warn( + `Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`, + ); + return { paid: false, unverifiable: true }; + } + } + async initiate(input: InitiateIntentInput): Promise { try { @@ -350,6 +374,53 @@ export class PaymentService { return this.formatIntentStatus(refreshed ?? local); } + /** + * Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by the LOCAL intent + * id (the invoice's `paymentId`) so the right invoice settles even when several + * invoices share a domain reference. The active gateway intent is looked up by + * reference, the OTP is forwarded, and the projection is refreshed. On success + * billing settles the linked invoice (idempotent — the outbox path converges too). + * A wrong/expired OTP bubbles up as a 400 and leaves the intent open for retry. + */ + async confirmOtp(intentId: string, otp: string): Promise { + const local = await this.paymentRepo.findOneBy({ id: intentId }); + if (!local) throw new NotFoundException("PaymentIntent not found"); + + const snapshot = await this.paymentClient.getIntentByReference( + (local.referenceType as PaymentReferenceType) ?? + PaymentReferenceType.SHIPMENT, + local.refId, + ); + if (!snapshot) { + throw new NotFoundException("No active payment to confirm"); + } + + const confirmed = await this.paymentClient.confirmOtp( + snapshot.intentId, + otp, + ); + + if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) { + await this.markIntentSucceeded(local.id, { + providerTxnId: confirmed.providerTxnId, + paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined, + notify: true, + }); + } else { + await this.paymentRepo.update( + { id: local.id }, + { + status: this.toLocalStatus(confirmed.status), + failerCode: confirmed.failureCode ?? undefined, + failureMessage: confirmed.failureMessage ?? undefined, + }, + ); + } + + const refreshed = await this.paymentRepo.findOneBy({ id: local.id }); + return this.formatIntentStatus(refreshed ?? local); + } + /** * Mark a gateway intent paid and (by default) notify billing to settle the * linked invoice. Idempotent — no-op when already success. Pass `notify: false` 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/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index 6f7882c76..6d9cb82a2 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { CargoUnitOfMeasure } from '@edr/types'; -import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsArray, IsBoolean, IsEnum, IsInt, IsObject, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; export class CreateCargoTypeDto { @ApiProperty({ description: 'Cargo type display name', maxLength: 255 }) @@ -32,6 +32,18 @@ export class CreateCargoTypeDto { @IsUUID('4', { each: true }) wagonTypeIds?: string[]; + @ApiPropertyOptional({ + description: + 'PER_ITEM cargo only: items that physically fit one wagon, keyed by wagon-type id ' + + '(e.g. { "": 4, "": 6 }). Required for every wagonTypeId when ' + + 'unitOfMeasure is PER_ITEM.', + type: 'object', + additionalProperties: { type: 'integer', minimum: 1 }, + }) + @IsOptional() + @IsObject() + itemsPerWagonMap?: Record | null; + @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index 037bf08be..b096613bf 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -50,6 +50,16 @@ export class CargoType extends BaseEntity { }) wagonTypes?: WagonType[]; + /** + * PER_ITEM (break-bulk) only: how many whole items physically fit each + * allowed wagon type, keyed by wagon-type id (e.g. cars → { NW5: 4, NW7: 6 }). + * Allocation loads min(this fit, floor(capacityTons / perItemTons)) per + * wagon — floor space and rated tonnage bind independently. Keys are kept a + * subset of the wagonTypes join rows by the cargo-types service. + */ + @Column({ name: 'items_per_wagon_map', type: 'jsonb', nullable: true }) + itemsPerWagonMap?: Record | null; + @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; 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..1570e0d23 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; @@ -27,6 +28,13 @@ export interface IRatesRepository { create(data: Partial): Promise; update(id: string, data: Partial): Promise; softDelete(id: string): Promise; + /** + * Flip a commodity's PER_TON↔PER_ITEM rates to match its unit of measure. + * Both units bill the same stored quantity — only the name differs — so a + * uom change must rename the units or bookings keep quoting "per ton" for + * counted cargo. Returns the number of rates flipped. + */ + syncBulkQuantityUnit(cargoTypeId: string, unitOfMeasure: 'PER_TON' | 'PER_ITEM'): Promise; } export const RATES_REPOSITORY = Symbol('RATES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts index 75baca5de..104603c6d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts @@ -6,6 +6,7 @@ import { Yard } from '../entities/yard.entity'; export interface IYardsRepository { findById(id: string): Promise; findByCode(code: string): Promise; + findByLabelInsensitive(label: string): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[Yard[], number]>; findPaged(query: ListYardsQueryDto): Promise>; 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..43c716a3b 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 { @@ -155,4 +177,16 @@ export class RatesRepository implements IRatesRepository { async softDelete(id: string): Promise { await this.repo.softDelete(id); } + + async syncBulkQuantityUnit( + cargoTypeId: string, + unitOfMeasure: 'PER_TON' | 'PER_ITEM', + ): Promise { + const from = unitOfMeasure === 'PER_ITEM' ? 'PER_TON' : 'PER_ITEM'; + const result = await this.repo.update( + { cargoTypeId, rateUnit: from }, + { rateUnit: unitOfMeasure }, + ); + return result.affected ?? 0; + } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts index 1cb74d9ce..5db5b72ae 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts @@ -22,6 +22,15 @@ export class YardsRepository implements IYardsRepository { return this.repo.findOne({ where: { code } }); } + /** Case/whitespace-insensitive label lookup — backs the duplicate-yard guard. */ + findByLabelInsensitive(label: string): Promise { + return this.repo + .createQueryBuilder('yard') + .where('LOWER(TRIM(yard.label)) = LOWER(TRIM(:label))', { label }) + .andWhere('yard.deleted_at IS NULL') + .getOne(); + } + findAll(options?: FindManyOptions): Promise { return this.repo.find(options); } 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/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 8a72cf1f8..34287a023 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -1,5 +1,11 @@ -import { PaginatedResponse } from '@edr/types'; -import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { CargoUnitOfMeasure, PaginatedResponse } from '@edr/types'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -11,6 +17,7 @@ import { CARGO_TYPES_REPOSITORY, ICargoTypesRepository, } from '../interfaces/cargo-types.repository.interface'; +import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; import { DisplayOrderService } from './display-order.service'; @Injectable() @@ -18,6 +25,8 @@ export class CargoTypesService { constructor( @Inject(CARGO_TYPES_REPOSITORY) private readonly repository: ICargoTypesRepository, + @Inject(RATES_REPOSITORY) + private readonly ratesRepository: IRatesRepository, private readonly displayOrder: DisplayOrderService, ) {} @@ -38,6 +47,34 @@ export class CargoTypesService { return this.repository.findByCode(code); } + /** + * PER_ITEM (break-bulk) cargo must carry a whole-items-fit for EVERY allowed + * wagon type — allocation caps each wagon at min(fit, tonnage) and a missing + * fit would silently fall back to tonnage-only loading. Returns the map + * trimmed to the allowed ids (stale keys from a removed wagon type drop out); + * null when the cargo is not PER_ITEM or has no wagon types. + */ + private resolveItemsPerWagonMap(input: { + unitOfMeasure?: CargoUnitOfMeasure | null; + wagonTypeIds: string[]; + itemsPerWagonMap?: Record | null; + }): Record | null { + if (input.unitOfMeasure !== CargoUnitOfMeasure.PerItem || !input.wagonTypeIds.length) { + return null; + } + const map: Record = {}; + for (const wagonTypeId of input.wagonTypeIds) { + const fit = Number(input.itemsPerWagonMap?.[wagonTypeId]); + if (!Number.isInteger(fit) || fit < 1) { + throw new BadRequestException( + `itemsPerWagonMap must define how many items fit wagon type ${wagonTypeId} (integer >= 1) for PER_ITEM cargo`, + ); + } + map[wagonTypeId] = fit; + } + return map; + } + /** Create a new cargo type. */ async create(dto: CreateCargoTypeDto): Promise { const code = generateCode(dto.cargoTypeName); @@ -62,26 +99,58 @@ export class CargoTypesService { unitOfMeasure: dto.unitOfMeasure ?? null, // Join rows are written by the save (RESTRICT FK rejects unknown ids). wagonTypes: (dto.wagonTypeIds ?? []).map((id) => ({ id }) as WagonType), + itemsPerWagonMap: this.resolveItemsPerWagonMap({ + unitOfMeasure: dto.unitOfMeasure ?? null, + wagonTypeIds: dto.wagonTypeIds ?? [], + itemsPerWagonMap: dto.itemsPerWagonMap, + }), displayOrder, }); } /** Update an existing cargo type. */ async update(id: string, dto: UpdateCargoTypeDto): Promise { - await this.findById(id); + const existing = await this.findById(id); if (dto.parentGroupId) { if (dto.parentGroupId === id) throw new ConflictException('A cargo type cannot be its own parent'); const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } - const { wagonTypeIds, insertAfterId: _insertAfterId, ...columns } = dto; + const { wagonTypeIds, itemsPerWagonMap, insertAfterId: _insertAfterId, ...columns } = dto; + // Re-validate the fit map whenever anything it depends on moves — a partial + // update merges with the stored values so e.g. adding a wagon type without + // its fit still 400s. Untouched fields leave the stored map alone. + const touchesItemsFit = + wagonTypeIds !== undefined || itemsPerWagonMap !== undefined || dto.unitOfMeasure !== undefined; const updated = await this.repository.update(id, { ...columns, ...(wagonTypeIds ? { wagonTypes: wagonTypeIds.map((wagonTypeId) => ({ id: wagonTypeId }) as WagonType) } : {}), + ...(touchesItemsFit + ? { + itemsPerWagonMap: this.resolveItemsPerWagonMap({ + unitOfMeasure: + dto.unitOfMeasure !== undefined ? dto.unitOfMeasure : existing.unitOfMeasure, + wagonTypeIds: wagonTypeIds ?? (existing.wagonTypes ?? []).map((wt) => wt.id), + itemsPerWagonMap: + itemsPerWagonMap !== undefined ? itemsPerWagonMap : existing.itemsPerWagonMap, + }), + } + : {}), }); if (!updated) throw new NotFoundException(`Cargo type ${id} not found`); + // A uom flip renames how existing rates bill (PER_TON ↔ PER_ITEM name the + // same stored quantity) — sync them or bookings keep quoting "per ton" for + // counted cargo. + if ( + dto.unitOfMeasure !== undefined && + dto.unitOfMeasure !== existing.unitOfMeasure && + (dto.unitOfMeasure === CargoUnitOfMeasure.PerTon || + dto.unitOfMeasure === CargoUnitOfMeasure.PerItem) + ) { + await this.ratesRepository.syncBulkQuantityUnit(id, dto.unitOfMeasure); + } return updated; } 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/rule-engine/services/yards.duplicate-label.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts new file mode 100644 index 000000000..8affab199 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts @@ -0,0 +1,36 @@ +import { ConflictException } from '@nestjs/common'; + +import { YardsService } from './yards.service'; +import type { Yard } from '../entities/yard.entity'; + +const sebeta = { id: 'yard-1', code: 'LEGACY_DEST', label: 'Sebeta' } as Yard; + +const service = (): YardsService => + new YardsService( + { + findById: async (id: string) => ({ ...sebeta, id }), + findByCode: async () => null, + findByLabelInsensitive: async (label: string) => + label.trim().toLowerCase() === 'sebeta' ? sebeta : null, + create: async (d: Partial) => d as Yard, + update: async (_id: string, d: Partial) => d as Yard, + } as never, + { resolveCreateOrder: async () => 1 } as never, + ); + +describe('duplicate yard labels are rejected', () => { + it('blocks create even when the generated code differs (Sebeta vs LEGACY_DEST)', async () => { + await expect( + service().create({ label: ' sebeta ', country: 'ET' } as never), + ).rejects.toThrow(ConflictException); + }); + + it('blocks renaming a yard onto another yard label, allows renaming itself', async () => { + await expect( + service().update('yard-2', { label: 'SEBETA' } as never), + ).rejects.toThrow(ConflictException); + await expect( + service().update('yard-1', { label: 'Sebeta' } as never), + ).resolves.toBeTruthy(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts index b698b456c..7dd29ded1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts @@ -31,6 +31,9 @@ export class YardsService { /** Create a yard. */ async create(dto: CreateYardDto): Promise { + // Label check first: the code check alone let "sebeta" in next to "Sebeta" + // when the existing yard's code didn't match its label (LEGACY_DEST). + await this.assertLabelAvailable(dto.label); const code = generateCode(dto.label).slice(0, 40); const existing = await this.repository.findByCode(code); if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`); @@ -53,11 +56,20 @@ export class YardsService { /** Update a yard. */ async update(id: string, dto: UpdateYardDto): Promise { await this.findById(id); + if (dto.label !== undefined) await this.assertLabelAvailable(dto.label, id); const updated = await this.repository.update(id, dto); if (!updated) throw new NotFoundException(`Yard ${id} not found`); return updated; } + /** No two active yards may share a label (case/whitespace-insensitive). */ + private async assertLabelAvailable(label: string, exceptId?: string): Promise { + const dupe = await this.repository.findByLabelInsensitive(label); + if (dupe && dupe.id !== exceptId) { + throw new ConflictException(`A yard named "${dupe.label}" already exists`); + } + } + /** * Soft-delete a yard. The unique `code` (and the label) get a `@` * suffix first — e.g. SEBETA → SEBETA@1755612345678 — so a new yard with the 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/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts index e84eb6124..fefbbc868 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -139,6 +139,17 @@ export class SchedulingRescheduleService { actorUserId?: string, ) { const plan = await this.previewReschedule(scheduleId, dto); + // Gov bookings may never be pushed off a train. Checked here (not only in + // unassignBooking) because the displacement loop below swallows unassign + // errors and force-detaches the booking anyway. + const govDisplaced = plan.displaced.filter((b) => b.isGovernment); + if (govDisplaced.length) { + throw new BadRequestException( + `Government bookings cannot be removed from a train: ${govDisplaced + .map((b) => b.reference) + .join(', ')}`, + ); + } const expectedDisplaced = new Set(plan.displaced.map((b) => b.id)); const providedDisplaced = new Set(dto.displacedBookingIds); if ( diff --git a/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts index a5ae63100..a1f245e23 100644 --- a/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts +++ b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts @@ -1,5 +1,5 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString, MinLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MinLength } from 'class-validator'; export class SaveSignatureDto { @ApiProperty() @@ -13,6 +13,15 @@ export class SaveSignatureDto { @IsString() @MinLength(20) signatureImageBase64!: string; + + @ApiPropertyOptional({ + description: + 'Company stamp/seal image as base64 (with or without data URL prefix). Omit to keep the existing saved stamp.', + }) + @IsOptional() + @IsString() + @MinLength(20) + stampImageBase64?: string; } export class SavedSignatureDto { @@ -21,4 +30,7 @@ export class SavedSignatureDto { @ApiProperty({ nullable: true }) signatureImageUrl!: string | null; + + @ApiProperty({ nullable: true }) + stampImageUrl!: string | null; } diff --git a/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts b/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts index 08263cf7d..3814eb017 100644 --- a/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts +++ b/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts @@ -22,4 +22,11 @@ export class SavedSignature extends BaseEntity { @ManyToOne(() => FileRecord, { nullable: true }) @JoinColumn({ name: 'signature_file_id' }) signatureFile?: FileRecord | null; + + @Column({ name: 'stamp_file_id', type: 'uuid', nullable: true }) + stampFileId?: string | null; + + @ManyToOne(() => FileRecord, { nullable: true }) + @JoinColumn({ name: 'stamp_file_id' }) + stampFile?: FileRecord | null; } diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts b/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts index d112edef3..24dd0aa5c 100644 --- a/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts +++ b/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts @@ -33,6 +33,7 @@ export class SignaturesController { userId, signerDisplayName: dto.signerDisplayName, signatureImageBase64: dto.signatureImageBase64, + stampImageBase64: dto.stampImageBase64, }); return this.signaturesService.getForUser(userId); } diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts b/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts index 70c04ad7b..de8007d65 100644 --- a/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts +++ b/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts @@ -16,7 +16,7 @@ export class SignaturesRepository extends BaseRepository { findByUserId(userId: string): Promise { return this.repository.findOne({ where: { userId } as never, - relations: ['signatureFile'], + relations: ['signatureFile', 'stampFile'], }); } diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts index 7137ab6a5..836888dab 100644 --- a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts +++ b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts @@ -13,6 +13,8 @@ export interface UpsertSignatureInput { userId: string; signerDisplayName: string; signatureImageBase64: string; + /** Optional company stamp/seal; omitted = keep the existing saved stamp. */ + stampImageBase64?: string; } @Injectable() @@ -31,15 +33,65 @@ export class SignaturesService { return { signerDisplayName: saved.signerDisplayName, signatureImageUrl: await this.inlineImageUrl(saved.signatureFile?.url), + stampImageUrl: await this.inlineImageUrl(saved.stampFile?.url), }; } - /** Insert or update the user's reusable signature, storing the image in MinIO. */ + /** Insert or update the user's reusable signature (and optional stamp), storing the images in MinIO. */ async upsertForUser(input: UpsertSignatureInput): Promise { - const buffer = this.decodeSignatureImage(input.signatureImageBase64); - const file: Express.Multer.File = { - fieldname: 'signature', - originalname: `signature-${input.userId}.png`, + // Capture the previously referenced files so we can remove them only AFTER + // the saved_signatures row is repointed — deleting first would violate the + // FK constraint (saved_signatures.*_file_id -> files.id). + const existing = await this.signaturesRepository.findByUserId(input.userId); + const previousFileId = existing?.signatureFileId ?? null; + const previousStampFileId = existing?.stampFileId ?? null; + + const fileRecord = await this.filesService.upload({ + resourceId: input.userId, + resource: 'saved_signatures', + code: 'signature', + file: this.toUploadFile('signature', input.userId, input.signatureImageBase64), + }); + + const stampRecord = input.stampImageBase64 + ? await this.filesService.upload({ + resourceId: input.userId, + resource: 'saved_signatures', + code: 'stamp', + file: this.toUploadFile('stamp', input.userId, input.stampImageBase64), + }) + : null; + + const saved = await this.signaturesRepository.upsert({ + userId: input.userId, + signerDisplayName: input.signerDisplayName, + signatureFileId: fileRecord.id, + // Omitted stamp keeps whatever was saved before. + ...(stampRecord ? { stampFileId: stampRecord.id } : {}), + }); + + const staleIds = [ + previousFileId !== fileRecord.id ? previousFileId : null, + stampRecord && previousStampFileId !== stampRecord.id + ? previousStampFileId + : null, + ].filter((id): id is string => Boolean(id)); + if (staleIds.length) { + await this.dataSource.getRepository(FileRecord).delete(staleIds); + } + + return saved; + } + + private toUploadFile( + kind: 'signature' | 'stamp', + userId: string, + base64: string, + ): Express.Multer.File { + const buffer = this.decodeSignatureImage(base64); + return { + fieldname: kind, + originalname: `${kind}-${userId}.png`, encoding: '7bit', mimetype: 'image/png', size: buffer.length, @@ -49,33 +101,6 @@ export class SignaturesService { filename: '', path: '', }; - - // Capture the previously referenced file so we can remove it only AFTER the - // saved_signatures row is repointed — deleting it first would violate the - // FK constraint (saved_signatures.signature_file_id -> files.id). - const existing = await this.signaturesRepository.findByUserId(input.userId); - const previousFileId = existing?.signatureFileId ?? null; - - const fileRecord = await this.filesService.upload({ - resourceId: input.userId, - resource: 'saved_signatures', - code: 'signature', - file, - }); - - const saved = await this.signaturesRepository.upsert({ - userId: input.userId, - signerDisplayName: input.signerDisplayName, - signatureFileId: fileRecord.id, - }); - - if (previousFileId && previousFileId !== fileRecord.id) { - await this.dataSource - .getRepository(FileRecord) - .delete({ id: previousFileId }); - } - - return saved; } private async inlineImageUrl( diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts index 485d29452..56459870c 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts @@ -1,15 +1,17 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index } from 'typeorm'; -export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE'] as const; +export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE', 'SWITCH'] as const; export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number]; /** * History row for a consist adjustment made from a schedule: staff coupled a - * wagon onto (ADD) or detached one from (REMOVE) the schedule's built train — - * e.g. trimming free wagons whose tare pushed gross weight over the - * locomotives' pull limit. Plain columns (no FK relations) so the history - * survives the wagon or train being deleted later. + * wagon onto (ADD), detached one from (REMOVE), or swapped the physical wagon + * under a loaded slot (SWITCH — wagonNumber reads "OLD → NEW") on the + * schedule's built train. `yardId` records WHERE it happened: the origin yard + * before departure, or the mid-route stop the train was standing at. Plain + * columns (no FK relations) so the history survives the wagon or train being + * deleted later. */ @Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' }) @Index(['trainScheduleId']) @@ -33,6 +35,9 @@ export class ScheduleWagonAdjustmentLog extends BaseEntity { @Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true }) adjustedByUserId!: string | null; + @Column({ name: 'yard_id', type: 'uuid', nullable: true }) + yardId!: string | null; + @Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' }) occurredAt!: Date; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index 2b9968f04..0581f6000 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -153,6 +153,14 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'rule_reopen_delay_minutes', type: 'int', nullable: true }) ruleReopenDelayMinutes?: number | null; + /** + * Per-schedule pay-window override (minutes). NULL = use the live global + * value for the schedule's direction. Unlike the other rule_* snapshots this + * is only written by an explicit staff override, never stamped at creation. + */ + @Column({ name: 'rule_payment_window_minutes', type: 'int', nullable: true }) + rulePaymentWindowMinutes?: number | null; + @Column({ name: 'rule_import_window_lead_days', type: 'int', nullable: true }) ruleImportWindowLeadDays?: number | null; diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index 294fb93f1..b0cbeb886 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -39,7 +39,11 @@ export class TrainSchedulesRepository extends BaseRepository { physicalWagon: true, allocations: { booking: { company: true, bookingContainers: { containerType: true } }, - containerItems: true, + // Both size sources loaded: the item's own container_type_id FK + // (always set for a manually-entered item) and the booking-line + // fallback via bookingContainer.containerType — the marshalling + // document's 40ft/20ft tally reads whichever is present. + containerItems: { containerType: true, bookingContainer: { containerType: true } }, }, }, }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index 62ed50c0a..d43cff60b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -9,6 +9,9 @@ export const BATCH_TIMEZONE = 'Africa/Addis_Ababa'; +/** How long before the pay deadline the one reminder notification goes out. */ +export const PAYMENT_REMINDER_LEAD_MS = 10 * 60_000; + /** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */ export const DEFAULT_WAGONS_PER_BOOKING = 1; 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..1a1a75275 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, @@ -99,6 +109,7 @@ describe('BookingBatchService — PAID reconcile', () => { windowDurationHours: 3, docReviewMinutes: 30, paymentWindowMinutes: 60, + exportPaymentWindowMinutes: 60, }), }; @@ -116,6 +127,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 = { @@ -136,6 +151,8 @@ describe('BookingBatchService — PAID reconcile', () => { { issuePayable: jest.fn().mockResolvedValue(null), expirePayable: jest.fn().mockResolvedValue(undefined), + // Gateway reconcile-before-expire: default = verifiably unpaid. + reconcilePayable: jest.fn().mockResolvedValue({ paid: false, unverifiable: false }), } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, @@ -694,7 +711,13 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, - { issuePayable: jest.fn(), expirePayable: jest.fn() } as never, + { + issuePayable: jest.fn(), + expirePayable: jest.fn(), + reconcilePayable: jest + .fn() + .mockResolvedValue({ paid: false, unverifiable: false }), + } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, @@ -717,7 +740,13 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, - { issuePayable: jest.fn(), expirePayable: jest.fn() } as never, + { + issuePayable: jest.fn(), + expirePayable: jest.fn(), + reconcilePayable: jest + .fn() + .mockResolvedValue({ paid: false, unverifiable: false }), + } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, @@ -748,7 +777,13 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, - { issuePayable: jest.fn(), expirePayable: jest.fn() } as never, + { + issuePayable: jest.fn(), + expirePayable: jest.fn(), + reconcilePayable: jest + .fn() + .mockResolvedValue({ paid: false, unverifiable: false }), + } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, @@ -1001,7 +1036,7 @@ describe('BookingBatchService — PAID reconcile', () => { ...(waiting as unknown as Record), status: 'SELECTED_FOR_BATCH', trainScheduleId: exportScheduleId, - paymentDeadline: new Date(Date.now() - 1_000), + paymentDeadline: new Date(Date.now() - 60_000), originYardId: 'yard-a', destinationYardId: 'yard-b', priorityScore: 0, @@ -1237,6 +1272,7 @@ describe('BookingBatchService — built-train wagon capacity', () => { return genericRepo; }), transaction: jest.fn(), + query: jest.fn().mockResolvedValue([]), }; const service = new BookingBatchService( dataSource as never, @@ -1292,10 +1328,9 @@ describe('BookingBatchService — built-train wagon capacity', () => { await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); }); - it('is FULL for the trade direction once the border edge is sold out, even with home legs free', async () => { - // Export b→c holds every wagon of the border crossing: no further export - // can board anywhere (they all must ride that edge), so the window closes — - // while intercity keeps booking the free a→b leg through the per-leg budget. + it('is NOT full when the border edge is sold out but a home leg still has room', async () => { + // FULL is corridor-wide now: b→dj holds every wagon, but a→b is empty, so + // sub-corridor bookings can still sell that leg — the window stays open. const { service } = buildService({ physicalWagons: 2, routeStops: ['yard-a', 'yard-b', 'yard-dj'], @@ -1309,6 +1344,23 @@ describe('BookingBatchService — built-train wagon capacity', () => { reservedBooking('b2', { origin: 'yard-b', dest: 'yard-dj' }), ], }); + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); + }); + + it('is FULL once every leg of the corridor is sold out', async () => { + const { service } = buildService({ + physicalWagons: 2, + routeStops: ['yard-a', 'yard-b', 'yard-dj'], + yardCountries: { + 'yard-a': 'ETHIOPIA', + 'yard-b': 'ETHIOPIA', + 'yard-dj': 'DJIBOUTI', + }, + reserved: [ + reservedBooking('b1', { origin: 'yard-a', dest: 'yard-dj' }), + reservedBooking('b2', { origin: 'yard-a', dest: 'yard-dj' }), + ], + }); await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true); }); @@ -1344,3 +1396,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..5203c3a14 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 @@ -27,13 +27,18 @@ import { BookingPricingService } from '../bookings/booking-pricing.service'; import { formatRouteLabel } from '../routes/entities/route.entity'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; import { BookingNotifierService } from './booking-notifier.service'; -import { TrainSchedulingService } from './train-scheduling.service'; -import { eatDay } from './batch-window.util'; +import { + TrainSchedulingService, + effectiveWindowConfig, +} from './train-scheduling.service'; +import { eatDay, listConfigBookingWindows } from './batch-window.util'; import { BATCH_BOARD_STATUSES, BatchBoardQueryDto, @@ -58,11 +63,14 @@ import { DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_TARE_TONS, DEFAULT_WAGONS_PER_BOOKING, + PAYMENT_REMINDER_LEAD_MS, } from "./booking-batch.constants"; import { LocomotiveLimits, WagonTypeDimensions, bookingCargoTons, + bulkItemsFitFor, + bulkItemWagonsRequired, bookingGrossWeightTons, deriveTrainCapacityFromLocomotive, sizePartialOfferWagons, @@ -87,6 +95,7 @@ import { OverageTolerance, stopYardsFor, } from './corridor-capacity.util'; +import { WagonStockLedger } from './wagon-stock-ledger.util'; export type { Capacity } from './corridor-capacity.util'; @@ -111,6 +120,36 @@ export interface ExportSpaceReport { fullMessage: string | null; } +/** + * One export train the customer can pick for a shipment day: live free-wagon + * space measured against THE BOOKING'S allowed wagon types (so the per-type + * list doubles as "what cargo this train can take for you"). Unpaid holds + * count as taken; lapsed holds free up via the lazy-expiry capacity filter. + */ +export interface ExportTrainOption { + scheduleId: string; + /** Schedule's train number (falls back to the built train's number). */ + trainNumber: string | null; + /** Built train's name/code, when the schedule runs a Train Builder train. */ + trainName: string | null; + departure: Date; + /** Booking cutoff for this train (windowClosesAt), null on legacy rows. */ + bookingClosesAt: Date | null; + /** Whether the export FCFS window is open for booking right now. */ + isOpen: boolean; + /** Best bookable wagons across the booking's allowed types. */ + freeWagons: number; + /** Wagons this booking needs — `fits` = freeWagons >= neededWagons. */ + neededWagons: number; + fits: boolean; + byWagonType: Array<{ + wagonTypeId: string | null; + code: string | null; + name: string | null; + freeWagons: number; + }>; +} + /** A day-level pool key: all trains on this route departing on this EAT day. */ interface RouteDayGroup { originYardId: string; @@ -166,6 +205,13 @@ export type BookingAllocationStatus = | "FAILED"; export interface BatchBoardBookingDetail extends BatchBoardBooking { + /** + * 0-based booking-window cycle this booking entered the pool in (derived from + * `fullyExecutedAt` against the schedule's window cycles). Ranking compares + * bookings within a cycle only — an earlier cycle always boards before a later + * one regardless of score. Null while the contract is still pending. + */ + windowCycleNo: number | null; fullyExecutedAt: string | null; selectedForBatchAt: string | null; allocationStatus: BookingAllocationStatus; @@ -256,11 +302,20 @@ export interface BatchBoardSchedule { /** Train length used by allocated bookings (from wagon-type dimensions). */ allocatedLengthMeters: number; maxLengthMeters: number | null; - /** Weight committed on the train (allocated + selected-for-batch). */ + /** + * Weight committed on the train (allocated + selected-for-batch). On a + * multi-stop corridor this is the HEAVIEST single edge, not the sum — + * disjoint legs (intercity + export) never ride together, so summing + * them over-reports the train against the pull limit. + */ usedWeightTons: number; maxWeightTons: number | null; /** Wagon-slot cap for the train (locomotive/wagon-type derived). */ maxWagons: number | null; + /** Physical consist length of the built train (Train Builder), null without one. */ + trainLengthMeters: number | null; + /** Committed gross weight per corridor edge, in stop order; null on 2-stop routes. */ + legUsage: Array<{ from: string; to: string; usedWeightTons: number }> | null; }; counts: { allocated: number; @@ -420,6 +475,9 @@ export class BookingBatchService implements OnModuleInit { group.destinationYardId, group.day, ); + // Backstop: PAID bookings stranded without a schedule (hold expired before + // the payment landed) get re-placed onto whatever fits today. + await this.rescueStrandedPaidForDay(group.day); for (const scheduleId of scheduleIds) { await this.settleDueReservations(scheduleId); await this.reconcilePaidUnlinked(scheduleId); @@ -480,17 +538,26 @@ export class BookingBatchService implements OnModuleInit { }); if (!booking) return; if (!booking.trainScheduleId) { - // A paid booking with no train is money taken and nothing boarding — - // scream so staff pin it to a schedule manually (batch board / assign). + // A paid booking with no train is money taken and nothing boarding. The + // hold was expired before the payment landed (webhook lag beat the + // reconcile, or the stranding predates it) — try to re-place it on a + // fitting same-day train before falling back to a manual-assign scream. if (booking.paymentStatus === "PAID" || booking.status === "PAID") { - this.logger.error( - `PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` + - `its reservation was likely expired before the payment landed. ` + - `Assign it to a schedule manually from the batch board.`, - ); + const rescuedScheduleId = await this.replaceStrandedPaidBooking(booking); + if (!rescuedScheduleId) { + this.logger.error( + `PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` + + `its reservation was likely expired before the payment landed and no ` + + `same-day train fits it. Assign it to a schedule manually from the batch board.`, + ); + return; + } + booking.trainScheduleId = rescuedScheduleId; + } else { + return; } - return; } + if (!booking.trainScheduleId) return; // unreachable — narrows the rescue path for TS const isBatchPaid = booking.status === "SELECTED_FOR_BATCH" || @@ -548,6 +615,26 @@ export class BookingBatchService implements OnModuleInit { const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); + // Intercity is allocated MANUALLY: payment secures the ride, staff then + // place it on whichever same-route train suits (intercity panel). Unpin + // from the train it reserved against — that train may be the wrong one by + // the time it departs — and return it to the waiting pool as PAID. + if (!linked && booking.tradeDirection === "DOMESTIC" && !booking.isGovernment) { + await this.dataSource.getRepository(Booking).update(bookingId, { + trainScheduleId: null, + schedulingStatus: "ELIGIBLE", + paymentDeadline: null, + } as never); + this.logger.log( + `[BATCH] intercity ${booking.reference ?? bookingId} PAID — awaiting manual placement by staff`, + ); + void this.completeTrackingMilestones(bookingId, [ + "FREIGHT_PAYMENT_PENDING", + "FREIGHT_PAYMENT_SETTLED", + ]); + this.notifyBoardChanged(booking.trainScheduleId, "intercity_paid_unplaced"); + return; + } if (!linked) { if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return; await this.allocate(booking.trainScheduleId, booking, "paid"); @@ -605,6 +692,69 @@ export class BookingBatchService implements OnModuleInit { await this.ensurePaidBookingAllocated(bookingId); } + /** + * Day-level backstop for stranded PAID bookings: reconcilePaidUnlinked is + * keyed on train_schedule_id, so a booking whose hold was expired (schedule + * cleared) before its payment landed never re-enters it. Sweep the day's + * PAID-but-unscheduled bookings through ensurePaidBookingAllocated, which + * re-places them on a fitting train. + */ + private async rescueStrandedPaidForDay(day: string): Promise { + const stranded: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.bookings + WHERE deleted_at IS NULL + AND train_schedule_id IS NULL + AND (payment_status = 'PAID' OR status = 'PAID') + AND scheduled_date IS NOT NULL + AND DATE(scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = $1`, + [day], + ); + for (const { id } of stranded) { + await this.ensurePaidBookingAllocated(id).catch((err) => + this.logger.error( + `Stranded-PAID rescue failed for booking ${id}: ${(err as Error).message}`, + ), + ); + } + } + + /** + * Re-place a PAID booking whose hold was expired before the payment landed + * (trainScheduleId already cleared). Picks the earliest same-day train that + * still fits the booking's whole need on ITS OWN leg and pins the booking to + * it. Returns the schedule id, or null when no train fits (manual assign). + */ + private async replaceStrandedPaidBooking( + booking: Booking, + ): Promise { + if (!booking.scheduledDate) return null; + // The booking loaded by ensurePaidBookingAllocated carries no cargo + // relations; needFor/fittingTrainsForDay derive the wagon need from them. + const full = await this.dataSource.getRepository(Booking).findOne({ + where: { id: booking.id }, + relations: { + bookingContainers: { containerType: true }, + cargoType: true, + }, + }); + if (!full) return null; + const day = eatDay(new Date(booking.scheduledDate)); + const direction = booking.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT"; + const wagonDims = await this.loadWagonDims(); + const need = this.needFor(full, wagonDims); + const fitting = await this.fittingTrainsForDay(full, day, direction); + const target = fitting.find((t) => t.freeWagons >= need.wagons); + if (!target) return null; + await this.dataSource + .getRepository(Booking) + .update(booking.id, { trainScheduleId: target.scheduleId }); + this.logger.warn( + `[BATCH] re-placed stranded PAID booking ${booking.reference ?? booking.id} ` + + `onto schedule ${target.scheduleId} — its hold expired before the payment landed`, + ); + return target.scheduleId; + } + /** Open partial-capacity offer summary for booking detail payloads (null when none). */ async getOpenOfferSummary(bookingId: string): Promise<{ offeredWagons: number; @@ -652,12 +802,16 @@ export class BookingBatchService implements OnModuleInit { { status: TrainScheduleStatusEnum.Scheduled }, ], }); + // A customer-picked train narrows the scan to that ONE schedule: export + // FCFS honors the pick or fails loudly (exportFullMessage names it). + const requestedId = booking.requestedTrainScheduleId ?? null; const candidates = corridor .filter( (s) => s.scheduledDepartureDate != null && eatDay(s.scheduledDepartureDate) === day && - this.isFillable(s), + this.isFillable(s) && + (!requestedId || s.id === requestedId), ) .sort( (a, b) => @@ -744,13 +898,18 @@ export class BookingBatchService implements OnModuleInit { /** Customer-facing "train is full" copy carrying the bookable leftover. */ private exportFullMessage(booking: Booking, report: ExportSpaceReport): string { + const picked = Boolean(booking.requestedTrainScheduleId); if (!report.trainsForDay || !report.corridorMatched) { - return 'No export train is accepting bookings for this day'; + return picked + ? 'The selected train is no longer accepting bookings — pick another train or day.' + : 'No export train is accepting bookings for this day'; } const best = report.bestAvailable; - const base = - 'Not enough train space — an export booking must ride a single train whole, ' + - 'and no open train on this day can carry it. '; + const base = picked + ? 'Not enough space left on the selected train — an export booking must ' + + 'ride one train whole. ' + : 'Not enough train space — an export booking must ride a single train whole, ' + + 'and no open train on this day can carry it. '; if (!best || best.wagons <= 0) { return base + 'No capacity is left on this day — pick another shipment day.'; } @@ -853,6 +1012,162 @@ export class BookingBatchService implements OnModuleInit { return out; } + /** + * The export train picker: every export train on the booking's corridor/day + * with its live space, measured per allowed wagon type so the customer sees + * what each train can still take for THEIR cargo. Includes full/not-yet-open + * trains (freeWagons 0 / isOpen false) so the UI can show them disabled — + * the request-time gate (exportSpaceReport) stays the enforcement point. + */ + async exportTrainOptionsForDay( + booking: Booking, + day: string, + overrides?: { + /** Cargo the customer is entering on a form (bare contract instance — + * nothing persisted yet): container types drive the per-type space. */ + containerTypeIds?: string[]; + /** Size labels ("20ft"/"40ft") when the form has no type ids. */ + containerSizes?: string[]; + /** Bulk counterparts of the container inputs. */ + cargoTypeId?: string; + cargoTypeCode?: string; + /** Needed wagons estimate from the form (drives the `fits` flag). */ + wagons?: number; + }, + ): Promise { + const sizeFts = (overrides?.containerSizes ?? []) + .map((s) => parseInt(s, 10)) + .filter((n) => Number.isFinite(n) && n > 0); + if (overrides?.containerTypeIds?.length || sizeFts.length) { + const types = await this.dataSource.getRepository(ContainerType).find({ + where: overrides?.containerTypeIds?.length + ? { id: In(overrides.containerTypeIds) } + : { sizeFt: In(sizeFts) }, + relations: { wagonTypes: true }, + }); + booking = { + ...booking, + freightType: "CONTAINER", + bookingContainers: types.map((ct) => ({ containerType: ct })), + } as Booking; + } else if (overrides?.cargoTypeId || overrides?.cargoTypeCode) { + const cargoType = await this.dataSource.getRepository(CargoType).findOne({ + where: overrides.cargoTypeId + ? { id: overrides.cargoTypeId } + : { code: overrides.cargoTypeCode }, + relations: { wagonTypes: true }, + }); + booking = { + ...booking, + freightType: "BULK", + cargoType: cargoType ?? undefined, + } as Booking; + } + if (overrides?.wagons && overrides.wagons > 0) { + booking = { ...booking, wagonsRequired: overrides.wagons } as Booking; + } + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, + ], + }); + const candidates = corridor + .filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + s.direction === 'EXPORT', + ) + .sort( + (a, b) => + a.scheduledDepartureDate!.getTime() - + b.scheduledDepartureDate!.getTime(), + ); + + const wagonDims = await this.loadWagonDims(); + const allowed = this.allowedDimsWithTypes(booking, wagonDims); + const neededWagons = this.wagonsFor(booking, wagonDims); + const typeIds = allowed + .map((a) => a.wagonTypeId) + .filter((id): id is string => Boolean(id)); + const types = typeIds.length + ? await this.dataSource + .getRepository(WagonType) + .find({ where: { id: In(typeIds) } }) + : []; + const typeById = new Map(types.map((t) => [t.id, t])); + + const out: ExportTrainOption[] = []; + for (const candidate of candidates) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + candidate.id, + ); + const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); + if (!schedule || !locomotive) continue; + const limits = await this.capacityLimits(locomotive); + const budget = await this.remainingBudget(schedule, limits, wagonDims); + const leg = budget.legOf(booking.originYardId, booking.destinationYardId); + if (!leg) continue; // this train's route doesn't carry the booking's leg + const room = budget.remainingFor(leg); + // The abstract budget can't tell wagon types apart — cap each type's free + // count with the PHYSICAL wagons of that type the train (or yard pool) + // actually holds on this leg, and on a built train hide types the consist + // doesn't carry at all. Otherwise a 47×NW5 train advertised "PW2: 47 free". + const stock = await this.trainSchedulingService.wagonStockForSchedule( + schedule.id, + schedule.originStationId, + budget.stops, + ); + const ledger = new WagonStockLedger( + stock.remainingByTypeId, + Math.max(1, budget.stops.length - 1), + ); + const byWagonType = allowed + .filter( + ({ wagonTypeId }) => + stock.mode !== 'TRAIN' || + !wagonTypeId || + (stock.remainingByTypeId.get(wagonTypeId) ?? 0) > 0, + ) + .map(({ wagonTypeId, dims }) => { + const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined; + const roomWagons = this.bookableWithin(room, dims).wagons; + const physical = wagonTypeId + ? ledger.availableFor([wagonTypeId], leg) + : roomWagons; + return { + wagonTypeId, + code: type?.code ?? null, + name: type?.name ?? null, + freeWagons: Math.min(roomWagons, physical), + }; + }); + const freeWagons = byWagonType.reduce( + (best, t) => Math.max(best, t.freeWagons), + 0, + ); + const builtTrain = schedule.trainSet?.train; + out.push({ + scheduleId: schedule.id, + trainNumber: + schedule.trainNumber ?? + builtTrain?.exportTrainNumber ?? + builtTrain?.trainNumber ?? + null, + trainName: builtTrain?.trainName ?? builtTrain?.code ?? null, + departure: schedule.scheduledDepartureDate!, + bookingClosesAt: schedule.windowClosesAt ?? null, + isOpen: this.isFillable(schedule), + freeWagons, + neededWagons, + fits: freeWagons >= neededWagons, + byWagonType, + }); + } + return out; + } + /** * Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day, * summed across every train on the booking's corridor that day. Unlike the @@ -1181,7 +1496,9 @@ export class BookingBatchService implements OnModuleInit { const [schedules, total] = await this.trainSchedulesRepository.findAndCount({ where, relations: { - trainSet: { locomotive: true, train: true }, + // locomotives (plural) too — the caps SUM the whole set's pull; the + // single legacy column alone under-reports a two-loco train by half. + trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true }, originStation: true, destinationStation: true, // Yards supply the route's display name for `routeName` below; @@ -1244,7 +1561,21 @@ export class BookingBatchService implements OnModuleInit { }; }); - board.push(this.buildScheduleSummary(s, items)); + board.push( + this.buildScheduleSummary( + s, + items, + new Map( + bookings.map((b) => [ + b.id, + { + originYardId: b.originYardId ?? null, + destinationYardId: b.destinationYardId ?? null, + }, + ]), + ), + ), + ); } return { items: board, meta: buildPaginationMeta(total, page, pageSize) }; @@ -1275,6 +1606,40 @@ export class BookingBatchService implements OnModuleInit { (s.scheduleBookings ?? []).map((l) => l.bookingId), ); const bookings = await this.bookingsRepository.findAllBySchedule(s.id); + // Under day-level pooling a booking is only pinned to a schedule by + // reserve() — until then its train_schedule_id is NULL and the query above + // misses it. Merge in the corridor-day candidates so staff see the whole + // waiting pool (the 7 that lost the batch), not just the winners. These are + // display-only candidates: they are excluded from the capacity meters below. + const pinnedIds = new Set(bookings.map((b) => b.id)); + // Corridor stops drive both the day-pool candidate merge and the per-leg + // capacity meters below; a failed lookup degrades to whole-route math. + let stops: string[] = []; + try { + stops = await this.stopsForSchedule(s); + } catch (err) { + this.logger.warn( + `Stop lookup failed for schedule ${s.id}: ${(err as Error).message}`, + ); + } + if (s.scheduledDepartureDate && stops.length) { + try { + const candidates = + await this.bookingsRepository.findBatchPoolByCorridorDay( + stops, + eatDay(s.scheduledDepartureDate), + ); + for (const b of candidates) { + if (!pinnedIds.has(b.id)) bookings.push(b); + } + } catch (err) { + // The board must still render the pinned bookings. + this.logger.warn( + `Corridor-day candidate merge failed for schedule ${s.id}: ` + + `${(err as Error).message}`, + ); + } + } let allocationPreview: Awaited< ReturnType @@ -1320,10 +1685,12 @@ export class BookingBatchService implements OnModuleInit { } } + const cycleOf = await this.windowCycleIndexer(s); const items: BatchBoardBookingDetail[] = bookings.map((b) => { const need = this.needFor(b, wagonDims); const alloc = allocationByBooking.get(b.id); return { + windowCycleNo: b.fullyExecutedAt ? cycleOf(b.fullyExecutedAt) : null, id: b.id, reference: b.reference ?? b.id.slice(0, 8), company: b.isGovernment @@ -1385,6 +1752,18 @@ export class BookingBatchService implements OnModuleInit { const windowBookings = items.filter((i) => i.fullyExecutedAt); const pendingBookings = items.filter((i) => !i.fullyExecutedAt); + const stopLabels = + stops.length > 2 ? await this.yardLabels(stops) : new Map(); + const yardsByBookingId = new Map( + bookings.map((b) => [ + b.id, + { + originYardId: b.originYardId ?? null, + destinationYardId: b.destinationYardId ?? null, + }, + ]), + ); + return { scheduleId: s.id, scheduleReference: s.reference ?? null, @@ -1424,7 +1803,19 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), + // Capacity holds come from bookings actually pinned to this train — + // unpinned day-pool candidates are shown in the lists but hold nothing. + capacity: this.computeBoardCapacity( + items.filter((i) => pinnedIds.has(i.id)), + loco, + s.maxWagons ?? null, + { + stops, + labelByYardId: stopLabels, + yardsByBookingId, + trainLengthMeters: this.builtTrainLengthOf(s), + }, + ), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -1464,6 +1855,7 @@ export class BookingBatchService implements OnModuleInit { */ private computeBoardCapacity( items: Array<{ + id: string; state: BatchBoardBookingState; wagons: number; weightTons: number; @@ -1471,6 +1863,16 @@ export class BookingBatchService implements OnModuleInit { }>, loco: LocomotiveLimits | null, maxWagons: number | null, + legCtx?: { + /** Ordered corridor stop yard ids; per-leg math needs 3+ stops. */ + stops: string[]; + labelByYardId: Map; + yardsByBookingId: Map< + string, + { originYardId: string | null; destinationYardId: string | null } + >; + trainLengthMeters: number | null; + }, ): BatchBoardSchedule["capacity"] { const allocated = items.filter((i) => i.state === "ALLOCATED"); // Every booking still targeting this train holds gross weight — including @@ -1488,23 +1890,128 @@ export class BookingBatchService implements OnModuleInit { : null; const round2 = (value: number) => Math.round(value * 100) / 100; + // Per-leg committed usage: a booking holds capacity only on the edges it + // rides, so every meter compares the HEAVIEST single edge against its cap + // — weight, wagons and length alike. Whole-route bookings (or yards + // missing from the stop list) load every edge — never under-reported. + const stops = legCtx?.stops ?? []; + let usedWeightTons = round2( + committed.reduce((sum, i) => sum + i.weightTons, 0), + ); + let allocatedWagons = allocated.reduce((sum, i) => sum + i.wagons, 0); + let allocatedLengthMeters = round2( + allocated.reduce((sum, i) => sum + i.lengthMeters, 0), + ); + let legUsage: BatchBoardSchedule["capacity"]["legUsage"] = null; + if (legCtx && stops.length > 2) { + const stopIndex = new Map(stops.map((yardId, i) => [yardId, i])); + const edgeCount = stops.length - 1; + const legOf = (bookingId: string): { from: number; to: number } => { + const yards = legCtx.yardsByBookingId.get(bookingId); + const from = yards?.originYardId + ? stopIndex.get(yards.originYardId) + : undefined; + const to = yards?.destinationYardId + ? stopIndex.get(yards.destinationYardId) + : undefined; + return from != null && to != null && from < to + ? { from, to } + : { from: 0, to: edgeCount }; + }; + const weightEdges = new Array(edgeCount).fill(0); + for (const item of committed) { + const leg = legOf(item.id); + for (let e = leg.from; e < leg.to; e += 1) weightEdges[e] += item.weightTons; + } + const wagonEdges = new Array(edgeCount).fill(0); + const lengthEdges = new Array(edgeCount).fill(0); + for (const item of allocated) { + const leg = legOf(item.id); + for (let e = leg.from; e < leg.to; e += 1) { + wagonEdges[e] += item.wagons; + lengthEdges[e] += item.lengthMeters; + } + } + const label = (yardId: string) => + legCtx.labelByYardId.get(yardId) ?? yardId; + legUsage = weightEdges.map((weight, i) => ({ + from: label(stops[i]), + to: label(stops[i + 1]), + usedWeightTons: round2(weight), + })); + usedWeightTons = round2(Math.max(0, ...weightEdges)); + allocatedWagons = Math.max(0, ...wagonEdges); + allocatedLengthMeters = round2(Math.max(0, ...lengthEdges)); + } + return { - allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0), - allocatedLengthMeters: round2( - allocated.reduce((sum, i) => sum + i.lengthMeters, 0), - ), + allocatedWagons, + allocatedLengthMeters, maxLengthMeters: caps ? caps.maxLengthMeters : null, - usedWeightTons: round2(committed.reduce((sum, i) => sum + i.weightTons, 0)), + usedWeightTons, maxWeightTons: caps ? caps.maxWeightTons : null, maxWagons: maxWagons ?? null, + trainLengthMeters: legCtx?.trainLengthMeters ?? null, + legUsage, }; } + /** Built consist's physical length (what Train Builder shows), null without a built train. */ + private builtTrainLengthOf(s: TrainSchedule): number | null { + const raw = s.trainSet?.totalLengthMeters; + const value = raw != null ? Number(raw) : NaN; + return Number.isFinite(value) && value > 0 ? value : null; + } + + /** Yard display labels for corridor stops (falls back to the yard id). */ + private async yardLabels(yardIds: string[]): Promise> { + if (!yardIds.length) return new Map(); + const yards = await this.dataSource + .getRepository(Yard) + .find({ where: { id: In(yardIds) } }); + return new Map(yards.map((y) => [y.id, y.label ?? y.code])); + } + + /** + * Corridor stops + labels from the already-loaded route graph (milestones + * with yards) — the list flow must not fire a query per schedule row. + */ + private stopsFromGraph(s: TrainSchedule): { + stops: string[]; + labelByYardId: Map; + } { + const milestones = [...(s.route?.milestones ?? [])].sort( + (a, b) => a.sequenceNo - b.sequenceNo, + ); + const stops: string[] = []; + const labelByYardId = new Map(); + const push = (yardId?: string | null, label?: string | null) => { + if (!yardId || labelByYardId.has(yardId)) return; + stops.push(yardId); + labelByYardId.set(yardId, label ?? yardId); + }; + if (milestones.length >= 2) { + for (const m of milestones) push(m.yardId, m.yard?.label ?? m.yard?.code); + } else { + push(s.originStationId, s.originStation?.label ?? s.originStation?.code); + push( + s.destinationStationId, + s.destinationStation?.label ?? s.destinationStation?.code, + ); + } + return { stops, labelByYardId }; + } + private buildScheduleSummary( s: TrainSchedule, items: BatchBoardBooking[], + yardsByBookingId: Map< + string, + { originYardId: string | null; destinationYardId: string | null } + >, ): BatchBoardSchedule { const loco = trainSetLocomotiveLimits(s.trainSet); + const { stops, labelByYardId } = this.stopsFromGraph(s); return { scheduleId: s.id, @@ -1546,7 +2053,12 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, { + stops, + labelByYardId, + yardsByBookingId, + trainLengthMeters: this.builtTrainLengthOf(s), + }), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -1619,6 +2131,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"); @@ -1629,7 +2143,7 @@ export class BookingBatchService implements OnModuleInit { // Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill // must rank bulk bookings by their wagon-derived priority too. await this.recomputeBulkPriorities(pool, wagonDims); - this.resortPoolByPriority(pool); + this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule)); const units = this.groupConsolidatedPool(pool); let armed = false; let preempted = false; @@ -1655,14 +2169,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 +2196,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 +2226,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,14 +2347,20 @@ 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; }> = []; + // The day group shares one booking window (route+day grouping), so any + // member's window grid stands for the pool's cycle derivation. + let cycleSchedule: TrainSchedule | null = null; for (const id of scheduleIds) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); @@ -1841,10 +2371,12 @@ export class BookingBatchService implements OnModuleInit { ); continue; } + cycleSchedule ??= schedule; 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 }; @@ -1860,7 +2392,10 @@ export class BookingBatchService implements OnModuleInit { // BULK bookings only get their real (wagon-derived) priority score now, at // batch time — stamp it and re-rank before the fill consumes the pool. await this.recomputeBulkPriorities(pool, wagonDims); - this.resortPoolByPriority(pool); + this.resortPoolByPriority( + pool, + cycleSchedule ? await this.windowCycleIndexer(cycleSchedule) : undefined, + ); // Consolidated partners collapse into one atomic unit (both-or-neither); a // consolidated booking whose partner isn't ready this cycle is skipped. const units = this.groupConsolidatedPool(pool); @@ -1884,12 +2419,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 +2477,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 +2513,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) { @@ -1997,14 +2549,16 @@ export class BookingBatchService implements OnModuleInit { * partial (split-on-payment). Consolidated pairs never split (both-or-neither * shared wagon) and government bookings never split (they preempt). * - * IMPORT is always eligible. EXPORT is eligible only when export split is - * enabled: export historically rides one train whole, so splitting it changes - * the FCFS money path — each split part still rides ONE train whole, and the - * leftover becomes its own booking on the next train. + * IMPORT and DOMESTIC (intercity ride-along) are always eligible. EXPORT is + * eligible only when export split is enabled: export historically rides one + * train whole, so splitting it changes the FCFS money path — each split part + * still rides ONE train whole, and the leftover becomes its own booking on + * the next train. */ private isSplitEligible(booking: Booking, isPair: boolean): boolean { const directionOk = booking.tradeDirection === "IMPORT" || + booking.tradeDirection === "DOMESTIC" || (booking.tradeDirection === "EXPORT" && this.exportSplitEnabled); return ( !isPair && @@ -2027,14 +2581,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 +2619,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; } @@ -2104,7 +2677,10 @@ export class BookingBatchService implements OnModuleInit { }; if (!this.fits(offeredNeed, budget)) return null; - const deadline = new Date(Date.now() + (await this.paymentWindowMs())); + const deadline = new Date( + Date.now() + + (await this.paymentWindowMsFor(await this.scheduleById(scheduleId))), + ); await this.splitService.createOffer(booking, scheduleId, sized, deadline); // Reserve like a normal batch selection, but the partial invoice + partial // pay-now notification were already produced by createOffer. @@ -2113,6 +2689,7 @@ export class BookingBatchService implements OnModuleInit { status: "SELECTED_FOR_BATCH", selectedForBatchAt: new Date(), paymentDeadline: deadline, + paymentReminderSentAt: null, } as never); booking.trainScheduleId = scheduleId; return offeredNeed; @@ -2141,6 +2718,8 @@ export class BookingBatchService implements OnModuleInit { const isPaid = (b: Booking) => b.paymentStatus === "PAID" || b.status === "PAID"; + // Deadline is the line — no fixed slack. A payment that beat the deadline + // but whose webhook is late is caught by expire()'s gateway reconcile. const isExpired = (b: Booking) => b.paymentDeadline ? b.paymentDeadline.getTime() <= now @@ -2441,6 +3020,39 @@ export class BookingBatchService implements OnModuleInit { this.notifyBoardChanged(newScheduleId, "booking_moved"); } + /** + * One reminder per hold, shortly before its pay deadline (the window tick + * calls this every pass; `payment_reminder_sent_at` dedups). Skips paid + * bookings — a landed payment the settle hasn't processed yet needs no nag. + */ + async sendPaymentReminders(): Promise { + const now = new Date(); + const due = await this.dataSource + .getRepository(Booking) + .createQueryBuilder("b") + .leftJoinAndSelect("b.company", "company") + .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .andWhere(`b.payment_status != 'PAID'`) + .andWhere("b.payment_reminder_sent_at IS NULL") + .andWhere("b.payment_deadline > :now", { now }) + .andWhere("b.payment_deadline <= :soon", { + soon: new Date(now.getTime() + PAYMENT_REMINDER_LEAD_MS), + }) + .getMany(); + for (const booking of due) { + // Stamp BEFORE sending so a slow notifier can't double-send next tick. + await this.bookingsRepository.update(booking.id, { + paymentReminderSentAt: new Date(), + } as never); + if (booking.paymentDeadline) { + await this.notifier.payDeadlineApproaching( + booking, + booking.paymentDeadline, + ); + } + } + } + /** Staff "expire" override → free a reservation now (booking becomes EXPIRED). */ async expireReservation(bookingId: string): Promise { const booking = await this.dataSource @@ -2461,6 +3073,52 @@ export class BookingBatchService implements OnModuleInit { } } + /** + * Customer cancel of an unpaid hold: the same immediate release as + * expireReservation, but the booking ends CANCELLED (the customer chose to + * walk away — "payment window missed" copy would be wrong). Consolidated + * pairs are rejected by the caller: the shared wagon is both-or-neither. + */ + async cancelReservation(bookingId: string): Promise { + const booking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + const freedScheduleId = booking.trainScheduleId; + await this.bookingsRepository.update(booking.id, { + trainScheduleId: null, + requestedTrainScheduleId: null, + status: "CANCELLED", + schedulingStatus: "ELIGIBLE", + paymentDeadline: null, + selectedForBatchAt: null, + paymentReminderSentAt: null, + } as never); + // An unpaid partial offer dies with the hold — same as expire(). + if (this.splitService) { + await this.splitService.expireOpenOffer(booking.id); + } + await this.billing.expirePayable( + Freight.InvoiceSource.Booking, + booking.id, + "PREPAID", + ); + if (freedScheduleId) { + // Same release choreography as expireReservation: reopen a FULL window, + // top up from the waiting list, push one board update with final state. + await this.refreshWindowStatus(freedScheduleId); + const topUpReserved = await this.topUpFill(freedScheduleId); + if (topUpReserved > 0) { + await this.extendPaymentPhaseForTopUp(freedScheduleId); + } + this.notifyBoardChanged(freedScheduleId, "reservation_expired"); + } + this.logger.log( + `[BATCH] CANCELLED hold ${booking.reference} — customer released the ` + + `reservation before paying; wagons freed`, + ); + } + // ---- intercity ride-along API --------------------------------------------- /** @@ -2506,11 +3164,59 @@ export class BookingBatchService implements OnModuleInit { this.notifyBoardChanged(scheduleId, 'intercity_accepted'); return; } + // Manual placement of an ALREADY-PAID intercity booking: payment landed + // earlier (and unpinned it back to the pool) — staff are now choosing its + // train, so link directly. No new pay window; wagon assignment stays with + // staff in the workspace. + if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') { + await this.dataSource + .getRepository(Booking) + .update(booking.id, { trainScheduleId: scheduleId }); + booking.trainScheduleId = scheduleId; + await this.allocate(scheduleId, booking, 'paid'); + this.notifyBoardChanged(scheduleId, 'intercity_accepted'); + return; + } await this.reserve(booking, scheduleId); this.armSettle(scheduleId); this.notifyBoardChanged(scheduleId, 'intercity_accepted'); } + /** + * Intercity booking that does not fit its leg whole: offer the largest part + * that does (split-on-payment, customer notified with a pay window), sized + * against the leg's remaining room AND the train's physical wagon stock. + * Returns true when an offer was opened. The caller's budget is mutated so + * later bookings in the same accept pass see the offer's consumption. + */ + async offerIntercityPartial( + booking: Booking, + scheduleId: string, + budget: CorridorBudget, + ): Promise { + const wagonDims = await this.loadWagonDims(); + const need = this.needFor(booking, wagonDims); + const allowed = await this.loadAllowedWagonTypeIds(); + const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowed); + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) return false; + const stock = await this.stockLedgerFor(schedule, budget); + const cand = { id: scheduleId, budget, armed: false, stock }; + const offered = await this.maybeOfferPartial( + booking, + false, + [cand], + need, + wagonTypeIds, + ); + if (offered && cand.armed) { + this.armSettle(scheduleId); + this.notifyBoardChanged(scheduleId, 'intercity_partial_offered'); + } + return offered; + } + // ---- mutations ------------------------------------------------------------ /** @@ -2545,14 +3251,14 @@ export class BookingBatchService implements OnModuleInit { return; } const now = new Date(); - let deadline = new Date(now.getTime() + (await this.paymentWindowMs())); + const targetSchedule = await this.scheduleById(scheduleId); + let deadline = new Date( + now.getTime() + (await this.paymentWindowMsFor(targetSchedule)), + ); // EXPORT parity: pay windows on an export train never outlive its booking // window — export bookings expire at close, so anything reserved onto the // same train (FCFS export or an intercity ride-along) must too. Import // keeps the plain payment window; its cycles re-fill after settle. - const targetSchedule = await this.dataSource - .getRepository(TrainSchedule) - .findOne({ where: { id: scheduleId } }); if (targetSchedule?.direction === "EXPORT") { const cutoff = targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate; @@ -2570,6 +3276,7 @@ export class BookingBatchService implements OnModuleInit { status: "SELECTED_FOR_BATCH", selectedForBatchAt: now, paymentDeadline: deadline, + paymentReminderSentAt: null, } as never); booking.trainScheduleId = scheduleId; // The invoice was generated DRAFT at booking creation / operation-accept, @@ -2649,6 +3356,22 @@ export class BookingBatchService implements OnModuleInit { booking: Booking, reason: "paid" | "gov", ): Promise { + // Stamp the computed wagon need on the link. Several callers pass a booking + // loaded without cargo relations (ensurePaidBookingAllocated), and a NULL + // wagonsRequired makes every capacity/occupancy reader miscount this + // booking as 1 wagon — reload with the relations wagonsFor sizes from. + const wagonDims = await this.loadWagonDims(); + const full = + booking.bookingContainers || booking.cargoType + ? booking + : await this.dataSource.getRepository(Booking).findOne({ + where: { id: booking.id }, + relations: { + bookingContainers: { containerType: true }, + cargoType: true, + }, + }); + const wagonsRequired = this.wagonsFor(full ?? booking, wagonDims); await this.dataSource.transaction(async (manager) => { const exists = await this.trainScheduleBookingsRepository.existsForBooking( @@ -2665,6 +3388,7 @@ export class BookingBatchService implements OnModuleInit { status: reason === "paid" ? "PAID" : booking.status, schedulingStatus: "SCHEDULED", scheduledAt: new Date(), + wagonsRequired, paymentDeadline: null, selectedForBatchAt: null, } as never); @@ -2673,7 +3397,11 @@ export class BookingBatchService implements OnModuleInit { `[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`, ); this.notifier.secured(booking, reason, scheduleId); - void this.triggerWagonAllocation(scheduleId); + // Intercity rides are placed on wagons BY STAFF (workspace wizard) — auto + // wagon assignment is for the import/export batch flow only. + if (booking.tradeDirection !== 'DOMESTIC') { + void this.triggerWagonAllocation(scheduleId); + } void this.markWagonAllocatedMilestone(booking.id); // Customer tracking: freight payment settled (commercial pay-window path). // Government allocations don't pay upfront — theirs stay pending. @@ -2753,14 +3481,42 @@ export class BookingBatchService implements OnModuleInit { } return; } + // Reconcile-before-expire (only when a pay window was actually open): + // no webhook arrived, so ask the gateway DIRECTLY whether the money + // landed. A late capture found there is registered as SUCCEEDED and + // emits payment.succeeded — that event marks the booking PAID and + // allocates it, so we just leave the hold alone here. `unverifiable` + // (provider query errored / payment still in flight) means we could not + // confirm "not paid" — never expire on unknown; the next settle tick + // asks again. + if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) { + const reconcile = await this.billing.reconcilePayable(booking.id); + if (reconcile.paid) { + this.logger.log( + `[BATCH] expire skipped for ${booking.reference} — gateway ` + + `reconcile found a settled payment; payment.succeeded will allocate it`, + ); + return; + } + if (reconcile.unverifiable) { + this.logger.warn( + `[BATCH] expire deferred for ${booking.reference} — settlement ` + + `unverifiable at the gateway; retrying next settle tick`, + ); + return; + } + } } const freedScheduleId = booking.trainScheduleId; await this.bookingsRepository.update(booking.id, { trainScheduleId: null, + // The customer's train pick died with the hold — a rebook re-picks. + requestedTrainScheduleId: null, status: "EXPIRED", schedulingStatus: "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, + paymentReminderSentAt: null, } as never); booking.trainScheduleId = null; // The wagons this reservation held are back — a schedule parked at FULL @@ -3000,6 +3756,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. @@ -3188,11 +3960,66 @@ export class BookingBatchService implements OnModuleInit { } } - /** Restore the batch pool ordering (mirrors findBatchPool's ORDER BY) after scores changed. */ - private resortPoolByPriority(pool: Booking[]): void { + /** + * Maps a booking's pool-entry time (`fullyExecutedAt`) to the 0-based + * booking-window cycle it arrived in: the last window whose open is at/before + * the timestamp (a timestamp in the doc-review/payment gap belongs to the + * cycle that just closed). The cycle grid comes from the schedule's frozen + * window-rule snapshot — the exact windows the cycle engine runs. + */ + private async windowCycleIndexer( + schedule: TrainSchedule, + ): Promise<(ts: Date | null | undefined) => number> { + if (!schedule.scheduledDepartureDate) return () => 0; + let starts: number[]; + try { + const liveCfg = await this.trainSchedulingService.getWindowConfig(); + const cfg = effectiveWindowConfig(schedule, liveCfg); + const windows = listConfigBookingWindows( + schedule.direction, + schedule.scheduledDepartureDate, + { + ...cfg, + reopenGapMinutes: + schedule.ruleReopenDelayMinutes ?? + cfg.docReviewMinutes + cfg.paymentWindowMinutes, + }, + ); + starts = windows.map((w) => w.start.getTime()); + } catch (err) { + // A failed cycle derivation must never block the batch — fall back to one + // flat cycle (pure priority order, the old behaviour). + this.logger.warn( + `Window-cycle derivation failed for schedule ${schedule.id}: ` + + `${(err as Error).message}`, + ); + return () => 0; + } + return (ts) => { + if (!ts) return 0; + const ms = ts.getTime(); + let idx = 0; + for (let i = 0; i < starts.length; i += 1) { + if (ms >= starts[i]) idx = i; + } + return idx; + }; + } + + /** + * Rank the batch pool: government first, then WINDOW CYCLE (bookings compete + * only within the cycle they arrived in — an earlier cycle's booking always + * outranks a later cycle's, whatever the scores), then priority score, then + * oldest. `cycleOf` comes from {@link windowCycleIndexer}. + */ + private resortPoolByPriority( + pool: Booking[], + cycleOf: (ts: Date | null | undefined) => number = () => 0, + ): void { pool.sort( (a, b) => Number(b.isGovernment) - Number(a.isGovernment) || + cycleOf(a.fullyExecutedAt) - cycleOf(b.fullyExecutedAt) || Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) || (a.fullyExecutedAt?.getTime() ?? Infinity) - (b.fullyExecutedAt?.getTime() ?? Infinity) || @@ -3232,7 +4059,17 @@ export class BookingBatchService implements OnModuleInit { const byWeight = cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; - return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight); + // Break-bulk (PER_ITEM): indivisible items can need more wagons than raw + // tonnage suggests (floor items-per-wagon loses the fractional capacity). + // `dimsFor` resolved dims from the first allowed wagon type, so charge that + // same type's configured items-fit alongside its capacity. + const byItems = bulkItemWagonsRequired( + booking, + capacityTons, + bulkItemsFitFor(booking.cargoType, booking.cargoType?.wagonTypes?.[0]?.id), + ); + + return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight, byItems); } /** @@ -3428,6 +4265,18 @@ export class BookingBatchService implements OnModuleInit { * representative dims when no allowed type is configured. */ private dimsForAllowed(booking: Booking, wagonDims: WagonDims): PerWagonDims[] { + return this.allowedDimsWithTypes(booking, wagonDims).map((p) => p.dims); + } + + /** + * Same allowed set as {@link dimsForAllowed} but keeping each wagon-type id, + * so callers (the export train picker) can label per-type availability. + * `wagonTypeId` is null only on the unconfigured fallback entry. + */ + private allowedDimsWithTypes( + booking: Booking, + wagonDims: WagonDims, + ): Array<{ wagonTypeId: string | null; dims: PerWagonDims }> { const fallback = booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container; const ids = @@ -3437,19 +4286,148 @@ export class BookingBatchService implements OnModuleInit { .flatMap((line) => line.containerType?.wagonTypes ?? []) .map((wt) => wt.id); const seen = new Set(); - const dims: PerWagonDims[] = []; + const out: Array<{ wagonTypeId: string | null; dims: PerWagonDims }> = []; for (const id of ids) { if (!id || seen.has(id)) continue; seen.add(id); const d = wagonDims.byWagonTypeId.get(id); if (d) { - dims.push({ - ...d, - capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons, + out.push({ + wagonTypeId: id, + dims: { + ...d, + capacityTons: + d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons, + }, }); } } - return dims.length ? dims : [fallback]; + return out.length ? out : [{ wagonTypeId: null, 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]; } /** @@ -3509,8 +4487,20 @@ export class BookingBatchService implements OnModuleInit { const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); - const reserved = await this.bookingsRepository.findReservedForSchedule( - schedule.id, + // Lazy-expiry guard: a hold whose deadline lapsed no longer blocks + // capacity, even before the 10s sweep flips it to EXPIRED — availability + // shown to the next customer is honest between ticks. A late capture the + // gateway reconcile later confirms lands as PAID and, if the wagons went + // meanwhile, degrades to WAITING_FOR_WAGON for manual placement. + const deadlineCutoff = Date.now(); + const reserved = ( + await this.bookingsRepository.findReservedForSchedule(schedule.id) + ).filter( + (b) => + b.paymentStatus === "PAID" || + b.status === "PAID" || + b.paymentDeadline == null || + b.paymentDeadline.getTime() > deadlineCutoff, ); for (const b of [...allocated, ...reserved]) { budget.subtract( @@ -3611,14 +4601,12 @@ export class BookingBatchService implements OnModuleInit { } /** - * FULL is DIRECTIONAL: the schedule's trade direction is full when the - * border-crossing edge (which every export/import must ride) can't take one + * FULL is CORRIDOR-WIDE: the train is full only when NO leg can take one * more minimal wagon on any axis — slots for built trains (the consist is * the capacity, weight/length settled at build), all three axes otherwise * (PW2: weight binds at 37 wagons = 3522.4T of 3500+90T, slots bind at 44). - * Home-side legs may still run empty; intercity ride-alongs keep filling - * them via the per-leg budget and never consult this flag. Domestic routes - * (no border) are full only when every edge is closed. + * A full DCT→Dire leg alone does NOT close the window while Dire→GMP still + * has room — sub-corridor bookings keep selling the open legs. */ async isScheduleFull(scheduleId: string): Promise { const schedule = @@ -3669,13 +4657,9 @@ export class BookingBatchService implements OnModuleInit { /** See {@link isScheduleFull} — same check for callers that already hold the full graph. */ private async isTrainFull(schedule: TrainSchedule): Promise { - // "Full" means full FOR THE TRAIN'S TRADE DIRECTION. Every export and - // every import must cross the ET↔DJ border edge, so once that edge can't - // take one more minimal wagon the booking window may close — even while - // home-side legs still run empty. Intercity ride-alongs never consult this - // flag; they keep booking the free legs through the per-leg budget. - // A single-country (domestic) corridor has no mandatory edge, so it is - // full only when EVERY edge is closed on some axis. + // Full only when EVERY edge is closed on some axis: a full border edge + // still leaves the home-side legs bookable by sub-corridor cargo, so the + // window must stay open until not even the smallest wagon fits anywhere. const wagonDims = await this.loadWagonDims(); const physicalWagons = await this.builtTrainWagonCount(schedule); let limits: TrainLimits; @@ -3698,42 +4682,9 @@ export class BookingBatchService implements OnModuleInit { } const budget = await this.remainingBudget(schedule, limits, wagonDims); const minNeed = this.minPerWagonNeed(wagonDims); - const border = await this.borderLeg(budget.stops); - if (border) { - return !budget.fits( - { - wagons: 1, - weightTons: minNeed.grossWeightTons, - lengthMeters: minNeed.lengthMeters, - }, - border, - ); - } return budget.isExhausted(minNeed); } - /** - * The corridor's single border-crossing edge (last home-country stop → first - * far-country stop), or null when every stop is in one country. This is the - * edge every EXPORT and IMPORT booking must ride, whichever sub-corridor it - * books — which makes it the train's directional fullness gauge. - */ - private async borderLeg(stops: string[]): Promise { - if (stops.length < 2) return null; - const yards = await this.dataSource - .getRepository(Yard) - .find({ where: { id: In(stops) } }); - const countryOf = new Map(yards.map((y) => [y.id, y.country])); - const first = countryOf.get(stops[0]); - if (!first) return null; - const crossIdx = stops.findIndex((id) => { - const country = countryOf.get(id); - return country != null && country !== first; - }); - if (crossIdx <= 0) return null; - return { fromEdge: crossIdx - 1, toEdge: crossIdx }; - } - /** * Smallest gross weight / shortest length one more wagon could add: the * lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted, @@ -3779,6 +4730,31 @@ export class BookingBatchService implements OnModuleInit { // nothing can board. if (await this.isTrainFull(schedule)) return; + // FULL concluded the cycle (phase DONE) and DONE rows are skipped by the + // window tick forever — so when wagons free up before departure, restart + // the cycle or nobody (customer or batch) can ever book the freed space. + // ponytail: reopens now and closes at departure; the office-hours clamp + // reapplies on the next conclude cycle. + const departure = schedule.scheduledDepartureDate; + if ( + schedule.windowPhase === "DONE" && + ["DRAFT", "SCHEDULED"].includes(schedule.status) && + departure && + departure.getTime() > Date.now() + ) { + await this.dataSource.getRepository(TrainSchedule).update(scheduleId, { + windowPhase: "PRE_WINDOW", + windowOpensAt: new Date(), + windowClosesAt: departure, + }); + await this.setWindow(scheduleId, "OPEN"); + this.logger.log( + `[BATCH] ${scheduleId} FULL cleared after wagons freed — window revived ` + + `(PRE_WINDOW, reopens immediately, closes at departure)`, + ); + return; + } + const customerWindowOpen = schedule.windowPhase == null || schedule.windowPhase === "OPEN"; await this.setWindow(scheduleId, customerWindowOpen ? "OPEN" : "CLOSED"); @@ -3790,10 +4766,33 @@ export class BookingBatchService implements OnModuleInit { // ---- timer plumbing ------------------------------------------------------- - /** Configured customer pay window in ms (global rules, with defaults). */ - private async paymentWindowMs(): Promise { + /** + * Effective customer pay window in ms for a target schedule: the staff + * per-schedule override wins, else the global value for the schedule's + * direction (export and import pay windows are tuned independently). + * No schedule (unknown target) falls back to the import global. + */ + private async paymentWindowMsFor( + schedule?: Pick< + TrainSchedule, + "direction" | "rulePaymentWindowMinutes" + > | null, + ): Promise { + if (schedule?.rulePaymentWindowMinutes != null) { + return schedule.rulePaymentWindowMinutes * 60_000; + } const cfg = await this.trainSchedulingService.getWindowConfig(); - return cfg.paymentWindowMinutes * 60_000; + const minutes = + schedule?.direction === "EXPORT" + ? cfg.exportPaymentWindowMinutes + : cfg.paymentWindowMinutes; + return minutes * 60_000; + } + + private scheduleById(id: string): Promise { + return this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id } }); } private timeoutName(scheduleId: string): string { @@ -3805,8 +4804,9 @@ export class BookingBatchService implements OnModuleInit { * engine's minute tick calling settleDueReservations off `paymentDeadline`. */ private armSettle(scheduleId: string): void { - void this.paymentWindowMs() - .then((delayMs) => { + void this.scheduleById(scheduleId) + .then((schedule) => this.paymentWindowMsFor(schedule)) + .then((delayMs: number) => { this.removeTimeout(scheduleId); const handle = setTimeout(() => { void this.settleBatch(scheduleId).catch((err) => @@ -3839,7 +4839,7 @@ export class BookingBatchService implements OnModuleInit { .getRepository(TrainSchedule) .findOne({ where: { id: scheduleId } }); if (!schedule || schedule.windowPhase !== "PAYMENT") return; - const windowMs = await this.paymentWindowMs(); + const windowMs = await this.paymentWindowMsFor(schedule); let target = new Date(Date.now() + windowMs); if ( schedule.scheduledDepartureDate && 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..87e756ba6 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, ) {} @@ -81,6 +83,11 @@ export class BookingJourneyService { loadedByUserId: userId ?? null, } as never); await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED'); + // Keep the schedule↔booking link's tracking flag in sync — the dispatch + // readiness warnings and workspace badges read loading_status, not loadedAt. + await manager + .getRepository(TrainScheduleBooking) + .update({ trainScheduleId: scheduleId, bookingId }, { loadingStatus: 'LOADED' }); // The facility handed the cargo over — raise its GRN. No-ops for yards // without a facility (import/export terminals), which keep their own flow. await this.facilityHandling.recordHandling(manager, { @@ -145,6 +152,20 @@ 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 }); + } + + // The cargo is physically off the train at its own yard — mid-corridor or + // final. WarehouseInventoryService picks this up to create the warehouse + // record (import/intercity only; export already has one from receive). + this.events.emit('booking.unloadedAtYard', { + bookingId, + tradeDirection: booking.tradeDirection, + }); + // Customer tracking: THIS booking arrived (train may still be rolling). void this.completeMilestones(booking, [ ...(booking.tradeDirection === 'IMPORT' @@ -228,7 +249,10 @@ export class BookingJourneyService { return { scheduleId, scheduleStatus: schedule.status, - trainAtYardId: latest?.yardId ?? (schedule.status === 'DISPATCHED' ? null : schedule.originStationId), + // No checkpoint yet ⇒ the train is still at its origin, even just after + // dispatch — assertTrainAtYard allows origin loading in that state, so + // the UI position must agree or origin Load buttons grey out wrongly. + trainAtYardId: latest?.yardId ?? schedule.originStationId, yards: [...byYard.values()], }; } @@ -303,6 +327,20 @@ export class BookingJourneyService { RETURNING b.id, b.trade_direction`, [schedule.id, schedule.destinationStationId, now], ); + for (const row of rows) { + // Intercity rows just completed — let a ONE_TIME contract close on delivery. + if (row.trade_direction === 'DOMESTIC') { + this.events.emit('booking.completed', { bookingId: row.id }); + } + // Same event the per-booking unloadBooking() path emits — WarehouseInventoryService + // listens for this to auto-create the warehouse_inventory row (import/intercity only, + // it filters EXPORT itself). The bulk SQL update above skipped this entirely, so + // bookings caught by this fallback never left "awaiting unload". + this.events.emit('booking.unloadedAtYard', { + bookingId: row.id, + tradeDirection: row.trade_direction, + }); + } return rows.map((r) => r.id); } @@ -487,7 +525,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-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index e17445b4d..b806de5ca 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -137,6 +137,25 @@ export class BookingNotifierService { }); } + /** One warning shortly before the pay window closes (sent once per hold). */ + async payDeadlineApproaching(b: Booking, deadline: Date): Promise { + const minutesLeft = Math.max( + 1, + Math.round((deadline.getTime() - Date.now()) / 60_000), + ); + const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); + const msg = + `Payment reminder: about ${minutesLeft} minute${minutesLeft === 1 ? '' : 's'} left ` + + `to pay for booking ${b.reference ?? b.id}. Deadline: ${eat} EAT — ` + + `unpaid reservations are released and the wagons go back on sale.`; + await this.notifyContact(b, msg, 'PAY REMINDER'); + // HIGH: minutes from losing the reserved wagons — must reach SMS/email. + this.inApp(b, 'Payment deadline approaching', msg, { + type: NotificationType.INVOICE_ISSUED, + priority: NotificationPriority.HIGH, + }); + } + /** * Partial-capacity offer: only `offeredWagons` of the booking's `totalWagons` fit * this train. Paying accepts the split; letting the deadline pass keeps the diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts index fc47d8a70..50cba543f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts @@ -72,7 +72,12 @@ describe('BookingSplitService — applySplit split marking', () => { dataSource as never, {} as never, {} as never, - { expirePayable: jest.fn() } as never, + { + expirePayable: jest.fn(), + reconcilePayable: jest + .fn() + .mockResolvedValue({ paid: false, unverifiable: false }), + } as never, { payNowPartial: jest.fn() } as never, ); return { service, bookingRepo, contractRepo }; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts index 617d83561..74e9a0bff 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts @@ -18,7 +18,10 @@ export interface BookingWindowConfig { windowDurationHours: number; /** Max staff document-review time after the window closes. */ docReviewMinutes: number; + /** Pay window for IMPORT/DOMESTIC bookings (also part of the reopen gap). */ paymentWindowMinutes: number; + /** Pay window for EXPORT bookings — independent of the import value. */ + exportPaymentWindowMinutes: number; /** * Minutes before departure the IMPORT/DOMESTIC booking window shuts. When set * (> 0), the effective booking cutoff is `departure − this`, capping the first 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..9dff62261 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 }; @@ -35,6 +36,7 @@ describe('BookingWindowService — window state machine', () => { windowDurationHours: 1, docReviewMinutes: 30, paymentWindowMinutes: 60, + exportPaymentWindowMinutes: 60, }; const baseSchedule = (over: Partial): TrainSchedule => @@ -79,6 +81,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 +270,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..6cdef8c05 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 @@ -119,6 +141,13 @@ export class BookingWindowService implements OnModuleInit { await this.settleOverdueReservations(); + // One pre-deadline pay reminder per hold (deduped via reminder stamp). + await this.bookingBatchService.sendPaymentReminders().catch((err) => + this.logger.warn( + `Payment reminder sweep failed: ${(err as Error).message}`, + ), + ); + // Legacy fill (DOMESTIC / pre-migration schedules) every 5 minutes // (30 ticks at the 10-second cadence). this.tickCount += 1; @@ -130,6 +159,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); @@ -514,6 +602,8 @@ export class BookingWindowService implements OnModuleInit { .createQueryBuilder('b') .select('DISTINCT b.train_schedule_id', 'scheduleId') .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + // Deadline is the line — expire() itself reconciles against the gateway + // before actually expiring, so a late in-window payment is still caught. .andWhere('b.payment_deadline <= now()') .andWhere('b.train_schedule_id IS NOT NULL') .getRawMany<{ scheduleId: string }>(); 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/adjust-schedule-consist.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts index e031bf9f0..215b6c806 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts @@ -1,5 +1,16 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsArray, IsOptional, IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsArray, IsOptional, IsUUID, ValidateNested } from 'class-validator'; + +export class ConsistWagonSwitchDto { + @ApiPropertyOptional({ format: 'uuid', description: 'Coupled wagon being taken out of the consist.' }) + @IsUUID() + fromWagonId!: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'AVAILABLE same-type wagon from the current yard that takes its place (and its slot, cargo included).' }) + @IsUUID() + toWagonId!: string; +} export class AdjustScheduleConsistDto { @ApiPropertyOptional({ @@ -23,4 +34,15 @@ export class AdjustScheduleConsistDto { @IsArray() @IsUUID('all', { each: true }) removeWagonIds?: string[]; + + @ApiPropertyOptional({ + type: [ConsistWagonSwitchDto], + description: + "Wagon swaps: the replacement takes over the outgoing wagon's position AND its slot, so cargo allocations ride the new wagon. This is how a LOADED wagon leaves the train — removal is blocked for it, switching is not. Replacement must be the same wagon type, AVAILABLE, standing in the train's current yard.", + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ConsistWagonSwitchDto) + switches?: ConsistWagonSwitchDto[]; } 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/dto/switch-government-booking.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/switch-government-booking.dto.ts new file mode 100644 index 000000000..c72a1879b --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/switch-government-booking.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator'; + +export class SwitchGovernmentBookingDto { + @ApiProperty({ format: 'uuid', description: 'Government booking to allocate onto the train' }) + @IsUUID() + governmentBookingId!: string; + + @ApiProperty({ + format: 'uuid', + isArray: true, + description: 'Assigned commercial bookings to switch out in its place', + }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + removeBookingIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index b10736f0f..74aba1383 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -61,13 +61,20 @@ export class UpdateTrainSchedulingGlobalRulesDto { @Min(0) docReviewMinutes?: number; - @ApiPropertyOptional({ example: 60 }) + @ApiPropertyOptional({ example: 60, description: 'IMPORT/DOMESTIC customer pay window, minutes' }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) paymentWindowMinutes?: number; + @ApiPropertyOptional({ example: 60, description: 'EXPORT customer pay window, minutes' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + exportPaymentWindowMinutes?: number; + // Booking-close offsets: minutes before departure the window shuts. The UI // enters days/hours/minutes and converts to minutes. 0 or null clears the // offset (close at departure). Nullable so it can be explicitly cleared. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index caa3ce24f..94882833b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -77,9 +77,14 @@ export class TrainSchedulingGlobalRules extends BaseEntity { @Column({ name: 'doc_review_minutes', type: 'int', default: 30 }) docReviewMinutes!: number; + /** Pay window for IMPORT/DOMESTIC bookings (also feeds the window reopen delay). */ @Column({ name: 'payment_window_minutes', type: 'int', default: 60 }) paymentWindowMinutes!: number; + /** Pay window for EXPORT bookings — tunable independently of import. */ + @Column({ name: 'export_payment_window_minutes', type: 'int', default: 60 }) + exportPaymentWindowMinutes!: number; + /** * Minutes before departure the IMPORT/DOMESTIC booking window shuts. When set, * the window's close (first cycle and every reopen) is capped at 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/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index cacffaba4..bea01af0a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -1,4 +1,4 @@ -import { bookingCargoTons } from './train-capacity.util'; +import { bookingCargoTons, bulkItemWagonsForAllowedTypes } from './train-capacity.util'; import type { Booking } from '../bookings/entities/booking.entity'; import type { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { @@ -51,8 +51,14 @@ export function sortBookingsForScheduling(bookings: Booking[]): Booking[] { export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: number): number { if (booking.freightType === 'BULK') { - const weight = Number(booking.cargoTotalWeightVgm ?? 0); const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1; + // Break-bulk (PER_ITEM) sizes by indivisible items; `cargoTotalWeightVgm` + // holds the item count there, not tons. No wagon type is fixed yet, so use + // the best count across the cargo's allowed types (per-type items-fit + // respected); falls back to `capacity` when the relation isn't loaded. + const byItems = bulkItemWagonsForAllowedTypes(booking, booking.cargoType, capacity); + if (byItems > 0) return byItems; + const weight = Number(booking.cargoTotalWeightVgm ?? 0); return Math.max(1, Math.ceil(weight / capacity)); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index 293fc8801..5775148d7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -217,10 +217,20 @@ export class IntercityService { // board a train that is full only on other legs. const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); if (!budget.fits(need, leg)) { + // Offer the part that DOES fit the leg (split-on-payment): customer is + // notified with a pay window for the fitting wagons; the remainder can + // be re-booked on a later train. Budget is consumed by the offer so the + // next booking in this pass sees the reduced room. + const offered = await this.bookingBatchService.offerIntercityPartial( + booking, + scheduleId, + budget, + ); rejected.push({ bookingId, - reason: - 'Does not fit the remaining wagon/weight/length capacity for this train', + reason: offered + ? 'Does not fit whole — a partial offer for the wagons that fit was sent to the customer' + : 'Does not fit the remaining wagon/weight/length capacity for this train', }); continue; } @@ -320,8 +330,10 @@ export class IntercityService { .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .where(`booking.trade_direction = 'DOMESTIC'`) .andWhere('booking.train_schedule_id IS NULL') + // PAID = customer paid but staff have not placed it on a train yet + // (intercity allocation is manual) — it stays in the pool until they do. .andWhere( - `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED') + `((booking.is_government = false AND booking.status IN ('FULLY_EXECUTED', 'PAID')) OR (booking.is_government = true AND booking.status = 'APPROVED'))`, ) .orderBy('booking.is_government', 'DESC') @@ -372,8 +384,12 @@ export class IntercityService { if (booking.trainScheduleId) { return 'Already assigned to a train'; } - const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED'; - if (booking.status !== readyStatus) { + // Commercial: FULLY_EXECUTED opens a pay window; PAID (payment landed, + // awaiting manual placement) links straight onto the chosen train. + const readyStatuses = booking.isGovernment + ? ['APPROVED'] + : ['FULLY_EXECUTED', 'PAID']; + if (!readyStatuses.includes(booking.status)) { return `Not ready to board (status ${booking.status})`; } if (!this.corridorOnRoute(booking, milestoneSeq)) { 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..ead3a4e8b 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 @@ -1,11 +1,14 @@ import { + bookingCargoTons, bookingGrossWeightTons, bookingTrainLengthMeters, + bulkItemWagonsForAllowedTypes, + bulkItemWagonsRequired, consistUsage, consistViolations, deriveTrainCapacityFromLocomotive, grossWagonWeightTons, - minLocomotiveLimits, + combinedLocomotiveLimits, sizePartialOfferWagons, trainSetLocomotiveLimits, } from './train-capacity.util'; @@ -30,6 +33,122 @@ describe('train-capacity.util', () => { cargoTons, })); + describe('bulkItemWagonsRequired (break-bulk PER_ITEM)', () => { + // cargoTotalWeightVgm carries the ITEM COUNT for PER_ITEM cargo; the real + // tonnage rides in bulkTotalWeightTons. + const breakBulk = (quantity: number, weightTons: number) => ({ + freightType: 'BULK', + cargoTotalWeightVgm: quantity, + bulkTotalWeightTons: weightTons, + }); + + it('floors items per wagon, then ceils wagons: 400 items / 800T on 69T wagons → 12', () => { + // 800/400 = 2T per item; floor(69/2) = 34 per wagon; ceil(400/34) = 12. + expect(bulkItemWagonsRequired(breakBulk(400, 800), 69)).toBe(12); + }); + + it('needs more wagons than raw tonnage suggests when the floor loses capacity', () => { + // 3 items × 40T on 69T wagons: by weight ceil(120/69) = 2, but only ONE + // whole 40T item fits a wagon → 3 wagons. + expect(bulkItemWagonsRequired(breakBulk(3, 120), 69)).toBe(3); + }); + + it('charges one wagon per item when a single item outweighs a wagon', () => { + expect(bulkItemWagonsRequired(breakBulk(2, 200), 69)).toBe(2); + }); + + it('returns 0 for PER_TON bulk (no stored weight) and container bookings', () => { + expect( + bulkItemWagonsRequired( + { freightType: 'BULK', cargoTotalWeightVgm: 500, bulkTotalWeightTons: null }, + 69, + ), + ).toBe(0); + expect( + bulkItemWagonsRequired( + { freightType: 'CONTAINER', cargoTotalWeightVgm: 100, bulkTotalWeightTons: 100 }, + 69, + ), + ).toBe(0); + }); + + it('returns 0 on zero/invalid capacity or amounts', () => { + expect(bulkItemWagonsRequired(breakBulk(400, 800), 0)).toBe(0); + expect(bulkItemWagonsRequired(breakBulk(0, 800), 69)).toBe(0); + expect(bulkItemWagonsRequired(breakBulk(400, 0), 69)).toBe(0); + }); + + describe('configured items-fit (floor space vs tonnage)', () => { + it('weight binds: 50 cars × 20T on a 70T wagon that fits 4 → 3 per wagon → 17', () => { + // floor(70/20) = 3 by tonnage < 4 by floor space. + expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 4)).toBe(17); + }); + + it('floor space binds: 50 cars × 10T on a 70T wagon that fits 4 → 4 per wagon → 13', () => { + // floor(70/10) = 7 by tonnage, but only 4 fit physically. + expect(bulkItemWagonsRequired(breakBulk(50, 500), 70, 4)).toBe(13); + }); + + it('ignores an absent/invalid fit (legacy cargo types): tonnage-only', () => { + expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, null)).toBe(17); + expect(bulkItemWagonsRequired(breakBulk(50, 1000), 70, 0)).toBe(17); + // floor(70/10) = 7 per wagon → ceil(50/7) = 8 wagons. + expect(bulkItemWagonsRequired(breakBulk(50, 500), 70)).toBe(8); + }); + }); + }); + + describe('bulkItemWagonsForAllowedTypes', () => { + const breakBulk = (quantity: number, weightTons: number) => ({ + freightType: 'BULK', + cargoTotalWeightVgm: quantity, + bulkTotalWeightTons: weightTons, + }); + + it('picks the fewest-wagon allowed type, each capped by its own fit', () => { + const cargoType = { + wagonTypes: [ + { id: 'nw5', capacityTons: 70 }, + { id: 'nw7', capacityTons: 80 }, + ], + itemsPerWagonMap: { nw5: 4, nw7: 6 }, + }; + // 50 cars × 20T: NW5 → min(4, floor(70/20)=3) = 3/wagon = 17 wagons; + // NW7 → min(6, floor(80/20)=4) = 4/wagon = 13 wagons. Best = 13. + expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 70)).toBe(13); + }); + + it('equals the old max-capacity estimate when no fits are configured', () => { + const cargoType = { + wagonTypes: [ + { id: 'a', capacityTons: 50 }, + { id: 'b', capacityTons: 70 }, + ], + }; + // Tonnage-only best = biggest wagon: floor(70/20) = 3/wagon → 17. + expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), cargoType, 1)).toBe(17); + }); + + it('falls back to the given capacity when the relation is missing', () => { + expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), null, 70)).toBe(17); + expect(bulkItemWagonsForAllowedTypes(breakBulk(50, 1000), { wagonTypes: [] }, 70)).toBe(17); + }); + }); + + describe('bookingCargoTons (break-bulk weight preference)', () => { + it('prefers bulkTotalWeightTons over the item-count VGM column', () => { + expect( + bookingCargoTons({ cargoTotalWeightVgm: 400, bulkTotalWeightTons: 800 }), + ).toBe(800); + }); + + it('falls back to cargoTotalWeightVgm when no break-bulk weight is stored', () => { + expect( + bookingCargoTons({ cargoTotalWeightVgm: 500, bulkTotalWeightTons: null }), + ).toBe(500); + }); + }); + describe('deriveTrainCapacityFromLocomotive', () => { it('derives wagon slots from train length, not a fixed 53', () => { const shortLoco = deriveTrainCapacityFromLocomotive( @@ -197,42 +316,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..867da564b 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 @@ -91,14 +91,21 @@ function num(value: unknown, fallback = 0): number { * its container lines (quantity × VGM per unit). The portal's container flow * stores per-line VGM and leaves `cargoTotalWeightVgm` at 0 — reading the * total alone made every such booking weigh only its tare. + * + * Break-bulk (PER_ITEM) bookings overload `cargoTotalWeightVgm` with the ITEM + * COUNT, so their real tonnage lives in `bulkTotalWeightTons` — prefer it, or + * a 400-item / 800T booking would "weigh" 400T against the pull limit. */ export function bookingCargoTons(booking: { cargoTotalWeightVgm?: number | string | null; + bulkTotalWeightTons?: number | string | null; bookingContainers?: Array<{ quantity?: number | null; vgmPerUnitTons?: number | string | null; }> | null; }): number { + const itemTons = num(booking.bulkTotalWeightTons); + if (itemTons > 0) return itemTons; const total = num(booking.cargoTotalWeightVgm); if (total > 0) return total; return (booking.bookingContainers ?? []).reduce( @@ -107,6 +114,85 @@ export function bookingCargoTons(booking: { ); } +/** + * Wagons a break-bulk (PER_ITEM) bulk booking needs. Items are indivisible, so + * floor how many whole items fit one wagon, then ceil the wagon count: + * 400 items / 800T on 69T wagons → 2T per item → 34 items per wagon → 12 wagons. + * Returns 0 when the booking is not item-counted (PER_TON bulk, containers) — + * callers then fall back to the pooled-tonnage math. + * + * `itemsFit` is the wagon type's PHYSICAL item capacity (floor space — from + * cargoType.itemsPerWagonMap). It binds independently of tonnage: a 70T wagon + * that fits 4 cars takes 3 cars of 20T (weight binds) but only 4 cars of 10T + * (floor binds, 30T of rated capacity ride empty). Absent/invalid fit falls + * back to tonnage-only (legacy cargo types without a configured fit). + */ +export function bulkItemWagonsRequired( + booking: { + freightType?: string | null; + cargoTotalWeightVgm?: number | string | null; + bulkTotalWeightTons?: number | string | null; + }, + capacityTons: number, + itemsFit?: number | null, +): number { + if (booking.freightType !== 'BULK' || !(capacityTons > 0)) return 0; + const quantity = num(booking.cargoTotalWeightVgm); + const totalWeightTons = num(booking.bulkTotalWeightTons); + if (!(quantity > 0) || !(totalWeightTons > 0)) return 0; + const perItemTons = totalWeightTons / quantity; + // ponytail: an item heavier than a whole wagon still charges 1 wagon per + // item; reject such bookings at creation time if the case turns real. + const byTonnage = Math.max(1, Math.floor(capacityTons / perItemTons)); + const byFloor = num(itemsFit) >= 1 ? Math.floor(num(itemsFit)) : Infinity; + const itemsPerWagon = Math.min(byTonnage, byFloor); + return Math.max(1, Math.ceil(quantity / itemsPerWagon)); +} + +type ItemFitCargoType = { + wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null; + itemsPerWagonMap?: Record | null; +} | null; + +/** Configured whole-items fit of one wagon type for a cargo type; null if unset. */ +export function bulkItemsFitFor( + cargoType: ItemFitCargoType | undefined, + wagonTypeId: string | null | undefined, +): number | null { + const fit = wagonTypeId ? Number(cargoType?.itemsPerWagonMap?.[wagonTypeId]) : NaN; + return Number.isFinite(fit) && fit >= 1 ? fit : null; +} + +/** + * Break-bulk wagon count when no single wagon type is fixed yet: the best + * (fewest-wagon) count across the cargo type's allowed wagon types, each + * respecting its own items-fit. With no fits configured this equals the old + * max-capacity estimate; with no allowed types it degrades to + * `fallbackCapacityTons` tonnage-only. + */ +export function bulkItemWagonsForAllowedTypes( + booking: { + freightType?: string | null; + cargoTotalWeightVgm?: number | string | null; + bulkTotalWeightTons?: number | string | null; + }, + cargoType: ItemFitCargoType | undefined, + fallbackCapacityTons: number, +): number { + const allowed = (cargoType?.wagonTypes ?? []).filter((wt) => num(wt.capacityTons) > 0); + if (!allowed.length) return bulkItemWagonsRequired(booking, fallbackCapacityTons); + let best = 0; + for (const wagonType of allowed) { + const wagons = bulkItemWagonsRequired( + booking, + num(wagonType.capacityTons), + bulkItemsFitFor(cargoType, wagonType.id), + ); + if (wagons > 0 && (best === 0 || wagons < best)) best = wagons; + } + return best; +} + /** Gross weight of one loaded wagon: it hauls itself plus its cargo. */ export function grossWagonWeightTons(slot: Pick): number { return num(slot.tareWeightTons) + num(slot.cargoTons); @@ -256,27 +342,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 +385,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 +411,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..7b0f37b66 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,28 +1,25 @@ -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"; import { AssignBookingsDto } from "./dto/assign-bookings.dto"; import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto"; +import { SwitchGovernmentBookingDto } from "./dto/switch-government-booking.dto"; import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto"; import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto"; import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto"; @@ -114,7 +111,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 +178,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)", @@ -198,6 +195,16 @@ export class TrainSchedulingController { ); } + @Get("schedules/:id/history") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Unified change history for a schedule: wagon consist adjustments (add/remove/switch, with the stop they happened at) merged with booking composition removals, newest first", + }) + getScheduleHistory(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getScheduleHistory(id); + } + @Get("bookable-schedules") // No staff guard: customers hit this while creating a booking to find OPEN // same-route schedules. Do not attach train_scheduling permissions here. @@ -283,21 +290,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 +316,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 +330,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 +344,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 +359,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 +372,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 +383,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 +404,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/assign-unassigned-booking") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Assign one linked unallocated booking to wagons (preserves existing assignments)", @@ -412,6 +419,25 @@ export class TrainSchedulingController { ); } + @Post("schedules/:id/switch-government-booking") + @TrainSchedulingUpdate() + @ApiOperation({ + summary: + "Switch out commercial bookings to allocate a government booking in their place", + }) + switchGovernmentBooking( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: SwitchGovernmentBookingDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainSchedulingService.switchGovernmentBooking( + id, + dto.governmentBookingId, + dto.removeBookingIds, + resolveAuthUserId(user), + ); + } + @Get("schedules/:id/composition-removals") @TrainSchedulingView() @ApiOperation({ summary: "Get removal log for a schedule" }) @@ -429,7 +455,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 +468,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 +481,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 +522,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 +545,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 +558,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 +571,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 +583,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 +603,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 +613,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 +623,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 +633,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 +643,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 +656,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 +666,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 +706,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 +714,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/run-allocation") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Run wagon-level allocation for all eligible linked bookings", }) @@ -697,7 +723,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 +737,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 +751,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 +765,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 +778,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 +802,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 +812,7 @@ export class TrainSchedulingController { } @Post("bookings/:bookingId/expire") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Staff: expire a reservation and free its capacity", }) @@ -784,7 +822,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 +844,7 @@ export class TrainSchedulingController { } @Post("schedules/:id/checkpoints") - @TrainSchedulingManage() + @TrainSchedulingUpdate() @ApiOperation({ summary: "Log the train passing a station (final station triggers arrival)", }) @@ -818,7 +856,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 +894,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..80030c6c3 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 () => { @@ -1290,4 +1351,88 @@ describe('TrainSchedulingService', () => { ).rejects.toThrow(/over its/); }); }); + + describe('government booking protection', () => { + const scheduleId = 'sched-gov-1'; + const govBooking = makeBooking('gov-1', 'BKG-GOV', 200, 10, '20FT', 10, undefined, undefined, undefined, { + isGovernment: true, + wagonsRequired: 10, + }); + const commercial = makeBooking('bk-1', 'BKG-COM', 100, 5, '20FT', 5, undefined, undefined, undefined, { + wagonsRequired: 5, + }); + + const scheduleGraph = { + id: scheduleId, + status: 'DRAFT', + scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'), + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + trainSetId: 'ts-1', + trainSet: { + id: 'ts-1', + locomotive, + wagons: [{ id: 'tsw-1' }, { id: 'tsw-2' }], + }, + scheduleBookings: [{ bookingId: 'gov-1' }, { bookingId: 'bk-1' }], + }; + + it('unassignBooking rejects a government booking', async () => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph); + bookingsRepository.findById = jest.fn().mockResolvedValue(govBooking); + + await expect(service.unassignBooking(scheduleId, 'gov-1')).rejects.toThrow( + /Government bookings cannot be removed/, + ); + }); + + it('switchGovernmentBooking rejects a non-government incoming booking', async () => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph); + bookingsRepository.findByIdsForScheduling.mockResolvedValueOnce([commercial]); + + await expect( + service.switchGovernmentBooking(scheduleId, 'bk-1', ['gov-1']), + ).rejects.toThrow(/Only government bookings/); + }); + + it('switchGovernmentBooking rejects when the freed wagons are fewer than the government booking needs', async () => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph); + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === WagonBookingAllocation) { + return { find: jest.fn().mockResolvedValue([{ bookingId: 'bk-1' }]) }; + } + return { find: jest.fn().mockResolvedValue([]) }; + }); + bookingsRepository.findByIdsForScheduling.mockImplementation((ids: string[]) => + Promise.resolve( + ids.map((id) => (id === 'gov-1' ? govBooking : commercial)), + ), + ); + jest + .spyOn(service as never as { resolveTrainLimitConfig: () => unknown }, 'resolveTrainLimitConfig') + .mockResolvedValue({} as never); + // Gov booking fits the plan (10 slots) but the switched-out booking only + // frees 5 wagons — the user-facing wagon rule must still reject it. + jest + .spyOn( + service as never as { validateBookingsForScheduling: () => unknown }, + 'validateBookingsForScheduling', + ) + .mockResolvedValue({ + valid: true, + violations: [], + warnings: [], + deferredBookings: [], + bookings: [govBooking], + wagonPlan: Array.from({ length: 10 }, (_, i) => ({ + sequenceNo: i + 1, + allocations: [{ bookingId: 'gov-1' }], + })), + } as never); + + await expect( + service.switchGovernmentBooking(scheduleId, 'gov-1', ['bk-1']), + ).rejects.toThrow(/free only 5/); + }); + }); }); 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..a23e763f2 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 @@ -43,6 +43,7 @@ import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; +import { Contract } from '../contracts/entities/contract.entity'; import { Container } from '../container-management/entities/container.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../locomotives/locomotives.repository'; @@ -63,6 +64,7 @@ import { TrainCompositionRemovalLogRepository } from '../train-schedules/train-c import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository'; import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository'; import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository'; +import { Yard } from '../rule-engine/entities/yard.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; import { Wagon } from '../wagons/entities/wagon.entity'; @@ -123,17 +125,19 @@ import { sumWagonsRequired, type TrainLimitConfig, maxEdgeConsistUsage, + perEdgeConsistUsage, validateContainerPlacements, validateMixedTrainLimitsPerEdge, type ContainerPlacementInput, type WagonPlanSlot, } from './wagon-plan.util'; +import { CorridorBudget } from './corridor-capacity.util'; import { deriveScheduleDirection } from './derive-schedule-direction.util'; import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util'; import { bookingCargoTons, deriveTrainCapacityFromLocomotive, - minLocomotiveLimits, + combinedLocomotiveLimits, trainSetLocomotiveLimits, wagonTypeDimensionsFromEntity, LocomotiveLimits, @@ -147,6 +151,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, @@ -212,6 +217,7 @@ export function effectiveWindowConfig( ruleWindowCloseHour?: number | null; ruleWindowDurationHours?: number | null; ruleReopenDelayMinutes?: number | null; + rulePaymentWindowMinutes?: number | null; ruleImportWindowLeadDays?: number | null; ruleExportBookingLeadHours?: number | null; ruleImportCloseOffsetMinutes?: number | null; @@ -231,7 +237,14 @@ export function effectiveWindowConfig( ? Number(schedule.ruleWindowDurationHours) : liveCfg.windowDurationHours, docReviewMinutes: liveCfg.docReviewMinutes, - paymentWindowMinutes: liveCfg.paymentWindowMinutes, + // Pay windows read live values unless staff explicitly overrode this ONE + // schedule (rule_payment_window_minutes is only ever written by that + // override, never stamped at creation). The override wins for whichever + // direction the schedule runs. + paymentWindowMinutes: + schedule.rulePaymentWindowMinutes ?? liveCfg.paymentWindowMinutes, + exportPaymentWindowMinutes: + schedule.rulePaymentWindowMinutes ?? liveCfg.exportPaymentWindowMinutes, // The close offset is frozen per-schedule: a snapshot value of null means // "created with no offset" and must NOT inherit a later live offset (that // would retro-shrink an open train's window). Only a truly legacy row that @@ -629,23 +642,41 @@ export class TrainSchedulingService { let day: string | undefined; let originStationId = query.originStationId; let destinationStationId = query.destinationStationId; + // Corridor mode: a schedule with intermediate stops pools every booking + // whose leg lies ON its route (Dire→DCT on a GMT→Dire→DCT train), not just + // exact endpoint matches — otherwise a mid-corridor booking unassigned from + // a wagon vanishes from the "Paid · unassigned" pool forever. + let corridorStops: string[] | undefined; if (query.trainScheduleId) { const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId); if (schedule?.scheduledDepartureDate) { day = eatDay(schedule.scheduledDepartureDate); originStationId = originStationId ?? schedule.originStationId; destinationStationId = destinationStationId ?? schedule.destinationStationId; + const stops = await this.stopYardsForSchedule(schedule); + if (stops.length > 2) corridorStops = stops; } } - const bookings = await this.bookingsRepository.findEligibleForScheduling({ + let bookings = await this.bookingsRepository.findEligibleForScheduling({ freightType: query.freightType, originStationId, destinationStationId, schedulingStatus: query.schedulingStatus, trainScheduleId: query.trainScheduleId, day, + corridorYardIds: corridorStops, }); + if (corridorStops) { + // The IN-filter admits both yards anywhere on the route; only origin + // strictly before destination is actually rideable on this train. + const stopIdx = new Map(corridorStops.map((yardId, i) => [yardId, i])); + bookings = bookings.filter((b) => { + const from = stopIdx.get(b.originYardId); + const to = stopIdx.get(b.destinationYardId); + return from != null && to != null && from < to; + }); + } const tareDims = await this.loadWagonTareDims(); return { count: bookings.length, @@ -695,6 +726,8 @@ export class TrainSchedulingService { if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours; if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes; if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes; + if (dto.exportPaymentWindowMinutes != null) + row.exportPaymentWindowMinutes = dto.exportPaymentWindowMinutes; // Store 0 as null so "no offset" is a single canonical value. if (dto.importCloseOffsetMinutes !== undefined) row.importCloseOffsetMinutes = dto.importCloseOffsetMinutes || null; @@ -789,7 +822,14 @@ export class TrainSchedulingService { // The reopen gap is doc review + payment; keep the config values unless the // override changes them, so the derived snapshot delay stays consistent. docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes, - paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes, + paymentWindowMinutes: + dto.paymentWindowMinutes ?? + schedule.rulePaymentWindowMinutes ?? + liveCfg.paymentWindowMinutes, + exportPaymentWindowMinutes: + dto.paymentWindowMinutes ?? + schedule.rulePaymentWindowMinutes ?? + liveCfg.exportPaymentWindowMinutes, // A per-schedule override isn't a close-offset control, so inherit the // offset already frozen on the schedule (null = none), or the live one for // legacy rows — the override must not silently drop the global offset. @@ -852,11 +892,17 @@ export class TrainSchedulingService { } } + // The pay-window override persists only when staff actually sent it (or the + // schedule already had one) — windowRuleSnapshot never stamps it, so NULL + // keeps meaning "follow the live global value for my direction". + const rulePaymentWindowMinutes = + dto.paymentWindowMinutes ?? schedule.rulePaymentWindowMinutes ?? null; for (const t of targets) { await repo.update(t.id, { windowOpensAt: cap(times.windowOpensAt, t.departure), windowClosesAt: cap(times.windowClosesAt, t.departure), ...ruleFields, + rulePaymentWindowMinutes, }); } this.logger.log( @@ -1198,13 +1244,37 @@ export class TrainSchedulingService { windowDurationHours: num(row?.windowDurationHours, 3), docReviewMinutes: num(row?.docReviewMinutes, 30), paymentWindowMinutes: num(row?.paymentWindowMinutes, 60), + exportPaymentWindowMinutes: num(row?.exportPaymentWindowMinutes, 60), importCloseOffsetMinutes: offset(row?.importCloseOffsetMinutes), exportCloseOffsetMinutes: offset(row?.exportCloseOffsetMinutes), }; } + /** + * Limits for a preview aimed at an EXISTING schedule must be the schedule's + * own: its locomotive set and its built-consist wagon cap. Resolving from + * the dto alone re-derived the global wagon cap (53) and rejected a + * physically-coupled 54-wagon train the assign path would accept. + */ + private async resolvePreviewLimitConfig(dto: { + targetScheduleId?: string; + maxTrainWeightTons?: number; + maxTrainLengthMeters?: number; + maxWagonsPerTrain?: number; + }): Promise> { + const target = dto.targetScheduleId + ? await this.trainSchedulesRepository.findByIdWithFullGraph(dto.targetScheduleId) + : null; + if (!target) return this.resolveTrainLimitConfig(dto); + return this.resolveTrainLimitConfig( + dto, + combinedLocomotiveLimits(this.locomotivesOfTrainSet(target.trainSet)), + target.maxWagons ?? undefined, + ); + } + async previewTrainSchedule(dto: PreviewTrainScheduleDto) { - const limits = await this.resolveTrainLimitConfig(dto); + const limits = await this.resolvePreviewLimitConfig(dto); return this.buildPreviewResponse( await this.validateBookingsForScheduling( dto, @@ -1219,7 +1289,7 @@ export class TrainSchedulingService { } async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) { - const limits = await this.resolveTrainLimitConfig(dto); + const limits = await this.resolvePreviewLimitConfig(dto); return this.buildPreviewResponse( await this.validateBookingsForScheduling( dto, @@ -1234,7 +1304,7 @@ export class TrainSchedulingService { } async previewBulkTrainSchedule(dto: PreviewBulkTrainScheduleDto) { - const limits = await this.resolveTrainLimitConfig(dto); + const limits = await this.resolvePreviewLimitConfig(dto); return this.buildPreviewResponse( await this.validateBookingsForScheduling( dto, @@ -1299,9 +1369,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 +1392,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 +1451,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 @@ -1468,7 +1538,9 @@ export class TrainSchedulingService { originStationId: route.originYardId, destinationStationId: route.destinationYardId, scheduledDepartureDate: departure, - status: TrainScheduleStatusEnum.Draft, + // Born SCHEDULED: there is no draft/finalize phase — a created train + // is immediately visible and bookable to customers. + status: TrainScheduleStatusEnum.Scheduled, direction, trainNumber: pairTrainNumber ?? undefined, maxWagons, @@ -1571,8 +1643,12 @@ export class TrainSchedulingService { }; const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet); - const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined; - const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco); + const limitLoco = combinedLocomotiveLimits(setLocomotives) ?? undefined; + const limits = await this.resolveTrainLimitConfig( + previewDto, + limitLoco, + schedule.maxWagons ?? undefined, + ); // Callers that add bookings without hand-picking container slots (the // workspace "Add from pool" button, re-adding a removed booking) send no @@ -1726,20 +1802,38 @@ export class TrainSchedulingService { // hauled at the same time. Coupled-but-unplanned wagons ride every edge, // so their tare rides on top of the binding edge. const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons); - const edgeUsage = maxEdgeConsistUsage( - wagonPlan, - await this.stopYardsForSchedule(schedule), - ); - const grossWeightTons = roundTons(edgeUsage.grossWeightTons + emptyConsistTareTons); - if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) { + const scheduleStops = await this.stopYardsForSchedule(schedule); + const perEdge = perEdgeConsistUsage(wagonPlan, scheduleStops); + const stopLabels = await this.yardLabelMap(scheduleStops); + // Each edge is its own consist — name EVERY leg that breaks the limit, + // not just the heaviest figure, so staff see where along A→…→E it fails. + const legName = (edge: number) => + scheduleStops.length > 2 + ? `${stopLabels.get(scheduleStops[edge]) ?? scheduleStops[edge]} → ${ + stopLabels.get(scheduleStops[edge + 1]) ?? scheduleStops[edge + 1] + }` + : 'the route'; + const overweightLegs = perEdge + .map((e) => ({ + edge: e.edge, + grossWeightTons: roundTons(e.grossWeightTons + emptyConsistTareTons), + })) + .filter((e) => e.grossWeightTons > weightCapWithOverage); + if (!dto.forceAssign && overweightLegs.length) { throw new BadRequestException( - `Train set locomotives cannot pull ${grossWeightTons}T gross on the heaviest leg (limit ${roundTons(weightCapWithOverage)}T incl. tolerance)`, + `Train set locomotives cannot pull the gross weight on ${overweightLegs + .map((e) => `leg ${legName(e.edge)} (${e.grossWeightTons}T)`) + .join(', ')} — limit ${roundTons(weightCapWithOverage)}T incl. tolerance`, ); } - const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters); - if (!dto.forceAssign && lengthCapWithOverage < maxEdgeLengthMeters) { + const overlongLegs = perEdge + .map((e) => ({ edge: e.edge, lengthMeters: roundTons(e.lengthMeters) })) + .filter((e) => e.lengthMeters > lengthCapWithOverage); + if (!dto.forceAssign && overlongLegs.length) { throw new BadRequestException( - `Train set locomotives cannot support ${maxEdgeLengthMeters}m`, + `Train set locomotives cannot support the train length on ${overlongLegs + .map((e) => `leg ${legName(e.edge)} (${e.lengthMeters}m)`) + .join(', ')} — limit ${roundTons(lengthCapWithOverage)}m incl. tolerance`, ); } @@ -1843,6 +1937,11 @@ export class TrainSchedulingService { } const booking = await this.bookingsRepository.findById(bookingId); + if (booking?.isGovernment) { + throw new BadRequestException( + 'Government bookings cannot be removed from a train. They can only be switched onto another allocation.', + ); + } const bookingReference = booking?.reference ?? null; await this.dataSource.transaction(async (manager) => { @@ -1878,6 +1977,16 @@ export class TrainSchedulingService { manager, ); + // Unassign only runs pre-dispatch, so an IN_TRANSIT status here is stale + // (e.g. auto-loaded by an earlier dispatch that was rolled back). Left as + // is, the booking becomes invisible: the eligible pool only admits PAID, + // so it can never be re-added to any train. Revert it to PAID. + if (booking?.status === 'IN_TRANSIT' && !booking.arrivedAt) { + await manager + .getRepository(Booking) + .update(bookingId, { status: 'PAID', loadedAt: null } as never); + } + // Recompute the train-set composition from whatever survives this removal. // The removed booking's allocations were already deleted above, so any slot // left with zero allocations was ridden only by this booking — release it @@ -1922,6 +2031,11 @@ export class TrainSchedulingService { }); }); + // Freed wagons may un-full the train — re-derive the window status (this + // also revives a DONE window pre-departure so the freed space is bookable + // again for import/export). + await this.bookingBatchService?.refreshWindowStatus(scheduleId); + await this.trainCompositionRemovalLogRepository.create({ scheduleId, bookingId, @@ -1930,8 +2044,14 @@ export class TrainSchedulingService { removedAt: new Date(), }); - console.log( - `[NOTIFY] Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer should be notified to reschedule or cancel.`, + // Ops decision, so the customer hears about it: SMS/email + inbox telling + // them to rebook or pick a new schedule (the removal log above is the record). + const removedBooking = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId }, relations: { company: true } }); + if (removedBooking) this.bookingNotifier.removedFromTrain(removedBooking); + this.logger.log( + `Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`, ); return this.getTrainScheduleById(scheduleId); @@ -2242,6 +2362,12 @@ export class TrainSchedulingService { if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } + // Schedules are born SCHEDULED now — finalize is a no-op for them so the + // allocate wizard and the window auto-finalize keep working. The DRAFT + // branch below only still runs for legacy rows. + if (schedule.status === TrainScheduleStatusEnum.Scheduled) { + return this.getTrainScheduleById(scheduleId); + } if (schedule.status !== TrainScheduleStatusEnum.Draft) { throw new BadRequestException('Only DRAFT schedules can be finalized'); } @@ -2303,14 +2429,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], ); @@ -2748,11 +2879,13 @@ export class TrainSchedulingService { allocations: (wagon.allocations ?? []).map((allocation) => ({ bookingId: allocation.bookingId, bookingReference: allocation.booking?.reference ?? null, + booking: allocation.booking, loadType: allocation.loadType ?? null, allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0, containerNumbers: (allocation.containerItems ?? []) .map((item) => item.containerNumber) .filter(Boolean), + containerItems: allocation.containerItems ?? [], })), })), operation: await this.getImportDjiboutiOperation(schedule.id), @@ -2793,6 +2926,32 @@ export class TrainSchedulingService { }; } + /** + * A container item's size in feet, for the marshalling document's 40ft/20ft + * tally. Two independent sources, since only one is populated depending on + * how the item was created: + * - `item.containerType` — the item's own container_type_id FK, set for + * manually-entered items (no booking-container line behind them). + * - `item.bookingContainer.containerType.sizeFt` / `.containerSize` — the + * booking-line fallback for items generated from an allocation. + * (`findByIdWithFullGraph` must load both relations or every item here + * silently resolves to null and the tally stays zero.) + */ + private resolveContainerItemSize(item: { + containerType?: { sizeFt?: number | null } | null; + bookingContainer?: { + containerSize?: string | null; + containerType?: { sizeFt?: number | null } | null; + } | null; + }): number | null { + const fromSizeFt = item.containerType?.sizeFt ?? item.bookingContainer?.containerType?.sizeFt; + if (fromSizeFt === 20 || fromSizeFt === 40) return fromSizeFt; + const label = item.bookingContainer?.containerSize; + if (label?.includes('40')) return 40; + if (label?.includes('20')) return 20; + return null; + } + private buildExportLoadListHtml(schedule: TrainSchedule): string { const esc = (value: unknown) => String(value ?? '-') @@ -2833,6 +2992,7 @@ export class TrainSchedulingService { return allocations.map((allocation) => { const booking = allocation.booking ?? bookingById.get(allocation.bookingId); const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; + const companyName = (booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-'; const containerItems = allocation.containerItems ?? []; const firstContainer = containerItems[0]; const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', '); @@ -2841,6 +3001,7 @@ export class TrainSchedulingService { return ` ${wagonCells} ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} + ${esc(companyName)} ${esc(containerNumbers || firstContainer?.containerNumber)} ${esc(chassisNumbers)} ${esc(sealNumbers)} @@ -2855,6 +3016,18 @@ export class TrainSchedulingService { 0, ); + // Container count summary (40ft, 20ft) + let count40ft = 0, count20ft = 0; + wagons.forEach((wagon) => { + (wagon.allocations ?? []).forEach((allocation) => { + (allocation.containerItems ?? []).forEach((item) => { + const size = this.resolveContainerItemSize(item); + if (size === 40) count40ft++; + else if (size === 20) count20ft++; + }); + }); + }); + return ` @@ -2904,6 +3077,9 @@ export class TrainSchedulingService {
Departure station${esc(schedule.originStation?.label ?? schedule.originStation?.code)}
Arrival station${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}
Total loaded weight${esc(totalWeight.toFixed(3))} T
+
Containers 40ft${esc(count40ft)}
+
Containers 20ft${esc(count20ft)}
+
Total containers${esc(count40ft + count20ft)}
Prepared person${esc(schedule.preparedByUserId)}
Check person${esc(schedule.checkedByUserId)}
Wagons${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
@@ -2922,6 +3098,7 @@ export class TrainSchedulingService { Tare Weight Load Capacity Cargo Type + Company Container No Chassis No Seal No @@ -3004,6 +3181,19 @@ export class TrainSchedulingService { 0, ); const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length; + + // Container count summary (40ft, 20ft) + let count40ft = 0, count20ft = 0; + loadList.wagons.forEach((wagon) => { + wagon.allocations.forEach((allocation) => { + (allocation.containerItems ?? []).forEach((item) => { + const size = this.resolveContainerItemSize(item); + if (size === 40) count40ft++; + else if (size === 20) count20ft++; + }); + }); + }); + const allocationRows = loadList.wagons .flatMap((wagon) => { const wagonCells = `${esc(wagon.sequenceNo)} @@ -3019,13 +3209,17 @@ export class TrainSchedulingService { ]; } return wagon.allocations.map( - (allocation) => ` + (allocation) => { + const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-'; + return ` ${wagonCells} ${esc(allocation.bookingReference ?? allocation.bookingId)} + ${esc(companyName)} ${esc(allocation.loadType)} ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))} - `, + `; + }, ); }) .join(''); @@ -3090,6 +3284,9 @@ export class TrainSchedulingService {
Wagons${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
Allocations${esc(totalAllocations)}
Total weight${esc(totalWeight.toFixed(3))} T
+
Containers 40ft${esc(count40ft)}
+
Containers 20ft${esc(count20ft)}
+
Total containers${esc(count40ft + count20ft)}
Gatepass granted${esc(date(loadList.operation.gatepassGrantedAt))}
@@ -3109,6 +3306,7 @@ export class TrainSchedulingService { Seq Wagon Booking + Company Load Container numbers Weight T @@ -3535,6 +3733,30 @@ export class TrainSchedulingService { // unload each one by hand. The final station is covered by // arriveSchedule's bulk fallback above. await this.bookingJourneyService.autoUnloadAtYard(scheduleId, station.yardId); + // A pass is also a position fix: the locomotives, every wagon still + // aboard, and the built train are physically AT this yard now — not at + // the origin they departed from. Wagons released at earlier stops no + // longer carry this schedule id and stay where they alighted; the final + // arrival settle still writes the wagon-movement ledger rows. + await this.dataSource.transaction(async (manager) => { + const locoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); + if (locoIds.length) { + await manager + .getRepository(Locomotive) + .update({ id: In(locoIds) }, { currentYardId: station.yardId }); + } + await manager + .getRepository(Wagon) + .update( + { currentTrainScheduleId: scheduleId }, + { currentYardId: station.yardId }, + ); + if (schedule.trainSet?.trainId) { + await manager + .getRepository(Train) + .update(schedule.trainSet.trainId, { currentYardId: station.yardId }); + } + }); } return this.getScheduleCheckpoints(scheduleId); @@ -3965,36 +4187,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. @@ -4042,6 +4240,7 @@ export class TrainSchedulingService { fittingBookings, dto.originStationId, dto.destinationStationId, + stops, ); violations.push( @@ -4072,12 +4271,16 @@ export class TrainSchedulingService { wagonPlan.map((slot) => [slot.wagonTypeId, { lengthMeters: slot.lengthMeters }]), ).values(), ]; + const stopLabelMap = + stops.length > 2 ? await this.yardLabelMap(stops) : new Map(); + const stopLabels = stops.map((yardId) => stopLabelMap.get(yardId) ?? yardId); pushLimit( validateMixedTrainLimitsPerEdge( wagonPlan, plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }], trainLimits, stops, + stopLabels, ), ); if (requireContainerPlacements && resolvedMode !== 'BULK') { @@ -4088,6 +4291,8 @@ export class TrainSchedulingService { wagonPlan, containerPlacements, placementRules, + legByBookingId, + Math.max(1, stops.length - 1), ), ); violations.push( @@ -4105,11 +4310,17 @@ export class TrainSchedulingService { ); // Weight/length limits are enforced PER EDGE by validateMixedTrainLimitsPerEdge // above — the whole-route totals here are informational (summary) only. The - // locomotive checks below also compare the heaviest single edge: a train is - // never heavier than its heaviest leg, so disjoint legs must not be summed. - const edgeUsage = maxEdgeConsistUsage(wagonPlan, stops); - const maxEdgeGrossTons = roundTons(edgeUsage.grossWeightTons); - const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters); + // locomotive checks below also compare per edge: a train is never heavier + // than its heaviest leg, so disjoint legs must not be summed. + const perEdgeUsage = perEdgeConsistUsage(wagonPlan, stops); + const maxEdgeGrossTons = roundTons( + Math.max(0, ...perEdgeUsage.map((e) => e.grossWeightTons)), + ); + const maxEdgeLengthMeters = roundTons( + Math.max(0, ...perEdgeUsage.map((e) => e.lengthMeters)), + ); + const legName = (edge: number) => + stops.length > 2 ? `${stopLabels[edge]} → ${stopLabels[edge + 1]}` : 'the route'; let assignedLocomotives: Locomotive[] = []; if (targetScheduleId) { @@ -4123,22 +4334,35 @@ 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`, ); } - if ( - setLimits && - (setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) < - maxEdgeGrossTons || - setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) < - maxEdgeLengthMeters) - ) { - pushLimit([ - 'Assigned locomotives cannot support the total train weight and length', - ]); + if (setLimits) { + // Name every leg the set cannot pull — staff must see WHERE along the + // corridor the train is too heavy/long, not just that it is somewhere. + const weightCap = + setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0); + const lengthCap = + setLimits.maxTrainLengthMeters + + (Number(setLimits.overageToleranceMeters) || 0); + const legIssues = perEdgeUsage.flatMap((e) => { + const issues: string[] = []; + if (roundTons(e.grossWeightTons) > weightCap) { + issues.push( + `Assigned locomotives cannot pull ${roundTons(e.grossWeightTons)}T gross on leg ${legName(e.edge)} (limit ${roundTons(weightCap)}T incl. tolerance)`, + ); + } + if (roundTons(e.lengthMeters) > lengthCap) { + issues.push( + `Assigned locomotives cannot support ${roundTons(e.lengthMeters)}m train length on leg ${legName(e.edge)} (limit ${roundTons(lengthCap)}m incl. tolerance)`, + ); + } + return issues; + }); + if (legIssues.length) pushLimit(legIssues); } } else { const inServiceLocomotives = await this.locomotivesRepository.findAll({ @@ -4215,6 +4439,7 @@ export class TrainSchedulingService { maxWagonsPerTrain?: number; }, locomotive?: LocomotiveLimits | null, + builtWagonCount?: number, ): Promise> { const row = await this.loadGlobalRulesRow(); const configured = this.configService?.get<{ @@ -4257,10 +4482,18 @@ export class TrainSchedulingService { return { maxWeightTons: derived.maxWeightTons, maxLengthMeters: derived.maxLengthMeters, + // A built train's own consist is the real capacity — the length-derived + // slot count is only an estimate for trains with no wagons coupled yet. + // Without this override, validation re-derives a DIFFERENT wagon cap + // than the one the train was actually built with (e.g. a 54-wagon + // consist rejected against a re-derived 53-slot cap that never matched + // what staff physically coupled). maxWagonsPerTrain: dto?.maxWagonsPerTrain != null ? Math.floor(this.positiveNumber(dto.maxWagonsPerTrain, derived.maxWagonSlots)) - : derived.maxWagonSlots, + : builtWagonCount && builtWagonCount > 0 + ? builtWagonCount + : derived.maxWagonSlots, max20ftContainerWeightTons: this.positiveNumber( undefined, Number(row?.max20ftContainerWeightTons) || @@ -4487,16 +4720,29 @@ export class TrainSchedulingService { * schedule. Used to guard consist trims — the Wagon entity itself carries no * schedule-occupancy state anymore. */ - private async wagonIdsPinnedToLiveSchedules(manager?: EntityManager): Promise> { + /** + * Physical wagons pinned to any live run's slot. `excludeTrainId` drops the + * pins of that BUILT TRAIN's own schedules (this run and its siblings — e.g. + * the paired return leg): a consist edit is an edit of the TRAIN, sibling + * runs ride whatever it is composed of and their pins are re-pointed by the + * edit itself. Only pins held by live schedules of OTHER trains block it. + */ + private async wagonIdsPinnedToLiveSchedules( + manager?: EntityManager, + excludeTrainId?: string, + ): Promise> { const runner = manager ?? this.dataSource; const rows: { physical_wagon_id: string }[] = await runner.query( `SELECT DISTINCT tsw.physical_wagon_id FROM freight.train_set_wagons tsw JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id + JOIN freight.train_sets tset ON tset.id = tsw.train_set_id WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') AND ts.deleted_at IS NULL AND tsw.deleted_at IS NULL - AND tsw.physical_wagon_id IS NOT NULL`, + AND tsw.physical_wagon_id IS NOT NULL + AND ($1::uuid IS NULL OR tset.train_id IS NULL OR tset.train_id <> $1)`, + [excludeTrainId ?? null], ); return new Set(rows.map((row) => row.physical_wagon_id)); } @@ -4525,8 +4771,12 @@ export class TrainSchedulingService { wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId, trainSetWagonId: slot.id, boardYardId: slot.boardYardId ?? null, + alightYardId: slot.alightYardId ?? null, })); + const pinSchedule = await this.trainSchedulesRepository.findById(scheduleId); + const stops = pinSchedule ? await this.stopYardsForSchedule(pinSchedule) : []; + const unpinnable = this.findUnpinnableWagonSlots( planSlots, wagons, @@ -4534,6 +4784,7 @@ export class TrainSchedulingService { originYardId, builtTrainId, pinnedToScheduleIds, + stops, ); if (unpinnable.length) { throw new BadRequestException({ @@ -4542,14 +4793,16 @@ export class TrainSchedulingService { }); } - const assignedPhysicalIds = new Set(); + const occupiedSpans = new Map>(); for (const slot of planSlots) { + const span = this.slotSpanOf(slot, stops); const physical = this.pickPhysicalWagonForSlot( slot, wagons, scheduleId, originYardId, - assignedPhysicalIds, + occupiedSpans, + span, builtTrainId, pinnedToScheduleIds, reverseWagonOrder, @@ -4563,7 +4816,9 @@ export class TrainSchedulingService { physicalWagonId: physical.id, status: 'RESERVED', }); - assignedPhysicalIds.add(physical.id); + const pinnedSpans = occupiedSpans.get(physical.id) ?? []; + pinnedSpans.push(span); + occupiedSpans.set(physical.id, pinnedSpans); } } @@ -4580,44 +4835,74 @@ export class TrainSchedulingService { this.builtTrainIdOfSchedule(targetScheduleId), this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId), ]); + const targetSchedule = targetScheduleId + ? await this.trainSchedulesRepository.findById(targetScheduleId) + : null; + const stops = targetSchedule + ? await this.stopYardsForSchedule(targetSchedule) + : []; return this.findUnpinnableWagonSlots( wagonPlan.map((slot) => ({ sequenceNo: slot.sequenceNo, wagonTypeId: slot.wagonTypeId, wagonTypeCode: slot.wagonTypeCode, boardYardId: slot.boardYardId ?? null, + alightYardId: slot.alightYardId ?? null, })), wagons, targetScheduleId, originYardId, builtTrainId, pinnedToScheduleIds, + stops, ); } + /** + * Stop-index span [board, alight) a slot occupies along the route. Slots with + * unknown/missing yards conservatively span the whole route (never share). + */ + private slotSpanOf( + slot: { boardYardId?: string | null; alightYardId?: string | null }, + stops: string[], + ): [number, number] { + const last = Math.max(1, stops.length - 1); + const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0; + const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : last; + if (from < 0 || to < 0 || from >= to) return [0, last]; + return [from, to]; + } + private findUnpinnableWagonSlots( slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string; boardYardId?: string | null; + alightYardId?: string | null; }>, wagons: Wagon[], scheduleId: string | undefined, originYardId: string, builtTrainId: string | null = null, pinnedToScheduleIds: Set = new Set(), + stops: string[] = [], ): string[] { const violations: string[] = []; - const assignedPhysicalIds = new Set(); + // One physical wagon may serve several slots whose leg spans don't overlap + // (freed at its alight yard, reloaded downstream) — track occupied spans + // per wagon, not a flat taken-set. + const occupiedSpans = new Map>(); for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) { + const span = this.slotSpanOf(slot, stops); const physical = this.pickPhysicalWagonForSlot( slot, wagons, scheduleId, originYardId, - assignedPhysicalIds, + occupiedSpans, + span, builtTrainId, pinnedToScheduleIds, ); @@ -4627,7 +4912,9 @@ export class TrainSchedulingService { ); continue; } - assignedPhysicalIds.add(physical.id); + const spans = occupiedSpans.get(physical.id) ?? []; + spans.push(span); + occupiedSpans.set(physical.id, spans); } return violations; @@ -4643,14 +4930,21 @@ export class TrainSchedulingService { wagons: Wagon[], scheduleId: string | undefined, originYardId: string, - assignedPhysicalIds: Set, + occupiedSpans: Map>, + span: [number, number], builtTrainId: string | null = null, pinnedToScheduleIds: Set = new Set(), reverseWagonOrder = false, ): Wagon | undefined { + // Free for this slot = no already-assigned span on this wagon overlaps the + // slot's own leg. Disjoint legs (alight before board) share the wagon. + const spanFree = (wagonId: string): boolean => + (occupiedSpans.get(wagonId) ?? []).every( + ([from, to]) => to <= span[0] || span[1] <= from, + ); const usable = (wagon: Wagon): boolean => { if (wagon.wagonTypeId !== slot.wagonTypeId) return false; - if (assignedPhysicalIds.has(wagon.id)) return false; + if (!spanFree(wagon.id)) return false; // Loose pool never lends a wagon coupled to a built train's consist. if (wagon.trainId) return false; // Out on a dispatched train right now — physically gone. @@ -4676,7 +4970,7 @@ export class TrainSchedulingService { (w) => w.trainId === builtTrainId && w.wagonTypeId === slot.wagonTypeId && - !assignedPhysicalIds.has(w.id), + spanFree(w.id), ) .sort((a, b) => { if (a.sequenceNumber == null || b.sequenceNumber == null) { @@ -4793,6 +5087,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 }, @@ -4821,6 +5160,7 @@ export class TrainSchedulingService { bookings: Booking[], scheduleOriginYardId: string, scheduleDestinationYardId: string, + stops: string[], ): void { const bookingById = new Map(bookings.map((b) => [b.id, b])); for (const slot of wagonPlan) { @@ -4836,13 +5176,33 @@ export class TrainSchedulingService { b.originYardId === first.originYardId && b.destinationYardId === first.destinationYardId, ); - if (!sameCorridor) continue; - slot.boardYardId = - first.originYardId === scheduleOriginYardId ? null : first.originYardId; - slot.alightYardId = - first.destinationYardId === scheduleDestinationYardId - ? null - : first.destinationYardId; + if (sameCorridor) { + slot.boardYardId = + first.originYardId === scheduleOriginYardId ? null : first.originYardId; + slot.alightYardId = + first.destinationYardId === scheduleDestinationYardId + ? null + : first.destinationYardId; + continue; + } + // Mixed corridors on one wagon (cross-leg TEU sharing): the wagon rides + // the UNION of its cargo legs. A yard missing from the stop list keeps + // the slot on the whole route so capacity is never under-occupied. + let from = Number.POSITIVE_INFINITY; + let to = Number.NEGATIVE_INFINITY; + for (const b of slotBookings) { + const f = stops.indexOf(b.originYardId); + const t = stops.indexOf(b.destinationYardId); + if (f < 0 || t <= f) { + from = Number.POSITIVE_INFINITY; + break; + } + from = Math.min(from, f); + to = Math.max(to, t); + } + if (!Number.isFinite(from) || to <= from) continue; + slot.boardYardId = from === 0 ? null : stops[from]; + slot.alightYardId = to === stops.length - 1 ? null : stops[to]; } } @@ -5170,6 +5530,7 @@ export class TrainSchedulingService { booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination', preferredDepartureDate: booking.scheduledDate?.toISOString() ?? null, status: booking.status, + isGovernment: Boolean(booking.isGovernment), }; } @@ -5341,7 +5702,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 +5740,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,13 +5772,85 @@ 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, }; }); } + /** + * Where consist work can physically happen right now. Before departure it is + * the built train's own yard. After dispatch it is the route stop the train + * is STANDING AT per its latest checkpoint — null while rolling between + * stops or when the last checkpoint is off-route, and consist work is closed + * there. Arrived/cancelled schedules always return null (history only). + */ + private async currentConsistYardId( + schedule: TrainSchedule, + ): Promise { + if ( + schedule.status === TrainScheduleStatusEnum.Draft || + schedule.status === TrainScheduleStatusEnum.Scheduled + ) { + return schedule.trainSet?.train?.currentYardId ?? schedule.originStationId; + } + if (schedule.status !== TrainScheduleStatusEnum.Dispatched) return null; + const rows: Array<{ yard_id: string | null }> = await this.dataSource.query( + `SELECT yard_id + FROM freight.train_checkpoint_events + WHERE train_schedule_id = $1 + ORDER BY occurred_at DESC, created_at DESC + LIMIT 1`, + [schedule.id], + ); + const yardId = rows[0]?.yard_id ?? null; + if (!yardId) return null; + return this.mapScheduleStops(schedule).some((s) => s.yardId === yardId) + ? yardId + : null; + } + + /** + * Physical wagons whose cargo still RIDES beyond the given stop: any + * allocation whose booking alights strictly after it. Cargo whose + * destination is this stop (or an earlier one) has been offloaded here and + * no longer blocks its wagon — that wagon may be trimmed or switched away. + * Before departure the stop is the origin, so every allocated wagon counts + * as aboard — one rule covers both phases. Unknown destinations and + * off-route stops stay conservative (aboard). + */ + // ponytail: trusts booking.destinationYardId, not a physical unload + // confirmation — if staff trim before actually unloading, the cargo strands. + // Wire the journey unload flag in if that ever bites. + private wagonIdsWithCargoBeyond( + schedule: TrainSchedule, + atYardId: string | null, + ): Set { + const stops = this.mapScheduleStops(schedule).map((s) => s.yardId); + const atIdx = atYardId ? stops.indexOf(atYardId) : -1; + const aboard = new Set(); + for (const slot of schedule.trainSet?.wagons ?? []) { + if (!slot.physicalWagonId || !(slot.allocations?.length ?? 0)) continue; + const ridesOn = (slot.allocations ?? []).some((allocation) => { + const destination = allocation.booking?.destinationYardId; + const destIdx = destination ? stops.indexOf(destination) : -1; + if (destIdx < 0 || atIdx < 0) return true; + return destIdx > atIdx; + }); + if (ridesOn) aboard.add(slot.physicalWagonId); + } + return aboard; + } + /** * Consist snapshot for the adjust-consist UI: the built train's wagons with * loaded/removable flags, gross weight (cargo + FULL consist tare) and length @@ -5434,34 +5867,43 @@ export class TrainSchedulingService { ); } + // Where the train stands right now — the origin yard before departure, the + // checkpoint stop after it. Null = rolling; the consist is view-only then. + const currentYardId = await this.currentConsistYardId(schedule); + const wagons = await this.dataSource.getRepository(Wagon).find({ where: { trainId: builtTrain.id }, relations: { wagonType: true }, order: { sequenceNumber: 'ASC' }, }); - const addableWagons = await this.dataSource.getRepository(Wagon).find({ - where: { - trainId: IsNull(), - status: WagonStatus.Available, - currentYardId: builtTrain.currentYardId ?? undefined, - }, - relations: { wagonType: true }, - order: { wagonNumber: 'ASC' }, - }); + const addableWagons = currentYardId + ? await this.dataSource.getRepository(Wagon).find({ + where: { + trainId: IsNull(), + status: WagonStatus.Available, + currentYardId, + }, + relations: { wagonType: true }, + order: { wagonNumber: 'ASC' }, + }) + : []; const adjustments = await this.dataSource .getRepository(ScheduleWagonAdjustmentLog) .find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 }); - // Slots with cargo aboard — their physical wagons are "loaded" and can - // never be trimmed. - const loadedWagonIds = new Set( - (schedule.trainSet?.wagons ?? []) - .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) - .map((slot) => slot.physicalWagonId as string), + // Slots whose cargo still rides beyond the current stop — those wagons + // cannot be trimmed, only switched. Cargo offloaded at this stop (or + // earlier) has released its wagon. + const loadedWagonIds = this.wagonIdsWithCargoBeyond(schedule, currentYardId); + // Only OTHER trains' pins block edits here — this train's own schedules + // (incl. the paired return run) have their pins managed by the edit itself + // (removal clears, switch re-points). + const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules( + undefined, + builtTrain.id, ); - 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)); @@ -5524,12 +5966,23 @@ export class TrainSchedulingService { bookingWindowStatus: schedule.bookingWindowStatus ?? null, } : null, - wagons: wagons.map((wagon) => ({ - ...mapWagon(wagon), - loaded: loadedWagonIds.has(wagon.id), - // Free = not pinned to any live run's slot; only free wagons can be trimmed. - removable: !pinnedToLiveIds.has(wagon.id) && !loadedWagonIds.has(wagon.id), - })), + wagons: wagons.map((wagon) => { + const loaded = loadedWagonIds.has(wagon.id); + const pinnedElsewhere = pinnedToLiveIds.has(wagon.id); + return { + ...mapWagon(wagon), + loaded, + removable: !pinnedElsewhere && !loaded, + // A loaded wagon can't leave, but its SLOT can change wagon: switch + // moves the cargo allocations onto a same-type replacement. + switchable: !pinnedElsewhere, + blockReason: pinnedElsewhere + ? 'Pinned by another live schedule' + : loaded + ? 'Cargo aboard rides beyond this stop — switch it instead' + : null, + }; + }), addableWagons: addableWagons.map(mapWagon), adjustments: adjustments.map((log) => ({ id: log.id, @@ -5537,9 +5990,26 @@ export class TrainSchedulingService { wagonId: log.wagonId, wagonNumber: log.wagonNumber, adjustedByUserId: log.adjustedByUserId, + yardId: log.yardId ?? null, occurredAt: log.occurredAt, })), - editable: ['DRAFT', 'SCHEDULED'].includes(schedule.status), + // Editable before departure, and after it whenever the train is standing + // at a route stop (mid-route wagon work at station B); frozen while + // rolling and once arrived/cancelled. + editable: + ['DRAFT', 'SCHEDULED'].includes(schedule.status) || + (schedule.status === TrainScheduleStatusEnum.Dispatched && + currentYardId != null), + currentStop: currentYardId + ? { + yardId: currentYardId, + label: + this.mapScheduleStops(schedule).find( + (s) => s.yardId === currentYardId, + )?.label ?? currentYardId, + isMidRoute: schedule.status === TrainScheduleStatusEnum.Dispatched, + } + : null, }; } @@ -5558,19 +6028,38 @@ export class TrainSchedulingService { ) { const addWagonIds = [...new Set(dto.addWagonIds ?? [])]; const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])]; - if (!addWagonIds.length && !removeWagonIds.length) { - throw new BadRequestException('Nothing to adjust — pass wagons to add and/or remove'); + const switches = dto.switches ?? []; + if (!addWagonIds.length && !removeWagonIds.length && !switches.length) { + throw new BadRequestException( + 'Nothing to adjust — pass wagons to add, remove and/or switch', + ); } - const overlap = addWagonIds.filter((id) => removeWagonIds.includes(id)); - if (overlap.length) { - throw new BadRequestException('A wagon cannot be added and removed in the same adjustment'); + const switchFromIds = switches.map((s) => s.fromWagonId); + const switchToIds = switches.map((s) => s.toWagonId); + const touched = new Map(); + for (const id of [...addWagonIds, ...removeWagonIds, ...switchFromIds, ...switchToIds]) { + touched.set(id, (touched.get(id) ?? 0) + 1); + } + if ([...touched.values()].some((count) => count > 1)) { + throw new BadRequestException( + 'Each wagon may appear once per adjustment — not in two lists or two switches', + ); } const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); - if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + // Consist edits are open before departure, and after it whenever the train + // is STANDING AT a route stop (checkpointed): that is exactly the "switch + // wagons at station B" window. Rolling between stops → frozen. + const currentYardId = await this.currentConsistYardId(schedule); + const editableStatus = + ['DRAFT', 'SCHEDULED'].includes(schedule.status) || + schedule.status === TrainScheduleStatusEnum.Dispatched; + if (!editableStatus || !currentYardId) { throw new BadRequestException( - 'The consist is frozen once the train is dispatched — adjust before departure', + schedule.status === TrainScheduleStatusEnum.Dispatched + ? 'The train is rolling — consist changes are only possible while it stands at a route stop (latest checkpoint)' + : 'The consist can no longer be adjusted — the run is over', ); } const builtTrainRef = schedule.trainSet?.train; @@ -5579,12 +6068,10 @@ export class TrainSchedulingService { 'This schedule was not created from a built train — its consist cannot be adjusted here', ); } - const loadedWagonIds = new Set( - (schedule.trainSet?.wagons ?? []) - .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) - .map((slot) => slot.physicalWagonId as string), - ); - const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); + // Wagons whose cargo still rides beyond the current stop: never removable, + // but switchable — the replacement inherits the slot, cargo included. + const loadedWagonIds = this.wagonIdsWithCargoBeyond(schedule, currentYardId); + const limits = combinedLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); const pullCapTons = roundTons( Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0), ); @@ -5606,28 +6093,47 @@ export class TrainSchedulingService { }); const consistById = new Map(consist.map((w) => [w.id, w])); - // --- validate removals: must be coupled and free (no cargo, no pin) --- - const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(manager); + // --- validate removals: coupled, cargo offloaded, no foreign pin --- + const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules( + manager, + train.id, + ); + // Every live train set of THIS built train (this run + siblings, e.g. + // the paired return leg) — their pins follow the consist edit. + const ownSetIds = ( + await manager.getRepository(TrainSet).find({ + where: { trainId: train.id }, + select: { id: true }, + }) + ).map((set) => set.id); const removed: Wagon[] = []; for (const wagonId of removeWagonIds) { const wagon = consistById.get(wagonId); if (!wagon) { throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`); } - if (loadedWagonIds.has(wagon.id) || pinnedToLiveIds.has(wagon.id)) { + if (loadedWagonIds.has(wagon.id)) { throw new ConflictException( - `Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`, + `Wagon ${wagon.wagonNumber} carries cargo riding beyond this stop — it cannot be trimmed, only switched`, + ); + } + if (pinnedToLiveIds.has(wagon.id)) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} is pinned by another live schedule and cannot be trimmed`, ); } removed.push(wagon); } - // --- validate additions: AVAILABLE, loose, standing in the train's yard --- - const added: Wagon[] = []; - for (const wagonId of addWagonIds) { + // Shared gate for every incoming wagon (couple or switch replacement): + // AVAILABLE, loose, and standing where the train stands right now. + const lockIncomingWagon = async (wagonId: string): Promise => { + // 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`); @@ -5639,17 +6145,57 @@ export class TrainSchedulingService { `Wagon ${wagon.wagonNumber} is not available (${wagon.status})`, ); } - if (wagon.currentYardId !== train.currentYardId) { + if (wagon.currentYardId !== currentYardId) { throw new BadRequestException( - `Wagon ${wagon.wagonNumber} is not in the train's yard — only wagons in the same yard can be coupled`, + `Wagon ${wagon.wagonNumber} is not at the train's current stop — only wagons standing there can be coupled`, ); } - added.push(wagon); + wagon.wagonType = + (await manager + .getRepository(WagonType) + .findOne({ where: { id: wagon.wagonTypeId } })) ?? undefined; + return wagon; + }; + + const added: Wagon[] = []; + for (const wagonId of addWagonIds) { + added.push(await lockIncomingWagon(wagonId)); } - // --- headroom check (only additions can push the train over a cap) --- + // --- validate switches: outgoing coupled + not foreign-pinned; the + // replacement passes the incoming gate AND matches the wagon type, so + // the slot's cargo (weight, TEU geometry) rides it unchanged --- + const switchPairs: Array<{ from: Wagon; to: Wagon }> = []; + for (const { fromWagonId, toWagonId } of switches) { + const from = consistById.get(fromWagonId); + if (!from) { + throw new NotFoundException( + `Wagon ${fromWagonId} is not coupled to train ${train.code}`, + ); + } + if (pinnedToLiveIds.has(from.id)) { + throw new ConflictException( + `Wagon ${from.wagonNumber} is pinned by another live schedule and cannot be switched`, + ); + } + const to = await lockIncomingWagon(toWagonId); + if (to.wagonTypeId !== from.wagonTypeId) { + throw new BadRequestException( + `Wagon ${to.wagonNumber} (${to.wagonType?.code ?? 'unknown type'}) is not the same type as ${from.wagonNumber} (${from.wagonType?.code ?? 'unknown type'}) — a switch must not change what the slot can carry`, + ); + } + switchPairs.push({ from, to }); + } + + // --- headroom check (only additions can push the train over a cap; + // switches are same-type and cancel out, but are computed honestly) --- const removedIds = new Set(removed.map((w) => w.id)); - const finalConsist = [...consist.filter((w) => !removedIds.has(w.id)), ...added]; + const switchedFromIds = new Set(switchPairs.map((p) => p.from.id)); + const finalConsist = [ + ...consist.filter((w) => !removedIds.has(w.id) && !switchedFromIds.has(w.id)), + ...added, + ...switchPairs.map((p) => p.to), + ]; const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0); const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0); const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0)); @@ -5667,21 +6213,72 @@ export class TrainSchedulingService { ); } - // --- apply: detach trims, couple additions, compact the sequence --- + // --- apply: detach trims, couple additions, swap switches, compact --- + // A wagon leaving the train stands wherever the train stands — stamping + // the stop yard is what makes it findable (and re-couplable) at B. + const detachPatch = { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Available, + trainSetWagonId: null, + currentTrainScheduleId: null, + currentYardId, + }; for (const wagon of removed) { - await manager.getRepository(Wagon).update(wagon.id, { - trainId: null, - sequenceNumber: null, - status: WagonStatus.Available, - }); + await manager.getRepository(Wagon).update(wagon.id, detachPatch); } - const remaining = consist.filter((w) => !removedIds.has(w.id)); - for (let i = 0; i < remaining.length; i++) { - if (remaining[i].sequenceNumber !== i + 1) { - await manager.getRepository(Wagon).update(remaining[i].id, { sequenceNumber: i + 1 }); + if (removed.length && ownSetIds.length) { + // This train's own pins (all its runs) on trimmed wagons are stale — + // clear them so the freed wagon isn't still claimed by slots it left. + await manager + .getRepository(TrainSetWagon) + .update( + { trainSetId: In(ownSetIds), physicalWagonId: In(removed.map((w) => w.id)) }, + { physicalWagonId: null }, + ); + } + + // Switches: the replacement takes the outgoing wagon's position AND its + // slot pins, so every cargo allocation now rides the new wagon. The + // outgoing wagon is left standing at the stop. + for (const { from, to } of switchPairs) { + const slots = ownSetIds.length + ? await manager.getRepository(TrainSetWagon).find({ + where: { trainSetId: In(ownSetIds), physicalWagonId: from.id }, + }) + : []; + for (const slot of slots) { + await manager + .getRepository(TrainSetWagon) + .update(slot.id, { physicalWagonId: to.id }); + } + const ownSlot = + slots.find((slot) => slot.trainSetId === schedule.trainSetId) ?? slots[0]; + await manager.getRepository(Wagon).update(to.id, { + trainId: train.id, + sequenceNumber: from.sequenceNumber, + status: WagonStatus.Assigned, + trainSetWagonId: ownSlot?.id ?? null, + currentTrainScheduleId: from.currentTrainScheduleId ?? null, + }); + // Mirror on the in-memory row — the compaction below sorts by it. + to.sequenceNumber = from.sequenceNumber; + await manager.getRepository(Wagon).update(from.id, detachPatch); + } + + const remaining = consist.filter( + (w) => !removedIds.has(w.id) && !switchedFromIds.has(w.id), + ); + const switchedIn = switchPairs.map((p) => p.to); + const compacted = [...remaining, ...switchedIn].sort( + (a, b) => (a.sequenceNumber ?? 0) - (b.sequenceNumber ?? 0), + ); + for (let i = 0; i < compacted.length; i++) { + if (compacted[i].sequenceNumber !== i + 1) { + await manager.getRepository(Wagon).update(compacted[i].id, { sequenceNumber: i + 1 }); } } - let sequence = remaining.length; + let sequence = compacted.length; for (const wagon of added) { sequence += 1; await manager.getRepository(Wagon).update(wagon.id, { @@ -5700,16 +6297,31 @@ export class TrainSchedulingService { const now = new Date(); await logRepo.save( [ - ...removed.map((wagon) => ({ action: 'REMOVE' as const, wagon })), - ...added.map((wagon) => ({ action: 'ADD' as const, wagon })), - ].map(({ action, wagon }) => + ...removed.map((wagon) => ({ + action: 'REMOVE' as const, + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + })), + ...added.map((wagon) => ({ + action: 'ADD' as const, + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + })), + ...switchPairs.map(({ from, to }) => ({ + action: 'SWITCH' as const, + wagonId: to.id, + // varchar(50) — two long wagon numbers could overflow the column. + wagonNumber: `${from.wagonNumber} → ${to.wagonNumber}`.slice(0, 50), + })), + ].map((entry) => logRepo.create({ trainScheduleId: scheduleId, trainId: train.id, - action, - wagonId: wagon.id, - wagonNumber: wagon.wagonNumber, + action: entry.action, + wagonId: entry.wagonId, + wagonNumber: entry.wagonNumber, adjustedByUserId: userId ?? null, + yardId: currentYardId, occurredAt: now, }), ), @@ -5749,6 +6361,125 @@ export class TrainSchedulingService { return { ...(await this.getScheduleConsist(scheduleId)), warnings }; } + /** + * Unified change history for the schedule detail "History" tab: wagon + * consist adjustments (ADD / REMOVE / SWITCH, with the stop they happened + * at) merged with booking composition removals, newest first. Actor resolves + * through iam.users; rows survive wagon/train deletion (log tables carry + * plain columns, no FKs). + */ + async getScheduleHistory(scheduleId: string) { + type HistoryRow = { + id: string; + kind: 'WAGON' | 'BOOKING'; + action: string; + subject: string | null; + yardLabel: string | null; + actor: string | null; + note: string | null; + occurredAt: Date; + }; + const wagonRows: HistoryRow[] = ( + await this.dataSource.query( + `SELECT l.id, + l.action, + l.wagon_number AS "subject", + COALESCE(y.label, y.code) AS "yardLabel", + COALESCE(u.username, u.email) AS "actor", + l.occurred_at AS "occurredAt" + FROM freight.schedule_wagon_adjustment_logs l + LEFT JOIN freight.yards y ON y.id = l.yard_id + LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id + WHERE l.train_schedule_id = $1 + AND l.deleted_at IS NULL + ORDER BY l.occurred_at DESC + LIMIT 200`, + [scheduleId], + ) + ).map((r: Omit) => ({ + ...r, + kind: 'WAGON' as const, + note: null, + })); + const bookingRows: HistoryRow[] = ( + await this.dataSource.query( + `SELECT r.id, + r.booking_reference AS "subject", + r.notes AS "note", + COALESCE(u.username, u.email) AS "actor", + r.removed_at AS "occurredAt" + FROM freight.train_composition_removal_logs r + LEFT JOIN iam.users u ON u.id = r.removed_by_user_id + WHERE r.schedule_id = $1 + AND r.deleted_at IS NULL + ORDER BY r.removed_at DESC + LIMIT 200`, + [scheduleId], + ) + ).map((r: Omit) => ({ + ...r, + kind: 'BOOKING' as const, + action: 'BOOKING_REMOVED', + yardLabel: null, + })); + // Per-booking journey events (load at boarding yard / unload at alighting + // yard) — sourced from the booking's own loaded_at/arrived_at stamps, so a + // multi-stop train's disjoint legs (a→b loads then unloads at b while a→c + // rides through) each show as their own row. Append-only: these columns are + // only ever set once per booking, never cleared, so rows never disappear. + const journeyRows: HistoryRow[] = ( + await this.dataSource.query( + `SELECT b.id, + b.reference AS "subject", + COALESCE(oy.label, oy.code) AS "yardLabel", + COALESCE(u.username, u.email) AS "actor", + b.loaded_at AS "occurredAt" + FROM freight.bookings b + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN iam.users u ON u.id = b.loaded_by_user_id + WHERE b.loaded_at IS NOT NULL + AND b.deleted_at IS NULL + ORDER BY b.loaded_at DESC + LIMIT 200`, + [scheduleId], + ) + ).map((r: Omit) => ({ + ...r, + kind: 'BOOKING' as const, + action: 'BOOKING_LOADED', + note: null, + })); + const unloadRows: HistoryRow[] = ( + await this.dataSource.query( + `SELECT b.id, + b.reference AS "subject", + COALESCE(dy.label, dy.code) AS "yardLabel", + COALESCE(u.username, u.email) AS "actor", + b.arrived_at AS "occurredAt" + FROM freight.bookings b + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN iam.users u ON u.id = b.arrived_by_user_id + WHERE b.arrived_at IS NOT NULL + AND b.deleted_at IS NULL + ORDER BY b.arrived_at DESC + LIMIT 200`, + [scheduleId], + ) + ).map((r: Omit) => ({ + ...r, + kind: 'BOOKING' as const, + action: 'BOOKING_UNLOADED', + note: null, + })); + return [...wagonRows, ...bookingRows, ...journeyRows, ...unloadRows].sort( + (a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(), + ); + } + /** * Re-derive a built train's lifecycle status from its schedules after one of * them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED → @@ -6039,7 +6770,15 @@ export class TrainSchedulingService { route: { milestones: true }, originStation: true, destinationStation: true, - scheduleBookings: { booking: true }, + // Cargo relations feed effectiveWagonsRequired for legacy links whose + // stored wagonsRequired is NULL — without them such a booking counts + // as 1 wagon and per-leg occupancy under-reports. + scheduleBookings: { + booking: { + bookingContainers: { containerType: true }, + cargoType: { wagonTypes: true }, + }, + }, }, order: { scheduledDepartureDate: 'ASC' }, }); @@ -6097,12 +6836,54 @@ export class TrainSchedulingService { }); } + /** + * Wagon slots still free for a leg of the schedule's corridor, per edge: + * capacity minus every linked booking ON ITS OWN LEG — wagon sharing means a + * booking alighting at a mid-stop frees its slots for the edges past it, so a + * train full Mojo→Dire can still sell Dire→DCT. Works for any corridor length + * (a→b→…→h). No leg given → the most open edge (can anything board at all?). + */ + private remainingWagonsForLeg( + schedule: TrainSchedule, + originYardId?: string, + destinationYardId?: string, + ): number { + const stops = this.mapScheduleStops(schedule).map((s) => s.yardId); + const budget = new CorridorBudget(stops, { + wagons: Number(schedule.maxWagons ?? 0), + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }); + for (const sb of schedule.scheduleBookings ?? []) { + if (!sb.booking) continue; + budget.subtract( + { + wagons: this.effectiveWagonsRequired(sb.booking), + weightTons: 0, + lengthMeters: 0, + }, + budget.legForYards(sb.booking.originYardId, sb.booking.destinationYardId), + ); + } + const leg = + originYardId && destinationYardId + ? budget.legOf(originYardId, destinationYardId) + : null; + const remaining = leg ? budget.remainingFor(leg) : budget.maxRemaining(); + return Math.max(0, remaining.wagons); + } + async getBookableSchedules(originYardId?: string, destinationYardId?: string) { const schedules = await this.getBookableScheduleEntities( originYardId, destinationYardId, ); - return schedules.map((s) => this.mapScheduleListItem(s)); + return schedules.map((s) => ({ + ...this.mapScheduleListItem(s), + // Leg-aware: the list item's own remainingWagons is consist-based + // (maxWagons − coupled wagons) and reads 0 on any fully-consisted train. + remainingWagons: this.remainingWagonsForLeg(s, originYardId, destinationYardId), + })); } /** @@ -6148,8 +6929,16 @@ export class TrainSchedulingService { ); if (schedules.length === 0) return { days: [] }; + // Leg-aware: a train full on Mojo→Dire still sells Dire→DCT — gate on the + // REQUESTED leg's free slots, not on how many wagons are coupled to the + // consist (a fully-consisted train read 0 remaining and hid its days). const withCapacity = schedules.filter( - (s) => Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0, + (s) => + this.remainingWagonsForLeg( + s, + input.originYardId, + input.destinationYardId, + ) > 0, ); const compatible = await this.filterCargoCompatibleSchedules(withCapacity, input); @@ -6580,11 +7369,21 @@ export class TrainSchedulingService { (snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]), ); + // Booking has no ORM relation to Contract (FK only) — fetched separately + // by id so the "on this train" cards can show the contract reference. + const contractIds = [ + ...new Set( + (schedule.scheduleBookings ?? []) + .map((sb) => sb.booking?.contractId) + .filter((id): id is string => Boolean(id)), + ), + ]; + // All independent lookups fired at once — they used to run one after // another, stacking round-trips onto every detail request. // tareDims: booking weights are reported GROSS (cargo + wagon tare) — the // number the locomotive actually hauls against its pull limit. - const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons] = + const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons, contracts] = await Promise.all([ this.loadWagonTareDims(), requiresLoadingConfirmation @@ -6614,7 +7413,13 @@ export class TrainSchedulingService { order: { sequenceNumber: schedule.reverseWagonOrder ? 'DESC' : 'ASC' }, }) : [], + contractIds.length + ? this.dataSource + .getRepository(Contract) + .find({ where: { id: In(contractIds) }, select: { id: true, reference: true } }) + : [], ]); + const contractReferenceById = new Map(contracts.map((c) => [c.id, c.reference])); const loadingConfirmed = requiresLoadingConfirmation ? Boolean(importOp?.loadedOnTrainAt) : true; @@ -6647,6 +7452,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 +7464,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, @@ -6665,6 +7476,9 @@ export class TrainSchedulingService { ? roundTons(Number(wagon.wagonType.tareWeightTons)) : null, status: 'EMPTY', + // Coupled wagons ride the whole corridor — they count on every leg. + boardYardId: null, + alightYardId: null, physicalWagonId: wagon.id, physicalWagonNumber: wagon.wagonNumber ?? null, wagonType: wagon.wagonType @@ -6678,6 +7492,54 @@ 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, + }); + + // Heaviest-edge consist usage. Cross-leg slot sharing means plain sums + // over-report a multi-stop train — a wagon reused Gelan→Adama and + // Adama→Doraleh is two slots but ONE physical wagon, and the train is never + // heavier/longer than its heaviest single leg. Same math as the pull-limit + // enforcement; coupled-but-empty consist wagons ride every edge. + const heaviestLeg = schedule.trainSet + ? (() => { + const usage = maxEdgeConsistUsage( + [ + ...(schedule.trainSet.wagons ?? []).map((w) => ({ + lengthMeters: Number(w.lengthMeters), + tareWeightTons: w.wagonType + ? Number(w.wagonType.tareWeightTons) + : 0, + assignedWeightTons: Number(w.assignedWeightTons), + boardYardId: w.boardYardId ?? null, + alightYardId: w.alightYardId ?? null, + allocations: w.allocations ?? [], + })), + ...emptyConsistWagons.map((w) => ({ + lengthMeters: w.lengthMeters, + tareWeightTons: Number(w.tareWeightTons ?? 0), + assignedWeightTons: 0, + allocations: [], + })), + ], + this.mapScheduleStops(schedule).map((s) => s.yardId), + ); + return { + grossWeightTons: roundTons(usage.grossWeightTons), + lengthMeters: roundTons(usage.lengthMeters), + loadedWagonCount: usage.loadedWagonCount, + }; + })() + : null; + return { id: schedule.id, reference: schedule.reference ?? null, @@ -6718,7 +7580,14 @@ export class TrainSchedulingService { importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null, exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null, docReviewMinutes: windowCfg.docReviewMinutes, - paymentWindowMinutes: windowCfg.paymentWindowMinutes, + // Editor prefill: this schedule's own override when staff set one, + // else the live global for the schedule's direction (import/export + // pay windows are tuned separately). + paymentWindowMinutes: + schedule.rulePaymentWindowMinutes ?? + (schedule.direction === 'EXPORT' + ? windowCfg.exportPaymentWindowMinutes + : windowCfg.paymentWindowMinutes), }, route: schedule.route ? { id: schedule.route.id, name: formatRouteLabel(schedule.route) } @@ -6743,6 +7612,9 @@ export class TrainSchedulingService { wagonCount: schedule.trainSet.wagonCount, totalWeightTons: roundTons(Number(schedule.trainSet.totalWeightTons)), totalLengthMeters: roundTons(Number(schedule.trainSet.totalLengthMeters)), + // What the locomotives actually haul: usage on the corridor's + // heaviest edge, not the sum of every leg's slots. + heaviestLeg, locomotive: schedule.trainSet.locomotive ? { id: schedule.trainSet.locomotive.id, @@ -6767,8 +7639,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 +7648,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)), @@ -6788,6 +7671,10 @@ export class TrainSchedulingService { ? roundTons(Number(wagon.wagonType.tareWeightTons)) : null, status: wagon.status, + // Corridor span this slot rides (null = schedule endpoint) — + // lets the UI compute per-leg utilization from real slots. + boardYardId: wagon.boardYardId ?? null, + alightYardId: wagon.alightYardId ?? null, physicalWagonId: frozenSlot ? frozenSlot.physicalWagonId : wagon.physicalWagonId ?? null, @@ -6848,6 +7735,7 @@ export class TrainSchedulingService { }; }) .concat(emptyConsistWagons), + ), } : null, bookings: @@ -6858,6 +7746,9 @@ export class TrainSchedulingService { weightTons: sb.booking ? this.grossBookingWeightTons(sb.booking, tareDims) : 0, + // Cargo only (VGM / bulk tons) — what the customer actually booked, + // without the wagons' tare. The legs tab shows this per booking. + cargoWeightTons: sb.booking ? bookingCargoTons(sb.booking) : 0, status: sb.booking?.status ?? null, schedulingStatus: sb.booking?.schedulingStatus ?? null, freightType: sb.booking?.freightType ?? null, @@ -6873,10 +7764,11 @@ export class TrainSchedulingService { sb.booking?.destinationYard?.label ?? sb.booking?.destinationYard?.code ?? null, - wagonsRequired: - sb.booking?.wagonsRequired != null - ? Number(sb.booking.wagonsRequired) - : null, + wagonsRequired: sb.booking ? this.effectiveWagonsRequired(sb.booking) : null, + contractReference: + (sb.booking?.contractId + ? contractReferenceById.get(sb.booking.contractId) + : null) ?? null, loadedAt: sb.booking?.loadedAt?.toISOString() ?? null, arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null, // Loaded/unloaded is tracked on the schedule↔booking link, not the @@ -6884,6 +7776,7 @@ export class TrainSchedulingService { // dispatch. Defaults UNLOADED for links written before the column. loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded, wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId), + isGovernment: Boolean(sb.booking?.isGovernment), })) ?? [], // Ordered corridor stops (route milestones; falls back to the two // endpoints) — lets the UI draw per-segment occupancy and label legs. @@ -6900,6 +7793,15 @@ export class TrainSchedulingService { ) : null; })(), + // Length ceiling per leg, same shape as maxGrossWeightTons: the set's + // most restrictive locomotive length plus its overage tolerance. + maxLengthMeters: (() => { + const setLimits = trainSetLocomotiveLimits(schedule.trainSet); + const cap = + Number(setLimits?.maxTrainLengthMeters) + + (Number(setLimits?.overageToleranceMeters) || 0); + return setLimits && Number.isFinite(cap) ? roundTons(cap) : null; + })(), // True when the wagon plan above is served from the frozen snapshot (schedule // is dispatched/arrived/cancelled) rather than the live joins — the UI can badge // it "historical" and skip re-pin affordances. @@ -6908,6 +7810,38 @@ export class TrainSchedulingService { }; } + /** + * A booking's wagon footprint with a computed fallback: rows linked by paths + * that never stamped `wagonsRequired` (legacy allocate) read NULL, and every + * occupancy consumer then counted them as 1 wagon — a 23-wagon booking showed + * a near-empty leg. Falls back to the TEU/weight-derived count when the cargo + * relations are loaded; a bare booking still degrades to 1. + */ + private effectiveWagonsRequired(booking: Booking): number { + const stored = Number(booking.wagonsRequired); + if (stored > 0) return Math.ceil(stored); + const bulkCapacities = (booking.cargoType?.wagonTypes ?? []) + .map((wt) => Number(wt.capacityTons)) + .filter((c) => c > 0); + const bulkCapacity = + booking.freightType === 'BULK' && bulkCapacities.length + ? Math.max(...bulkCapacities) + : undefined; + return wagonsRequiredForBooking(booking, bulkCapacity); + } + + /** + * yardId → display label for error messages that name corridor legs. One + * query; unknown ids fall back to the raw id so a message never goes blank. + */ + private async yardLabelMap(yardIds: string[]): Promise> { + if (!yardIds.length) return new Map(); + const yards = await this.dataSource + .getRepository(Yard) + .find({ where: { id: In(yardIds) } }); + return new Map(yards.map((y) => [y.id, y.label || y.code || y.id])); + } + /** Ordered corridor stops with labels, from the loaded route graph (no extra query). */ private mapScheduleStops( schedule: TrainSchedule, @@ -6998,6 +7932,7 @@ export class TrainSchedulingService { const limits = await this.resolveTrainLimitConfig( undefined, trainSetLocomotiveLimits(schedule.trainSet), + schedule.maxWagons ?? undefined, ); const validation = await this.validateBookingsForScheduling( @@ -7056,6 +7991,143 @@ export class TrainSchedulingService { ); } + /** + * Government-priority switch: free wagons by unassigning the selected + * commercial bookings, then allocate the government booking in their place. + * The gov booking must need no more wagons than the switched-out bookings + * free (ops selects more bookings otherwise), and the post-switch + * composition is fully validated BEFORE anything is unassigned so a failing + * switch never leaves the train half-emptied. + */ + async switchGovernmentBooking( + scheduleId: string, + governmentBookingId: string, + removeBookingIds: string[], + userId?: string, + ) { + if (removeBookingIds.includes(governmentBookingId)) { + throw new BadRequestException('Government booking cannot be switched out by itself'); + } + + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException( + `Cannot switch bookings on a schedule in status ${schedule.status}`, + ); + } + + const [govBooking] = await this.bookingsRepository.findByIdsForScheduling([ + governmentBookingId, + ]); + if (!govBooking) { + throw new NotFoundException(`Booking ${governmentBookingId} not found`); + } + if (!govBooking.isGovernment) { + throw new BadRequestException('Only government bookings can be switched onto a train'); + } + + const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId); + if (wagonAssignedIds.has(governmentBookingId)) { + throw new BadRequestException('Government booking is already allocated on this train'); + } + + const removed = await this.bookingsRepository.findByIdsForScheduling(removeBookingIds); + if (removed.length !== removeBookingIds.length) { + throw new NotFoundException('One or more bookings to switch out were not found'); + } + const notOnTrain = removed.filter((b) => !wagonAssignedIds.has(b.id)); + if (notOnTrain.length) { + throw new BadRequestException( + `Not allocated on this train: ${notOnTrain.map((b) => b.reference).join(', ')}`, + ); + } + const govRemoved = removed.filter((b) => b.isGovernment); + if (govRemoved.length) { + throw new BadRequestException( + `Government bookings cannot be switched out: ${govRemoved.map((b) => b.reference).join(', ')}`, + ); + } + + // Dry-run the post-switch composition: survivors + the gov booking. + const survivorIds = [...wagonAssignedIds].filter((id) => !removeBookingIds.includes(id)); + const previewDto = { + bookingIds: [...survivorIds, governmentBookingId], + scheduleDate: schedule.scheduledDepartureDate.toISOString(), + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + }; + const limits = await this.resolveTrainLimitConfig( + undefined, + trainSetLocomotiveLimits(schedule.trainSet), + schedule.maxWagons ?? undefined, + ); + const validation = await this.validateBookingsForScheduling( + previewDto, + null, + false, + [], + false, + limits, + scheduleId, + ); + const freedWagons = removed.reduce((sum, b) => sum + Number(b.wagonsRequired ?? 0), 0); + if (!validation.valid) { + throw new BadRequestException({ + message: `Switch validation failed: ${validation.violations.join('; ')}`, + violations: validation.violations, + warnings: validation.warnings, + }); + } + if (!validation.bookings.some((b) => b.id === governmentBookingId)) { + throw new BadRequestException( + `Switching out ${removed.map((b) => b.reference).join(', ')} frees ${freedWagons} wagon(s) — not enough for this government booking. Select more bookings to switch out.`, + ); + } + const govWagons = sumWagonsRequired(govBooking, validation.wagonPlan); + if (govWagons > freedWagons) { + throw new BadRequestException( + `Government booking needs ${govWagons} wagon(s) but the selected bookings free only ${freedWagons}. Select more bookings to switch out.`, + ); + } + + // Same container-number gate as single-booking assignment, applied to the + // incoming gov booking only. + const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER'); + const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings); + const slots = getContainerSlotSequenceNos(validation.wagonPlan); + const placements = autoFillPlacements(units, slots); + const missingForGov = findMissingContainerNumberIssues(units, placements).find( + (m) => m.bookingId === governmentBookingId, + ); + if (missingForGov) { + throw new BadRequestException({ + message: missingForGov.issue, + violations: [missingForGov.issue], + }); + } + + // ponytail: unassign + assign run as sequential own-transaction steps, not + // one atomic unit — the dry-run above means the assign step can only fail + // on a concurrent edit; staff re-add from the eligible pool if it does. + for (const booking of removed) { + await this.unassignBooking(scheduleId, booking.id, userId); + } + + const assignableSet = new Set(validation.bookings.map((b) => b.id)); + const assignPlacements = placementsForBookings(placements, assignableSet, units); + return this.assignBookingsToSchedule( + scheduleId, + { + bookingIds: validation.bookings.map((b) => b.id), + containerPlacements: containerBookings.length > 0 ? assignPlacements : undefined, + }, + undefined, + ); + } + /** Preview wagon allocation issues per linked booking without mutating the schedule. */ async previewAllocationForSchedule( scheduleId: string, @@ -7125,6 +8197,7 @@ export class TrainSchedulingService { const limits = await this.resolveTrainLimitConfig( undefined, trainSetLocomotiveLimits(schedule.trainSet), + schedule.maxWagons ?? undefined, ); let validation: Awaited>; @@ -7406,8 +8479,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 +8488,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 +8557,7 @@ export class TrainSchedulingService { if (targetAllocs.length) { checkReceives( targetAllocs, - `#${source.sequenceNo}`, + slotLabel(source), source.wagonType, Number(source.capacityTons), ); @@ -7478,19 +8567,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 +8581,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) { @@ -7669,6 +8786,7 @@ export class TrainSchedulingService { const limits = await this.resolveTrainLimitConfig( undefined, trainSetLocomotiveLimits(schedule.trainSet), + schedule.maxWagons ?? undefined, ); let validation: Awaited>; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index 778dc70dd..6bafcc730 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -162,25 +162,36 @@ describe('applyWagonOrderReversal', () => { expect(applyWagonOrderReversal(plan, null)).toBe(plan); }); - it('flips the order and renumbers sequenceNo 1..N when the flag is true', () => { + it('flips the position numbers when the flag is true', () => { const reversed = applyWagonOrderReversal(plan, true); - // Physically-last wagon (was seq 3, wt-c) is now position 1. - expect(reversed.map((s) => s.wagonTypeId)).toEqual(['wt-c', 'wt-b', 'wt-a']); - expect(reversed.map((s) => s.sequenceNo)).toEqual([1, 2, 3]); + // Physically-last wagon (wt-c) is now position 1. + expect(reversed.map((s) => s.sequenceNo)).toEqual([3, 2, 1]); }); it('keeps each booking with its own wagon — only the position changes', () => { const reversed = applyWagonOrderReversal(plan, true); // The booking that was in the last wagon now sits at sequenceNo 1. - expect(reversed[0].sequenceNo).toBe(1); + const atPosition1 = reversed.find((s) => s.sequenceNo === 1); expect( - (reversed[0].allocations as { bookingId: string }[])[0].bookingId, + (atPosition1?.allocations as { bookingId: string }[])[0].bookingId, ).toBe('BKG-C'); + const atPosition3 = reversed.find((s) => s.sequenceNo === 3); expect( - (reversed[2].allocations as { bookingId: string }[])[0].bookingId, + (atPosition3?.allocations as { bookingId: string }[])[0].bookingId, ).toBe('BKG-A'); }); + // The regression that emptied every reversed train's container items: the + // placement generators pair unit k (booking order) with slot k of this array, + // and persistAllocationsAndLoads matches that sequenceNo against the + // allocation's booking. Array order must stay packing order. + it('keeps array order aligned with booking order so placements still match', () => { + const reversed = applyWagonOrderReversal(plan, true); + expect( + reversed.map((s) => (s.allocations as { bookingId: string }[])[0].bookingId), + ).toEqual(['BKG-A', 'BKG-B', 'BKG-C']); + }); + it('does not mutate the input plan', () => { applyWagonOrderReversal(plan, true); expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]); @@ -198,7 +209,9 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => new Map(entries); it('lets an intercity booking ride the empty leg of a train that is full on the other leg', () => { - // 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only. + // 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only: + // the intercity 20ft alights where the export 20ft boards, so both share + // the single physical wagon (cross-leg TEU sharing). const result = planWagonsWithStock({ bookings: [ containerBooking('EXPORT-1', 1, 1), @@ -222,16 +235,19 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => 'EXPORT-1', 'INTERCITY-1', ]); - // Two slots planned, but both drawn from the single physical wagon. - expect(result.plan).toHaveLength(2); + expect(result.plan).toHaveLength(1); }); - it('still defers when the legs overlap and stock is exhausted', () => { + it('still defers when the wagon has no per-edge TEU room and stock is exhausted', () => { + // Export is a 40ft (2 TEU) riding the whole corridor — no edge has room + // for the intercity 20ft, and there is no second wagon to open. + const fortyFooter = containerBooking('EXPORT-1', 1, 1); + fortyFooter.bookingContainers![0]!.containerType = { + code: '40GP', + sizeFt: 40, + } as never; const result = planWagonsWithStock({ - bookings: [ - containerBooking('EXPORT-1', 1, 1), - containerBooking('INTERCITY-1', 1, 1), - ], + bookings: [fortyFooter, containerBooking('INTERCITY-1', 1, 1)], allowed, stock: { mode: 'TRAIN', @@ -239,7 +255,6 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => codesByTypeId: new Map([[nw6.id, nw6.code]]), }, legs: legs([ - // Both ride edge 0 — they compete for the one wagon. ['EXPORT-1', { from: 0, to: 2 }], ['INTERCITY-1', { from: 0, to: 1 }], ]), @@ -252,9 +267,9 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => expect(result.deferred[0]!.reason).toContain('Train has no free NW6 wagon left'); }); - it('never packs bookings with different legs into the same wagon slot', () => { - // Two 20ft units with room to share one wagon by TEU — but disjoint legs - // must open separate slots (each with its own leg), not one mixed slot. + it('packs disjoint-leg 20fts onto one wagon instead of appending a second', () => { + // Two 20ft units, two wagons in stock — cross-leg TEU sharing still fills + // the open wagon (span grows to the union) rather than opening wagon #2. const result = planWagonsWithStock({ bookings: [ containerBooking('EXPORT-1', 1, 1), @@ -273,11 +288,12 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => edgeCount: 2, }); - expect(result.plan).toHaveLength(2); - const bookingsPerSlot = result.plan.map((s) => - [...new Set(s.allocations.map((a) => a.bookingId))].sort(), - ); - expect(bookingsPerSlot).toEqual([['EXPORT-1'], ['INTERCITY-1']]); + expect(result.deferred).toHaveLength(0); + expect(result.plan).toHaveLength(1); + const bookingsInSlot = [ + ...new Set(result.plan[0]!.allocations.map((a) => a.bookingId)), + ].sort(); + expect(bookingsInSlot).toEqual(['EXPORT-1', 'INTERCITY-1']); }); it('behaves exactly like the whole-route planner when no legs are given', () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 699d7a432..0cf56ded5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -53,18 +53,25 @@ export type FlexPlanResult = { type OpenSlot = { slot: WagonPlanSlot; - teuUsed: number; + /** + * TEU occupied PER CORRIDOR EDGE. Containers on different legs share the + * same physical wagon as long as no single edge exceeds the wagon's TEU + * geometry — an intercity 20ft alighting at Adama frees its slot for a 20ft + * boarding there, and two overlapping-leg 20fts coexist while both ride. + */ + teuPerEdge: number[]; kind: SlotLoadType; /** Kind purity: a bulk wagon carries ONE cargo type at a time. */ cargoTypeId: string | null; freeCapacityTons: number; /** - * Corridor leg this slot rides (`"from-to"` stop indexes). Bookings only - * share a slot when their legs are identical — mixing corridors in one slot - * would degrade it to a whole-route slot (see stampSlotLegs) and silently - * re-occupy edges the cargo never rides. + * Leg of the FIRST booking placed (`"from-to"` stop indexes). Containers + * prefer a same-leg slot but may extend onto a different-leg one (span + * grows to the union); bulk still shares only on an identical leg. */ legKey: string; + /** Contiguous stop-index span this wagon physically rides (union of its cargo legs). */ + covered: { from: number; to: number }; }; /** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */ @@ -227,16 +234,54 @@ export function planWagonsWithStock(params: { for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1; const open: OpenSlot = { slot: slotFromWagonType(chosen, kind), - teuUsed: 0, + teuPerEdge: new Array(edgeCount).fill(0), kind, cargoTypeId, freeCapacityTons: Number(chosen.capacityTons), legKey: legKeyOf(leg), + covered: { ...leg }, }; openSlots.push(open); return open; }; + /** TEU room on every edge of the unit's leg. */ + const teuFits = (open: OpenSlot, leg: BookingLeg, teu: number): boolean => { + for (let e = leg.from; e < leg.to; e += 1) { + if ((open.teuPerEdge[e] ?? 0) + teu > MAX_TEU_SLOTS_PER_WAGON) return false; + } + return true; + }; + + /** + * Whether the slot's ridden span can grow to include this leg: every NEW + * edge (outside the current span) must still have a physical wagon of the + * slot's type spare — extending the span puts this wagon on those edges. + */ + const canExtendSpan = (open: OpenSlot, leg: BookingLeg): boolean => { + const total = stock.remainingByTypeId.get(open.slot.wagonTypeId) ?? 0; + const row = usedPerEdge.get(open.slot.wagonTypeId); + const from = Math.min(open.covered.from, leg.from); + const to = Math.max(open.covered.to, leg.to); + for (let e = from; e < to; e += 1) { + if (e >= open.covered.from && e < open.covered.to) continue; + if (total - (row?.[e] ?? 0) <= 0) return false; + } + return true; + }; + + /** Grow the slot's span onto the leg's new edges, consuming stock there. */ + const extendSpan = (open: OpenSlot, leg: BookingLeg): void => { + const row = usedRow(open.slot.wagonTypeId); + const from = Math.min(open.covered.from, leg.from); + const to = Math.max(open.covered.to, leg.to); + for (let e = from; e < to; e += 1) { + if (e >= open.covered.from && e < open.covered.to) continue; + row[e] = (row[e] ?? 0) + 1; + } + open.covered = { from, to }; + }; + const tryPlaceBooking = (booking: Booking): PlacementProblem | null => { const leg = legFor(booking); const legKey = legKeyOf(leg); @@ -260,17 +305,23 @@ export function planWagonsWithStock(params: { } const allowedIds = new Set(candidates.map((wt) => wt.id)); const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20); - let target = openSlots.find( - (open) => - open.kind === 'CONTAINER' && - open.legKey === legKey && - allowedIds.has(open.slot.wagonTypeId) && - open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON, - ); + const fitsSlot = (open: OpenSlot): boolean => + open.kind === 'CONTAINER' && + allowedIds.has(open.slot.wagonTypeId) && + teuFits(open, leg, teu) && + canExtendSpan(open, leg); + // Same-leg slots first (keeps legacy packing byte-identical), then any + // open wagon with per-edge TEU room — an intercity 20ft rides an + // export wagon's spare slot instead of appending a new wagon. + let target = + openSlots.find((open) => open.legKey === legKey && fitsSlot(open)) ?? + openSlots.find(fitsSlot); if (!target) { const openedSlot = openSlot(candidates, 'CONTAINER', null, leg); if ('message' in openedSlot) return openedSlot; target = openedSlot; + } else { + extendSpan(target, leg); } addAllocation( target.slot, @@ -279,7 +330,9 @@ export function planWagonsWithStock(params: { unit.grossWeightTons, AllocationLoadType.Container, ); - target.teuUsed += teu; + for (let e = leg.from; e < leg.to; e += 1) { + target.teuPerEdge[e] = (target.teuPerEdge[e] ?? 0) + teu; + } } return null; } @@ -343,7 +396,8 @@ export function planWagonsWithStock(params: { ); const slotCountSnapshot = openSlots.length; const slotStateSnapshot = openSlots.map((open) => ({ - teuUsed: open.teuUsed, + teuPerEdge: [...open.teuPerEdge], + covered: { ...open.covered }, freeCapacityTons: open.freeCapacityTons, assignedWeightTons: open.slot.assignedWeightTons, allocationCount: open.slot.allocations.length, @@ -363,7 +417,8 @@ export function planWagonsWithStock(params: { openSlots.forEach((open, index) => { const snap = slotStateSnapshot[index]; if (!snap) return; - open.teuUsed = snap.teuUsed; + open.teuPerEdge = [...snap.teuPerEdge]; + open.covered = { ...snap.covered }; open.freeCapacityTons = snap.freeCapacityTons; open.slot.assignedWeightTons = snap.assignedWeightTons; open.slot.allocations.length = snap.allocationCount; @@ -415,15 +470,22 @@ export function planWagonsWithStock(params: { * sequenceNos, the snapshot re-sorts by them, and the board/allocation views all * read them — so the stored train order and the schedule order stay identical, * just reversed. A false/absent flag returns the plan unchanged. + * + * Only the NUMBERS flip — the array itself stays in packing order. Container + * placements are generated by walking the container units in booking order + * against getContainerSlotSequenceNos(plan) in array order, then matched back to + * their allocation by `sequenceNo:bookingId`. Reordering the array here broke + * that pairing on every reversed schedule: unit 1 was handed the number of the + * slot holding the LAST booking, the match missed, and persistAllocationsAndLoads + * silently dropped every container item — which is why a reversed export train + * printed a marshalling doc with no container numbers and 0/0 container counts. */ export function applyWagonOrderReversal( plan: WagonPlanSlot[], reverse: boolean | null | undefined, ): WagonPlanSlot[] { if (!reverse) return plan; - return [...plan] - .reverse() - .map((slot, index) => ({ ...slot, sequenceNo: index + 1 })); + return plan.map((slot, index) => ({ ...slot, sequenceNo: plan.length - index })); } /** Unbounded stock — used to compute pure demand for availability reporting. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts index a7d430b91..43c9d0f31 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts @@ -308,6 +308,7 @@ describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', () expect(maxEdgeConsistUsage(plan, stops)).toEqual({ grossWeightTons: 89, lengthMeters: 14, + loadedWagonCount: 1, }); }); @@ -328,6 +329,7 @@ describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', () expect(maxEdgeConsistUsage(plan, ['a', 'b'])).toEqual({ grossWeightTons: 178, lengthMeters: 28, + loadedWagonCount: 2, }); }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 84a2dc1f5..ef3089925 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -3,7 +3,12 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -import { consistViolations } from './train-capacity.util'; +import { + bookingCargoTons, + bulkItemsFitFor, + bulkItemWagonsRequired, + consistViolations, +} from './train-capacity.util'; export const MAX_TRAIN_WEIGHT_TONS = 3500; export const MAX_TRAIN_LENGTH_METERS = 760; @@ -171,11 +176,25 @@ export function buildBulkWagonPlan( bookings: Booking[], wagonType: WagonType, ): WagonPlanSlot[] { - const totalWeight = roundTons( - bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0), - ); const capacity = Number(wagonType.capacityTons); - const slots = Math.max(1, Math.ceil(totalWeight / capacity)); + // Break-bulk (PER_ITEM) bookings size by indivisible items per booking — + // their tonnage must NOT pool with PER_TON cargo (an item can't split + // across wagons the way loose tonnage can). + const itemSlotsByBooking = bookings.map((b) => + // The plan fixed THIS wagon type, so its configured items-fit binds — not + // the best fit across the cargo's allowed types. + bulkItemWagonsRequired(b, capacity, bulkItemsFitFor(b.cargoType, wagonType.id)), + ); + const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0); + const totalWeight = roundTons( + bookings.reduce( + (sum, b, i) => + itemSlotsByBooking[i] > 0 ? sum : sum + Number(b.cargoTotalWeightVgm ?? 0), + 0, + ), + ); + const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0; + const slots = Math.max(1, tonSlots + itemSlots); const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({ sequenceNo: index + 1, @@ -293,7 +312,9 @@ function allocateBookingsToSlots( const remaining = bookings.map((booking) => ({ bookingId: booking.id, bookingReference: booking.reference, - remainingWeightTons: roundTons(Number(booking.cargoTotalWeightVgm ?? 0)), + // bookingCargoTons, not the raw VGM column: for break-bulk (PER_ITEM) + // bookings that column is an item COUNT, not tons. + remainingWeightTons: roundTons(bookingCargoTons(booking)), })); let bookingIndex = 0; @@ -537,9 +558,12 @@ export function validateMixedTrainLimitsPerEdge( wagonTypes: Array>, limits: TrainLimitConfig | undefined, stops: string[], + /** Display names parallel to `stops` — violations then name the leg they hit. */ + stopLabels?: string[], ): string[] { if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits); const spans = slotSpans(wagonPlan, stops); + const label = (i: number) => stopLabels?.[i] ?? stops[i]; const violations = new Set(); for (let edge = 0; edge < stops.length - 1; edge += 1) { const active = wagonPlan.filter( @@ -547,15 +571,28 @@ export function validateMixedTrainLimitsPerEdge( ); if (!active.length) continue; for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) { - violations.add(violation); + violations.add(`Leg ${label(edge)} → ${label(edge + 1)}: ${violation}`); } } return [...violations]; } +/** + * The slot fields per-edge usage math actually reads — lets callers feed + * persisted TrainSetWagon rows (or any structural subset), not only plan slots. + */ +export type EdgeUsageSlot = Pick< + WagonPlanSlot, + 'lengthMeters' | 'tareWeightTons' | 'assignedWeightTons' +> & { + boardYardId?: string | null; + alightYardId?: string | null; + allocations?: unknown[]; +}; + /** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */ function slotSpans( - wagonPlan: WagonPlanSlot[], + wagonPlan: EdgeUsageSlot[], stops: string[], ): Array<{ from: number; to: number }> { const lastIdx = stops.length - 1; @@ -575,28 +612,57 @@ function slotSpans( * Two stops or fewer degrade to the whole-train totals. */ export function maxEdgeConsistUsage( - wagonPlan: WagonPlanSlot[], + wagonPlan: EdgeUsageSlot[], stops: string[], -): { grossWeightTons: number; lengthMeters: number } { - const totals = (slots: WagonPlanSlot[]) => ({ +): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } { + return perEdgeConsistUsage(wagonPlan, stops).reduce( + (max, e) => ({ + grossWeightTons: Math.max(max.grossWeightTons, e.grossWeightTons), + lengthMeters: Math.max(max.lengthMeters, e.lengthMeters), + loadedWagonCount: Math.max(max.loadedWagonCount, e.loadedWagonCount), + }), + { grossWeightTons: 0, lengthMeters: 0, loadedWagonCount: 0 }, + ); +} + +/** Usage of one corridor edge (between stops[edge] and stops[edge + 1]). */ +export type EdgeConsistUsage = { + edge: number; + grossWeightTons: number; + lengthMeters: number; + loadedWagonCount: number; + wagonCount: number; +}; + +/** + * Per-edge breakdown behind {@link maxEdgeConsistUsage}: every edge's own + * consist totals, so callers can name WHICH leg breaks a limit instead of + * only reporting the heaviest figure. Two stops or fewer collapse to a + * single whole-route edge. + */ +export function perEdgeConsistUsage( + wagonPlan: EdgeUsageSlot[], + stops: string[], +): EdgeConsistUsage[] { + const totals = (edge: number, slots: EdgeUsageSlot[]): EdgeConsistUsage => ({ + edge, grossWeightTons: slots.reduce( (sum, w) => sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0), 0, ), lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0), + loadedWagonCount: slots.filter((w) => (w.allocations?.length ?? 1) > 0).length, + wagonCount: slots.length, }); - if (stops.length <= 2) return totals(wagonPlan); + if (stops.length <= 2) return [totals(0, wagonPlan)]; const spans = slotSpans(wagonPlan, stops); - const usage = { grossWeightTons: 0, lengthMeters: 0 }; - for (let edge = 0; edge < stops.length - 1; edge += 1) { - const active = totals( + return Array.from({ length: stops.length - 1 }, (_, edge) => + totals( + edge, wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to), - ); - usage.grossWeightTons = Math.max(usage.grossWeightTons, active.grossWeightTons); - usage.lengthMeters = Math.max(usage.lengthMeters, active.lengthMeters); - } - return usage; + ), + ); } export function validate20ftContainerRules( @@ -653,6 +719,14 @@ export function validateContainerPlacements( wagonPlan: WagonPlanSlot[], placements: ContainerPlacementInput[], rules?: ContainerPlacementRules, + /** + * Leg-aware occupancy (cross-leg TEU sharing): booking id → stop-index leg. + * With legs, a wagon's TEU/weight caps hold PER CORRIDOR EDGE — an intercity + * 20ft and an export 20ft coexist on one wagon when their edges allow it. + * Omitted → one edge, byte-identical to the whole-route check. + */ + legs?: Map, + edgeCount?: number, ): string[] { const violations: string[] = []; const units = expandBookingContainerUnits(containerBookings); @@ -709,8 +783,18 @@ export function validateContainerPlacements( } } - const slotTeuUsed = new Map(); - const slotWeightUsed = new Map(); + // TEU and weight are tracked PER EDGE of a unit's leg; without legs there is + // a single edge and this is exactly the old whole-route accounting. + const edges = Math.max(1, edgeCount ?? 1); + const legOf = (bookingId: string): { from: number; to: number } => { + const leg = legs?.get(bookingId); + if (!leg || leg.from < 0 || leg.to > edges || leg.from >= leg.to) { + return { from: 0, to: edges }; + } + return leg; + }; + const slotTeuUsed = new Map(); + const slotWeightUsed = new Map(); const slotBySeq = new Map(wagonPlan.map((s) => [s.sequenceNo, s])); for (const placement of placements) { @@ -722,22 +806,38 @@ export function validateContainerPlacements( if (!unit) continue; const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20); - const usedTeu = slotTeuUsed.get(placement.sequenceNo) ?? 0; - if (usedTeu + teu > MAX_TEU_SLOTS_PER_WAGON) { + const leg = legOf(unit.bookingId); + const teuRow = + slotTeuUsed.get(placement.sequenceNo) ?? new Array(edges).fill(0); + let teuFits = true; + for (let e = leg.from; e < leg.to; e += 1) { + if ((teuRow[e] ?? 0) + teu > MAX_TEU_SLOTS_PER_WAGON) { + teuFits = false; + break; + } + } + if (!teuFits) { violations.push( `Wagon #${placement.sequenceNo} cannot fit another ${unit.containerTypeCode} (max 1×40ft or 2×20ft per wagon)`, ); } else { - slotTeuUsed.set(placement.sequenceNo, usedTeu + teu); + for (let e = leg.from; e < leg.to; e += 1) teuRow[e] = (teuRow[e] ?? 0) + teu; + slotTeuUsed.set(placement.sequenceNo, teuRow); } const slot = slotBySeq.get(placement.sequenceNo); if (slot) { - const weight = roundTons(slotWeightUsed.get(placement.sequenceNo) ?? 0) + unit.grossWeightTons; - slotWeightUsed.set(placement.sequenceNo, weight); - if (weight > slot.capacityTons) { + const weightRow = + slotWeightUsed.get(placement.sequenceNo) ?? new Array(edges).fill(0); + let heaviestEdge = 0; + for (let e = leg.from; e < leg.to; e += 1) { + weightRow[e] = roundTons((weightRow[e] ?? 0) + unit.grossWeightTons); + heaviestEdge = Math.max(heaviestEdge, weightRow[e]); + } + slotWeightUsed.set(placement.sequenceNo, weightRow); + if (heaviestEdge > slot.capacityTons) { violations.push( - `Wagon #${placement.sequenceNo} total container weight ${weight}T exceeds capacity ${slot.capacityTons}T`, + `Wagon #${placement.sequenceNo} total container weight ${heaviestEdge}T exceeds capacity ${slot.capacityTons}T`, ); } } 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..0c9415e42 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 @@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ArrayMinSize, IsArray, + IsNotEmpty, IsOptional, IsString, IsUUID, @@ -33,10 +34,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[]; @@ -50,11 +51,11 @@ export class BuildTrainDto { @IsUUID('all', { each: true }) wagonIds?: string[]; - @ApiPropertyOptional({ maxLength: 100 }) - @IsOptional() + @ApiProperty({ maxLength: 100, description: 'Vogue number' }) @IsString() + @IsNotEmpty({ message: 'Vogue number is required' }) @MaxLength(100) - trainName?: string; + trainName!: string; @ApiPropertyOptional() @IsOptional() 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..a5cd1ff5c 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 @@ -13,8 +13,11 @@ import { Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; import { FleetManage, FleetView } from '../../common/booking-guards'; +import type { AuthUserPayload } from '../../common/resolve-auth-user-id'; +import { resolveAuthUserId } from '../../common/resolve-auth-user-id'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; @@ -62,7 +65,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, @@ -94,8 +97,12 @@ export class TrainBuilderController { @Post(':id/wagons') @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" }) - assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) { - return this.trainBuilderService.assignWagons(id, dto); + assignWagons( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AssignTrainWagonsDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainBuilderService.assignWagons(id, dto, resolveAuthUserId(user)); } @Delete(':id/wagons/:wagonId') @@ -104,8 +111,9 @@ export class TrainBuilderController { removeWagon( @Param('id', ParseUUIDPipe) id: string, @Param('wagonId', ParseUUIDPipe) wagonId: string, + @CurrentUser() user: AuthUserPayload, ) { - return this.trainBuilderService.removeWagon(id, wagonId); + return this.trainBuilderService.removeWagon(id, wagonId, resolveAuthUserId(user)); } @Post(':id/wagons/:wagonId/maintenance') @@ -114,8 +122,9 @@ export class TrainBuilderController { sendWagonToMaintenance( @Param('id', ParseUUIDPipe) id: string, @Param('wagonId', ParseUUIDPipe) wagonId: string, + @CurrentUser() user: AuthUserPayload, ) { - return this.trainBuilderService.sendWagonToMaintenance(id, wagonId); + return this.trainBuilderService.sendWagonToMaintenance(id, wagonId, resolveAuthUserId(user)); } @Post(':id/reorder-wagons') diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.reorder.spec.ts b/apps/edr-freight-api/src/modules/trains/train-builder.reorder.spec.ts new file mode 100644 index 000000000..dffa8caed --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/train-builder.reorder.spec.ts @@ -0,0 +1,44 @@ +import { orderSlotsByWagonSequence } from './train-builder.service'; + +describe('orderSlotsByWagonSequence', () => { + const slot = (sequenceNo: number, physicalWagonId: string | null) => ({ + sequenceNo, + physicalWagonId, + }); + + it('reorders pinned slots to the wagons’ new positions, unpinned trail in old order', () => { + // Built train reordered to w3, w1, w2. Slots 1..5: three pinned + two empty. + const newSeq = new Map([ + ['w3', 1], + ['w1', 2], + ['w2', 3], + ]); + const slots = [ + slot(1, 'w1'), + slot(2, 'w2'), + slot(3, 'w3'), + slot(4, null), + slot(5, null), + ]; + expect(orderSlotsByWagonSequence(slots, newSeq).map((s) => s.physicalWagonId)).toEqual([ + 'w3', + 'w1', + 'w2', + null, + null, + ]); + // Unpinned keep their old relative order (4 before 5). + expect(orderSlotsByWagonSequence(slots, newSeq).map((s) => s.sequenceNo)).toEqual([ + 3, 1, 2, 4, 5, + ]); + }); + + it('slots pinned to wagons outside the reorder trail like unpinned ones', () => { + const newSeq = new Map([['w2', 1]]); + const slots = [slot(1, 'w-foreign'), slot(2, 'w2')]; + expect(orderSlotsByWagonSequence(slots, newSeq).map((s) => s.physicalWagonId)).toEqual([ + 'w2', + 'w-foreign', + ]); + }); +}); 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..fbb5301fb 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,12 @@ 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 { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.util'; +import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { TrainSet } from '../train-sets/entities/train-set.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; 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 +61,17 @@ export interface ActiveScheduleRef { */ @Injectable() export class TrainBuilderService { - constructor(private readonly dataSource: DataSource) {} + private readonly logger = new Logger(TrainBuilderService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly bookingBatchService: BookingBatchService, + ) {} 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 +107,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 +294,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 +350,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 +371,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) }); @@ -464,19 +475,26 @@ export class TrainBuilderService { } /** Append AVAILABLE wagons from the train's own yard to the consist. */ - async assignWagons(id: string, dto: AssignTrainWagonsDto) { + async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) { await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); const currentCount = await manager .getRepository(Wagon) .count({ where: { trainId: train.id } }); - await this.attachWagons(manager, train, dto.wagonIds, currentCount); + const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount); + await this.syncLiveScheduleAfterConsistChange( + manager, + train.id, + attached.map((w) => ({ action: 'ADD' as const, wagonId: w.id, wagonNumber: w.wagonNumber })), + userId ?? null, + train.currentYardId ?? null, + ); }); return this.getComposition(id); } /** Detach one wagon and close the sequence gap it leaves. */ - async removeWagon(id: string, wagonId: string) { + async removeWagon(id: string, wagonId: string, userId?: string | null) { await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); @@ -494,6 +512,13 @@ export class TrainBuilderService { status: WagonStatus.Available, }); await this.resequenceWagons(manager, train.id); + await this.syncLiveScheduleAfterConsistChange( + manager, + train.id, + [{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }], + userId ?? null, + wagon.currentYardId ?? train.currentYardId ?? null, + ); }); return this.getComposition(id); } @@ -503,7 +528,7 @@ export class TrainBuilderService { * moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until * it clears maintenance. The freed sequence gap is closed. */ - async sendWagonToMaintenance(id: string, wagonId: string) { + async sendWagonToMaintenance(id: string, wagonId: string, userId?: string | null) { await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); @@ -520,7 +545,36 @@ 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); + await this.syncLiveScheduleAfterConsistChange( + manager, + train.id, + [{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }], + userId ?? null, + yardId, + ); }); return this.getComposition(id); } @@ -560,9 +614,63 @@ 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'); } + // Only a rolling train is frozen. Pre-dispatch (DRAFT/SCHEDULED) reorder + // is allowed — the pinned schedules' consists are resequenced below so + // they can never desync from the built train's real order. + const dispatched: { 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 = 'DISPATCHED' + AND ts.deleted_at IS NULL + AND tsw.deleted_at IS NULL + LIMIT 1`, + [[...current]], + ); + if (dispatched.length > 0) { + throw new ConflictException( + 'This train is dispatched — wagons cannot be reordered while it is rolling.', + ); + } for (let i = 0; i < dto.wagonIds.length; i++) { await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 }); } + + // Propagate the new order to every live (DRAFT/SCHEDULED) schedule of + // this train: slots pinned to a reordered wagon adopt the wagon's new + // position, unpinned slots trail in their old relative order. Allocations + // ride the slot row (by id), so cargo stays with its physical wagon. + const newSeq = new Map(dto.wagonIds.map((wid, i) => [wid, i + 1])); + const sets: { train_set_id: string }[] = await manager.query( + `SELECT DISTINCT ts.train_set_id + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE tset.train_id = $1 + AND ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED')`, + [id], + ); + for (const { train_set_id: trainSetId } of sets) { + const slots = await manager.getRepository(TrainSetWagon).find({ + where: { trainSetId }, + order: { sequenceNo: 'ASC' }, + }); + const sorted = orderSlotsByWagonSequence(slots, newSeq); + // (train_set_id, sequence_no) is unique — shift to a temp range first + // so the final renumbering can't collide mid-loop. + await manager.query( + `UPDATE freight.train_set_wagons + SET sequence_no = sequence_no + 100000 + WHERE train_set_id = $1 AND deleted_at IS NULL`, + [trainSetId], + ); + for (let i = 0; i < sorted.length; i++) { + await manager + .getRepository(TrainSetWagon) + .update(sorted[i].id, { sequenceNo: i + 1 }); + } + } }); return this.getComposition(id); } @@ -712,10 +820,94 @@ 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, + ), }; } + /** + * Train Builder edits a train's physical consist directly on `Wagon.trainId` + * — it never touches `TrainSchedule.maxWagons` / `TrainSet.wagonCount`, so a + * wagon added/removed here (while the train already has a live DRAFT/ + * SCHEDULED schedule) used to leave the schedule's capacity, history, and + * booking-window status silently stale. This mirrors what + * TrainSchedulingService.adjustScheduleConsist does when the SAME edit is + * made from the schedule's own consist editor, so both entry points agree. + */ + private async syncLiveScheduleAfterConsistChange( + manager: EntityManager, + trainId: string, + changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>, + userId: string | null, + yardId: string | null, + ): Promise { + if (!changes.length) return; + const trainSet = await manager + .getRepository(TrainSet) + .findOne({ where: { trainId }, order: { createdAt: 'DESC' } }); + const schedule = trainSet + ? await manager.getRepository(TrainSchedule).findOne({ + where: { trainSetId: trainSet.id, status: In(['DRAFT', 'SCHEDULED']) }, + }) + : null; + + const consist = await manager.getRepository(Wagon).find({ + where: { trainId }, + relations: { wagonType: true }, + }); + const wagonCount = consist.length; + const totalWeightTons = round( + consist.reduce((sum, w) => sum + (w.wagonType?.tareWeightTons ? Number(w.wagonType.tareWeightTons) : 0), 0), + ); + const totalLengthMeters = round( + consist.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ? Number(w.wagonType.lengthMeters) : 0), 0), + ); + if (trainSet) { + await manager + .getRepository(TrainSet) + .update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters }); + } + if (!schedule) return; + + await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount }); + + const now = new Date(); + await manager.getRepository(ScheduleWagonAdjustmentLog).save( + changes.map((c) => + manager.getRepository(ScheduleWagonAdjustmentLog).create({ + trainScheduleId: schedule.id, + trainId, + action: c.action, + wagonId: c.wagonId, + wagonNumber: c.wagonNumber, + adjustedByUserId: userId, + yardId, + occurredAt: now, + }), + ), + ); + + // Same full/reopen rule as adjustScheduleConsist: freeing a slot on a FULL + // schedule reopens its booking window; filling the last one closes it. + const wasFull = schedule.bookingWindowStatus === 'FULL'; + const usage = await this.bookingBatchService.scheduleWagonUsage(schedule.id); + if (!usage) return; + const nowFull = usage.remainingSlots <= 0; + if (wasFull && !nowFull) { + await this.bookingBatchService.refreshWindowStatus(schedule.id); + } else if (!wasFull && nowFull) { + await this.bookingBatchService.setWindow(schedule.id, 'FULL'); + } + } + /** Load + freeze the train row for edit; block edits while it is out on a run. */ private async getEditableTrain(manager: EntityManager, id: string): Promise { const train = await manager.getRepository(Train).findOne({ @@ -791,7 +983,7 @@ export class TrainBuilderService { train: Train, wagonIds: string[], startCount: number, - ): Promise { + ): Promise { const uniqueIds = [...new Set(wagonIds)]; const wagonRepo = manager.getRepository(Wagon); @@ -818,7 +1010,7 @@ export class TrainBuilderService { } toAttach.push(wagon); } - if (!toAttach.length) return; + if (!toAttach.length) return []; await this.assertConsistLengthWithinLimit(manager, train, toAttach); @@ -831,6 +1023,7 @@ export class TrainBuilderService { status: WagonStatus.Assigned, }); } + return toAttach; } /** @@ -847,7 +1040,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)), @@ -898,3 +1091,20 @@ export class TrainBuilderService { } } } + +/** + * New consist order for a schedule's slots after a built-train reorder: slots + * pinned to a reordered wagon adopt the wagon's new position; unpinned slots + * trail behind in their previous relative order. + */ +export function orderSlotsByWagonSequence< + T extends Pick, +>(slots: T[], newSeq: Map): T[] { + const key = (s: T): number => + (s.physicalWagonId ? newSeq.get(s.physicalWagonId) : undefined) ?? Infinity; + return [...slots].sort((a, b) => { + const sa = key(a); + const sb = key(b); + return sa !== sb ? sa - sb : a.sequenceNo - b.sequenceNo; + }); +} diff --git a/apps/edr-freight-api/src/modules/trains/trains.module.ts b/apps/edr-freight-api/src/modules/trains/trains.module.ts index 0c2ce8ded..6009ebef7 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.module.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.module.ts @@ -1,6 +1,7 @@ // apps/edr-freight-api/src/modules/trains/trains.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { TrainLocomotive } from './entities/train-locomotive.entity'; import { Train } from './entities/train.entity'; import { TrainBuilderController } from './train-builder.controller'; @@ -9,7 +10,7 @@ import { TrainsController } from './trains.controller'; import { TrainsService } from './trains.service'; @Module({ - imports: [TypeOrmModule.forFeature([Train, TrainLocomotive])], + imports: [TypeOrmModule.forFeature([Train, TrainLocomotive]), TrainSchedulingModule], controllers: [TrainsController, TrainBuilderController], providers: [TrainsService, TrainBuilderService], exports: [TrainsService, TrainBuilderService], diff --git a/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts b/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts new file mode 100644 index 000000000..be3810c0b --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsDateString, IsOptional, IsString, MaxLength } from 'class-validator'; + +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 CreateTransitAgentDto { + @ApiProperty({ maxLength: 150, example: 'Ahmed Bourhan' }) + @IsString() + @MaxLength(150) + name!: string; + + @ApiProperty({ example: '2026-01-01' }) + @IsDateString() + validFrom!: string; + + @ApiProperty({ example: '2026-12-31' }) + @IsDateString() + validTo!: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts b/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts new file mode 100644 index 000000000..7e18a93da --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/mapped-types'; + +import { CreateTransitAgentDto } from './create-transit-agent.dto'; + +export class UpdateTransitAgentDto extends PartialType(CreateTransitAgentDto) {} diff --git a/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts b/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts new file mode 100644 index 000000000..6d0ef9158 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts @@ -0,0 +1,24 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * Djibouti transit officer GL Djibouti may assign against a shipment's + * transit-assignee handshake. Admin-managed so the roster and each officer's + * validity window arrive without a code change; `isActive` is the manual + * suspend/reactivate switch, independent of the validity window. + */ +@Entity({ schema: 'freight', name: 'transit_agents' }) +@Index(['isActive']) +export class TransitAgent extends BaseEntity { + @Column({ name: 'name', type: 'varchar', length: 150 }) + name!: string; + + @Column({ name: 'valid_from', type: 'date' }) + validFrom!: string; + + @Column({ name: 'valid_to', type: 'date' }) + validTo!: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts new file mode 100644 index 000000000..4f4b90c7b --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts @@ -0,0 +1,87 @@ +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 { CreateTransitAgentDto } from './dto/create-transit-agent.dto'; +import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto'; +import { TransitAgentsService } from './transit-agents.service'; + +@ApiTags('transit-agents') +@Controller('transit-agents') +@ApiBearerAuth() +export class TransitAgentsController { + constructor(private readonly transitAgentsService: TransitAgentsService) {} + + @Get() + @RuleEngineView('transit-agents') + @ApiOperation({ summary: 'List transit agents' }) + findAll(@Query() query: Record) { + return this.transitAgentsService.findAll({ + isActive: + query.isActive === 'all' + ? undefined + : query.isActive !== undefined + ? query.isActive === 'true' + : undefined, + page: query.page ? parseInt(query.page, 10) : undefined, + pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + }); + } + + /** Active + currently valid officers — the transit-assignee assignment dropdown. */ + @Get('assignable') + @RuleEngineView('transit-agents') + @ApiOperation({ summary: 'List transit agents assignable right now (active and in-window)' }) + findAssignable() { + return this.transitAgentsService.findAssignable(); + } + + @Get(':id') + @RuleEngineView('transit-agents') + @ApiOperation({ summary: 'Get a transit agent by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.transitAgentsService.findById(id); + } + + @Post() + @RuleEngineCreate('transit-agents') + @ApiOperation({ summary: 'Create a transit agent' }) + create(@Body() dto: CreateTransitAgentDto) { + return this.transitAgentsService.create(dto); + } + + @Patch(':id') + @RuleEngineUpdate('transit-agents') + @ApiOperation({ summary: 'Update a transit agent' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTransitAgentDto) { + return this.transitAgentsService.update(id, dto); + } + + @Delete(':id') + @RuleEngineDelete('transit-agents') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a transit agent' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.transitAgentsService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts new file mode 100644 index 000000000..47e655e94 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { TransitAgent } from './entities/transit-agent.entity'; +import { TransitAgentsController } from './transit-agents.controller'; +import { TransitAgentsRepository } from './transit-agents.repository'; +import { TransitAgentsService } from './transit-agents.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([TransitAgent])], + controllers: [TransitAgentsController], + providers: [TransitAgentsRepository, TransitAgentsService], + exports: [TransitAgentsRepository, TransitAgentsService], +}) +export class TransitAgentsModule {} diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts new file mode 100644 index 000000000..4418ad938 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts @@ -0,0 +1,28 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; + +import { TransitAgent } from './entities/transit-agent.entity'; + +@Injectable() +export class TransitAgentsRepository extends BaseRepository { + constructor( + @InjectRepository(TransitAgent) + repository: Repository, + ) { + super(repository); + } + + /** Active AND currently inside its validity window (today's date, server-side). */ + findAssignable(today: string): Promise { + return this.repository.find({ + where: { + isActive: true, + validFrom: LessThanOrEqual(today), + validTo: MoreThanOrEqual(today), + }, + order: { name: 'ASC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts new file mode 100644 index 000000000..ec9c24e9d --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts @@ -0,0 +1,138 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { FindOptionsOrder } from 'typeorm'; + +import { CreateTransitAgentDto } from './dto/create-transit-agent.dto'; +import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto'; +import { TransitAgent } from './entities/transit-agent.entity'; +import { TransitAgentsRepository } from './transit-agents.repository'; + +export type TransitAgentValidityStatus = 'VALID' | 'NOT_STARTED' | 'EXPIRED'; + +export type TransitAgentView = TransitAgent & { + validityStatus: TransitAgentValidityStatus; +}; + +type TransitAgentListFilter = { + isActive?: boolean; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: string; +}; + +/** Today as `yyyy-MM-dd`, matching the `date`-typed validity columns. */ +function todayISODate(): string { + return new Date().toISOString().slice(0, 10); +} + +function validityStatus(agent: Pick): TransitAgentValidityStatus { + const today = todayISODate(); + if (today < agent.validFrom) return 'NOT_STARTED'; + if (today > agent.validTo) return 'EXPIRED'; + return 'VALID'; +} + +function withValidityStatus(agent: TransitAgent): TransitAgentView { + return { ...agent, validityStatus: validityStatus(agent) }; +} + +@Injectable() +export class TransitAgentsService { + constructor(private readonly transitAgentsRepository: TransitAgentsRepository) {} + + async findAll(filter: TransitAgentListFilter = {}): Promise<{ + data: TransitAgentView[]; + meta: { total: number; page: number; pageSize: number; totalPages: number }; + }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 500; + const sortBy = ['name', 'validFrom', 'validTo', 'isActive'].includes(filter.sortBy ?? '') + ? (filter.sortBy as keyof TransitAgent) + : 'name'; + const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + + const [data, total] = await this.transitAgentsRepository.findAndCount({ + where: filter.isActive === undefined ? {} : { isActive: filter.isActive }, + order: { [sortBy]: sortOrder } as FindOptionsOrder, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + return { + data: data.map(withValidityStatus), + meta: { + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }, + }; + } + + /** Active and currently inside its validity window — the DJ assignment dropdown. */ + async findAssignable(): Promise { + return this.transitAgentsRepository.findAssignable(todayISODate()); + } + + async findById(id: string): Promise { + const agent = await this.transitAgentsRepository.findById(id); + if (!agent) { + throw new NotFoundException(`Transit agent ${id} not found`); + } + return withValidityStatus(agent); + } + + /** Used by the assignment flow — rejects a suspended or out-of-window officer. */ + async getAssignable(id: string): Promise { + const agent = await this.transitAgentsRepository.findById(id); + if (!agent) { + throw new BadRequestException('Selected transit officer was not found.'); + } + if (!agent.isActive) { + throw new BadRequestException(`${agent.name} is suspended — pick another transit officer.`); + } + if (validityStatus(agent) !== 'VALID') { + throw new BadRequestException( + `${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`, + ); + } + return agent; + } + + async create(dto: CreateTransitAgentDto): Promise { + if (dto.validTo < dto.validFrom) { + throw new BadRequestException('Valid-to date must be on or after valid-from date.'); + } + const agent = await this.transitAgentsRepository.create({ + name: dto.name.trim(), + validFrom: dto.validFrom, + validTo: dto.validTo, + isActive: dto.isActive ?? true, + }); + return withValidityStatus(agent); + } + + async update(id: string, dto: UpdateTransitAgentDto): Promise { + const current = await this.findById(id); + const nextValidFrom = dto.validFrom ?? current.validFrom; + const nextValidTo = dto.validTo ?? current.validTo; + if (nextValidTo < nextValidFrom) { + throw new BadRequestException('Valid-to date must be on or after valid-from date.'); + } + + const updated = await this.transitAgentsRepository.update(id, { + ...dto, + ...(dto.name ? { name: dto.name.trim() } : {}), + }); + + if (!updated) { + throw new NotFoundException(`Transit agent ${id} not found`); + } + return withValidityStatus(updated); + } + + async remove(id: string): Promise { + await this.findById(id); + await this.transitAgentsRepository.softDelete(id); + } +} 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/verifayda/verifayda.dto.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts index 1885b11e9..f9ffe04cb 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.dto.ts @@ -13,14 +13,14 @@ export class StartVerificationDto { purpose?: 'LOGIN' | 'VERIFY'; @ApiPropertyOptional({ - enum: ['WEB', 'MOBILE'], + enum: ['WEB', 'MOBILE', 'PORTAL'], default: 'WEB', description: - 'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB uses FAYDA_WEB_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. Both land on the same /complete endpoint with identical handling.', + 'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB (backoffice) uses FAYDA_WEB_REDIRECT_URI, PORTAL uses FAYDA_PORTAL_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. All land on the same /complete handling.', }) @IsOptional() - @IsIn(['WEB', 'MOBILE']) - platform?: 'WEB' | 'MOBILE'; + @IsIn(['WEB', 'MOBILE', 'PORTAL']) + platform?: 'WEB' | 'MOBILE' | 'PORTAL'; @ApiPropertyOptional({ type: Boolean, @@ -57,6 +57,12 @@ export class CompleteVerificationResultDto { agentId?: string; }; + @ApiPropertyOptional({ + description: + 'Fayda OIDC subject — the stable key a verified identity is stored under (VERIFY flow). Pairwise pseudonymous.', + }) + sub?: string; + @ApiPropertyOptional({ description: 'Verified full name from Fayda (VERIFY flow).' }) fullName?: string; @@ -74,6 +80,11 @@ export class CompleteVerificationResultDto { @ApiPropertyOptional({ description: 'Verified gender from Fayda (VERIFY flow).' }) gender?: string; + @ApiPropertyOptional({ + description: 'Verified address from Fayda, English rendering (VERIFY flow).', + }) + address?: string; + @ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' }) userDataSaved?: boolean; diff --git a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts index 6e3e2e095..16441bd0d 100644 --- a/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-freight-api/src/modules/verifayda/verifayda.service.ts @@ -56,11 +56,15 @@ export interface CompleteVerificationResult { promptPasswordSetup?: boolean; iamUserId?: string; user?: FaydaUserSummary; + /** Fayda OIDC subject — the stable key a verified identity is stored under. */ + sub?: string; fullName?: string; email?: string; phoneNumber?: string; birthdate?: string; gender?: string; + /** Verified address, English rendering (falls back to Amharic). */ + address?: string; userDataSaved?: boolean; } @@ -125,11 +129,15 @@ export class VerifaydaService { }); } - /** WEB clients use `webRedirectUri`; MOBILE uses the base `redirectUri`. */ + /** + * Each client lands on its own registered redirect_uri: MOBILE on the base + * one, the customer portal on its own origin, everything else (backoffice) on + * the web one. All three must be registered with eSignet. + */ private redirectUriForPlatform(platform?: FaydaPlatform): string { - return platform === 'MOBILE' - ? this.faydaConfig.redirectUri - : this.faydaConfig.webRedirectUri; + if (platform === 'MOBILE') return this.faydaConfig.redirectUri; + if (platform === 'PORTAL') return this.faydaConfig.portalRedirectUri; + return this.faydaConfig.webRedirectUri; } async completeVerification( @@ -210,11 +218,13 @@ export class VerifaydaService { result = { purpose: 'VERIFY', verified: true, + sub: normalized.sub, fullName: normalized.fullName, email: normalized.email, phoneNumber: normalized.phoneNumber, birthdate: normalized.birthdate, gender: normalized.gender, + address: normalized.addressEn ?? normalized.addressAm, userDataSaved, iamUserId: iamUserId ?? undefined, token: sessionToken?.token, 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/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index 7bfb59e1d..28789b8e5 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -16,6 +16,7 @@ export const WAGON_STATUSES = [ WagonStatus.ExportReady, WagonStatus.Maintenance, WagonStatus.Detained, + WagonStatus.OutOfService, ] as const; export type WagonStatusType = (typeof WAGON_STATUSES)[number]; 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/booking-unloaded-at-yard.spec.ts b/apps/edr-freight-api/src/modules/warehouses/booking-unloaded-at-yard.spec.ts new file mode 100644 index 000000000..836dd38b7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/booking-unloaded-at-yard.spec.ts @@ -0,0 +1,79 @@ +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +/** + * A mid-corridor booking (import destined at an intermediate yard, or any + * DOMESTIC/intercity ride-along) used to have its booking.status flipped by + * the checkpoint-driven unload but never got a warehouse_inventory row — the + * Arrival Queue's unload count never moved and the booking was effectively + * stranded. handleBookingUnloadedAtYard reacts to the 'booking.unloadedAtYard' + * event BookingJourneyService.unloadBooking() emits and creates that row. + */ +function makeService(opts: { + existingInventory?: unknown; + booking?: Record | null; +}) { + const created: Record[] = []; + + const inventoryRepository = { + findAll: jest.fn().mockResolvedValue(opts.existingInventory ? [opts.existingInventory] : []), + create: jest.fn((row: Record) => { + created.push(row); + return Promise.resolve({ id: 'new-inv', ...row }); + }), + }; + + const bookingRow = + opts.booking === undefined + ? [{ weight: '10', freightType: 'CONTAINER', cargoTypeCode: 'GEN', customer: 'Acme' }] + : opts.booking + ? [opts.booking] + : []; + + const service = Object.create(WarehouseInventoryService.prototype) as Record; + service.inventoryRepository = inventoryRepository; + service.dataSource = { query: jest.fn().mockResolvedValue(bookingRow), manager: {} }; + service.allocation = { resolveLocation: jest.fn().mockResolvedValue(null) }; + service.pickDefaultLocation = jest.fn().mockResolvedValue({ warehouseId: 'w1', yardId: 'y1', zoneId: 'z1' }); + service.applyCapacityDelta = jest.fn().mockResolvedValue(undefined); + service.activityLog = { record: jest.fn().mockResolvedValue(undefined) }; + service.logger = { warn: jest.fn() }; + + return { service: service as unknown as WarehouseInventoryService, created }; +} + +describe('handleBookingUnloadedAtYard', () => { + it('creates an UNLOADED row with an IMPORT GRN for a fresh import booking', async () => { + const { service, created } = makeService({}); + await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise }) + .handleBookingUnloadedAtYard({ bookingId: 'b1', tradeDirection: 'IMPORT' }); + + expect(created).toHaveLength(1); + expect(created[0]).toMatchObject({ bookingId: 'b1', status: 'UNLOADED' }); + expect(created[0].grnNumber).toMatch(/^GRN-IMPORT-/); + }); + + it('creates one for a DOMESTIC/intercity ride-along too', async () => { + const { service, created } = makeService({}); + await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise }) + .handleBookingUnloadedAtYard({ bookingId: 'b2', tradeDirection: 'DOMESTIC' }); + + expect(created).toHaveLength(1); + expect(created[0].grnNumber).toMatch(/^GRN-DOMESTIC-/); + }); + + it('skips EXPORT — its warehouse record already exists from the origin receive', async () => { + const { service, created } = makeService({}); + await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise }) + .handleBookingUnloadedAtYard({ bookingId: 'b3', tradeDirection: 'EXPORT' }); + + expect(created).toHaveLength(0); + }); + + it('is idempotent — a booking that already has an inventory row is left alone', async () => { + const { service, created } = makeService({ existingInventory: { id: 'existing' } }); + await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise }) + .handleBookingUnloadedAtYard({ bookingId: 'b4', tradeDirection: 'IMPORT' }); + + expect(created).toHaveLength(0); + }); +}); 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-inspection.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts index b5894c21b..be1c68bbd 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts @@ -95,11 +95,9 @@ export class WarehouseInspectionService { b.company_id AS "companyId", b.trade_direction AS "tradeDirection", b.last_mile_delivery_address AS "lastMileDeliveryAddress", - b.customer_truck_assigned_at AS "customerTruckAssignedAt", - COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile" + b.customer_truck_assigned_at AS "customerTruckAssignedAt" FROM freight.warehouse_inventory inv LEFT JOIN freight.bookings b ON b.id = inv.booking_id - LEFT JOIN freight.service_types st ON st.id = b.service_type_id WHERE inv.id = $1 AND inv.deleted_at IS NULL LIMIT 1`, [inventoryId], @@ -111,8 +109,11 @@ export class WarehouseInspectionService { readyForPickupAt: new Date(), }); - const hasLastMile = - Boolean(row.lastMileDeliveryAddress?.trim?.()) || Boolean(row.serviceIncludesLastMile); + // service_types.includes_last_mile is NOT read here — every service type + // ships with it true, which made this always true regardless of the + // customer's actual self-haul/EDR-haul choice and permanently dead-coded + // the self-haul nudge below. The delivery address is the real signal. + const hasLastMile = Boolean(row.lastMileDeliveryAddress?.trim?.()); if (row.bookingReference && hasLastMile) { await this.lastMileService.acceptBooking(row.bookingReference); 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..1159c1151 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 @@ -1,5 +1,5 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; +import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; import { Cron, CronExpression } from '@nestjs/schedule'; import { Between, @@ -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); @@ -1316,8 +1339,11 @@ export class WarehouseInventoryService { bcu.seal_numbers AS "sealNumbers", bc.container_quantity AS "containerQuantity", bc.container_packaging_type AS "containerPackagingType", - (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL - OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested", + -- service_types.includes_last_mile/first_mile are NOT read here: every + -- service type ships with both true, so OR-ing them in made this always + -- true regardless of the customer's actual self-haul/EDR-haul choice. + -- The address is the only per-booking record of that choice. + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested", oy.code AS "origin", dy.code AS "destination", oy.country AS "originCountry", @@ -1328,8 +1354,7 @@ export class WarehouseInventoryService { b.cargo_total_weight_vgm AS "weight", b.payment_status AS "paymentStatus", b.status AS "status", - (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL - OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile", + (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile", fm.id AS "firstMileRequestId", fm.status AS "firstMileStatus", fm.vehicle_id AS "firstMileVehicleId", @@ -1363,7 +1388,6 @@ export class WarehouseInventoryService { LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id - LEFT JOIN freight.service_types st ON st.id = b.service_type_id LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN LATERAL ( SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, @@ -1471,8 +1495,8 @@ export class WarehouseInventoryService { bc.container_packaging_type AS "containerPackagingType", COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription", oy.country AS "originCountry", dy.country AS "destinationCountry", - (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL - OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile", + -- No service_types OR here either — see eligibleBookings above. + (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile", fm.id AS "firstMileRequestId", fm.status AS "firstMileStatus", v.plate_number AS "firstMileTruckPlateNumber", @@ -1496,14 +1520,12 @@ export class WarehouseInventoryService { b.customer_truck_container_number AS "customerTruckContainerNumber", b.customer_truck_assigned_at AS "customerTruckAssignedAt", b.company_id AS "companyId", - (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL - OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile" + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile" FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id ${primaryContactUserJoin('company')} LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id - LEFT JOIN freight.service_types st ON st.id = b.service_type_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id LEFT JOIN LATERAL ( SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, @@ -1565,7 +1587,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; @@ -1659,6 +1681,7 @@ export class WarehouseInventoryService { for (const pending of pendingNotifications) { void this.notifyOwnerInventoryReceived(pending.owner); void this.notifyTruckAssignmentNeeded(pending.booking, pending.bookingId); + if (dto.direction === 'EXPORT') void this.notifyCarriageAcceptanceReady(pending.bookingId); } return result; @@ -1956,11 +1979,10 @@ export class WarehouseInventoryService { COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", ts.train_number AS "trainSchedule", inv.inspection_status AS "inspectionStatus", + -- No service_types OR here either — see eligibleBookings above. CASE WHEN NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL - OR COALESCE(st.includes_last_mile, false) THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption", - (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL - OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested", + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested", -- Multi-truck self-haul writes plates/drivers to -- customer_truck_assignments and leaves the booking columns null, -- so read the assignments first and keep the legacy column as the @@ -1981,6 +2003,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", @@ -1994,7 +2017,6 @@ export class WarehouseInventoryService { LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id - LEFT JOIN freight.service_types st ON st.id = b.service_type_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL @@ -2088,6 +2110,108 @@ export class WarehouseInventoryService { ); } + /** + * A booking alighted from a train at ITS OWN destination yard — emitted by + * BookingJourneyService.unloadBooking() for every direction, whether that + * yard is a mid-corridor stop (checkpoint auto-unload) or the train's final + * yard (manual per-booking unload). That per-booking flow only ever flips + * booking.status; it never creates a warehouse_inventory row, which used to + * strand mid-corridor IMPORT and DOMESTIC/intercity bookings — their status + * read ARRIVED/COMPLETED but the Arrival Queue's unload count never moved + * (nothing else was watching for a mid-corridor arrival). This creates that + * row the moment the cargo is physically off the train. + * + * EXPORT is deliberately skipped: its warehouse_inventory row (and GRN) is + * created at the ORIGIN warehouse receive, before the cargo ever boards — + * see BookingsService.carriageAcceptanceSheet and receive()/bulkReceive() + * above. Creating a second row here would duplicate that receipt. + * + * Idempotent — a booking already unloaded via this listener, a retried + * checkpoint, or the final-yard "Auto Unload" bulk action is left alone. + */ + @OnEvent('booking.unloadedAtYard') + async handleBookingUnloadedAtYard(payload: { + bookingId: string; + tradeDirection: string | null; + }): Promise { + if (payload.tradeDirection !== 'IMPORT' && payload.tradeDirection !== 'DOMESTIC') return; + try { + const existing = ( + await this.inventoryRepository.findAll({ where: { bookingId: payload.bookingId } }) + )[0]; + if (existing) return; + + const [booking]: Array<{ + weight: string | null; + freightType: string | null; + cargoTypeCode: string | null; + customer: string | null; + }> = await this.dataSource.query( + `SELECT 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", + cgt.code AS "cargoTypeCode", + company.name AS customer + FROM freight.bookings b + 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.id = $1 AND b.deleted_at IS NULL`, + [payload.bookingId], + ); + if (!booking) return; + + const allocated = await this.allocation.resolveLocation({ + freightType: booking.freightType, + tradeDirection: payload.tradeDirection, + cargoTypeCode: booking.cargoTypeCode, + }); + const location = allocated ?? (await this.pickDefaultLocation()); + if (!location) { + this.logger.warn( + `Checkpoint auto-unload for booking ${payload.bookingId}: no warehouse/yard/zone configured`, + ); + return; + } + + const now = new Date(); + const saved = await this.inventoryRepository.create({ + warehouseId: location.warehouseId, + yardId: location.yardId, + zoneId: location.zoneId, + bookingId: payload.bookingId, + quantity: 1, + weight: Number(booking.weight) || 0, + status: 'UNLOADED', + grnNumber: this.generateGrnNumber(payload.tradeDirection, payload.bookingId, now, booking.customer), + arrivedAt: now, + unloadedAt: now, + notes: + (allocated as { rule?: { name: string } | null } | null)?.rule + ? `Unloaded → ${(allocated as { path?: string | null }).path}` + : 'Unloaded from arrived train (checkpoint auto-unload)', + }); + await this.activityLog.record({ + activityType: 'INVENTORY_UNLOADED', + inventoryId: saved.id, + warehouseId: saved.warehouseId, + description: 'Unloaded from arrived train (checkpoint auto-unload)', + }); + if (Number(saved.weight) > 0) { + await this.applyCapacityDelta(this.dataSource.manager, location, Number(saved.weight), 0, 0); + } + } catch (err) { + this.logger.warn( + `Checkpoint auto-unload inventory create failed for ${payload.bookingId}: ${(err as Error).message}`, + ); + } + } + /** * Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state. * Reuses the allocation + inventory + activity-log plumbing. Does NOT store and does NOT inspect — @@ -2130,6 +2254,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 +2266,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 +2341,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 +2359,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 +2400,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 +2412,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) { @@ -2625,16 +2789,16 @@ export class WarehouseInventoryService { if (!bookingId) return; const [booking] = await this.dataSource.query( `SELECT reference, - last_mile_delivery_address AS "lastMileDeliveryAddress", - COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile" + last_mile_delivery_address AS "lastMileDeliveryAddress" FROM freight.bookings b - LEFT JOIN freight.service_types st ON st.id = b.service_type_id WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, [bookingId], ); - const hasLastMile = - Boolean(booking?.lastMileDeliveryAddress?.trim?.()) || Boolean(booking?.serviceIncludesLastMile); + // service_types.includes_last_mile is NOT read here — every service type + // ships with it true, so it can't distinguish EDR last-mile from self-haul. + // The delivery address is the only per-booking record of that choice. + const hasLastMile = Boolean(booking?.lastMileDeliveryAddress?.trim?.()); if (!booking?.reference || !hasLastMile) return; await this.lastMileService.acceptBooking(booking.reference); } @@ -2670,7 +2834,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', @@ -2740,6 +2909,10 @@ export class WarehouseInventoryService { return saved.id; }); + if (dto.bookingId && bookingDirection === 'EXPORT') { + void this.notifyCarriageAcceptanceReady(dto.bookingId); + } + return this.findById(id); } @@ -2988,14 +3161,15 @@ export class WarehouseInventoryService { */ private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise { const runner = manager ?? this.dataSource; + // service_types.includes_last_mile is NOT read here — every service type + // ships with it true, which made the second disjunct below unreachable and + // this method effectively return true only from an assigned truck. const [row]: Array<{ ok: number }> = await runner.query( `SELECT 1 AS ok FROM freight.bookings b - LEFT JOIN freight.service_types st ON st.id = b.service_type_id WHERE b.id = $1 AND b.deleted_at IS NULL AND (b.customer_truck_assigned_at IS NOT NULL - OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL - AND COALESCE(st.includes_last_mile, false) = false))`, + OR NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL)`, [bookingId], ); return Boolean(row); @@ -3610,6 +3784,7 @@ export class WarehouseInventoryService { contractId: string | null; hasLastMile: boolean; handoverSigned: boolean; + inspectionStatus: string | null; }> > { const rows: Array<{ @@ -3627,6 +3802,7 @@ export class WarehouseInventoryService { contractId: string | null; hasLastMile: boolean; delivered: boolean; + inspectionStatus: string | null; }> = await this.dataSource.query( `SELECT bcu.container_number AS "containerNumber", COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods, @@ -3641,7 +3817,8 @@ export class WarehouseInventoryService { b.reference AS "bookingReference", b.contract_id AS "contractId", (b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile", - COALESCE(inv.status = 'DELIVERED', false) AS delivered + COALESCE(inv.status = 'DELIVERED', false) AS delivered, + inv.inspection_status AS "inspectionStatus" FROM freight.booking_container_units bcu JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL @@ -3693,6 +3870,7 @@ export class WarehouseInventoryService { bookingReference: r.bookingReference, contractId: r.contractId, hasLastMile: r.hasLastMile, + inspectionStatus: r.inspectionStatus, handoverSigned, })); } @@ -3944,7 +4122,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 +4173,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 +4570,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 +5479,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], @@ -5815,6 +6083,36 @@ export class WarehouseInventoryService { return booking ?? {}; } + /** + * Tell the customer their export carriage acceptance sheet is ready to + * download from the portal. Export acceptance happens at the warehouse gate + * (see BookingsService.carriageAcceptanceSheet) — the sheet is generatable + * as soon as the cargo is received, no wagon allocation required, so this + * fires right after receive, not at marshalling. + */ + private async notifyCarriageAcceptanceReady(bookingId: string): Promise { + try { + const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query( + `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!b?.companyId) return; + const body = `Your carriage acceptance sheet for booking ${b.reference} is ready to download from the portal.`; + await this.inbox.notify({ + recipients: { companyId: b.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.DOCUMENT_ACTION, + title: 'Carriage acceptance sheet ready', + body, + link: `/bookings/${bookingId}`, + data: { bookingId, reference: b.reference }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body); + } catch (err) { + this.logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`); + } + } + private async notifyOwnerInventoryReceived(params: { phone?: string | null; ownerName?: string | null; @@ -5844,8 +6142,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/scripts/backfill-missing-unload-inventory.ts b/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts new file mode 100644 index 000000000..9d51107fe --- /dev/null +++ b/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts @@ -0,0 +1,63 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { NestFactory } from '@nestjs/core'; +import { DataSource } from 'typeorm'; + +config({ path: resolve(__dirname, '../../.env') }); +process.env.TYPEORM_LOGGING = 'false'; + +import { AppModule } from '../app.module'; +import { WarehouseInventoryService } from '../modules/warehouses/warehouse-inventory.service'; + +/** + * One-off backfill for bookings caught by the autoArriveAtFinalYard bug + * (fixed in booking-journey.service.ts): the bulk final-yard arrival used to + * flip booking status to ARRIVED/COMPLETED without ever emitting + * booking.unloadedAtYard, so WarehouseInventoryService never created their + * warehouse_inventory row. Reuses the same idempotent listener the live + * event now calls, so it's safe to re-run. + */ +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn'], + }); + + try { + const dataSource = app.get(DataSource); + const inventory = app.get(WarehouseInventoryService); + + const bookings: { id: string; tradeDirection: string }[] = await dataSource.query( + `SELECT b.id, b.trade_direction AS "tradeDirection" + FROM freight.bookings b + WHERE b.deleted_at IS NULL + AND b.trade_direction IN ('IMPORT', 'DOMESTIC') + AND b.status IN ('ARRIVED', 'COMPLETED') + AND NOT EXISTS ( + SELECT 1 FROM freight.warehouse_inventory wi + WHERE wi.booking_id = b.id AND wi.deleted_at IS NULL + )`, + ); + + if (bookings.length === 0) { + console.log('No bookings missing their unload inventory row.'); + return; + } + + console.log(`Backfilling ${bookings.length} booking(s)...`); + for (const booking of bookings) { + await inventory.handleBookingUnloadedAtYard({ + bookingId: booking.id, + tradeDirection: booking.tradeDirection, + }); + console.log(` - ${booking.id} (${booking.tradeDirection})`); + } + } finally { + await app.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); 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/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts index 6b8529225..9f10c3e0e 100644 --- a/apps/edr-freight-api/src/seed/edr-org.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -164,20 +164,25 @@ export class EdrOrgSeeder { manager: EntityManager, applicationId: string, ) { - const permissionRepository = manager.getRepository(Permission); - - // Upsert by key so reruns are idempotent; applicationId ties every - // permission to the EDR Freight application (also backfills rows that - // were previously seeded without the relation). - await permissionRepository.upsert( - EDR_FREIGHT_PERMISSIONS.map((permission) => ({ - id: permission.id, - key: permission.key, - name: { ...permission.name }, - applicationId, - })), - { conflictPaths: { key: true } }, - ); + // iam.permissions has TWO unique columns (PK id, UQ key) but ON CONFLICT + // can only target one. Seeding a hand-minted id that some older/retired key + // already owns in an environment slips past ON CONFLICT (key) and dies on + // the PK. The key is the identity every consumer resolves by (positions + // seeder maps key -> id at runtime), so ids are left to the column default + // and never sent — no id can collide. + await manager + .createQueryBuilder() + .insert() + .into(Permission) + .values( + EDR_FREIGHT_PERMISSIONS.map((permission) => ({ + key: permission.key, + name: { ...permission.name }, + applicationId, + })), + ) + .orUpdate(["name", "application_id"], ["key"]) + .execute(); this.logger.log( `Ensured ${EDR_FREIGHT_PERMISSIONS.length} permissions on application '${EDR_FREIGHT_APPLICATION.key}'`, 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..7bcae753b 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 @@ -2,6 +2,7 @@ import { Injectable, Logger } from "@nestjs/common"; import { DataSource } from "typeorm"; import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity"; +import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants"; interface OnboardingField { fileKey: string; @@ -17,27 +18,14 @@ interface OnboardingField { const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"]; -/** fileKey of the delegation letter attached to the Power of Attorney step. */ -export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; - /** - * Seeded as optional: the delegation letter is only mandatory once a PoA has - * been entered, or when the company operates as a freight forwarder. That rule - * spans form fields as well as files, so it lives in the onboarding gate - * (companies.service.getOnboardingRequirements) rather than in `isRequired`. + * Listed in the sets below only so the reference defaults stay a complete + * picture of a company onboarding form. Unlike every other field here, the DARS + * delegation paper is not admin-managed: `FileUploadSettingsService.getByCode` + * injects it from poa-delegation.constants.ts whether or not a row exists. */ -const poaDelegationField = (displayOrder: number): OnboardingField => ({ - fileKey: POA_DELEGATION_FILE_KEY, - fileLabel: "PoA Delegation Letter", - helpText: - "Signed letter in which the General Manager delegates the representative named above.", - isRequired: false, - isMultiple: false, - maxFiles: 1, - allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, - displayOrder, -}); +const poaDelegationDefault = (displayOrder: number): OnboardingField => + poaDelegationField(displayOrder) as unknown as OnboardingField; /** Documents required from an Ethiopian company at onboarding. */ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ @@ -75,7 +63,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 3, }, - poaDelegationField(4), + poaDelegationDefault(4), ]; /** Documents required from a Foreign company at onboarding. */ @@ -124,46 +112,47 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 4, }, - poaDelegationField(5), + poaDelegationDefault(5), ]; -/** Legacy combined set, kept for the older per-company-type codes. */ -const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [ - { - fileKey: "business_license", - fileLabel: "Business License / Trade License", - helpText: - "Verified against the government trade system during registration.", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, - displayOrder: 1, - }, - { - fileKey: "tin_certificate", - fileLabel: "TIN Certificate", - helpText: "Verified against the TIN registry during registration.", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, - displayOrder: 2, - }, - { - fileKey: "national_id_passport", - fileLabel: "National ID / Passport", - helpText: "Verified against the National ID API during registration.", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: DOC_EXTENSIONS, - maxSizeMb: 10, - displayOrder: 3, - }, -]; +// Legacy combined set for the removed per-company-type codes +// (company_onboarding_documents_customer/forwarder/transporter/forwarder_dj). +// const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [ +// { +// fileKey: "business_license", +// fileLabel: "Business License / Trade License", +// helpText: +// "Verified against the government trade system during registration.", +// isRequired: true, +// isMultiple: false, +// maxFiles: 1, +// allowedExtensions: DOC_EXTENSIONS, +// maxSizeMb: 10, +// displayOrder: 1, +// }, +// { +// fileKey: "tin_certificate", +// fileLabel: "TIN Certificate", +// helpText: "Verified against the TIN registry during registration.", +// isRequired: true, +// isMultiple: false, +// maxFiles: 1, +// allowedExtensions: DOC_EXTENSIONS, +// maxSizeMb: 10, +// displayOrder: 2, +// }, +// { +// fileKey: "national_id_passport", +// fileLabel: "National ID / Passport", +// helpText: "Verified against the National ID API during registration.", +// isRequired: true, +// isMultiple: false, +// maxFiles: 1, +// allowedExtensions: DOC_EXTENSIONS, +// maxSizeMb: 10, +// displayOrder: 3, +// }, +// ]; interface OnboardingDocumentSetting { code: string; @@ -187,31 +176,32 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [ entity: "customer", fields: FOREIGN_ONBOARDING_FIELDS, }, - // Legacy per-company-type codes (kept for back-compat; no longer used by the portal). - { - code: "company_onboarding_documents_customer", - label: "Customer onboarding documents", - entity: "customer", - fields: LEGACY_ONBOARDING_FIELDS, - }, - { - code: "company_onboarding_documents_forwarder", - label: "Forwarder onboarding documents", - entity: "other", - fields: LEGACY_ONBOARDING_FIELDS, - }, - { - code: "company_onboarding_documents_transporter", - label: "Transporter onboarding documents", - entity: "other", - fields: LEGACY_ONBOARDING_FIELDS, - }, - { - code: "company_onboarding_documents_forwarder_dj", - label: "Djibouti forwarder onboarding documents", - entity: "other", - fields: LEGACY_ONBOARDING_FIELDS, - }, + // Legacy per-company-type codes — removed, unused by any resolver or portal + // lookup (only `company_onboarding_documents_ethiopian`/`_foreign` are live). + // { + // code: "company_onboarding_documents_customer", + // label: "Customer onboarding documents", + // entity: "customer", + // fields: LEGACY_ONBOARDING_FIELDS, + // }, + // { + // code: "company_onboarding_documents_forwarder", + // label: "Forwarder onboarding documents", + // entity: "other", + // fields: LEGACY_ONBOARDING_FIELDS, + // }, + // { + // code: "company_onboarding_documents_transporter", + // label: "Transporter onboarding documents", + // entity: "other", + // fields: LEGACY_ONBOARDING_FIELDS, + // }, + // { + // code: "company_onboarding_documents_forwarder_dj", + // label: "Djibouti forwarder onboarding documents", + // entity: "other", + // fields: LEGACY_ONBOARDING_FIELDS, + // }, ]; const COMPANY_ONBOARDING_DESCRIPTION = @@ -224,7 +214,7 @@ const COMPANY_ONBOARDING_DESCRIPTION = // Two kinds of set per customs category: a CUSTOMER-INPUT set (the customer // uploads) and a GL-OUTPUT set (Global Logistics uploads the customs outputs). -const JPG_EXTENSIONS = ["jpg", "jpeg", "png", "pdf"]; +// const JPG_EXTENSIONS = ["jpg", "jpeg", "png", "pdf"]; // only used by the commented-out output field sets below const CLEARANCE_ENTITY = "booking_clearance"; /** Build a clearance field with sensible defaults; `critical` marks isRequired. */ @@ -293,24 +283,23 @@ const EXPORT_BULK_FIELDS: OnboardingField[] = [ clearanceField("port_invoice", "Port Invoice", 3), ]; -/** GL-uploaded customs output documents (import container). */ -const IMPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [ - clearanceField("im4", "IM4 — Permanent Import Document", 1), - clearanceField("im5", "IM5 — Temporary Import Document", 2, { - required: false, - }), - clearanceField("transit_permitted", "Transit Permitted Screenshot", 3, { - extensions: JPG_EXTENSIONS, - }), -]; - -/** GL-uploaded customs output documents (export container). */ -const EXPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [ - clearanceField("ex3", "EX3 — Permanent Export Document", 1), - clearanceField("ex8", "EX8 — Export Transit Document", 2), - clearanceField("export_release", "Export Release", 3), - clearanceField("t1", "T1 — Transport Document", 4), -]; +// GL-uploaded customs output documents — unused now that all +// clearance_output_*/contract_clearance_output_* settings are commented out. +// const IMPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [ +// clearanceField("im4", "IM4 — Permanent Import Document", 1), +// clearanceField("im5", "IM5 — Temporary Import Document", 2, { +// required: false, +// }), +// clearanceField("transit_permitted", "Transit Permitted Screenshot", 3, { +// extensions: JPG_EXTENSIONS, +// }), +// ]; +// const EXPORT_CONTAINER_OUTPUT_FIELDS: OnboardingField[] = [ +// clearanceField("ex3", "EX3 — Permanent Export Document", 1), +// clearanceField("ex8", "EX8 — Export Transit Document", 2), +// clearanceField("export_release", "Export Release", 3), +// clearanceField("t1", "T1 — Transport Document", 4), +// ]; const CLEARANCE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ // ── Customer-input sets ── @@ -363,181 +352,194 @@ const CLEARANCE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ fields: EXPORT_BULK_FIELDS, }, // ── GL-output sets (customs only) ── - { - code: "clearance_output_import_container", - label: "Customs output documents (import container)", - entity: CLEARANCE_ENTITY, - fields: IMPORT_CONTAINER_OUTPUT_FIELDS, - }, - { - code: "clearance_output_export_container", - label: "Customs output documents (export container)", - entity: CLEARANCE_ENTITY, - fields: EXPORT_CONTAINER_OUTPUT_FIELDS, - }, + // Commented out per request. NOTE: these codes are still resolved and + // looked up at runtime by clearanceOutputSettingCode() in clearance.util.ts, + // called from booking-transition.service.ts (finalizeClearance, + // uploadClearanceOutputDocuments) and booking-clearance.service.ts — + // reachable from bookings.controller.ts's finalizeClearance/ + // uploadClearanceOutputDocuments endpoints. finalizeClearance does not + // catch getByCode()'s NotFoundException, so finalizing a customs-enabled + // booking will 500 once these rows are gone from the DB too. + // { + // code: "clearance_output_import_container", + // label: "Customs output documents (import container)", + // entity: CLEARANCE_ENTITY, + // fields: IMPORT_CONTAINER_OUTPUT_FIELDS, + // }, + // { + // code: "clearance_output_export_container", + // label: "Customs output documents (export container)", + // entity: CLEARANCE_ENTITY, + // fields: EXPORT_CONTAINER_OUTPUT_FIELDS, + // }, // Bulk output sets mirror the container output docs so customs+bulk bookings // can finalize (previously bulk had no output set and got stuck at finalize). - { - code: "clearance_output_import_bulk", - label: "Customs output documents (import bulk)", - entity: CLEARANCE_ENTITY, - fields: IMPORT_CONTAINER_OUTPUT_FIELDS, - }, - { - code: "clearance_output_export_bulk", - label: "Customs output documents (export bulk)", - entity: CLEARANCE_ENTITY, - fields: EXPORT_CONTAINER_OUTPUT_FIELDS, - }, + // { + // code: "clearance_output_import_bulk", + // label: "Customs output documents (import bulk)", + // entity: CLEARANCE_ENTITY, + // fields: IMPORT_CONTAINER_OUTPUT_FIELDS, + // }, + // { + // code: "clearance_output_export_bulk", + // label: "Customs output documents (export bulk)", + // entity: CLEARANCE_ENTITY, + // fields: EXPORT_CONTAINER_OUTPUT_FIELDS, + // }, ]; -// ── Contract pre-booking clearance settings (Path B) ──────────────────────── -// For customs-clearance contracts the customer uploads clearance documents on -// the CONTRACT (before any booking exists). Same customer doc sets as the legacy -// booking clearance, plus the GL output sets, keyed on the contract. Resolved by -// contract-clearance.util.ts (codes: contract_clearance_{op}_{freight} and -// contract_clearance_output_{op}_container). -const CONTRACT_CLEARANCE_ENTITY = "contract_clearance"; +// ── Contract pre-booking clearance settings ───────────────────────────────── +// Removed — clearance is now collected once, per booking, instead of also on +// the contract. See bookings/clearance.util.ts + CLEARANCE_DOCUMENT_SETTINGS. +// const CONTRACT_CLEARANCE_ENTITY = "contract_clearance"; -const CONTRACT_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [ - { - code: "contract_clearance_import_container", - label: "Contract clearance documents (import container)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: IMPORT_CONTAINER_FIELDS, - }, - { - code: "contract_clearance_export_container", - label: "Contract clearance documents (export container)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: EXPORT_CONTAINER_FIELDS, - }, - { - code: "contract_clearance_import_bulk", - label: "Contract clearance documents (import bulk)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: IMPORT_BULK_FIELDS, - }, - { - code: "contract_clearance_export_bulk", - label: "Contract clearance documents (export bulk)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: EXPORT_BULK_FIELDS, - }, - // GL ET output sets uploaded during pre-booking clearance. - { - code: "contract_clearance_output_import_container", - label: "Contract customs output documents (import container)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: IMPORT_CONTAINER_OUTPUT_FIELDS, - }, - { - code: "contract_clearance_output_export_container", - label: "Contract customs output documents (export container)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: EXPORT_CONTAINER_OUTPUT_FIELDS, - }, - // Bulk output sets mirror the container output docs so customs+bulk contracts - // can finalize (previously bulk had no output set and got stuck at finalize). - { - code: "contract_clearance_output_import_bulk", - label: "Contract customs output documents (import bulk)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: IMPORT_CONTAINER_OUTPUT_FIELDS, - }, - { - code: "contract_clearance_output_export_bulk", - label: "Contract customs output documents (export bulk)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: EXPORT_CONTAINER_OUTPUT_FIELDS, - }, -]; +// Contract-level IMPORT/EXPORT clearance input codes — removed. Clearance is +// now collected once, per booking, via bookings/clearance.util.ts's 4+4 +// clearance_{op}_{freight}_{with|without}_customs codes. Kept here as +// reference in case a contract-level step is reinstated. +// const CONTRACT_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [ +// { +// code: "contract_clearance_import_container_with_customs", +// label: "Contract clearance documents (import container, with customs)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: IMPORT_CONTAINER_FIELDS, +// }, +// { +// code: "contract_clearance_export_container_with_customs", +// label: "Contract clearance documents (export container, with customs)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: EXPORT_CONTAINER_FIELDS, +// }, +// { +// code: "contract_clearance_import_bulk_with_customs", +// label: "Contract clearance documents (import bulk, with customs)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: IMPORT_BULK_FIELDS, +// }, +// { +// code: "contract_clearance_export_bulk_with_customs", +// label: "Contract clearance documents (export bulk, with customs)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: EXPORT_BULK_FIELDS, +// }, +// ]; -// ── Path A self-clearance settings (no EDR customs service) ────────────────── -// When the contract does NOT bundle customs clearance, the customer clears the -// cargo himself and uploads his OWN clearance proof on the contract. Operations -// (not GL) reviews this smaller set before the customer may create the booking. -// Resolved by contract-clearance.util.ts as contract_clearance_selfclear_{op}_{freight}. -const SELF_CLEARANCE_IMPORT_FIELDS: OnboardingField[] = [ - clearanceField("customs_declaration", "Customs Declaration (IM4/IM5)", 1), - clearanceField("import_release", "Import Release Permit", 2), - clearanceField("duty_tax_receipt", "Duty & Tax Payment Receipt", 3, { - required: false, - }), - clearanceField("delivery_order", "Delivery Order", 4, { required: false }), - clearanceField("supporting_document", "Other Clearance Document", 5, { - required: false, - }), -]; +// GL ET output sets uploaded during pre-booking clearance — commented out +// per request. NOTE: still resolved/looked up at runtime by +// contractClearanceOutputSettingCode() in contract-clearance.util.ts, +// called from contract-clearance.service.ts (finalize, uploadOutputDocuments) +// — reachable from contracts.controller.ts. `finalize` does not catch +// getByCode()'s NotFoundException, so finalizing a customs-enabled contract +// will 500 once these rows are gone from the DB too. +// const CONTRACT_CLEARANCE_OUTPUT_SETTINGS: OnboardingDocumentSetting[] = [ +// { +// code: "contract_clearance_output_import_container", +// label: "Contract customs output documents (import container)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: IMPORT_CONTAINER_OUTPUT_FIELDS, +// }, +// { +// code: "contract_clearance_output_export_container", +// label: "Contract customs output documents (export container)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: EXPORT_CONTAINER_OUTPUT_FIELDS, +// }, +// { +// code: "contract_clearance_output_import_bulk", +// label: "Contract customs output documents (import bulk)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: IMPORT_CONTAINER_OUTPUT_FIELDS, +// }, +// { +// code: "contract_clearance_output_export_bulk", +// label: "Contract customs output documents (export bulk)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: EXPORT_CONTAINER_OUTPUT_FIELDS, +// }, +// ]; -const SELF_CLEARANCE_EXPORT_FIELDS: OnboardingField[] = [ - clearanceField("customs_declaration", "Customs Declaration (EX3/EX8)", 1), - clearanceField("export_release", "Export Release", 2), - clearanceField("transit_document", "Transit Document (T1)", 3, { - required: false, - }), - clearanceField("supporting_document", "Other Clearance Document", 4, { - required: false, - }), -]; - -const SELF_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [ - { - code: "contract_clearance_selfclear_import_container", - label: "Self-clearance documents (import container)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: SELF_CLEARANCE_IMPORT_FIELDS, - }, - { - code: "contract_clearance_selfclear_export_container", - label: "Self-clearance documents (export container)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: SELF_CLEARANCE_EXPORT_FIELDS, - }, - { - code: "contract_clearance_selfclear_import_bulk", - label: "Self-clearance documents (import bulk)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: SELF_CLEARANCE_IMPORT_FIELDS, - }, - { - code: "contract_clearance_selfclear_export_bulk", - label: "Self-clearance documents (export bulk)", - entity: CONTRACT_CLEARANCE_ENTITY, - fields: SELF_CLEARANCE_EXPORT_FIELDS, - }, -]; +// ── Path A self-clearance settings — removed (contract-level clearance input +// codes no longer exist; see CONTRACT_CLEARANCE_SETTINGS above). Kept as +// reference in case a contract-level step is reinstated. +// const SELF_CLEARANCE_IMPORT_FIELDS: OnboardingField[] = [ +// clearanceField("customs_declaration", "Customs Declaration (IM4/IM5)", 1), +// clearanceField("import_release", "Import Release Permit", 2), +// clearanceField("duty_tax_receipt", "Duty & Tax Payment Receipt", 3, { +// required: false, +// }), +// clearanceField("delivery_order", "Delivery Order", 4, { required: false }), +// clearanceField("supporting_document", "Other Clearance Document", 5, { +// required: false, +// }), +// ]; +// const SELF_CLEARANCE_EXPORT_FIELDS: OnboardingField[] = [ +// clearanceField("customs_declaration", "Customs Declaration (EX3/EX8)", 1), +// clearanceField("export_release", "Export Release", 2), +// clearanceField("transit_document", "Transit Document (T1)", 3, { +// required: false, +// }), +// clearanceField("supporting_document", "Other Clearance Document", 4, { +// required: false, +// }), +// ]; +// const SELF_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [ +// { +// code: "contract_clearance_import_container_without_customs", +// label: "Contract clearance documents (import container, without customs)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: SELF_CLEARANCE_IMPORT_FIELDS, +// }, +// { +// code: "contract_clearance_export_container_without_customs", +// label: "Contract clearance documents (export container, without customs)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: SELF_CLEARANCE_EXPORT_FIELDS, +// }, +// { +// code: "contract_clearance_import_bulk_without_customs", +// label: "Contract clearance documents (import bulk, without customs)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: SELF_CLEARANCE_IMPORT_FIELDS, +// }, +// { +// code: "contract_clearance_export_bulk_without_customs", +// label: "Contract clearance documents (export bulk, without customs)", +// entity: CONTRACT_CLEARANCE_ENTITY, +// fields: SELF_CLEARANCE_EXPORT_FIELDS, +// }, +// ]; // ── Contract intake settings ──────────────────────────────────────────────── // Commercial/framework documents attached at contract submission (wizard step 5), // distinct from the post-sign clearance docs above. const CONTRACT_INTAKE_ENTITY = "contract_intake"; -const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [ - clearanceField( - "commercial_framework", - "Commercial Framework / Agreement", - 1, - { - required: false, - }, - ), - clearanceField("onboarding_attachment", "Onboarding Attachment", 2, { - required: false, - }), - clearanceField("supporting_document", "Supporting Document", 3, { - required: false, - }), -]; - -const CONTRACT_INTAKE_SETTINGS: OnboardingDocumentSetting[] = [ - { - code: "contract_intake_documents", - label: "Contract intake documents", - entity: CONTRACT_INTAKE_ENTITY, - fields: CONTRACT_INTAKE_FIELDS, - }, -]; +// Removed — unused. Nothing resolves/looks up `contract_intake_documents` by +// code anywhere in the API or web apps. +// const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [ +// clearanceField( +// "commercial_framework", +// "Commercial Framework / Agreement", +// 1, +// { +// required: false, +// }, +// ), +// clearanceField("onboarding_attachment", "Onboarding Attachment", 2, { +// required: false, +// }), +// clearanceField("supporting_document", "Supporting Document", 3, { +// required: false, +// }), +// ]; +// const CONTRACT_INTAKE_SETTINGS: OnboardingDocumentSetting[] = [ +// { +// code: "contract_intake_documents", +// label: "Contract intake documents", +// entity: CONTRACT_INTAKE_ENTITY, +// fields: CONTRACT_INTAKE_FIELDS, +// }, +// ]; const CLEARANCE_DESCRIPTION = "Operation/clearance documents collected after contract execution, by operation, freight type and customs."; @@ -582,6 +584,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); @@ -613,26 +628,16 @@ export class FileUploadSettingsSeeder { ...s, description: CLEARANCE_DESCRIPTION, })), - ...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({ - ...s, - description: - "Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.", - })), - ...SELF_CLEARANCE_SETTINGS.map((s) => ({ - ...s, - description: - "Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.", - })), - ...CONTRACT_INTAKE_SETTINGS.map((s) => ({ - ...s, - description: - "Commercial/framework documents attached at contract submission.", - })), ...DRIVER_DOCUMENT_SETTINGS.map((s) => ({ ...s, 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.spec.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts new file mode 100644 index 000000000..caab85c3e --- /dev/null +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.spec.ts @@ -0,0 +1,13 @@ +import { EDR_FREIGHT_PERMISSIONS } from './edr-freight.seed'; + +describe('EDR_FREIGHT_PERMISSIONS', () => { + // The seeder inserts the whole catalog in one ON CONFLICT (key) DO UPDATE + // statement — a duplicated key there is a Postgres 21000 at boot, not a + // silent no-op. + it('has no duplicate keys', () => { + const keys = EDR_FREIGHT_PERMISSIONS.map((permission) => permission.key); + const duplicates = [...new Set(keys.filter((key, i) => keys.indexOf(key) !== i))]; + + expect(duplicates).toEqual([]); + }); +}); 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..9190316cd 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,10 @@ 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', + 'transit-agents', ] as const; export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number]; @@ -57,11 +61,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 +89,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 +100,57 @@ 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' }, +// Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and +// lets the column default mint the uuid — so these are kept only as a record of +// which ids each environment already holds. A hand-picked id must still never +// be recycled from a retired key: `edr_freight_app:rule_engine:truck_types:manage` +// owned …001b, and reusing it for transit-agents crashed boot with a PK 23505 +// on every environment that still had the retired row. +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', + 'transit-agents': 'b2000003-0001-4000-8000-000000000001', +}; + +// 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 +161,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 +242,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 +423,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 +442,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 +456,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 +474,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 +540,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 +813,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 +906,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 +928,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/package.json b/apps/edr-freight-web/backoffice/package.json index 9f7fd27bf..568b3a47a 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -94,6 +94,7 @@ "react-intersection-observer": "^9.16.0", "react-pdf": "^10.4.1", "react-pdf-html": "^2.1.5", + "react-quill-new": "^3.8.3", "react-resizable-panels": "^3.0.6", "react-router-dom": "^6.27.0", "react-signature-canvas": "1.1.0-alpha.2", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index bb4d024c8..d523d2e8a 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"; @@ -116,9 +118,12 @@ import TrainDetailPage from "./pages/trains/TrainDetailPage"; import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage"; import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage"; import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; +import EDRLastMileReturnsPage from "./pages/warehouses/EDRLastMileReturnsPage"; +import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage"; import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage"; +import ImportTrucksPage from "./pages/warehouses/ImportTrucksPage"; import ImportWarehouseFlowPage from "./pages/warehouses/ImportWarehouseFlowPage"; import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPage"; import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; @@ -166,8 +171,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 +302,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", @@ -393,6 +407,24 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.warehouseInventory.view, }, + { + label: "Import Trucks", + href: "/dashboard/import-trucks", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "EDR Last Mile Returns", + href: "/dashboard/edr-last-mile-returns", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, + { + label: "Container Returns", + href: "/dashboard/container-returns", + icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, + }, { label: "Dispatch Queue", href: "/dashboard/dispatch-queue", @@ -1041,6 +1073,9 @@ const App = () => { } /> } /> } /> + } /> + } /> + } /> } /> } /> { } /> + + + + } + /> { } /> + + + + } + /> { config.headers.Authorization = `Bearer ${token}`; } + // Tells the backend which app is asking, so /auth/login can reject + // cross-audience credentials (EDRFREIGHT-415). + config.headers["X-Client-App"] = "backoffice"; + return config; }); diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx new file mode 100644 index 000000000..1a77632ec --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx @@ -0,0 +1,301 @@ +import { useMemo, useState } from "react"; +import { useQueries, useQuery } from "@tanstack/react-query"; +import { Badge, Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core"; +import { Coins, Truck } from "lucide-react"; + +import { api } from "@/services/api"; +import { warehouseService } from "@/services/warehouse.service"; +import { lastMileService } from "@/services/last-mile.service"; +import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal"; +import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal"; + +import { SectionCard } from "./SectionCard"; +import { MetricTile } from "./MetricTile"; + +const money = (amount: number, currency: string) => + `${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; + +const fmt = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleString() : "—"); + +function inspectionLabel(status: string | null | undefined): { text: string; color: string } { + if (!status) return { text: "Pending", color: "gray" }; + if (status === "PASSED") return { text: "Passed", color: "edr-green" }; + if (status === "FAILED") return { text: "Failed", color: "red" }; + return { text: status, color: "gray" }; +} + +interface TruckRow { + key: string; + plate: string; + driver: string | null; + truckType: string | null; + containers: string[]; + warehouseArrived: string | null; + warehouseDeparted: string | null; + destinationArrived: string | null; + returned: string | null; + detentionOpen: boolean; + detentionDays: number | null; + detentionAmount: number | null; + hasDetentionRule: boolean; + inspection: { text: string; color: string }; +} + +/** + * Every truck tied to a booking's last mile — EDR-dispatched or customer + * self-haul (a booking only ever uses one), each with its own warehouse-gate + * and destination-detention clocks, plus the booking's cargo-side cost totals + * (storage/demurrage/double handling — billed per row internally, always + * shown here as one booking-level total). Detention stays EDR-only; customer + * self-haul rows show "—" since EDR only bills detention on its own fleet. + */ +export function BookingTrucksPanel({ bookingId }: { bookingId: string }) { + const [feeModalOpen, setFeeModalOpen] = useState(false); + const [detentionModalOpen, setDetentionModalOpen] = useState(false); + + const inventoryQuery = useQuery( + api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }), + ); + const inventoryItems = inventoryQuery.data ?? []; + const latestInventory = inventoryItems[0] ?? null; + + const edrTrucksQuery = useQuery({ + queryKey: ["booking-edr-trucks", bookingId], + queryFn: () => warehouseService.getLastMileTrucks(bookingId), + }); + const edrTrucks = edrTrucksQuery.data ?? []; + + const customerTrucksQuery = useQuery({ + queryKey: ["booking-customer-trucks", bookingId], + queryFn: () => warehouseService.getCustomerTrucks(bookingId), + enabled: edrTrucksQuery.isSuccess && edrTrucks.length === 0, + }); + const customerTrucks = customerTrucksQuery.data ?? []; + + const mode: "EDR" | "CUSTOMER" | "NONE" = + edrTrucks.length > 0 ? "EDR" : customerTrucks.length > 0 ? "CUSTOMER" : "NONE"; + + const containerItemsQuery = useQuery({ + queryKey: ["booking-container-items-for-trucks", bookingId], + queryFn: () => warehouseService.getContainerItems(bookingId), + }); + const inspectionByContainer = new Map( + (containerItemsQuery.data ?? []).map((c) => [c.containerNumber, c.inspectionStatus]), + ); + + const lastMileId = edrTrucks[0]?.lastMileId ?? null; + + const detentionPreviewQuery = useQuery({ + queryKey: ["truck-detention-preview-for-trucks-tab", lastMileId], + queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data), + enabled: Boolean(lastMileId), + }); + const detentionPreview = detentionPreviewQuery.data; + const detentionByVehicle = new Map( + (detentionPreview?.groups ?? []).map((g) => [g.vehicleId ?? "", g]), + ); + + const lastMileRecordQuery = useQuery({ + queryKey: ["last-mile-record-for-trucks-tab", lastMileId], + queryFn: () => lastMileService.getById(lastMileId as string).then((r) => r.data), + enabled: Boolean(lastMileId), + }); + + // Booking-level cost strip: same per-row fee preview the accrual dashboard + // and FeePreviewModal already use, summed across every inventory row on + // this booking rather than duplicated per row. + const feeQueries = useQueries({ + queries: inventoryItems.map((item) => + api.warehouses.feePreview.queryOptions({ input: { inventoryId: item.id, billingCurrency: "USD" } }), + ), + }); + const allFees = feeQueries.flatMap((q) => q.data ?? []); + const feeCurrency = allFees[0]?.currency ?? "USD"; + const sumByType = (type: string) => + allFees.filter((f) => f.ruleType === type).reduce((sum, f) => sum + Number(f.amount || 0), 0); + + const rows: TruckRow[] = useMemo(() => { + if (mode === "EDR") { + return edrTrucks.map((t) => { + const g = detentionByVehicle.get(t.vehicleId); + return { + key: t.vehicleId, + plate: [t.truckPlateNumber, t.trailerPlateNumber].filter(Boolean).join(" + ") || "—", + driver: t.driverName, + truckType: t.truckType, + containers: t.containerNumber ? [t.containerNumber] : [], + warehouseArrived: t.arrivedAt, + warehouseDeparted: t.departedAt, + destinationArrived: g?.startDate ?? null, + returned: g?.endIsOpen ? null : g?.endDate ?? null, + detentionOpen: Boolean(g?.endIsOpen), + detentionDays: g?.chargeableDays ?? null, + detentionAmount: g?.amount ?? null, + hasDetentionRule: Boolean(g?.ruleId), + inspection: inspectionLabel(t.containerNumber ? inspectionByContainer.get(t.containerNumber) : undefined), + }; + }); + } + if (mode === "CUSTOMER") { + return customerTrucks.map((t) => { + const containers = (t.containers ?? []).map((c) => c.containerNumber); + const statuses = new Set(containers.map((cn) => inspectionByContainer.get(cn) ?? null)); + const inspection = + containers.length === 0 + ? inspectionLabel(undefined) + : statuses.size > 1 + ? { text: "Mixed", color: "yellow" } + : inspectionLabel([...statuses][0]); + return { + key: t.id, + plate: t.plateNumber, + driver: t.driverName, + truckType: t.truckType, + containers, + warehouseArrived: t.arrivedAt ?? null, + warehouseDeparted: t.departedAt ?? null, + destinationArrived: null, + returned: null, + detentionOpen: false, + detentionDays: null, + detentionAmount: null, + hasDetentionRule: false, + inspection, + }; + }); + } + return []; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, edrTrucks, customerTrucks, inspectionByContainer, detentionByVehicle]); + + if (inventoryQuery.isLoading || edrTrucksQuery.isLoading) { + return ( +
+ + + Loading trucks… + +
+ ); + } + + return ( + + setFeeModalOpen(true)}> + View breakdown + + ) + } + > + + + + + + + + setDetentionModalOpen(true)}> + Detention times + + ) + } + > + {rows.length === 0 ? ( + + No trucks assigned to this booking's last mile yet. + + ) : ( + + + + + Plate + Driver + Type + Container(s) + Wh. arrived + Wh. departed + Dest. arrived + Returned + Detention + Inspection + + + + {rows.map((r) => ( + + {r.plate} + {r.driver ?? "—"} + {r.truckType ?? "—"} + {r.containers.length ? r.containers.join(", ") : "—"} + {fmt(r.warehouseArrived)} + {fmt(r.warehouseDeparted)} + {fmt(r.destinationArrived)} + + {r.detentionOpen ? ( + + still out + + ) : ( + fmt(r.returned) + )} + + + {mode !== "EDR" || r.detentionDays == null ? ( + "—" + ) : ( + <> + {r.detentionDays}d · {money(r.detentionAmount ?? 0, detentionPreview?.currency ?? "USD")} + {!r.hasDetentionRule && ( + + {" "} + · no rule + + )} + + )} + + + + {r.inspection.text} + + + + ))} + +
+
+ )} +
+ + setFeeModalOpen(false)} + inventoryId={latestInventory?.id ?? null} + /> + {mode === "EDR" && ( + setDetentionModalOpen(false)} + record={lastMileRecordQuery.data ?? null} + /> + )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index f4a677991..ecbb0488e 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -2,6 +2,7 @@ export * from "./booking-detail.styles"; export * from "./SectionCard"; export * from "./ClearanceReviewSection"; export * from "./BookingDocumentsPanel"; +export * from "./BookingTrucksPanel"; export * from "./ContractOrdersPanel"; export * from "./MetricTile"; export * from "./BookingDetailToolbar"; diff --git a/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx b/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx new file mode 100644 index 000000000..92ab514ad --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx @@ -0,0 +1,96 @@ +import { Button, Group, TextInput } from "@mantine/core"; +import { DatePickerInput } from "@mantine/dates"; +import { Search, X } from "lucide-react"; +import type { ReactNode } from "react"; + +export interface ListControlsProps { + search: string; + onSearchChange: (value: string) => 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. + + +