diff --git a/.gitignore b/.gitignore
index cadb36cea..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)
@@ -47,3 +48,4 @@ e2e-ui-report/
test-results/
playwright-report/
blob-report/
+RUNNING_LOCALLY.md
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/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index 9270dd0a1..aab9e0e4a 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -12,7 +12,7 @@ import {
ensurePostgresSchemas,
APPLICATION_SEARCH_PATH,
} from "./config/ensure-postgres-schemas";
-import { IamModule } from "@tria-plc/iamapi-common";
+import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
import appConfig from "./config/app.config";
@@ -29,6 +29,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module";
// import { TrainsModule } from "./modules/trains/trains.module";
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
+import { TruckTypesModule } from "./modules/truck-types/truck-types.module";
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
@@ -158,6 +159,7 @@ import { LoggerMiddleware } from "./logger.middleware";
FilesModule,
ConsignmentsModule,
LocomotivesModule,
+ TruckTypesModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,
@@ -229,7 +231,7 @@ import { LoggerMiddleware } from "./logger.middleware";
})
export class AppModule implements OnApplicationBootstrap {
constructor(
- // private readonly seeder: DataSeeder,
+ private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
@@ -264,7 +266,7 @@ export class AppModule implements OnApplicationBootstrap {
// freightPositionsSeeder → seeds Position + PositionPermission rows
// (depends on edrOrgSeeder, must run after)
await this.freightPermissionKeyMigrationSeeder.run();
- // await this.seeder.run();
+ await this.seeder.run();
await this.edrOrgSeeder.run();
await this.freightPositionsSeeder.run();
diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts
index a412bf990..92958b364 100644
--- a/apps/edr-freight-api/src/common/booking-guards.ts
+++ b/apps/edr-freight-api/src/common/booking-guards.ts
@@ -26,8 +26,23 @@ export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
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,
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/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/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts
index 4ca578f0b..3df681d9c 100644
--- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts
@@ -85,6 +85,24 @@ export class CustomerTruckService {
if (isBulk) {
const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId);
assertBulkTonnageRemains(totalTons, remainingTons);
+
+ // Assignment-time drawdown: planned tonnage across live trucks (weighed
+ // net once departed, planned before) may not exceed the declared total.
+ if (totalTons > 0) {
+ const [p]: Array<{ planned: string | null }> = await this.dataSource.query(
+ `SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned
+ FROM freight.customer_truck_assignments a
+ WHERE a.booking_id = $1 AND a.deleted_at IS NULL`,
+ [bookingId],
+ );
+ const alreadyPlanned = Number(p?.planned ?? 0);
+ const requestedTons = Number(dto.plannedTons ?? 0);
+ if (requestedTons > 0 && alreadyPlanned + requestedTons > totalTons + 0.001) {
+ throw new BadRequestException(
+ `Planned tonnage exceeds the booking: ${alreadyPlanned} t already assigned of ${totalTons} t — at most ${Math.max(0, totalTons - alreadyPlanned)} t left for this truck`,
+ );
+ }
+ }
}
if (requested.length) {
@@ -108,6 +126,8 @@ export class CustomerTruckService {
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
driverName: dto.driverName.trim(),
truckType: dto.truckType.trim(),
+ plannedTons: isBulk ? (dto.plannedTons ?? null) : null,
+ plannedQuantity: isBulk ? (dto.plannedQuantity ?? null) : null,
}),
);
await manager.getRepository(CustomerTruckContainer).save(
@@ -186,23 +206,52 @@ export class CustomerTruckService {
throw new ConflictException('Cannot edit a truck that has already arrived');
}
- const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
- if (requested.length < 1) {
+ // Bulk trucks carry loose tonnage, not containers — planned tonnage is
+ // editable instead, capped by what the other trucks haven't claimed.
+ const isBulk = booking.freightType === 'BULK';
+ const requested = isBulk
+ ? []
+ : (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
+ if (!isBulk && requested.length < 1) {
throw new BadRequestException('Select at least one container for this truck');
}
- assertTruckLoad({
- containers: requested,
- bookingContainers: await this.bookingContainerNumbers(bookingId),
- sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
- // Exclude THIS truck's own containers so re-saving the same set is allowed.
- assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
- });
+ if (!isBulk) {
+ assertTruckLoad({
+ containers: requested,
+ bookingContainers: await this.bookingContainerNumbers(bookingId),
+ sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
+ // Exclude THIS truck's own containers so re-saving the same set is allowed.
+ assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
+ });
+ } else if (dto.plannedTons != null) {
+ const { totalTons } = await remainingBulkTons(this.dataSource, bookingId);
+ if (totalTons > 0) {
+ const [p]: Array<{ planned: string | null }> = await this.dataSource.query(
+ `SELECT SUM(COALESCE(a.net_weight_tons, a.planned_tons, 0)) AS planned
+ FROM freight.customer_truck_assignments a
+ WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.id <> $2`,
+ [bookingId, assignmentId],
+ );
+ const others = Number(p?.planned ?? 0);
+ if (others + Number(dto.plannedTons) > totalTons + 0.001) {
+ throw new BadRequestException(
+ `Planned tonnage exceeds the booking: ${others} t on other trucks of ${totalTons} t — at most ${Math.max(0, totalTons - others)} t left for this truck`,
+ );
+ }
+ }
+ }
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
driverName: dto.driverName.trim(),
truckType: dto.truckType.trim(),
+ ...(isBulk
+ ? {
+ plannedTons: dto.plannedTons ?? null,
+ plannedQuantity: dto.plannedQuantity ?? null,
+ }
+ : {}),
});
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
await manager.getRepository(CustomerTruckContainer).save(
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts
index 4356d66ec..9816b3405 100644
--- a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts
+++ b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts
@@ -4,10 +4,12 @@ import {
IsArray,
IsIn,
IsNotEmpty,
+ IsNumber,
IsOptional,
IsString,
Matches,
MaxLength,
+ Min,
} from 'class-validator';
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
@@ -44,4 +46,16 @@ export class AddCustomerTruckDto {
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})
containerNumbers?: string[];
+
+ /** Bulk: planned tonnage this truck hauls — draws down the booking total at assignment. */
+ @IsOptional()
+ @IsNumber()
+ @Min(0)
+ plannedTons?: number;
+
+ /** Bulk: optional item/piece count on this truck. */
+ @IsOptional()
+ @IsNumber()
+ @Min(0)
+ plannedQuantity?: number;
}
diff --git a/apps/edr-freight-api/src/modules/bookings/entities/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/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
index ba80cc035..a015cd469 100644
--- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts
@@ -526,6 +526,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 +579,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, {
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/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/scheduling-reschedule/scheduling-reschedule.controller.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts
index 5be404ead..e4f8a1e46 100644
--- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts
+++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts
@@ -2,7 +2,7 @@ import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
-import { TrainSchedulingManage } from '../../common/booking-guards';
+import { TrainSchedulingReschedule } from '../../common/booking-guards';
import {
type AuthUserPayload,
resolveAuthUserId,
@@ -18,7 +18,7 @@ export class SchedulingRescheduleController {
constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {}
@Post('preview')
- @TrainSchedulingManage()
+ @TrainSchedulingReschedule()
@ApiOperation({ summary: 'Preview reschedule / government preempt plan' })
preview(
@Param('id', ParseUUIDPipe) id: string,
@@ -28,7 +28,7 @@ export class SchedulingRescheduleController {
}
@Post('execute')
- @TrainSchedulingManage()
+ @TrainSchedulingReschedule()
@ApiOperation({ summary: 'Execute a confirmed reschedule plan' })
execute(
@Param('id', ParseUUIDPipe) id: string,
@@ -50,7 +50,7 @@ export class SchedulingMaintenanceController {
constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {}
@Post('maintenance')
- @TrainSchedulingManage()
+ @TrainSchedulingReschedule()
@ApiOperation({ summary: 'Reschedule train for maintenance (new departure + rebalance)' })
maintenance(
@Param('id', ParseUUIDPipe) id: string,
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
index b1f79733e..a05286cb3 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
@@ -1,23 +1,18 @@
-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 {
+ TrainSchedulingCancel,
+ TrainSchedulingCreate,
+ TrainSchedulingReschedule,
+ TrainSchedulingRulesManage,
+ TrainSchedulingUpdate,
TrainSchedulingView,
} from "../../common/booking-guards";
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
@@ -114,7 +109,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 +176,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/adjust-consist")
- @TrainSchedulingManage()
+ @TrainSchedulingUpdate()
@ApiOperation({
summary:
"Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)",
@@ -283,21 +278,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 +304,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 +318,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 +332,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 +347,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 +360,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 +371,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 +392,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/assign-unassigned-booking")
- @TrainSchedulingManage()
+ @TrainSchedulingUpdate()
@ApiOperation({
summary:
"Assign one linked unallocated booking to wagons (preserves existing assignments)",
@@ -429,7 +424,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 +437,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 +450,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 +491,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 +514,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 +527,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 +540,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 +552,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 +572,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 +582,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 +592,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 +602,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 +612,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 +625,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 +635,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 +675,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 +683,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/run-allocation")
- @TrainSchedulingManage()
+ @TrainSchedulingUpdate()
@ApiOperation({
summary: "Run wagon-level allocation for all eligible linked bookings",
})
@@ -697,7 +692,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 +706,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 +720,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 +734,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",
@@ -753,7 +748,7 @@ export class TrainSchedulingController {
}
@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 +759,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 +769,7 @@ export class TrainSchedulingController {
}
@Post("bookings/:bookingId/expire")
- @TrainSchedulingManage()
+ @TrainSchedulingUpdate()
@ApiOperation({
summary: "Staff: expire a reservation and free its capacity",
})
@@ -784,7 +779,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 +801,7 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/checkpoints")
- @TrainSchedulingManage()
+ @TrainSchedulingUpdate()
@ApiOperation({
summary: "Log the train passing a station (final station triggers arrival)",
})
@@ -818,7 +813,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 +851,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.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
index 03b293e4c..614afe275 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
@@ -2303,14 +2303,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],
);
@@ -5625,9 +5630,12 @@ export class TrainSchedulingService {
// --- validate additions: AVAILABLE, loose, standing in the train's yard ---
const added: Wagon[] = [];
for (const wagonId of addWagonIds) {
+ // No `relations` on this query: Postgres refuses FOR UPDATE through the
+ // nullable side of the wagonType LEFT JOIN ("FOR UPDATE cannot be
+ // applied to the nullable side of an outer join"). Lock the row alone,
+ // then attach its type with a separate unlocked lookup.
const wagon = await manager.getRepository(Wagon).findOne({
where: { id: wagonId },
- relations: { wagonType: true },
lock: { mode: 'pessimistic_write' },
});
if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`);
@@ -5644,6 +5652,10 @@ export class TrainSchedulingService {
`Wagon ${wagon.wagonNumber} is not in the train's yard — only wagons in the same yard can be coupled`,
);
}
+ wagon.wagonType =
+ (await manager
+ .getRepository(WagonType)
+ .findOne({ where: { id: wagon.wagonTypeId } })) ?? undefined;
added.push(wagon);
}
diff --git a/apps/edr-freight-api/src/modules/truck-types/dto/create-truck-type.dto.ts b/apps/edr-freight-api/src/modules/truck-types/dto/create-truck-type.dto.ts
new file mode 100644
index 000000000..ec673a471
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/truck-types/dto/create-truck-type.dto.ts
@@ -0,0 +1,55 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { Transform } from 'class-transformer';
+import { IsBoolean, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
+
+const toNumber = ({ value }: { value: unknown }) =>
+ value === '' || value == null ? value : Number(value);
+
+const toBoolean = ({ value }: { value: unknown }) => {
+ if (typeof value === 'boolean') return value;
+ if (value === 'true') return true;
+ if (value === 'false') return false;
+ return value;
+};
+
+export class CreateTruckTypeDto {
+ @ApiProperty({ maxLength: 32, example: 'CASONI' })
+ @IsString()
+ @MaxLength(32)
+ code!: string;
+
+ @ApiProperty({ maxLength: 100, example: 'Casoni (rigid, no trailer)' })
+ @IsString()
+ @MaxLength(100)
+ name!: string;
+
+ @ApiPropertyOptional({
+ description: 'Payload capacity in metric tons — pre-fills a vehicle registered against this type',
+ example: 30,
+ })
+ @IsOptional()
+ @Transform(toNumber)
+ @IsNumber()
+ @Min(0)
+ capacityTons?: number;
+
+ @ApiPropertyOptional({
+ description: 'Whether this configuration pulls a trailer. False (e.g. Casoni) forbids a trailer plate.',
+ default: false,
+ })
+ @IsOptional()
+ @Transform(toBoolean)
+ @IsBoolean()
+ hasTrailer?: boolean;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ description?: string;
+
+ @ApiPropertyOptional({ default: true })
+ @IsOptional()
+ @Transform(toBoolean)
+ @IsBoolean()
+ isActive?: boolean;
+}
diff --git a/apps/edr-freight-api/src/modules/truck-types/dto/update-truck-type.dto.ts b/apps/edr-freight-api/src/modules/truck-types/dto/update-truck-type.dto.ts
new file mode 100644
index 000000000..269bce685
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/truck-types/dto/update-truck-type.dto.ts
@@ -0,0 +1,5 @@
+import { PartialType } from '@nestjs/mapped-types';
+
+import { CreateTruckTypeDto } from './create-truck-type.dto';
+
+export class UpdateTruckTypeDto extends PartialType(CreateTruckTypeDto) {}
diff --git a/apps/edr-freight-api/src/modules/truck-types/entities/truck-type.entity.ts b/apps/edr-freight-api/src/modules/truck-types/entities/truck-type.entity.ts
new file mode 100644
index 000000000..4a09dcd40
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/truck-types/entities/truck-type.entity.ts
@@ -0,0 +1,40 @@
+import { BaseEntity } from '@edr/api-common';
+import { Column, Entity, Index } from 'typeorm';
+
+/**
+ * A truck configuration EDR registers vehicles against — back-office managed so
+ * new configurations arrive without a code change.
+ *
+ * Two fields drive vehicle registration:
+ * - `capacityTons` pre-fills a vehicle's capacity (capacity belongs to the type,
+ * not to each individual truck).
+ * - `hasTrailer` decides whether a trailer plate applies at all. A rigid truck
+ * (e.g. Casoni) has none, and registering one with a trailer plate is rejected.
+ */
+@Entity({ schema: 'freight', name: 'truck_types' })
+@Index(['code'])
+@Index(['isActive'])
+export class TruckType extends BaseEntity {
+ /**
+ * Matching key, upper-case. Denormalised onto `vehicles.vehicle_type`, which
+ * truck-detention billing groups and matches fee rules by — so a code change
+ * here is a billing-visible change.
+ */
+ @Column({ name: 'code', type: 'varchar', length: 32, unique: true })
+ code!: string;
+
+ @Column({ name: 'name', type: 'varchar', length: 100 })
+ name!: string;
+
+ @Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
+ capacityTons?: number | null;
+
+ @Column({ name: 'has_trailer', type: 'boolean', default: false })
+ hasTrailer!: boolean;
+
+ @Column({ name: 'description', type: 'text', nullable: true })
+ description?: string | null;
+
+ @Column({ name: 'is_active', type: 'boolean', default: true })
+ isActive!: boolean;
+}
diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.controller.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.controller.ts
new file mode 100644
index 000000000..516d1d7d5
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.controller.ts
@@ -0,0 +1,79 @@
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ HttpCode,
+ HttpStatus,
+ Param,
+ ParseUUIDPipe,
+ Patch,
+ Post,
+ Query,
+} from '@nestjs/common';
+import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
+
+import {
+ RuleEngineCreate,
+ RuleEngineDelete,
+ RuleEngineUpdate,
+ RuleEngineView,
+} from '../../common/rule-engine-guards';
+
+import { CreateTruckTypeDto } from './dto/create-truck-type.dto';
+import { UpdateTruckTypeDto } from './dto/update-truck-type.dto';
+import { TruckTypesService } from './truck-types.service';
+
+@ApiTags('truck-types')
+@Controller('truck-types')
+@ApiBearerAuth()
+export class TruckTypesController {
+ constructor(private readonly truckTypesService: TruckTypesService) {}
+
+ @Get()
+ @RuleEngineView('truck-types')
+ @ApiOperation({ summary: 'List truck types' })
+ findAll(@Query() query: Record) {
+ return this.truckTypesService.findAll({
+ isActive:
+ query.isActive === 'all'
+ ? undefined
+ : query.isActive !== undefined
+ ? query.isActive === 'true'
+ : true,
+ page: query.page ? parseInt(query.page, 10) : undefined,
+ pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined,
+ sortBy: query.sortBy,
+ sortOrder: query.sortOrder,
+ });
+ }
+
+ @Get(':id')
+ @RuleEngineView('truck-types')
+ @ApiOperation({ summary: 'Get a truck type by ID' })
+ findOne(@Param('id', ParseUUIDPipe) id: string) {
+ return this.truckTypesService.findById(id);
+ }
+
+ @Post()
+ @RuleEngineCreate('truck-types')
+ @ApiOperation({ summary: 'Create a truck type' })
+ create(@Body() dto: CreateTruckTypeDto) {
+ return this.truckTypesService.create(dto);
+ }
+
+ @Patch(':id')
+ @RuleEngineUpdate('truck-types')
+ @ApiOperation({ summary: 'Update a truck type' })
+ update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTruckTypeDto) {
+ return this.truckTypesService.update(id, dto);
+ }
+
+ @Delete(':id')
+ @RuleEngineDelete('truck-types')
+ @HttpCode(HttpStatus.NO_CONTENT)
+ @ApiOperation({ summary: 'Soft-delete a truck type' })
+ remove(@Param('id', ParseUUIDPipe) id: string) {
+ return this.truckTypesService.remove(id);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.module.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.module.ts
new file mode 100644
index 000000000..466caa91f
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.module.ts
@@ -0,0 +1,15 @@
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+
+import { TruckType } from './entities/truck-type.entity';
+import { TruckTypesController } from './truck-types.controller';
+import { TruckTypesRepository } from './truck-types.repository';
+import { TruckTypesService } from './truck-types.service';
+
+@Module({
+ imports: [TypeOrmModule.forFeature([TruckType])],
+ controllers: [TruckTypesController],
+ providers: [TruckTypesRepository, TruckTypesService],
+ exports: [TruckTypesRepository, TruckTypesService],
+})
+export class TruckTypesModule {}
diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.repository.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.repository.ts
new file mode 100644
index 000000000..bb803bd8c
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.repository.ts
@@ -0,0 +1,20 @@
+import { BaseRepository } from '@edr/api-common';
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+
+import { TruckType } from './entities/truck-type.entity';
+
+@Injectable()
+export class TruckTypesRepository extends BaseRepository {
+ constructor(
+ @InjectRepository(TruckType)
+ repository: Repository,
+ ) {
+ super(repository);
+ }
+
+ findByCode(code: string): Promise {
+ return this.repository.findOne({ where: { code } });
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/truck-types/truck-types.service.ts b/apps/edr-freight-api/src/modules/truck-types/truck-types.service.ts
new file mode 100644
index 000000000..1cc6421d6
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/truck-types/truck-types.service.ts
@@ -0,0 +1,116 @@
+import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
+import { FindOptionsOrder } from 'typeorm';
+
+import { CreateTruckTypeDto } from './dto/create-truck-type.dto';
+import { UpdateTruckTypeDto } from './dto/update-truck-type.dto';
+import { TruckType } from './entities/truck-type.entity';
+import { TruckTypesRepository } from './truck-types.repository';
+
+type TruckTypeListFilter = {
+ isActive?: boolean;
+ page?: number;
+ pageSize?: number;
+ sortBy?: string;
+ sortOrder?: string;
+};
+
+@Injectable()
+export class TruckTypesService {
+ constructor(private readonly truckTypesRepository: TruckTypesRepository) {}
+
+ async findAll(filter: TruckTypeListFilter = {}): Promise<{
+ data: TruckType[];
+ meta: { total: number; page: number; pageSize: number; totalPages: number };
+ }> {
+ const page = filter.page ?? 1;
+ const pageSize = filter.pageSize ?? 500;
+ const sortBy = ['code', 'name', 'capacityTons', 'hasTrailer', 'isActive'].includes(
+ filter.sortBy ?? '',
+ )
+ ? (filter.sortBy as keyof TruckType)
+ : 'code';
+ const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
+
+ const [data, total] = await this.truckTypesRepository.findAndCount({
+ where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
+ order: { [sortBy]: sortOrder } as FindOptionsOrder,
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ });
+
+ return {
+ data,
+ meta: {
+ total,
+ page,
+ pageSize,
+ totalPages: Math.max(1, Math.ceil(total / pageSize)),
+ },
+ };
+ }
+
+ async findById(id: string): Promise {
+ const truckType = await this.truckTypesRepository.findById(id);
+
+ if (!truckType) {
+ throw new NotFoundException(`Truck type ${id} not found`);
+ }
+
+ return truckType;
+ }
+
+ async findByCode(code: string): Promise {
+ const truckType = await this.truckTypesRepository.findByCode(code);
+ if (!truckType) {
+ throw new NotFoundException(`Truck type ${code} not found`);
+ }
+ return truckType;
+ }
+
+ async create(dto: CreateTruckTypeDto): Promise {
+ const code = dto.code.trim().toUpperCase();
+ const existing = await this.truckTypesRepository.findByCode(code);
+
+ if (existing) {
+ throw new ConflictException(`Truck type code "${code}" already exists`);
+ }
+
+ return this.truckTypesRepository.create({
+ code,
+ name: dto.name.trim(),
+ capacityTons: dto.capacityTons ?? null,
+ hasTrailer: dto.hasTrailer ?? false,
+ description: dto.description?.trim() ?? null,
+ isActive: dto.isActive ?? true,
+ });
+ }
+
+ async update(id: string, dto: UpdateTruckTypeDto): Promise {
+ const truckType = await this.findById(id);
+ const nextCode = dto.code?.trim().toUpperCase();
+
+ if (nextCode && nextCode !== truckType.code) {
+ const existing = await this.truckTypesRepository.findByCode(nextCode);
+ if (existing) {
+ throw new ConflictException(`Truck type code "${nextCode}" already exists`);
+ }
+ }
+
+ const updated = await this.truckTypesRepository.update(id, {
+ ...dto,
+ ...(nextCode ? { code: nextCode } : {}),
+ ...(dto.name ? { name: dto.name.trim() } : {}),
+ });
+
+ if (!updated) {
+ throw new NotFoundException(`Truck type ${id} not found`);
+ }
+
+ return updated;
+ }
+
+ async remove(id: string): Promise {
+ await this.findById(id);
+ await this.truckTypesRepository.softDelete(id);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts
index 4dda3c053..79ecc76c5 100644
--- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts
+++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts
@@ -1,6 +1,11 @@
import { IsString, IsEnum, IsNumber, IsOptional, IsUUID, Matches } from 'class-validator';
import { Transform } from 'class-transformer';
-import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity';
+import {
+ FuelType,
+ VehicleAvailability,
+ VehicleOwnership,
+ VehicleStatus,
+} from '../entities/vehicle.entity';
/**
* A vehicle plate is two or three letters, a hyphen, then two to six digits —
@@ -28,8 +33,9 @@ export class CreateVehicleDto {
@IsString()
plateNumber!: string;
- @IsEnum(VehicleType)
- vehicleType!: VehicleType;
+ /** Truck configuration from `freight.truck_types` — drives capacity and whether a trailer plate applies. */
+ @IsUUID()
+ truckTypeId!: string;
@IsString()
manufacturer!: string;
@@ -43,8 +49,18 @@ export class CreateVehicleDto {
@IsEnum(FuelType)
fuelType!: FuelType;
+ /** Defaults to the truck type's capacity when omitted. */
+ @IsOptional()
@IsNumber()
- capacity!: number;
+ capacity?: number;
+
+ @IsOptional()
+ @IsString()
+ vin?: string;
+
+ @IsOptional()
+ @IsEnum(VehicleOwnership)
+ ownership?: VehicleOwnership;
@IsEnum(VehicleStatus)
status!: VehicleStatus;
diff --git a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts
index 534019bc4..59725fc5e 100644
--- a/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts
+++ b/apps/edr-freight-api/src/modules/vehicles/entities/vehicle.entity.ts
@@ -1,6 +1,17 @@
import { Entity, Column } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
+/**
+ * Legacy classification. Truck configurations are now back-office data in
+ * `freight.truck_types` — register a vehicle with `truckTypeId`, not this.
+ *
+ * The `vehicle_type` COLUMN survives as a denormalised copy of the truck type's
+ * code because truck-detention billing groups by it in raw SQL and matches it
+ * against `warehouse_fee_rules.vehicle_type`. The service writes it through on
+ * every save; nothing should set it by hand.
+ *
+ * @deprecated use `truckTypeId` / `freight.truck_types`
+ */
export enum VehicleType {
TRUCK = 'TRUCK',
VAN = 'VAN',
@@ -11,6 +22,12 @@ export enum VehicleType {
FLATBED = 'FLATBED',
}
+/** Who supplies the truck. Supplier selection is deferred until EDR commits to outsourcing. */
+export enum VehicleOwnership {
+ OWNED = 'OWNED',
+ OUTSOURCED = 'OUTSOURCED',
+}
+
export enum FuelType {
PETROL = 'PETROL',
DIESEL = 'DIESEL',
@@ -47,8 +64,12 @@ export class Vehicle extends BaseEntity {
@Column({ name: 'registration_number', unique: true, nullable: true })
registrationNumber?: string;
+ /** Denormalised `truck_types.code` — written through by the service, never set by hand. */
@Column({ name: 'vehicle_type', type: 'varchar', nullable: true })
- vehicleType?: VehicleType;
+ vehicleType?: string;
+
+ @Column({ name: 'truck_type_id', type: 'uuid', nullable: true })
+ truckTypeId?: string | null;
@Column({ nullable: true })
manufacturer?: string;
@@ -101,7 +122,7 @@ export class Vehicle extends BaseEntity {
@Column({ name: 'vin', type: 'varchar', nullable: true })
vin?: string;
- /** Owned | Leased | Rented */
+ /** OWNED | OUTSOURCED — see {@link VehicleOwnership}. */
@Column({ name: 'ownership', type: 'varchar', nullable: true })
ownership?: string;
diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts
index cb810abaf..6171602d7 100644
--- a/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts
+++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts
@@ -11,6 +11,7 @@ describe('VehiclesService driver assignment guard', () => {
new VehiclesService(
{ findOne, create: jest.fn((x) => x), save: jest.fn(async (x) => x) } as any,
{ record: jest.fn() } as any,
+ { findById: jest.fn(async () => ({ code: 'TRUCK', name: 'Truck', hasTrailer: true })) } as any,
);
it('rejects create when the driver is on another truck', async () => {
@@ -18,7 +19,7 @@ describe('VehiclesService driver assignment guard', () => {
const findOne = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(otherTruck);
const svc = makeService(findOne);
await expect(
- svc.create({ plateNumber: '3-22222', vehicleType: 'TRUCK', assignedDriverId: 'd1' } as any),
+ svc.create({ plateNumber: '3-22222', truckTypeId: 'tt1', assignedDriverId: 'd1' } as any),
).rejects.toThrow(ConflictException);
});
diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts
index 07aa4bd2f..a3febac70 100644
--- a/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts
+++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.module.ts
@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Vehicle } from './entities/vehicle.entity';
import { VehiclesService } from './vehicles.service';
import { VehiclesController } from './vehicles.controller';
+import { TruckTypesModule } from '../truck-types/truck-types.module';
@Module({
- imports: [TypeOrmModule.forFeature([Vehicle])],
+ imports: [TypeOrmModule.forFeature([Vehicle]), TruckTypesModule],
providers: [VehiclesService],
controllers: [VehiclesController],
exports: [VehiclesService],
diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts
index 98d38cbce..30c72e1ba 100644
--- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts
+++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts
@@ -1,9 +1,16 @@
-import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
+import {
+ BadRequestException,
+ ConflictException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
import { Vehicle, VehicleAvailability, VehicleStatus } from './entities/vehicle.entity';
+import { TruckType } from '../truck-types/entities/truck-type.entity';
+import { TruckTypesService } from '../truck-types/truck-types.service';
import { FirstMile, FirstMileStatus } from '../first-mile/entities/first-mile.entity';
import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity';
import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity';
@@ -18,8 +25,26 @@ export class VehiclesService {
@InjectRepository(Vehicle)
private readonly vehicleRepo: Repository,
private readonly history: FleetHistoryService,
+ private readonly truckTypes: TruckTypesService,
) {}
+ /**
+ * A trailer plate only exists on a configuration that pulls a trailer — a
+ * rigid truck (Casoni) has none. Checked against the RESULTING record, not
+ * just the patch, so switching an articulated truck to a rigid type cannot
+ * leave its old trailer plate stranded on the row.
+ */
+ private assertTrailerPlateAllowed(
+ truckType: TruckType,
+ trailerPlateNo?: string | null,
+ ): void {
+ if (!truckType.hasTrailer && trailerPlateNo) {
+ throw new BadRequestException(
+ `${truckType.name} has no trailer — remove the trailer plate number`,
+ );
+ }
+ }
+
/**
* A driver holds one truck at a time — reassignment requires detaching them
* from their current truck first.
@@ -54,10 +79,17 @@ export class VehiclesService {
await this.assertDriverUnassigned(dto.assignedDriverId);
}
- const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
+ const truckType = await this.truckTypes.findById(dto.truckTypeId);
+ this.assertTrailerPlateAllowed(truckType, dto.trailerPlateNo);
+
+ const registrationNumber = `REG-${truckType.code}-${Date.now()}`;
const vehicle = this.vehicleRepo.create({
...dto,
registrationNumber,
+ // Denormalised for truck-detention billing, which groups on this column.
+ vehicleType: truckType.code,
+ // Capacity belongs to the type; an explicit value still wins for one-offs.
+ capacity: dto.capacity ?? truckType.capacityTons ?? undefined,
});
const saved = await this.vehicleRepo.save(vehicle);
@@ -148,6 +180,17 @@ export class VehiclesService {
await this.assertDriverUnassigned(dto.assignedDriverId, id);
}
+ // Re-resolve the truck type whenever the type OR the trailer plate moves —
+ // either edit can produce a rigid truck holding a trailer plate.
+ const nextTruckTypeId = dto.truckTypeId ?? vehicle.truckTypeId;
+ let nextTruckType: TruckType | null = null;
+ if (nextTruckTypeId && (dto.truckTypeId !== undefined || dto.trailerPlateNo !== undefined)) {
+ nextTruckType = await this.truckTypes.findById(nextTruckTypeId);
+ const nextTrailerPlate =
+ dto.trailerPlateNo !== undefined ? dto.trailerPlateNo : vehicle.trailerPlateNo;
+ this.assertTrailerPlateAllowed(nextTruckType, nextTrailerPlate);
+ }
+
const prev = {
assignedDriverId: vehicle.assignedDriverId,
assignedDriverName: vehicle.assignedDriverName,
@@ -156,6 +199,11 @@ export class VehiclesService {
};
Object.assign(vehicle, dto);
+ // After the patch is applied, so the denormalised billing code always
+ // reflects the type the vehicle actually ends up on.
+ if (nextTruckType) {
+ vehicle.vehicleType = nextTruckType.code;
+ }
const saved = await this.vehicleRepo.save(vehicle);
// Driver (re)assignment — emit an unassign for the old driver and/or an
diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.trailer-plate-guard.spec.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.trailer-plate-guard.spec.ts
new file mode 100644
index 000000000..e8f73a38f
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.trailer-plate-guard.spec.ts
@@ -0,0 +1,81 @@
+import { BadRequestException } from '@nestjs/common';
+
+import { VehiclesService } from './vehicles.service';
+
+// A trailer plate only exists on a configuration that pulls a trailer. A rigid
+// truck (Casoni) has none, so registering or editing one into a trailer plate
+// must be refused server-side — the form hiding the field is not enforcement.
+describe('VehiclesService trailer plate guard', () => {
+ const CASONI = { code: 'CASONI', name: 'Casoni (rigid, no trailer)', hasTrailer: false, capacityTons: 30 };
+ const ARTIC = { code: 'TRUCK', name: 'Truck', hasTrailer: true, capacityTons: 40 };
+
+ const makeService = (findOne: jest.Mock, truckType: unknown) => {
+ const save = jest.fn(async (x) => x);
+ const svc = new VehiclesService(
+ { findOne, create: jest.fn((x) => x), save } as any,
+ { record: jest.fn() } as any,
+ { findById: jest.fn(async () => truckType) } as any,
+ );
+ return { svc, save };
+ };
+
+ it('rejects creating a rigid truck that carries a trailer plate', async () => {
+ const findOne = jest.fn().mockResolvedValueOnce(null); // plate is free
+ const { svc } = makeService(findOne, CASONI);
+ await expect(
+ svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-casoni', trailerPlateNo: 'ET-1234' } as any),
+ ).rejects.toThrow(BadRequestException);
+ });
+
+ it('accepts a rigid truck with no trailer plate, and takes capacity from the type', async () => {
+ const findOne = jest.fn().mockResolvedValueOnce(null);
+ const { svc } = makeService(findOne, CASONI);
+ const saved = await svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-casoni' } as any);
+ expect(saved.capacity).toBe(30);
+ // Denormalised code is what truck-detention billing groups on.
+ expect(saved.vehicleType).toBe('CASONI');
+ });
+
+ it('keeps an explicit capacity over the type default', async () => {
+ const findOne = jest.fn().mockResolvedValueOnce(null);
+ const { svc } = makeService(findOne, CASONI);
+ const saved = await svc.create({
+ plateNumber: 'ET-9875',
+ truckTypeId: 'tt-casoni',
+ capacity: 25,
+ } as any);
+ expect(saved.capacity).toBe(25);
+ });
+
+ it('allows a trailer plate on an articulated type', async () => {
+ const findOne = jest.fn().mockResolvedValueOnce(null);
+ const { svc } = makeService(findOne, ARTIC);
+ await expect(
+ svc.create({ plateNumber: 'ET-9875', truckTypeId: 'tt-truck', trailerPlateNo: 'ET-1234' } as any),
+ ).resolves.toBeDefined();
+ });
+
+ // The regression that motivated validating the RESULT rather than the patch:
+ // switching type alone leaves the stored trailer plate behind.
+ it('rejects switching an existing truck to a rigid type while its trailer plate stands', async () => {
+ const findOne = jest
+ .fn()
+ .mockResolvedValueOnce({ id: 'v1', plateNumber: 'ET-9875', trailerPlateNo: 'ET-1234' });
+ const { svc } = makeService(findOne, CASONI);
+ await expect(svc.update('v1', { truckTypeId: 'tt-casoni' } as any)).rejects.toThrow(
+ BadRequestException,
+ );
+ });
+
+ it('allows the switch when the trailer plate is cleared in the same edit', async () => {
+ const findOne = jest
+ .fn()
+ .mockResolvedValueOnce({ id: 'v1', plateNumber: 'ET-9875', trailerPlateNo: 'ET-1234' });
+ const { svc } = makeService(findOne, CASONI);
+ const saved = await svc.update('v1', {
+ truckTypeId: 'tt-casoni',
+ trailerPlateNo: null,
+ } as any);
+ expect(saved.vehicleType).toBe('CASONI');
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts
index 8f6417220..fca35c124 100644
--- a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts
+++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.controller.ts
@@ -13,7 +13,7 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
-import { RuleEngineManage, RuleEngineView } from '../../common/rule-engine-guards';
+import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../common/rule-engine-guards';
import { CreateWagonTypeDto } from './dto/create-wagon-type.dto';
import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
@@ -51,21 +51,21 @@ export class WagonTypesController {
}
@Post()
- @RuleEngineManage('wagon-types')
+ @RuleEngineCreate('wagon-types')
@ApiOperation({ summary: 'Create a wagon type' })
create(@Body() dto: CreateWagonTypeDto) {
return this.wagonTypesService.create(dto);
}
@Patch(':id')
- @RuleEngineManage('wagon-types')
+ @RuleEngineUpdate('wagon-types')
@ApiOperation({ summary: 'Update a wagon type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonTypeDto) {
return this.wagonTypesService.update(id, dto);
}
@Delete(':id')
- @RuleEngineManage('wagon-types')
+ @RuleEngineDelete('wagon-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a wagon type' })
remove(@Param('id', ParseUUIDPipe) id: string) {
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts
index 3b61d7ff0..a60261c1e 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
@@ -791,15 +791,22 @@ 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.
+ // Group the leg's vehicles by CANONICAL truck type so each type is billed
+ // by its own matching rule (rates differ by truck type). The FK to
+ // truck_types is the source of truth — renaming a type's label no longer
+ // silently unmatches its rule; the normalized legacy vehicle_type code is
+ // only a fallback for vehicles without the FK (LEFT JOIN keeps them billed
+ // instead of dropping them). 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"
+ `SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(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
+ 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
- GROUP BY v.vehicle_type`,
+ GROUP BY 1`,
[lastMileId],
);
const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }];
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts
index 7bd56e593..cdf34cd66 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts
@@ -20,8 +20,13 @@ import { WarehouseInspectionService } from './warehouse-inspection.service';
@ApiTags('warehouse-inspection')
@ApiBearerAuth()
+// Baseline read: inspection reports are opened from inventory screens too —
+// either view permission grants reads; writes stack their own per route.
@Controller()
-@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.view)
+@BookingStaff([
+ FREIGHT_PERMS.warehouseInspectionReports.view,
+ FREIGHT_PERMS.warehouseInventory.view,
+])
export class WarehouseInspectionController {
constructor(private readonly inspectionService: WarehouseInspectionService) {}
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
index 0174ef3e9..b3868b465 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';
@@ -456,6 +456,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 +467,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 +483,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 +506,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 +524,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 +535,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,
@@ -545,18 +553,21 @@ export class WarehouseInventoryController {
}
@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-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-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts
index b0371cbcc..594fd7a6f 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts
@@ -8,8 +8,11 @@ import { WarehouseZonesService } from './warehouse-zones.service';
@ApiTags('warehouse-zones')
@ApiBearerAuth()
+// Baseline read: zone reference data also serves inventory flows (allocation,
+// receive/move pickers) — either view permission grants reads; writes stack
+// their specific permission per route.
@Controller('warehouse-zones')
-@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
+@BookingStaff([FREIGHT_PERMS.warehouseZones.view, FREIGHT_PERMS.warehouseInventory.view])
export class WarehouseZonesController {
constructor(private readonly zonesService: WarehouseZonesService) {}
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts
index 63c40de94..3ee381a8c 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts
@@ -13,8 +13,15 @@ import { WarehousesService } from './warehouses.service';
@ApiTags('warehouses')
@ApiBearerAuth()
+// Baseline read: warehouse reference data is consumed by inventory/dashboard
+// flows too, so any of the three view permissions grants reads. Writes stack
+// their specific create/update permission per route on top.
@Controller('warehouses')
-@BookingStaff(FREIGHT_PERMS.warehouses.view)
+@BookingStaff([
+ FREIGHT_PERMS.warehouses.view,
+ FREIGHT_PERMS.warehouseInventory.view,
+ FREIGHT_PERMS.warehouseDashboard.view,
+])
export class WarehousesController {
constructor(
private readonly warehousesService: WarehousesService,
diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts
index 03908b62b..fa74f9043 100644
--- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts
+++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts
@@ -582,6 +582,19 @@ const INTERCITY_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
},
];
+// ── Hazardous cargo documents ───────────────────────────────────────────────
+// Asked for in the contract wizard the moment the customer flags the cargo as
+// hazardous (ONE_TIME contracts only). Fields start empty and are configured in
+// the backoffice file-settings editor.
+const HAZARDOUS_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
+ {
+ code: "hazardous_documents",
+ label: "Hazardous cargo documents",
+ entity: CONTRACT_INTAKE_ENTITY,
+ fields: [],
+ },
+];
+
@Injectable()
export class FileUploadSettingsSeeder {
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
@@ -633,6 +646,11 @@ export class FileUploadSettingsSeeder {
description:
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
})),
+ ...HAZARDOUS_DOCUMENT_SETTINGS.map((s) => ({
+ ...s,
+ description:
+ "Documents required when a one-time contract's cargo is flagged hazardous.",
+ })),
...INTERCITY_DOCUMENT_SETTINGS.map((s) => ({
...s,
description:
diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
index 9871f6980..537bac332 100644
--- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
+++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
@@ -19,6 +19,9 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
'rates',
'approval-rules',
'yard-distances',
+ // Keep new slugs at the END: ruleEngineCrudId derives ids from list index,
+ // so a mid-list insert would shift ids already seeded for later slugs.
+ 'truck-types',
] as const;
export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number];
@@ -57,7 +60,6 @@ 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'),
@@ -83,7 +85,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'),
@@ -93,24 +98,43 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
perm('a3000001-0001-4000-8000-000000000010', 'edr_freight_app:contracts:clearance_duty_advise', 'Advise contract duty/tax'),
];
-const RULE_ENGINE_PERMISSION_IDS: Record = {
- 'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' },
- 'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' },
- 'wagon-types': { view: 'b2000001-0001-4000-8000-000000000015', manage: 'b2000001-0001-4000-8000-000000000016' },
- 'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' },
- yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' },
- 'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },
- 'weight-limit-rules': { view: 'b2000001-0001-4000-8000-00000000000b', manage: 'b2000001-0001-4000-8000-00000000000c' },
- 'priority-configs': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' },
- rates: { view: 'b2000001-0001-4000-8000-000000000011', manage: 'b2000001-0001-4000-8000-000000000012' },
- 'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' },
- 'yard-distances': { view: 'b2000001-0001-4000-8000-000000000018', manage: 'b2000001-0001-4000-8000-000000000019' },
+// Existing per-slug view ids are kept as-is: position-type grants reference
+// them by id, so re-minting would orphan those rows.
+const RULE_ENGINE_VIEW_IDS: Record = {
+ 'cargo-types': 'b2000001-0001-4000-8000-000000000001',
+ 'container-types': 'b2000001-0001-4000-8000-000000000003',
+ 'wagon-types': 'b2000001-0001-4000-8000-000000000015',
+ 'truck-types': 'b2000001-0001-4000-8000-00000000001a',
+ 'service-types': 'b2000001-0001-4000-8000-000000000005',
+ yards: 'b2000001-0001-4000-8000-000000000007',
+ 'shipping-lines': 'b2000001-0001-4000-8000-000000000009',
+ 'weight-limit-rules': 'b2000001-0001-4000-8000-00000000000b',
+ 'priority-configs': 'b2000001-0001-4000-8000-00000000000f',
+ rates: 'b2000001-0001-4000-8000-000000000011',
+ 'approval-rules': 'b2000001-0001-4000-8000-000000000013',
+ 'yard-distances': 'b2000001-0001-4000-8000-000000000018',
+};
+
+// CRUD replaces the retired coarse `:manage`. New ids live in a fresh block
+// (b2000002-…) so a stale `:manage` grant can never silently confer a CRUD
+// action — the migration re-grants create/update/delete explicitly.
+const RULE_ENGINE_CRUD_ACTIONS = ['create', 'update', 'delete'] as const;
+type RuleEngineCrudAction = (typeof RULE_ENGINE_CRUD_ACTIONS)[number];
+const ruleEngineCrudId = (
+ slug: RuleEngineResourceSlug,
+ action: RuleEngineCrudAction,
+): string => {
+ const n =
+ RULE_ENGINE_RESOURCE_SLUGS.indexOf(slug) * 3 +
+ RULE_ENGINE_CRUD_ACTIONS.indexOf(action) +
+ 1; // 1..36
+ return `b2000002-0001-4000-8000-${n.toString(16).padStart(12, '0')}`;
};
/**
- * Slugs whose changes go through a separate approver. `manage` lets a staff
- * member propose a change; only `approve` lets someone put it into effect.
- * Only listed slugs get the permission — the rest are manage-only.
+ * Slugs whose changes go through a separate approver. CRUD lets a staff member
+ * propose a change; only `approve` lets someone put it into effect. Only listed
+ * slugs get the permission.
*/
const RULE_ENGINE_APPROVE_PERMISSION_IDS: Partial> = {
rates: 'b2000001-0001-4000-8000-000000000017',
@@ -121,11 +145,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`)]
: []),
@@ -395,7 +420,10 @@ export const FREIGHT_PERMS = {
approveDirector: 'edr_freight_app:contracts:approve_director',
approveCeo: 'edr_freight_app:contracts:approve_ceo',
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',
@@ -406,7 +434,6 @@ export const FREIGHT_PERMS = {
},
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 +448,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`,
},
@@ -751,7 +782,11 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.operations,
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 +871,7 @@ 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),
],
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
} as const;
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 && (
+ }
+ onClick={onReset}
+ >
+ Clear
+
+ )}
+
+);
+
+export default ListControls;
diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx
index 5d1247358..f0ccf0be0 100644
--- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx
@@ -126,6 +126,28 @@ const FleetFormDialog = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, recordId]);
+ // Seed the `_`-prefixed scratch that `onOptionSelected` derives (e.g.
+ // _hasTrailer) for the value already on the record. Without this, editing a
+ // rigid truck would show a Trailer Plate field until the type is re-picked.
+ // Only scratch keys are written, so a stored one-off capacity is never
+ // clobbered by the type's default; re-deriving from the live value is
+ // idempotent, so this is safe to run again when the options finally load.
+ useEffect(() => {
+ if (!open) return;
+ setValues((current) => {
+ const scratch: Record = {};
+ fields.forEach((field) => {
+ if (!field.onOptionSelected) return;
+ const selected = field.options?.find((o) => o.value === current[field.name]);
+ if (!selected) return;
+ Object.entries(field.onOptionSelected(selected, current)).forEach(([key, value]) => {
+ if (key.startsWith("_")) scratch[key] = value;
+ });
+ });
+ return Object.keys(scratch).length ? { ...current, ...scratch } : current;
+ });
+ }, [open, fields]);
+
// Receive the ?code&state relayed by the /callback popup, exchange it for
// the verified identity, and prefill the matching form fields.
useEffect(() => {
@@ -202,18 +224,52 @@ const FleetFormDialog = ({
const faydaVerified = values.faydaVerified === true;
+ /**
+ * Fields the current answers actually apply to — a rigid truck type (Casoni)
+ * has no trailer, so its plate field disappears. Honoured in three places, not
+ * just here: a hidden field must also skip validation (an invisible "required"
+ * error blocks submit with nothing to fix) and must submit an explicit null
+ * (so switching to a rigid type CLEARS the stored trailer plate rather than
+ * stranding it on the row).
+ */
+ const visibleFields = useMemo(
+ () =>
+ fields.filter((field) => {
+ if (
+ field.hideWhen &&
+ field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? ""))
+ ) {
+ return false;
+ }
+ if (
+ field.showWhen &&
+ !field.showWhen.equals.includes(String(values[field.showWhen.field] ?? ""))
+ ) {
+ return false;
+ }
+ if (field.showIf && !field.showIf(values)) return false;
+ return true;
+ }),
+ [fields, values],
+ );
+
+ const hiddenFieldNames = useMemo(() => {
+ const visible = new Set(visibleFields.map((f) => f.name));
+ return fields.filter((f) => !visible.has(f.name)).map((f) => f.name);
+ }, [fields, visibleFields]);
+
const shortFields = useMemo(
- () => fields.filter((f) => f.type !== "textarea"),
- [fields],
+ () => visibleFields.filter((f) => f.type !== "textarea"),
+ [visibleFields],
);
const longFields = useMemo(
- () => fields.filter((f) => f.type === "textarea"),
- [fields],
+ () => visibleFields.filter((f) => f.type === "textarea"),
+ [visibleFields],
);
const validate = () => {
const next: Record = {};
- fields.forEach((field) => {
+ visibleFields.forEach((field) => {
const value = values[field.name];
const stringValue =
typeof value === "string" ? value.trim() : String(value ?? "");
@@ -295,9 +351,19 @@ const FleetFormDialog = ({
fields.forEach((field) => {
if (field.derivedValue) submitted[field.name] = field.derivedValue(values);
});
+ // A field the answers hid no longer applies to this record — send an explicit
+ // null so the column is unset, instead of leaving a stale value behind.
+ hiddenFieldNames.forEach((name) => {
+ submitted[name] = null;
+ });
const payload = Object.fromEntries(
Object.entries(submitted)
+ // `_`-prefixed keys are form-local scratch written by `onOptionSelected`
+ // (e.g. _hasTrailer, which drives visibility). The API validates with
+ // forbidNonWhitelisted, so an undeclared key would 400 the whole save.
+ .filter(([key]) => !key.startsWith("_"))
.map(([key, value]) => {
+ if (hiddenFieldNames.includes(key)) return [key, null];
if (value === FLEET_SELECT_NONE || value === "" || value == null)
return [key, clearableByName[key] ? null : undefined];
if (fieldTypeByName[key] === "number") {
@@ -371,7 +437,15 @@ const FleetFormDialog = ({
: String(value)
}
onChange={(next) =>
- setValues((current) => ({ ...current, [field.name]: next ?? "" }))
+ setValues((current) => {
+ const patch = field.onOptionSelected
+ ? field.onOptionSelected(
+ field.options?.find((o) => o.value === next),
+ current,
+ )
+ : {};
+ return { ...current, [field.name]: next ?? "", ...patch };
+ })
}
error={error}
searchable
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx
new file mode 100644
index 000000000..0e1db09be
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx
@@ -0,0 +1,403 @@
+import {
+ Alert,
+ Badge,
+ Button,
+ Divider,
+ Group,
+ Loader,
+ Modal,
+ Stack,
+ Table,
+ Text,
+ ThemeIcon,
+ Tooltip,
+} from "@mantine/core";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import {
+ CheckCircle2,
+ Flag,
+ MapPin,
+ PackageCheck,
+ TrainFront,
+} from "lucide-react";
+import { useEffect, useState } from "react";
+import { Freight } from "@edr/types";
+
+import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
+import { useToast } from "@/hooks/use-toast";
+import { api } from "@/services/api";
+import type { TrackStation, YardWorkBookingRow } from "@/types/trainScheduling";
+
+const parseError = (error: unknown, fallback: string) => {
+ const message = (error as { response?: { data?: { message?: string | string[] } } })
+ ?.response?.data?.message;
+ if (Array.isArray(message)) return message.join("; ");
+ return message || (error as Error)?.message || fallback;
+};
+
+const fmtDate = (iso: string) => {
+ const d = new Date(iso);
+ return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
+};
+
+const DIRECTION_COLORS: Record = {
+ IMPORT: "blue",
+ EXPORT: "teal",
+ DOMESTIC: "violet",
+};
+
+/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */
+const DIRECTION_LABELS: Record = Freight.TRADE_DIRECTION_LABELS;
+
+function DirectionChip({ direction }: { direction: string }) {
+ return (
+
+ {DIRECTION_LABELS[direction] ?? direction}
+
+ );
+}
+
+function SectionLabel({
+ icon,
+ title,
+ count,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ count: number;
+}) {
+ return (
+
+
+ {icon}
+
+
+ {title}
+
+
+ {count}
+
+
+ );
+}
+
+/**
+ * Yard-work modal for the track page's "Log pass" step.
+ *
+ * A train runs A→B→C→D and bookings board/alight at any stop, so logging the
+ * pass at a yard is the moment its yard work happens: bookings destined here
+ * flip to ARRIVED (import/export) or COMPLETED (intercity) automatically the
+ * instant the pass is logged, and bookings boarding here become loadable —
+ * the server only accepts a load while the train's latest checkpoint is this
+ * yard. The modal therefore drives the sequence: log the pass first, then
+ * load anything that boards here (including cargo the operator forgot — it
+ * stays loadable until the next pass is logged).
+ */
+export function LogPassYardWorkModal({
+ opened,
+ onClose,
+ scheduleId,
+ station,
+ isFinal,
+ alreadyLogged,
+}: {
+ opened: boolean;
+ onClose: () => void;
+ scheduleId: string;
+ station: TrackStation | null;
+ isFinal: boolean;
+ /** True when opened for the current station (pass already logged). */
+ alreadyLogged: boolean;
+}) {
+ const { toast } = useToast();
+ const [justLogged, setJustLogged] = useState(false);
+ useEffect(() => setJustLogged(false), [station?.sequenceNo, opened]);
+ const logged = alreadyLogged || justLogged;
+
+ const yardWorkQuery = useQuery(
+ api.trainScheduling.yardWork.queryOptions({
+ input: { scheduleId },
+ enabled: opened && Boolean(scheduleId),
+ }),
+ );
+ const recordCheckpoint = useMutation(
+ api.trainScheduling.recordCheckpoint.mutationOptions(),
+ );
+ const load = useMutation(api.trainScheduling.loadScheduleBooking.mutationOptions());
+
+ const yard = yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId);
+ const boarders: YardWorkBookingRow[] = yard?.toLoad ?? [];
+ const arrivals: YardWorkBookingRow[] = yard?.toUnload ?? [];
+ const pendingBoarders = boarders.filter((r) => !r.loadedAt);
+
+ const doLogPass = () => {
+ if (!station) return;
+ recordCheckpoint.mutate(
+ { id: scheduleId, payload: { sequenceNo: station.sequenceNo } },
+ {
+ onSuccess: () => {
+ setJustLogged(true);
+ toast({
+ title: isFinal
+ ? "Train arrived — remaining bookings marked arrived, assets freed"
+ : `Pass logged at ${station.label}`,
+ description: isFinal
+ ? undefined
+ : arrivals.some((r) => r.canUnload)
+ ? "Bookings arriving here have been marked arrived."
+ : undefined,
+ });
+ void yardWorkQuery.refetch();
+ },
+ onError: (err) =>
+ toast({
+ title: "Could not log checkpoint",
+ description: parseError(err, "Please try again"),
+ variant: "destructive",
+ }),
+ },
+ );
+ };
+
+ const doLoad = (row: YardWorkBookingRow) => {
+ load.mutate(
+ { scheduleId, bookingId: row.id },
+ {
+ onSuccess: () => {
+ toast({
+ title: `${row.reference ?? "Booking"} loaded`,
+ description: `Cargo boarded the train at ${station?.label ?? "this yard"}.`,
+ });
+ void yardWorkQuery.refetch();
+ },
+ onError: (err) =>
+ toast({
+ title: "Could not load booking",
+ description: parseError(err, "Please try again"),
+ variant: "destructive",
+ }),
+ },
+ );
+ };
+
+ const hasWork = boarders.length > 0 || arrivals.length > 0;
+
+ return (
+
+ {isFinal ? : }
+
+ {isFinal ? "Arrival" : "Yard work"} — {station?.label ?? ""}
+
+ {logged ? (
+
+ {isFinal ? "Arrived" : "Pass logged"}
+
+ ) : null}
+
+ }
+ >
+
+ {yardWorkQuery.isLoading ? (
+
+
+
+ ) : !hasWork ? (
+ }>
+ No bookings board or alight at this station.
+
+ ) : (
+ <>
+ {/* ── Arriving here ─────────────────────────────────────────── */}
+ {arrivals.length > 0 ? (
+
+ }
+ title="Arriving at this yard"
+ count={arrivals.length}
+ />
+ {!logged ? (
+
+ Logging the pass marks the loaded bookings below as Arrived
+ (import/export) or Completed (intercity) automatically.
+
+ ) : null}
+
+