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/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
index 985123fd3..b2bfc2842 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
@@ -5635,9 +5635,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`);
@@ -5654,6 +5657,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/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-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx
index 6a53be5cc..8832279ad 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/LocationPicker.tsx
@@ -257,6 +257,20 @@ function usePlacesSearch(): PlacesSearch | null {
}, [placesLib]);
}
+/**
+ * Coerce a coordinate to a finite number or null. Numeric-typed API fields
+ * (e.g. contract/booking `..Lat`/`..Lng`) come back from Postgres `numeric`
+ * columns as strings — the TS type says `number` but the value crossing the
+ * network is not. Google Maps' `panTo`/`Marker` throw on anything that isn't
+ * a real finite number, so every coordinate is normalised at this one shared
+ * entry point rather than trusting each caller to have converted it.
+ */
+function toFiniteNumber(v: number | string | null | undefined): number | null {
+ if (v == null) return null;
+ const n = typeof v === "number" ? v : Number(v);
+ return Number.isFinite(n) ? n : null;
+}
+
/** Recenters the map imperatively when the pinned coordinate changes. */
function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) {
const map = useMap();
@@ -290,12 +304,17 @@ export interface LocationPickerProps {
* Reports the resolved address and coordinates up via `onChange`.
*/
export function LocationPicker(props: LocationPickerProps) {
+ const value: LocationValue = {
+ ...props.value,
+ lat: toFiniteNumber(props.value.lat),
+ lng: toFiniteNumber(props.value.lng),
+ };
return (
{props.variant === "modal" ? (
-
+
) : (
-
+
)}
);
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx
index b77f9d36b..64653abe3 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx
@@ -42,21 +42,27 @@ export function Step2ServiceType({
const lastMileEnabled = form.watch("lastMile.enabled");
const prevServiceType = useRef(serviceType);
+ // Only clear a mile when the current service doesn't include it — this ran
+ // unconditionally before, so it wiped an already-entered (or prefilled, or
+ // draft-restored) pickup/delivery address on every service change, even one
+ // that still includes that mile.
useEffect(() => {
+ if (includesFirstMile) return;
form.setValue(
"firstMile",
{ enabled: false, pickUpAddress: "", lat: null, lng: null },
{ shouldValidate: true },
);
- }, [includesFirstMile]);
+ }, [includesFirstMile, form]);
useEffect(() => {
+ if (includesLastMile) return;
form.setValue(
"lastMile",
{ enabled: false, deliveryAddress: "", lat: null, lng: null },
{ shouldValidate: true },
);
- }, [includesLastMile]);
+ }, [includesLastMile, form]);
useEffect(() => {
const prev = prevServiceType.current;
@@ -82,6 +88,10 @@ export function Step2ServiceType({
);
useEffect(() => {
+ // Skip right after a hydration `form.reset()` (draft restore / renewal
+ // prefill) — a restored selection is trusted as-is until the customer
+ // actually changes something live.
+ if (!form.formState.isDirty) return;
const currentId = form.getValues("serviceTypeId");
if (!currentId) return;
if (!bookableServices.some((s) => s.id === currentId)) {
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
index b9564bb66..34807faf5 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
@@ -100,12 +100,17 @@ export function Step4Route({
// here — e.g. switching import→export flips which end must be in Ethiopia.
// Clear any selection that no longer matches the operation's required country
// so the customer can't submit a route that contradicts the operation.
+ // Skipped right after a hydration `form.reset()` (draft restore / renewal
+ // prefill) — a restored yard is trusted as-is until the customer changes
+ // something live; `isDirty` is false only in that pristine post-reset render.
useEffect(() => {
+ if (!form.formState.isDirty) return;
if (originCountry && origin && origin.country !== originCountry) {
form.setValue("originYard", "");
}
}, [originCountry, origin, form]);
useEffect(() => {
+ if (!form.formState.isDirty) return;
if (destinationCountry && dest && dest.country !== destinationCountry) {
form.setValue("destinationYard", "");
}
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx
index f5dff9be0..44f8063cb 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx
@@ -335,6 +335,11 @@ export function Step2ServiceType({
);
useEffect(() => {
+ // Skip right after a hydration `form.reset()` (edit mode) — a saved
+ // contract's service is trusted as-is until the customer actually
+ // changes something live; `isDirty` is false only in that pristine
+ // post-hydration render, so this never clears a value nobody touched.
+ if (!form.formState.isDirty) return;
const currentId = form.getValues("serviceTypeId");
if (!currentId) return;
if (!standaloneServices.some((s) => s.id === currentId)) {
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx
index 68b0d79c9..f89a0008d 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx
@@ -1,23 +1,44 @@
-import { useEffect, useMemo, useRef } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
-import { Container, Flame, RotateCcw, Snowflake } from "lucide-react";
+// Snowflake — restore with the Refrigerated Cargo switch below.
+import { Container, Flame, RotateCcw } from "lucide-react";
import {
Box,
+ Button,
Group,
+ Loader,
+ Modal,
Select,
Skeleton,
Stack,
Switch,
Text,
} from "@mantine/core";
+import { useQuery } from "@tanstack/react-query";
+import { SmartFileInput } from "@edr/ui-common";
import type { Freight } from "@edr/types";
+import { api } from "@/services/api";
import {
CONTAINER_SIZES,
ContractFormInputValues,
+ type ContractDocuments,
type ContractFormValues,
} from "./schema";
+import { operationToTradeDirection } from "./helpers";
import { fieldStyles, SelectField, StepLabel } from "./shared";
+/**
+ * file_upload_settings code holding the hazardous-cargo document requirements.
+ * Fields are configured by an admin in the backoffice file-settings editor —
+ * whatever is configured there is what the modal asks for.
+ */
+const HAZARDOUS_DOC_SETTING_CODE = "hazardous_documents";
+
+function hasUploaded(value: File | File[] | null | undefined): boolean {
+ if (!value) return false;
+ return Array.isArray(value) ? value.length > 0 : true;
+}
+
const CARGO_TYPE_OPTIONS = [
{ value: "container", label: "Containerized (20ft / 40ft)" },
{ value: "bulk", label: "General / Bulk cargo" },
@@ -51,6 +72,101 @@ export function Step3CargoScope({
const cargoTypePath = form.watch("cargoTypePath") ?? [];
const parentId = cargoTypePath[0];
+ // Hazardous cargo is a ONE-TIME-contract-only option; refrigerated cargo and
+ // empty-container return are offered on IMPORT operations only.
+ const operationType = form.watch("operationType");
+ const isOneTime = (form.watch("contractKind") ?? "one_time") === "one_time";
+ const isImport = operationType
+ ? operationToTradeDirection(operationType) === "IMPORT"
+ : false;
+
+ const hazardSettingQuery = useQuery(
+ api.fileUploadSettings.getByCode.queryOptions({
+ input: { code: HAZARDOUS_DOC_SETTING_CODE },
+ enabled: isOneTime,
+ }),
+ );
+ const hazardFields = hazardSettingQuery.data?.fields ?? [];
+ const hazardKeys = useMemo(
+ () => hazardFields.map((f) => f.fileKey),
+ [hazardFields],
+ );
+
+ const [hazardModalOpen, setHazardModalOpen] = useState(false);
+ const [hazardDraft, setHazardDraft] = useState({});
+ const [hazardErrors, setHazardErrors] = useState>({});
+
+ /** Drop every hazardous document from the contract's document map. */
+ const clearHazardDocs = () => {
+ const docs = { ...((form.getValues("documents") ?? {}) as ContractDocuments) };
+ let changed = false;
+ for (const key of hazardKeys) {
+ if (key in docs) {
+ delete docs[key];
+ changed = true;
+ }
+ }
+ if (changed) form.setValue("documents", docs, { shouldDirty: true });
+ };
+
+ const openHazardModal = () => {
+ const docs = (form.getValues("documents") ?? {}) as ContractDocuments;
+ setHazardDraft(
+ Object.fromEntries(
+ hazardKeys.filter((k) => k in docs).map((k) => [k, docs[k]]),
+ ),
+ );
+ setHazardErrors({});
+ setHazardModalOpen(true);
+ };
+
+ const confirmHazardDocs = () => {
+ const missing = hazardFields.filter(
+ (f) => f.isRequired && !hasUploaded(hazardDraft[f.fileKey]),
+ );
+ if (missing.length > 0) {
+ setHazardErrors(
+ Object.fromEntries(
+ missing.map((f) => [f.fileKey, `${f.fileLabel} is required.`]),
+ ),
+ );
+ return;
+ }
+ form.setValue(
+ "documents",
+ {
+ ...((form.getValues("documents") ?? {}) as ContractDocuments),
+ ...hazardDraft,
+ },
+ { shouldDirty: true },
+ );
+ form.setValue("isHazardous", true, { shouldDirty: true });
+ setHazardModalOpen(false);
+ };
+
+ // A hidden flag must never leak into the payload: a general contract can't be
+ // hazardous, and a non-import contract carries neither reefer nor empty return.
+ useEffect(() => {
+ if (isOneTime) return;
+ if (form.getValues("isHazardous")) {
+ form.setValue("isHazardous", false, { shouldDirty: true });
+ }
+ // Runs again once the hazard field list loads — a no-op when nothing matches.
+ clearHazardDocs();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isOneTime, hazardKeys, form]);
+
+ useEffect(() => {
+ // Reefer has no switch right now, so it must never leave the form true.
+ if (form.getValues("isRefrigerated")) {
+ form.setValue("isRefrigerated", false, { shouldDirty: true });
+ }
+ if (isImport) return;
+ if (form.getValues("equipmentReturn") === "with_return") {
+ form.setValue("equipmentReturn", "without_return", { shouldDirty: true });
+ }
+ }, [isImport, form]);
+
// Reset the commodity child only when the parent group really changes.
const prevParentIdRef = useRef(parentId);
useEffect(() => {
@@ -233,40 +349,55 @@ export function Step3CargoScope({
Cargo handling
- (
- }
- iconBg="#FBEAE7"
- iconColor="#C0392B"
- title="Hazardous Material"
- description="Applies a hazard surcharge as a per-container unit rate."
- checked={field.value ?? false}
- onChange={(v) => field.onChange(v)}
- />
- )}
- />
- (
- }
- iconBg="#E9F0F8"
- iconColor="#2E5B96"
- title="Refrigerated Cargo"
- description="Temperature-controlled transport applies a reefer surcharge."
- checked={field.value ?? false}
- onChange={(v) => field.onChange(v)}
- />
- )}
- />
- {/* Empty-container return is a container-only service. Like hazardous,
- enabling it here adds the return surcharge as a unit rate; at
- booking time the customer/GL sets how many containers return. */}
- {cargoType === "container" && (
+ {/* Hazardous cargo is only carried under a one-time contract, and
+ enabling it requires the configured hazard documents up front. */}
+ {isOneTime && (
+ (
+ }
+ iconBg="#FBEAE7"
+ iconColor="#C0392B"
+ title="Hazardous Material"
+ description="Applies a hazard surcharge as a per-container unit rate. Requires hazard documents."
+ checked={field.value ?? false}
+ onChange={(v) => {
+ if (v) {
+ openHazardModal();
+ return;
+ }
+ field.onChange(false);
+ clearHazardDocs();
+ }}
+ />
+ )}
+ />
+ )}
+ {/* Refrigerated cargo is hidden for now — import-only when re-enabled.
+ The effect above keeps isRefrigerated false while it's off.
+ {isImport && (
+ (
+ }
+ iconBg="#E9F0F8"
+ iconColor="#2E5B96"
+ title="Refrigerated Cargo"
+ description="Temperature-controlled transport applies a reefer surcharge."
+ checked={field.value ?? false}
+ onChange={(v) => field.onChange(v)}
+ />
+ )}
+ />
+ )} */}
+ {/* Empty-container return is a container-only IMPORT service. Like
+ hazardous, enabling it here adds the return surcharge as a unit
+ rate; at booking time the customer/GL sets how many return. */}
+ {isImport && cargoType === "container" && (
+
+ setHazardModalOpen(false)}
+ title="Hazardous cargo documents"
+ size="lg"
+ centered
+ radius={14}
+ >
+
+
+ Hazardous cargo can only move once the documents below are attached
+ to the contract.
+
+
+ {hazardSettingQuery.isLoading ? (
+
+
+
+ ) : hazardFields.length > 0 && hazardSettingQuery.data ? (
+ setHazardDraft(v)}
+ errors={hazardErrors}
+ />
+ ) : (
+
+ No hazardous document requirements are configured yet. You can
+ still flag the cargo as hazardous — EDR will request the paperwork
+ during review.
+
+ )}
+
+
+
+
+
+
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx
index 7b96676ef..756dd7fd9 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx
@@ -72,12 +72,17 @@ export function Step4Route({
const direction = getRouteDirection(origin, dest);
// Clear yards that no longer match the operation's required country.
+ // Skipped right after a hydration `form.reset()` (edit mode) — a saved
+ // contract's yards are trusted as-is until the customer changes something
+ // live; `isDirty` is false only in that pristine post-hydration render.
useEffect(() => {
+ if (!form.formState.isDirty) return;
if (originCountry && origin && origin.country !== originCountry) {
form.setValue("originYard", "");
}
}, [originCountry, origin, form]);
useEffect(() => {
+ if (!form.formState.isDirty) return;
if (destinationCountry && dest && dest.country !== destinationCountry) {
form.setValue("destinationYard", "");
}
diff --git a/e2e/freight/cypress/e2e/flows/booking_cancel.cy.ts b/e2e/freight/cypress/e2e/flows/booking_cancel.cy.ts
new file mode 100644
index 000000000..9eba43831
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/booking_cancel.cy.ts
@@ -0,0 +1,134 @@
+/**
+ * BOOKING — cancel lifecycle + the commit gate:
+ *
+ * `POST /bookings/:id/cancel` is only legal for a booking that has not yet
+ * been committed to a train (DRAFT … OPERATION_REQUEST_PENDING). This spec
+ * proves both sides end to end, which the unit tests never do:
+ *
+ * – an un-accepted booking (OPERATION_REQUEST_PENDING) cancels → CANCELLED,
+ * and its open payable invoice is expired (no dangling payable)
+ * – cancelling it again is rejected (status CANCELLED not allowed)
+ * – a PAID + allocated booking CANNOT be cancelled — the guard blocks it.
+ * (There is deliberately no cancel-after-allocation / refund path; a
+ * committed booking leaves only by EXPIRE, never customer cancel.)
+ *
+ * EXPORT bulk corridor (FCFS: accept reserves immediately, mark-paid allocates).
+ * Retries off — sequential steps of one journey.
+ */
+
+import {
+ acceptExport,
+ apiPost,
+ bookBulk,
+ createImportSchedule,
+ db,
+ departureAt,
+ eatDayStr,
+ ensureExportRoute,
+ EXP_DEST,
+ EXP_ORIGIN,
+ forceWindowOpen,
+ markPaid,
+ pollAllocations,
+ resetCorridorDay,
+ seedImportContract,
+ superAdmin,
+ withBooking,
+ withExportSchedule,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(6);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+const stamp = String(Date.now());
+
+function seedBulkExport(suffix: string) {
+ seedImportContract({
+ suffix,
+ reference: `CTR-IMP-${stamp}-${suffix}`,
+ freight: "BULK",
+ direction: "EXPORT",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+}
+
+/** Fire cancel with a reason; caller decides whether to fail on non-2xx. */
+function cancel(bookingId: string, failOnStatusCode = true) {
+ return apiPost(
+ superAdmin,
+ `/api/bookings/${bookingId}/cancel`,
+ { reason: `E2E cancel ${stamp}` },
+ failOnStatusCode,
+ );
+}
+
+describe("booking cancel: pre-commit only, blocked once allocated", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ ["CXA", "CXB"].forEach(seedBulkExport);
+ });
+
+ it("operations opens the export corridor + a D+6 CW4 train", () => {
+ ensureExportRoute();
+ resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
+ createImportSchedule({
+ departure: DEPARTURE,
+ locoPair: ["LOCO-EXP-1", "LOCO-EXP-2"],
+ kind: "bulk",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withExportSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
+ });
+
+ it("an un-accepted booking cancels, its invoice is expired, and re-cancel is rejected", () => {
+ bookBulk({ suffix: "CXA", tons: 700, scheduledDate: BOOKING_DAY });
+
+ // Fresh non-customs export booking sits at OPERATION_REQUEST_PENDING.
+ withBooking("CXA", (b) => {
+ expect(b.status, "pre-accept status").to.eq("OPERATION_REQUEST_PENDING");
+ cancel(b.id).then((res) => expect(res.status, "cancelled").to.be.oneOf([200, 201]));
+ });
+
+ withBooking("CXA", (b) => {
+ expect(b.status, "now CANCELLED").to.eq("CANCELLED");
+ // No open payable invoice may survive a cancel.
+ db<{ n: string }>(
+ `SELECT count(*) AS n FROM freight.invoices
+ WHERE source = 'booking' AND source_id = $1
+ AND status NOT IN ('EXPIRED','CANCELLED','PAID')
+ AND deleted_at IS NULL`,
+ [b.id],
+ ).then(({ rows }) => expect(Number(rows[0].n), "no open invoice left").to.eq(0));
+
+ // Cancelling a CANCELLED booking is rejected by the status guard.
+ cancel(b.id, false).then((res) => {
+ expect(res.status, "re-cancel rejected").to.be.within(400, 422);
+ expect(JSON.stringify(res.body)).to.include("Cannot perform this action");
+ expect(JSON.stringify(res.body)).to.include("CANCELLED");
+ });
+ });
+ });
+
+ it("a PAID + allocated booking cannot be cancelled — the commit gate blocks it", () => {
+ bookBulk({ suffix: "CXB", tons: 700, scheduledDate: BOOKING_DAY });
+ acceptExport("CXB");
+ markPaid("CXB");
+ pollAllocations("CXB", 10); // 700T / 70T = 10 CW4 wagons
+
+ withBooking("CXB", (b) => {
+ expect(b.status, "committed + paid").to.eq("PAID");
+ cancel(b.id, false).then((res) => {
+ expect(res.status, "cancel blocked after allocation").to.be.within(400, 422);
+ expect(JSON.stringify(res.body)).to.include("Cannot perform this action");
+ expect(JSON.stringify(res.body)).to.include("PAID");
+ });
+ });
+
+ // The block is real: the booking still rides, wagons still allocated.
+ withBooking("CXB", (b) => expect(b.status, "still PAID").to.eq("PAID"));
+ pollAllocations("CXB", 10);
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/booking_clearance_query.cy.ts b/e2e/freight/cypress/e2e/flows/booking_clearance_query.cy.ts
new file mode 100644
index 000000000..b920586b1
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/booking_clearance_query.cy.ts
@@ -0,0 +1,141 @@
+/**
+ * BOOKING — clearance document QUERY (reject) + re-upload lifecycle:
+ *
+ * Every other spec that touches clearance only ever APPROVES documents
+ * (`clearGeneralBooking` in import-utils.ts). This spec drives the other
+ * branch of `reviewDocument` — QUERIED — which no spec exercises:
+ *
+ * – querying a document with no note is rejected (a note is required)
+ * – querying with a note succeeds: reviewStatus → QUERIED, the note is
+ * stored, and a CHANGES_REQUESTED review note is recorded on the booking
+ * – re-uploading the SAME document resets its review to PENDING (the
+ * customer's fix clears the query — submitClearanceDocuments always
+ * resets reviewed docs back to PENDING on re-upload)
+ * – GL re-reviews it APPROVED → finalize succeeds → CLEARANCE_READY
+ *
+ * GENERAL contract, per-booking clearance gate (AWAITING_DOCUMENTS at
+ * creation, before ops ever sees it) — same fixture shape as
+ * clearGeneralBooking, with an ad-hoc document. Retries off.
+ */
+
+import {
+ apiPost,
+ bookContainers,
+ db,
+ departureAt,
+ eatDayStr,
+ glUpload,
+ seedImportContract,
+ superAdmin,
+ withBooking,
+} from "./import-utils";
+
+const stamp = String(Date.now());
+// Per-booking clearance (Path A) skips the booking-window gate entirely, so
+// this date never needs a real schedule behind it — just a binding day string.
+const BOOKING_DAY = eatDayStr(departureAt(33));
+
+interface ReviewRow {
+ status: string;
+ note: string | null;
+}
+
+function reviewFor(bookingId: string, fn: (row: ReviewRow) => void) {
+ db(
+ `SELECT status, note FROM freight.booking_document_review
+ WHERE booking_id = $1 AND file_key = 'custom_e2e'`,
+ [bookingId],
+ ).then(({ rows }) => {
+ expect(rows, "review row for custom_e2e").to.have.length(1);
+ fn(rows[0]);
+ });
+}
+
+describe("booking: clearance document query (reject) + re-upload lifecycle", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ seedImportContract({ suffix: "CQ1", reference: `CTR-IMP-${stamp}-CQ1`, kind: "GENERAL" });
+ });
+
+ it("books under the GENERAL contract — starts at the clearance gate", () => {
+ bookContainers({
+ suffix: "CQ1",
+ runStamp: stamp,
+ isoSeed: 0,
+ forty: 1,
+ scheduledDate: BOOKING_DAY,
+ });
+ withBooking("CQ1", (b) =>
+ expect(b.status, "AWAITING_DOCUMENTS").to.eq("AWAITING_DOCUMENTS"),
+ );
+ });
+
+ it("uploads a document; querying it with no note is rejected", () => {
+ withBooking("CQ1", (b) => {
+ glUpload(`/api/bookings/${b.id}/clearance/documents`, {}, "custom_e2e");
+
+ apiPost(
+ superAdmin,
+ `/api/bookings/${b.id}/clearance/review`,
+ { fileKey: "custom_e2e", status: "QUERIED" },
+ false,
+ ).then((res) => {
+ expect(res.status, "note required").to.be.within(400, 422);
+ expect(JSON.stringify(res.body)).to.include("A note is required");
+ });
+ });
+ });
+
+ it("querying with a note succeeds — reviewStatus QUERIED, note stored, review note recorded", () => {
+ withBooking("CQ1", (b) => {
+ apiPost(superAdmin, `/api/bookings/${b.id}/clearance/review`, {
+ fileKey: "custom_e2e",
+ status: "QUERIED",
+ note: "E2E: wrong document, please re-upload the correct one",
+ }).then((res) => expect(res.status, "queried").to.be.oneOf([200, 201]));
+
+ reviewFor(b.id, (row) => {
+ expect(row.status, "QUERIED").to.eq("QUERIED");
+ expect(row.note, "note stored").to.include("wrong document");
+ });
+
+ db<{ n: string }>(
+ `SELECT count(*) AS n FROM freight.booking_review_note
+ WHERE booking_id = $1 AND type = 'CHANGES_REQUESTED'`,
+ [b.id],
+ ).then(({ rows }) =>
+ expect(Number(rows[0].n), "queried review note recorded").to.be.at.least(1),
+ );
+
+ // Still under review — the query doesn't advance the booking status.
+ withBooking("CQ1", (fresh) =>
+ expect(fresh.status, "still DOCUMENTS_UNDER_REVIEW").to.eq("DOCUMENTS_UNDER_REVIEW"),
+ );
+ });
+ });
+
+ it("re-uploading resets the review to PENDING; GL approves it and finalize succeeds", () => {
+ withBooking("CQ1", (b) => {
+ glUpload(`/api/bookings/${b.id}/clearance/documents`, {}, "custom_e2e");
+
+ reviewFor(b.id, (row) => expect(row.status, "reset to PENDING").to.eq("PENDING"));
+
+ apiPost(superAdmin, `/api/bookings/${b.id}/clearance/review`, {
+ fileKey: "custom_e2e",
+ status: "APPROVED",
+ }).then((res) => expect(res.status, "approved").to.be.oneOf([200, 201]));
+
+ reviewFor(b.id, (row) => expect(row.status, "APPROVED").to.eq("APPROVED"));
+
+ apiPost(superAdmin, `/api/bookings/${b.id}/clearance/finalize`).then((res) =>
+ expect(res.status, "finalized").to.be.oneOf([200, 201]),
+ );
+ });
+
+ withBooking("CQ1", (b) =>
+ expect(b.status, "CLEARANCE_READY").to.eq("CLEARANCE_READY"),
+ );
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts b/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts
index e78fe1905..128ee1ab6 100644
--- a/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts
+++ b/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts
@@ -2,7 +2,7 @@
* Contract creation → finalization, spanning portal + backoffice:
*
* 1. portal (user@gmail.com, company seeded active by seed-company.sql):
- * wizard → GENERAL / Import / Container / 20ft → submit + approve quote
+ * wizard → GENERAL / Import / Container (both sizes) → submit + approve quote
* 2. backoffice marketer: "Accept for approval" (validity) + approve the
* LINE_STAFF step
* 3. backoffice director: approve the DIRECTOR step → PDF → CONTRACT_READY
@@ -62,12 +62,10 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
cy.mantineSelect(/^Payment Currency/, /^ETB/);
cy.contains("button", "Continue").click({ force: true });
- // 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");
cy.mantineSelect(/^Destination Yard/, "Mojo Dry Port");
cy.contains("button", "Continue").click({ force: true });
@@ -80,7 +78,10 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
cy.contains("button", "Approve & submit").click();
cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts");
- 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");
dbContract().then(({ rows }) => {
expect(rows, "contract row").to.have.length(1);
diff --git a/e2e/freight/cypress/e2e/flows/contract_rejection_send_back.cy.ts b/e2e/freight/cypress/e2e/flows/contract_rejection_send_back.cy.ts
new file mode 100644
index 000000000..2202a4184
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/contract_rejection_send_back.cy.ts
@@ -0,0 +1,197 @@
+/**
+ * CONTRACT — rejection / send-back (import & export contracts):
+ *
+ * Covered elsewhere only for the INTERCITY contract wizard (rejection +
+ * reason + resubmit). This spec drives the CONTRACT-level reject/
+ * request-changes/approval-chain guards directly via the API against a
+ * plain IMPORT container contract, something no other spec exercises:
+ *
+ * – SUBMITTED → staff reject → REJECTED (terminal, reason recorded)
+ * – SUBMITTED → staff request-changes → CHANGES_REQUESTED
+ * – SUBMITTED → staff accept → PENDING_APPROVAL (chain instantiated) →
+ * reject the first step straight to the customer (no returnToStepId)
+ * → REJECTED
+ * – SUBMITTED → accept → approve step 1 → reject step 2 WITH
+ * returnToStepId=step1 (send-back) → step 1 and 2 both reset to
+ * PENDING, contract stays PENDING_APPROVAL (alive, not rejected)
+ * – guard: a rejection can only return to an EARLIER step
+ *
+ * Contracts are seeded directly at SUBMITTED (skipping the wizard — that
+ * journey is covered by contract-lifecycle.cy.ts) so this spec is pure API.
+ * Retries off — each `it` is an independent contract's own short journey.
+ */
+
+import { apiPost, companyTin, db, DEST, ORIGIN, superAdmin } from "./import-utils";
+
+const stamp = String(Date.now());
+let seq = 0;
+
+/** A plain IMPORT/CONTAINER/ONE_TIME contract seeded straight to SUBMITTED —
+ * no cargo scope (so instantiateApprovalSteps resolves the non-director
+ * chain), no pricing (reject/request-changes/accept never read it). */
+function seedSubmittedContract(): Cypress.Chainable {
+ const reference = `CTR-REJ-${stamp}-${seq++}`;
+ return db<{ id: string }>(
+ `WITH c AS (
+ INSERT INTO freight.contracts
+ (reference, company_id, company_profile_id, contract_kind,
+ trade_direction, freight_type, service_type_id, payment_currency,
+ customs_clearing_enabled, clearance_status, status, contract_summary)
+ SELECT $1, comp.id,
+ (SELECT p.id FROM freight.company_profiles p
+ WHERE p.company_id = comp.id AND p.deleted_at IS NULL
+ ORDER BY CASE WHEN p.type = 'importer' THEN 0 ELSE 1 END
+ LIMIT 1),
+ 'ONE_TIME', 'IMPORT', 'CONTAINER',
+ (SELECT st.id FROM freight.service_types st ORDER BY st.created_at LIMIT 1),
+ 'ETB', false, 'NOT_APPLICABLE', 'SUBMITTED', 'E2E contract-reject fixture'
+ FROM freight.companies comp WHERE comp.tin = $2
+ RETURNING id
+ )
+ INSERT INTO freight.contract_routes
+ (contract_id, origin_yard_id, destination_yard_id, sort_order)
+ SELECT c.id, o.id, d.id, 0 FROM c
+ JOIN freight.yards o ON o.code = $3
+ JOIN freight.yards d ON d.code = $4
+ RETURNING contract_id AS id`,
+ [reference, companyTin, ORIGIN, DEST],
+ ).then(({ rows }) => {
+ expect(rows, "seeded SUBMITTED contract").to.have.length(1);
+ return cy.wrap(rows[0].id, { log: false });
+ });
+}
+
+interface ContractRow {
+ status: string;
+}
+
+function contractStatus(id: string, fn: (row: ContractRow) => void) {
+ db(`SELECT status FROM freight.contracts WHERE id = $1`, [id]).then(
+ ({ rows }) => {
+ expect(rows, "contract row").to.have.length(1);
+ fn(rows[0]);
+ },
+ );
+}
+
+interface StepRow {
+ id: string;
+ step_order: number;
+ status: string;
+}
+
+function approvalSteps(contractId: string, fn: (rows: StepRow[]) => void) {
+ db(
+ `SELECT id, step_order, status FROM freight.contract_approval_steps
+ WHERE contract_id = $1 AND deleted_at IS NULL ORDER BY step_order ASC`,
+ [contractId],
+ ).then(({ rows }) => fn(rows));
+}
+
+describe("contract: rejection and approval-chain send-back", { retries: 0 }, () => {
+ it("SUBMITTED → staff reject → REJECTED, reason recorded", () => {
+ seedSubmittedContract().then((id) => {
+ apiPost(superAdmin, `/api/contracts/${id}/staff/reject`, {
+ reason: "E2E: missing documents",
+ }).then((res) => expect(res.status, "rejected").to.be.oneOf([200, 201]));
+
+ contractStatus(id, (c) => expect(c.status, "REJECTED").to.eq("REJECTED"));
+
+ db<{ n: string }>(
+ `SELECT count(*) AS n FROM freight.contract_review_notes
+ WHERE contract_id = $1 AND note_type = 'REJECTION'`,
+ [id],
+ ).then(({ rows }) => expect(Number(rows[0].n), "rejection note recorded").to.be.at.least(1));
+
+ // Terminal: rejecting again is refused by the status guard.
+ apiPost(
+ superAdmin,
+ `/api/contracts/${id}/staff/reject`,
+ { reason: "again" },
+ false,
+ ).then((res) => expect(res.status, "re-reject refused").to.be.within(400, 422));
+ });
+ });
+
+ it("SUBMITTED → staff request-changes → CHANGES_REQUESTED", () => {
+ seedSubmittedContract().then((id) => {
+ apiPost(superAdmin, `/api/contracts/${id}/staff/request-changes`, {
+ note: "E2E: please add the missing cargo description",
+ }).then((res) => expect(res.status, "changes requested").to.be.oneOf([200, 201]));
+
+ contractStatus(id, (c) =>
+ expect(c.status, "CHANGES_REQUESTED").to.eq("CHANGES_REQUESTED"),
+ );
+
+ // request-changes without a note is refused.
+ seedSubmittedContract().then((id2) => {
+ apiPost(
+ superAdmin,
+ `/api/contracts/${id2}/staff/request-changes`,
+ {},
+ false,
+ ).then((res) => expect(res.status, "note required").to.be.within(400, 422));
+ });
+ });
+ });
+
+ it("accept → PENDING_APPROVAL; rejecting the first step straight to the customer → REJECTED", () => {
+ seedSubmittedContract().then((id) => {
+ apiPost(superAdmin, `/api/contracts/${id}/staff/accept`, { validityDays: 365 }).then(
+ (res) => expect(res.status, "accepted into chain").to.be.oneOf([200, 201]),
+ );
+
+ approvalSteps(id, (steps) => {
+ expect(steps.length, "chain has at least one step").to.be.at.least(1);
+ const step1 = steps[0];
+
+ apiPost(superAdmin, `/api/contracts/${id}/approval-steps/${step1.id}/reject`, {
+ reason: "E2E: rejected to customer",
+ }).then((res) => expect(res.status, "rejected to customer").to.be.oneOf([200, 201]));
+ });
+
+ contractStatus(id, (c) => expect(c.status, "REJECTED (terminal)").to.eq("REJECTED"));
+ });
+ });
+
+ it("send-back: reject step 2 back to step 1 — both reset PENDING, contract stays alive", () => {
+ seedSubmittedContract().then((id) => {
+ apiPost(superAdmin, `/api/contracts/${id}/staff/accept`, { validityDays: 365 });
+
+ approvalSteps(id, (steps) => {
+ expect(steps.length, "chain has at least 2 steps for a send-back").to.be.at.least(2);
+ const [step1, step2] = steps;
+
+ apiPost(superAdmin, `/api/contracts/${id}/approval-steps/${step1.id}/approve`).then(
+ (res) => expect(res.status, "step 1 approved").to.be.oneOf([200, 201]),
+ );
+
+ // Guard: a rejection can only return to an EARLIER step — step2 → step2 rejected.
+ apiPost(
+ superAdmin,
+ `/api/contracts/${id}/approval-steps/${step2.id}/reject`,
+ { reason: "bad", returnToStepId: step2.id },
+ false,
+ ).then((res) => {
+ expect(res.status, "same-step return rejected").to.be.within(400, 422);
+ expect(JSON.stringify(res.body)).to.include("EARLIER step");
+ });
+
+ apiPost(superAdmin, `/api/contracts/${id}/approval-steps/${step2.id}/reject`, {
+ reason: "E2E: send back for a fix",
+ returnToStepId: step1.id,
+ }).then((res) => expect(res.status, "sent back to step 1").to.be.oneOf([200, 201]));
+ });
+
+ contractStatus(id, (c) =>
+ expect(c.status, "still PENDING_APPROVAL (alive)").to.eq("PENDING_APPROVAL"),
+ );
+ approvalSteps(id, (steps) => {
+ expect(steps[0].status, "step 1 reset to PENDING").to.eq("PENDING");
+ expect(steps[1].status, "step 2 reset to PENDING").to.eq("PENDING");
+ });
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts b/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts
index 8e7c6334d..5794d3708 100644
--- a/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts
+++ b/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts
@@ -387,10 +387,9 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
cy.mantineSelect(/^Payment Currency/, /^ETB/);
cy.contains("button", "Continue").click({ force: true });
+ // Container contracts auto-cover both 20ft & 40ft (no size picker) and the
+ // cargo description moved to booking time — the scope select is enough here.
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);
cy.mantineSelect(/^Destination Yard/, PORT_YARD);
cy.contains("button", "Continue").click({ force: true });
diff --git a/e2e/freight/cypress/e2e/flows/fleet_wagon_transfer.cy.ts b/e2e/freight/cypress/e2e/flows/fleet_wagon_transfer.cy.ts
new file mode 100644
index 000000000..bb9980636
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/fleet_wagon_transfer.cy.ts
@@ -0,0 +1,227 @@
+/**
+ * FLEET — wagon transfer between yards (two-person OCC queue):
+ *
+ * A requester files a COUNT-ONLY transfer request (source yard + type + how
+ * many, never the specific wagons); OCC later hand-picks the physical wagons
+ * and fulfils it. This spec drives the real endpoints and asserts the guards
+ * the unit tests never exercise end to end:
+ *
+ * – create: same-yard rejected, empty-source rejected, over-available capped
+ * – fulfil: wrong count rejected, off-source wagon rejected, exact picks move
+ * the wagons (current_yard_id flips + wagon_movements ledger written)
+ * – cancel: only PENDING cancellable; a cancelled request can't be re-cancelled
+ * or fulfilled
+ *
+ * Uses the corridor seed's CW4 export pocket at KALITY (120 available) and the
+ * empty mid-corridor E2E_AWASH yard as the destination. Retries off.
+ */
+
+import { apiPost, db, superAdmin } from "./import-utils";
+
+const stamp = String(Date.now());
+const REASON_A = `WTR-${stamp}-A`; // fulfil flow
+const REASON_B = `WTR-${stamp}-B`; // cancel flow
+
+interface Ids {
+ kality: string;
+ awash: string;
+ dire: string;
+ cw4: string;
+}
+
+/** Resolve the yard + wagon-type ids this spec works with (one query). */
+function ids(fn: (v: Ids) => void) {
+ db(
+ `SELECT
+ (SELECT id FROM freight.yards WHERE code = 'KALITY') AS kality,
+ (SELECT id FROM freight.yards WHERE code = 'E2E_AWASH') AS awash,
+ (SELECT id FROM freight.yards WHERE code = 'DIRE_DAWA') AS dire,
+ (SELECT id FROM freight.wagon_types WHERE code = 'CW4') AS cw4`,
+ ).then(({ rows }) => fn(rows[0]));
+}
+
+/** N AVAILABLE CW4 wagon ids sitting in a yard, lowest number first. */
+function availableCw4(yardCode: string, limit: number) {
+ return db<{ id: string }>(
+ `SELECT w.id
+ FROM freight.wagons w
+ JOIN freight.yards y ON y.id = w.current_yard_id AND y.code = $1
+ JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id AND wt.code = 'CW4'
+ WHERE w.status = 'AVAILABLE' AND w.deleted_at IS NULL
+ ORDER BY w.wagon_number
+ LIMIT $2`,
+ [yardCode, limit],
+ );
+}
+
+/** The single request this run filed under `reason`. */
+function requestByReason(reason: string) {
+ return db<{ id: string; status: string; quantity: number }>(
+ `SELECT id, status, quantity FROM freight.wagon_transfer_requests
+ WHERE reason = $1 AND deleted_at IS NULL
+ ORDER BY created_at DESC LIMIT 1`,
+ [reason],
+ );
+}
+
+describe("fleet: wagon transfer requests between yards", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ });
+
+ it("files a count-only request — same-yard and empty-source are rejected", () => {
+ ids(({ kality, awash, cw4 }) => {
+ // Valid: KALITY has plenty of available CW4.
+ apiPost(superAdmin, "/api/wagon-transfer-requests", {
+ fromYardId: kality,
+ toYardId: awash,
+ wagonTypeId: cw4,
+ quantity: 3,
+ reason: REASON_A,
+ }).then((res) => {
+ expect(res.status, "request filed").to.be.oneOf([200, 201]);
+ });
+
+ // Guard: source must differ from destination.
+ apiPost(
+ superAdmin,
+ "/api/wagon-transfer-requests",
+ { fromYardId: kality, toYardId: kality, wagonTypeId: cw4, quantity: 1, reason: "x" },
+ false,
+ ).then((res) => {
+ expect(res.status, "same-yard rejected").to.eq(400);
+ expect(JSON.stringify(res.body)).to.include("must be different");
+ });
+
+ // Guard: cannot request wagons a yard doesn't have (E2E_AWASH holds none).
+ apiPost(
+ superAdmin,
+ "/api/wagon-transfer-requests",
+ { fromYardId: awash, toYardId: kality, wagonTypeId: cw4, quantity: 1, reason: "x" },
+ false,
+ ).then((res) => {
+ expect(res.status, "empty-source rejected").to.eq(400);
+ expect(JSON.stringify(res.body)).to.match(/no available wagons/i);
+ });
+
+ // The filed request is PENDING for exactly 3.
+ requestByReason(REASON_A).then(({ rows }) => {
+ expect(rows, "request row").to.have.length(1);
+ expect(rows[0].status, "PENDING").to.eq("PENDING");
+ expect(Number(rows[0].quantity), "quantity").to.eq(3);
+ });
+ });
+ });
+
+ it("fulfil rejects wrong count and off-source wagons, then moves the exact picks", () => {
+ ids(({ dire }) => {
+ requestByReason(REASON_A).then(({ rows }) => {
+ const reqId = rows[0].id;
+
+ // Wrong count: request is for 3, offer 2.
+ availableCw4("KALITY", 2).then(({ rows: two }) => {
+ apiPost(
+ superAdmin,
+ `/api/wagon-transfer-requests/${reqId}/fulfill`,
+ { wagonIds: two.map((w) => w.id) },
+ false,
+ ).then((res) => {
+ expect(res.status, "wrong count rejected").to.eq(400);
+ expect(JSON.stringify(res.body)).to.include("Select exactly 3");
+ });
+ });
+
+ // Off-source: 3 wagons but one sits at DIRE_DAWA, not the source yard.
+ availableCw4("KALITY", 2).then(({ rows: k2 }) => {
+ availableCw4("DIRE_DAWA", 1).then(({ rows: d1 }) => {
+ apiPost(
+ superAdmin,
+ `/api/wagon-transfer-requests/${reqId}/fulfill`,
+ { wagonIds: [...k2, ...d1].map((w) => w.id) },
+ false,
+ ).then((res) => {
+ expect(res.status, "off-source rejected").to.eq(400);
+ expect(JSON.stringify(res.body)).to.include("not in the source yard");
+ });
+ });
+ });
+
+ // Exact 3 valid KALITY picks — the move runs.
+ availableCw4("KALITY", 3).then(({ rows: three }) => {
+ const picked = three.map((w) => w.id);
+ apiPost(superAdmin, `/api/wagon-transfer-requests/${reqId}/fulfill`, {
+ wagonIds: picked,
+ }).then((res) => {
+ expect(res.status, "fulfilled").to.be.oneOf([200, 201]);
+ });
+
+ // Request FULFILLED; the 3 wagons now sit at E2E_AWASH; each move is
+ // written to the wagon_movements ledger stamped with this request.
+ requestByReason(REASON_A).then(({ rows: r }) =>
+ expect(r[0].status, "FULFILLED").to.eq("FULFILLED"),
+ );
+ db<{ n: string }>(
+ `SELECT count(*) AS n FROM freight.wagons w
+ JOIN freight.yards y ON y.id = w.current_yard_id AND y.code = 'E2E_AWASH'
+ WHERE w.id = ANY($1::uuid[])`,
+ [picked],
+ ).then(({ rows: at }) => expect(Number(at[0].n), "3 moved to AWASH").to.eq(3));
+ db<{ n: string }>(
+ `SELECT count(*) AS n FROM freight.wagon_movements
+ WHERE transfer_request_id = $1 AND wagon_id = ANY($2::uuid[])`,
+ [reqId, picked],
+ ).then(({ rows: mv }) =>
+ expect(Number(mv[0].n), "3 movement rows").to.eq(3),
+ );
+ });
+ });
+ });
+ });
+
+ it("only PENDING requests cancel — a cancelled one can't be re-cancelled or fulfilled", () => {
+ ids(({ kality, awash, cw4 }) => {
+ apiPost(superAdmin, "/api/wagon-transfer-requests", {
+ fromYardId: kality,
+ toYardId: awash,
+ wagonTypeId: cw4,
+ quantity: 2,
+ reason: REASON_B,
+ }).then((res) => expect(res.status).to.be.oneOf([200, 201]));
+
+ requestByReason(REASON_B).then(({ rows }) => {
+ const reqId = rows[0].id;
+
+ // Withdraw it.
+ apiPost(superAdmin, `/api/wagon-transfer-requests/${reqId}/cancel`).then((res) =>
+ expect(res.status, "cancelled").to.be.oneOf([200, 201]),
+ );
+ requestByReason(REASON_B).then(({ rows: r }) =>
+ expect(r[0].status, "CANCELLED").to.eq("CANCELLED"),
+ );
+
+ // Re-cancel is a conflict.
+ apiPost(superAdmin, `/api/wagon-transfer-requests/${reqId}/cancel`, {}, false).then(
+ (res) => {
+ expect(res.status, "re-cancel conflict").to.eq(409);
+ expect(JSON.stringify(res.body)).to.match(/only pending/i);
+ },
+ );
+
+ // Fulfilling a cancelled request is a conflict too.
+ availableCw4("KALITY", 2).then(({ rows: two }) => {
+ apiPost(
+ superAdmin,
+ `/api/wagon-transfer-requests/${reqId}/fulfill`,
+ { wagonIds: two.map((w) => w.id) },
+ false,
+ ).then((res) => {
+ expect(res.status, "fulfil-after-cancel conflict").to.eq(409);
+ expect(JSON.stringify(res.body)).to.include("already cancelled");
+ });
+ });
+ });
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/government_preemption.cy.ts b/e2e/freight/cypress/e2e/flows/government_preemption.cy.ts
new file mode 100644
index 000000000..ddae0c42f
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/government_preemption.cy.ts
@@ -0,0 +1,254 @@
+/**
+ * BATCH — government preemption:
+ *
+ * A government booking that fits nowhere on an already-committed train
+ * displaces the LOWEST-priority commercial reservation first (never the
+ * higher-priority one), then allocates directly — no pay window, since
+ * government rides unpaid (see booking-batch.service.ts `preemptForGovernment`
+ * + `allocate(..., "gov")`).
+ *
+ * Setup: a 3-wagon import train. CGA (priority 1, higher) and CGB (priority
+ * 2, lower) each book one 40ft container and reserve via the normal batch —
+ * 2/3 wagons used, 1 free. A standalone government booking (2×40ft = 2
+ * wagons) then needs more than the 1 free slot: it doesn't fit, so the
+ * engine preempts CGB (the lower-priority reservation) to free the second
+ * wagon, then allocates the government booking. CGA is untouched throughout.
+ *
+ * Government bookings don't go through the customer contract wizard —
+ * they're created directly via `POST /bookings` (isGovernment: true, billed
+ * to a real government company/profile — see seed-government.sql) and
+ * promoted with `POST /bookings/:id/government-expedite`.
+ *
+ * Sequential steps of one scenario — retries off.
+ */
+
+import {
+ acceptOperation,
+ apiPost,
+ bookContainers,
+ closeBookingWindow,
+ completeDocReview,
+ createImportSchedule,
+ db,
+ departureAt,
+ DEST,
+ eatDayStr,
+ ensureCorridorRoute,
+ forceWindowOpen,
+ ORIGIN,
+ pollBookingStatus,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ setPriority,
+ superAdmin,
+ withBooking,
+ withSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(27);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+const GOV_COMPANY_ID = "0a1b0001-0000-4000-8000-000000000001";
+const GOV_PROFILE_ID = "0b1c0001-0000-4000-8000-000000000001";
+
+function withScheduleId(fn: (id: string, s: ScheduleRow) => void) {
+ withSchedule(DEPARTURE, (s) => fn(s.id, s));
+}
+
+interface GovBookingRow {
+ id: string;
+ status: string;
+ scheduling_status: string;
+ is_government: boolean;
+ train_schedule_id: string | null;
+}
+
+/** This run's government booking — always re-queried, never trusted from a
+ * create-response shape (only one exists per run: newest for this gov company). */
+function withGovBooking(fn: (b: GovBookingRow) => void) {
+ db(
+ `SELECT id, status, scheduling_status, is_government, train_schedule_id
+ FROM freight.bookings
+ WHERE company_id = $1 AND is_government = true AND deleted_at IS NULL
+ ORDER BY created_at DESC LIMIT 1`,
+ [GOV_COMPANY_ID],
+ ).then(({ rows }) => {
+ expect(rows, "this run's government booking").to.have.length(1);
+ fn(rows[0]);
+ });
+}
+
+describe("batch: government booking preempts the lowest-priority commercial reservation", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ cy.task("db:seedFile", "seed-government.sql");
+ ["CGA", "CGB"].forEach((suffix) =>
+ seedImportContract({ suffix, reference: stampedRef(suffix) }),
+ );
+ });
+
+ it("operations schedules the dedicated 3-wagon built train (TRN-GOV-1) — window forced open", () => {
+ ensureCorridorRoute();
+ resetCorridorDay(DEPARTURE);
+ // A loco-pair schedule's max_wagons is NOT a real cap — it gets recomputed
+ // from the locomotive's length every fill pass (54 for a standard loco),
+ // ignoring maxWagonsPerTrain. A BUILT train's coupled count IS the cap.
+ createImportSchedule({
+ departure: DEPARTURE,
+ trainCode: "TRN-GOV-1",
+ maxWagons: 3,
+ });
+ withScheduleId((id) => forceWindowOpen(id, 45));
+ });
+
+ it("CGA and CGB each book one 40ft and reserve — 2/3 wagons used, 1 free", () => {
+ bookContainers({
+ suffix: "CGA",
+ runStamp: stamp,
+ isoSeed: 0,
+ forty: 1,
+ scheduledDate: BOOKING_DAY,
+ });
+ acceptOperation("CGA");
+ bookContainers({
+ suffix: "CGB",
+ runStamp: stamp,
+ isoSeed: 10,
+ forty: 1,
+ scheduledDate: BOOKING_DAY,
+ });
+ acceptOperation("CGB");
+ setPriority("CGA", 1); // higher priority — must survive
+ setPriority("CGB", 2); // lower priority — the preemption target
+
+ withScheduleId((id) => {
+ closeBookingWindow(id);
+ completeDocReview(id);
+ });
+
+ withBooking("CGA", (b) =>
+ expect(b.status, "CGA reserved").to.be.oneOf(["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ withBooking("CGB", (b) =>
+ expect(b.status, "CGB reserved").to.be.oneOf(["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ });
+
+ it("a government booking (2×40ft) is created and expedited to PAID, then pinned to the train", () => {
+ db<{ id: string }>(
+ `SELECT id FROM freight.yards WHERE code = $1`,
+ [ORIGIN],
+ ).then(({ rows: o }) => {
+ db<{ id: string }>(`SELECT id FROM freight.yards WHERE code = $1`, [DEST]).then(
+ ({ rows: d }) => {
+ db<{ id: string }>(
+ `SELECT id FROM freight.service_types ORDER BY created_at LIMIT 1`,
+ ).then(({ rows: st }) => {
+ db<{ id: string }>(
+ `SELECT id FROM freight.container_types WHERE size_ft = 40 AND is_active LIMIT 1`,
+ ).then(({ rows: ct }) => {
+ apiPost(superAdmin, "/api/bookings", {
+ isGovernment: true,
+ companyId: GOV_COMPANY_ID,
+ companyProfileId: GOV_PROFILE_ID,
+ contractType: "NEW",
+ serviceTypeId: st[0].id,
+ equipmentReturn: "WITHOUT_RETURN",
+ originYardId: o[0].id,
+ destinationYardId: d[0].id,
+ tradeDirection: "IMPORT",
+ freightType: "CONTAINER",
+ containers: [{ containerTypeId: ct[0].id, quantity: 2, vgmPerUnitTons: 10 }],
+ cargoTotalWeightVgm: 20,
+ paymentCurrency: "USD",
+ }).then((res) => {
+ expect(res.status, "government booking created").to.be.oneOf([200, 201]);
+
+ withGovBooking((created) => {
+ apiPost(
+ superAdmin,
+ `/api/bookings/${created.id}/government-expedite`,
+ ).then((expRes) => {
+ expect(expRes.status, "expedited").to.be.oneOf([200, 201]);
+ });
+ });
+ withGovBooking((b) => {
+ expect(b.status, "PAID after expedite").to.eq("PAID");
+ expect(b.is_government, "flagged government").to.eq(true);
+
+ // Staff pin onto this schedule (the customer-facing OPEN-window
+ // pin can't be used here — the window already closed when
+ // completeDocReview ran). Mirrors a staff manual assign.
+ withScheduleId((scheduleId) => {
+ db(
+ `UPDATE freight.bookings SET train_schedule_id = $1 WHERE id = $2`,
+ [scheduleId, b.id],
+ );
+ });
+ });
+ });
+ });
+ });
+ },
+ );
+ });
+ });
+
+ it("run-batch: the government booking preempts CGB (lower priority), CGA is untouched", () => {
+ withScheduleId((scheduleId) => {
+ apiPost(superAdmin, `/api/train-scheduling/schedules/${scheduleId}/run-batch`).then(
+ (res) => expect(res.status, "run-batch").to.be.oneOf([200, 201]),
+ );
+ });
+
+ // CGB displaced: EXPIRED, back to ELIGIBLE, no pay window, invoice expired.
+ pollBookingStatus("CGB", "EXPIRED");
+ withBooking("CGB", (b) => {
+ expect(b.scheduling_status, "CGB back to ELIGIBLE").to.eq("ELIGIBLE");
+ expect(b.payment_deadline, "CGB pay window cleared").to.be.null;
+ db<{ n: string }>(
+ `SELECT count(*) AS n FROM freight.invoices
+ WHERE source = 'booking' AND source_id = $1
+ AND status NOT IN ('EXPIRED','CANCELLED','PAID') AND deleted_at IS NULL`,
+ [b.id],
+ ).then(({ rows }) => expect(Number(rows[0].n), "CGB invoice expired").to.eq(0));
+ });
+
+ // CGA survives untouched — still reserved on the same train.
+ withScheduleId((scheduleId) => {
+ withBooking("CGA", (b) => {
+ expect(b.status, "CGA still reserved").to.be.oneOf([
+ "SELECTED_FOR_BATCH",
+ "AWAITING_PAYMENT",
+ ]);
+ expect(b.train_schedule_id, "CGA still on this train").to.eq(scheduleId);
+ });
+ });
+
+ // Government booking allocated: SCHEDULED + linked via the schedule-bookings
+ // table (allocate() never sets the train_schedule_id column for gov — only
+ // the link row — so the pin from the previous step is what carries it).
+ withGovBooking((b) => {
+ expect(b.scheduling_status, "gov SCHEDULED").to.eq("SCHEDULED");
+ withScheduleId((scheduleId) => {
+ db<{ n: string }>(
+ `SELECT count(*) AS n FROM freight.train_schedule_bookings
+ WHERE booking_id = $1 AND train_schedule_id = $2 AND deleted_at IS NULL`,
+ [b.id, scheduleId],
+ ).then(({ rows }) => expect(Number(rows[0].n), "gov linked to schedule").to.eq(1));
+ });
+ pollDb<{ n: string }>(
+ "gov booking allocated 2 wagons",
+ `SELECT count(*) AS n FROM freight.wagon_booking_allocations WHERE booking_id = $1`,
+ [b.id],
+ (row) => Number(row?.n ?? 0) >= 2,
+ );
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/import-utils.ts b/e2e/freight/cypress/e2e/flows/import-utils.ts
index 86d255ba9..0ab5af12a 100644
--- a/e2e/freight/cypress/e2e/flows/import-utils.ts
+++ b/e2e/freight/cypress/e2e/flows/import-utils.ts
@@ -67,6 +67,34 @@ export function apiPost(
);
}
+export function apiGet(email: string, path: string, failOnStatusCode = true) {
+ return tokenFor(email).then((token) =>
+ cy.request({
+ method: "GET",
+ url: `${apiUrl()}${path}`,
+ headers: { Authorization: `Bearer ${token}` },
+ failOnStatusCode,
+ }),
+ );
+}
+
+export function apiPatch(
+ email: string,
+ path: string,
+ body?: unknown,
+ failOnStatusCode = true,
+) {
+ return tokenFor(email).then((token) =>
+ cy.request({
+ method: "PATCH",
+ url: `${apiUrl()}${path}`,
+ headers: { Authorization: `Bearer ${token}` },
+ body: body ?? {},
+ failOnStatusCode,
+ }),
+ );
+}
+
/** Poll a 1-row query until `check` passes (10s window tick ⇒ 3s cadence). */
export function pollDb(
label: string,
diff --git a/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts b/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts
index b8ca2fd1b..0be1bf8aa 100644
--- a/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts
+++ b/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts
@@ -149,12 +149,10 @@ function createIntercityContract() {
cy.mantineSelect(/^Payment Currency/, /^ETB/);
cy.contains("button", "Continue").click({ force: true });
- // Step 1 — Cargo & Route (Ethiopian yards only for intercity).
+ // Step 1 — Cargo & Route (Ethiopian yards only for intercity). Container
+ // contracts auto-cover both 20ft & 40ft (no size picker) and the cargo
+ // description moved to booking time — the scope select is enough here.
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);
cy.mantineSelect(/^Destination Yard/, DEST_YARD);
cy.contains("button", "Continue").click({ force: true });
diff --git a/e2e/freight/cypress/e2e/flows/rate_change_mid_window.cy.ts b/e2e/freight/cypress/e2e/flows/rate_change_mid_window.cy.ts
new file mode 100644
index 000000000..902c54055
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/rate_change_mid_window.cy.ts
@@ -0,0 +1,229 @@
+/**
+ * RULES & CONFIG — rate lifecycle + live rate-change mid-window:
+ *
+ * A LIVE rate is what pricing charges right now, so it is never edited in
+ * place (rates.service.ts `applyApprovedUpdate` doc comment) — an edit is
+ * filed as a change request and only takes effect once approved. This spec
+ * drives the full lifecycle end to end, something the unit tests never do:
+ *
+ * – create (DRAFT) → duplicate-combo rejected → submit → PENDING_APPROVAL
+ * → a non-DRAFT edit is rejected → approve → LIVE, visible on /rates/live
+ * – re-approving an already-LIVE rate is rejected
+ * – a rate-change-request proposing the SAME value is rejected ("nothing
+ * changed"); a real change files PENDING; a second concurrent change is
+ * rejected ("already has a change awaiting approval")
+ * – approving the change updates the LIVE row'S VALUE IN PLACE (same rate
+ * id, same row) — the "mid-window" live edit this whole flow protects
+ * – re-approving the same (now-decided) change request is rejected
+ *
+ * BULK/ALWAYS/IMPORT rate on a yard pair the corridor seed never touches
+ * (DJIB_PORT → E2E_AWASH), so the duplicate-combo guard has a clean slate.
+ * Retries off — sequential steps of one rate's lifecycle.
+ */
+
+import { apiPost, db, superAdmin } from "./import-utils";
+
+interface RateRow {
+ id: string;
+ status: string;
+ rate_value: string;
+}
+
+function rateByPattern() {
+ return db(
+ `SELECT r.id, r.status, r.rate_value
+ FROM freight.rates r
+ JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'DJIB_PORT'
+ JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'E2E_AWASH'
+ WHERE r.rate_type = 'BULK_IMPORT' AND r.rate_unit = 'PER_TON'
+ AND r.deleted_at IS NULL
+ ORDER BY r.created_at DESC LIMIT 1`,
+ ).then(({ rows }) => {
+ expect(rows, "the test rate").to.have.length(1);
+ return rows[0];
+ });
+}
+
+interface ChangeRequestRow {
+ id: string;
+ status: string;
+ rate_id: string;
+}
+
+function changeRequestByRate(rateId: string) {
+ return db(
+ `SELECT id, status, rate_id FROM freight.rate_change_requests
+ WHERE rate_id = $1 ORDER BY created_at DESC LIMIT 1`,
+ [rateId],
+ ).then(({ rows }) => {
+ expect(rows, "change request for this rate").to.have.length(1);
+ return rows[0];
+ });
+}
+
+function yardId(code: string) {
+ return db<{ id: string }>(`SELECT id FROM freight.yards WHERE code = $1`, [code]).then(
+ ({ rows }) => rows[0].id,
+ );
+}
+
+const RATE_BODY = (originYardId: string, destinationYardId: string, rateValue: number) => ({
+ appliesTo: "BULK",
+ trigger: "ALWAYS",
+ tradeDirection: "IMPORT",
+ originYardId,
+ destinationYardId,
+ rateValue,
+ rateUnit: "PER_TON",
+});
+
+describe("rules & config: rate lifecycle + live rate-change mid-window", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ // Soft-delete any leftover rate from a prior run of this spec — the
+ // duplicate-combo guard under test would otherwise reject THIS run's
+ // very first create against yesterday's row on a persistent DB.
+ db(
+ `UPDATE freight.rates r
+ SET deleted_at = now()
+ FROM freight.yards o, freight.yards d
+ WHERE r.origin_yard_id = o.id AND o.code = 'DJIB_PORT'
+ AND r.destination_yard_id = d.id AND d.code = 'E2E_AWASH'
+ AND r.rate_type = 'BULK_IMPORT' AND r.rate_unit = 'PER_TON'
+ AND r.deleted_at IS NULL`,
+ );
+ });
+
+ it("creates a DRAFT rate — a duplicate combination is rejected", () => {
+ yardId("DJIB_PORT").then((originYardId) => {
+ yardId("E2E_AWASH").then((destinationYardId) => {
+ apiPost(superAdmin, "/api/rates", RATE_BODY(originYardId, destinationYardId, 100)).then(
+ (res) => expect(res.status, "rate created").to.be.oneOf([200, 201]),
+ );
+
+ apiPost(
+ superAdmin,
+ "/api/rates",
+ RATE_BODY(originYardId, destinationYardId, 999),
+ false,
+ ).then((res) => {
+ expect(res.status, "duplicate combo rejected").to.eq(409);
+ expect(JSON.stringify(res.body)).to.include("already exists on this route");
+ });
+ });
+ });
+
+ rateByPattern().then((r) => {
+ expect(r.status, "starts DRAFT").to.eq("DRAFT");
+ expect(Number(r.rate_value), "original value 100").to.eq(100);
+ });
+ });
+
+ it("submit → PENDING_APPROVAL; a non-DRAFT rate can't be edited directly", () => {
+ rateByPattern().then((r) => {
+ apiPost(superAdmin, `/api/rates/${r.id}/submit`).then((res) =>
+ expect(res.status, "submitted").to.be.oneOf([200, 201]),
+ );
+ });
+
+ rateByPattern().then((r) => {
+ expect(r.status, "PENDING_APPROVAL").to.eq("PENDING_APPROVAL");
+
+ apiPost(superAdmin, `/api/rates/${r.id}`, { rateValue: 150 }, false).then((res) => {
+ expect(res.status, "direct edit rejected").to.be.within(400, 422);
+ });
+ });
+ });
+
+ it("approve → LIVE, visible on /rates/live; re-approving is rejected", () => {
+ rateByPattern().then((r) => {
+ apiPost(superAdmin, `/api/rates/${r.id}/approve`).then((res) =>
+ expect(res.status, "approved").to.be.oneOf([200, 201]),
+ );
+ });
+
+ rateByPattern().then((r) => {
+ expect(r.status, "LIVE").to.eq("LIVE");
+ expect(Number(r.rate_value), "still 100 at approval").to.eq(100);
+
+ db<{ n: string }>(
+ `SELECT count(*) AS n FROM freight.rates WHERE id = $1 AND status = 'LIVE'`,
+ [r.id],
+ ).then(({ rows }) => expect(Number(rows[0].n), "present as LIVE").to.eq(1));
+
+ apiPost(superAdmin, `/api/rates/${r.id}/approve`, {}, false).then((res) => {
+ expect(res.status, "re-approve rejected").to.be.within(400, 422);
+ expect(JSON.stringify(res.body)).to.include("PENDING_APPROVAL");
+ });
+ });
+ });
+
+ it("a change proposing the SAME value is rejected — nothing changed", () => {
+ rateByPattern().then((r) => {
+ apiPost(
+ superAdmin,
+ "/api/rate-change-requests",
+ { rateId: r.id, update: { rateValue: 100 } },
+ false,
+ ).then((res) => {
+ expect(res.status, "no-op change rejected").to.eq(400);
+ expect(JSON.stringify(res.body)).to.include("Nothing changed");
+ });
+ });
+ });
+
+ it("a real change files PENDING; a second concurrent change is rejected", () => {
+ rateByPattern().then((r) => {
+ apiPost(superAdmin, "/api/rate-change-requests", {
+ rateId: r.id,
+ update: { rateValue: 250 },
+ }).then((res) => expect(res.status, "change filed").to.be.oneOf([200, 201]));
+
+ apiPost(
+ superAdmin,
+ "/api/rate-change-requests",
+ { rateId: r.id, update: { rateValue: 300 } },
+ false,
+ ).then((res) => {
+ expect(res.status, "second concurrent change rejected").to.eq(409);
+ expect(JSON.stringify(res.body)).to.include("already has a change awaiting approval");
+ });
+
+ changeRequestByRate(r.id).then((cr) => {
+ expect(cr.status, "PENDING").to.eq("PENDING");
+ });
+
+ // The rate itself is untouched while the change is pending.
+ rateByPattern().then((live) =>
+ expect(Number(live.rate_value), "still 100 while pending").to.eq(100),
+ );
+ });
+ });
+
+ it("approving the change updates the LIVE row in place; re-approving it is rejected", () => {
+ rateByPattern().then((r) => {
+ changeRequestByRate(r.id).then((cr) => {
+ apiPost(superAdmin, `/api/rate-change-requests/${cr.id}/approve`, {}).then((res) =>
+ expect(res.status, "change approved").to.be.oneOf([200, 201]),
+ );
+ });
+ });
+
+ rateByPattern().then((r) => {
+ expect(r.status, "still LIVE (same row)").to.eq("LIVE");
+ expect(Number(r.rate_value), "value now 250").to.eq(250);
+
+ changeRequestByRate(r.id).then((cr) => {
+ expect(cr.status, "APPROVED").to.eq("APPROVED");
+ apiPost(superAdmin, `/api/rate-change-requests/${cr.id}/approve`, {}, false).then(
+ (res) => {
+ expect(res.status, "re-approve rejected").to.eq(409);
+ expect(JSON.stringify(res.body)).to.include("already approved");
+ },
+ );
+ });
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/schedule_window_rule_snapshot.cy.ts b/e2e/freight/cypress/e2e/flows/schedule_window_rule_snapshot.cy.ts
new file mode 100644
index 000000000..b5545232d
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/schedule_window_rule_snapshot.cy.ts
@@ -0,0 +1,142 @@
+/**
+ * SCHEDULING — window-rule snapshot survives a global-rule edit:
+ *
+ * Each `train_schedules` row freezes the booking-window rule it was CREATED
+ * with, into 5 `rule_*` columns (migration 1920000000000). Editing the
+ * global rules must apply to FUTURE schedules only — an already-created
+ * schedule keeps its OWN frozen rule, so the batch board/customer window
+ * never redraws under an already-open train (see the
+ * schedule-window-rule-snapshot memory for the regression this protects:
+ * a global-rule edit used to redraw an open schedule's board as a
+ * synthetic grid that no longer matched the window the customer saw).
+ *
+ * – schedule A is created under the CURRENT global rules → its snapshot
+ * matches them
+ * – PATCH /global-rules to DIFFERENT windowOpenHour/windowDurationHours
+ * – schedule A's snapshot is UNCHANGED (frozen) after the edit
+ * – schedule B, created AFTER the edit, snapshots the NEW values
+ * – global rules are restored at the end — a shared singleton every
+ * other spec in the suite reads
+ *
+ * Retries off — sequential steps against the shared global-rules singleton.
+ */
+
+import {
+ apiGet,
+ apiPatch,
+ createImportSchedule,
+ db,
+ departureAt,
+ ensureCorridorRoute,
+ forceWindowOpen,
+ resetCorridorDay,
+ superAdmin,
+ withSchedule,
+} from "./import-utils";
+
+const DAY_A = departureAt(30);
+const DAY_B = departureAt(31);
+
+interface GlobalRules {
+ windowOpenHour: number;
+ windowDurationHours: number;
+}
+
+interface SnapshotRow {
+ rule_window_open_hour: number;
+ rule_window_duration_hours: string;
+}
+
+function snapshotFor(departure: Date, fn: (row: SnapshotRow) => void) {
+ withSchedule(departure, (s) => {
+ db(
+ `SELECT rule_window_open_hour, rule_window_duration_hours
+ FROM freight.train_schedules WHERE id = $1`,
+ [s.id],
+ ).then(({ rows }) => {
+ expect(rows, "schedule snapshot row").to.have.length(1);
+ fn(rows[0]);
+ });
+ });
+}
+
+let original: GlobalRules;
+let changed: GlobalRules;
+
+describe("scheduling: window-rule snapshot is frozen against later global-rule edits", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ ensureCorridorRoute();
+ resetCorridorDay(DAY_A);
+ resetCorridorDay(DAY_B);
+ });
+
+ after(() => {
+ // Restore the shared global-rules singleton for every other spec.
+ if (original) apiPatch(superAdmin, "/api/train-scheduling/global-rules", original);
+ });
+
+ it("captures the current global rules, then creates schedule A under them", () => {
+ apiGet(superAdmin, "/api/train-scheduling/global-rules").then((res) => {
+ const body = res.body.data ?? res.body;
+ original = {
+ windowOpenHour: Number(body.windowOpenHour),
+ windowDurationHours: Number(body.windowDurationHours),
+ };
+ changed = {
+ windowOpenHour: (original.windowOpenHour + 5) % 24,
+ windowDurationHours: original.windowDurationHours >= 6 ? 2 : 8,
+ };
+ expect(changed.windowOpenHour, "changed open hour differs").to.not.eq(original.windowOpenHour);
+ expect(changed.windowDurationHours, "changed duration differs").to.not.eq(
+ original.windowDurationHours,
+ );
+ });
+
+ createImportSchedule({ departure: DAY_A, locoPair: ["LOCO-IMP-1", "LOCO-IMP-2"] });
+ // restampPendingWindows refreshes the snapshot for PRE_WINDOW schedules —
+ // the freeze only takes hold once a schedule is OPEN (see the
+ // schedule-window-rule-snapshot memory). Force it open now so the global-
+ // rule edit below lands on an already-frozen schedule, not a pending one.
+ withSchedule(DAY_A, (s) => forceWindowOpen(s.id, 45));
+
+ snapshotFor(DAY_A, (row) => {
+ expect(row.rule_window_open_hour, "A snapshots current open hour").to.eq(
+ original.windowOpenHour,
+ );
+ expect(Number(row.rule_window_duration_hours), "A snapshots current duration").to.eq(
+ original.windowDurationHours,
+ );
+ });
+ });
+
+ it("edits the global rules, creates schedule B — B snapshots the NEW values", () => {
+ apiPatch(superAdmin, "/api/train-scheduling/global-rules", changed).then((res) =>
+ expect(res.status, "global rules updated").to.be.oneOf([200, 201]),
+ );
+
+ createImportSchedule({ departure: DAY_B, locoPair: ["LOCO-IMP-3", "LOCO-IMP-4"] });
+
+ snapshotFor(DAY_B, (row) => {
+ expect(row.rule_window_open_hour, "B snapshots the NEW open hour").to.eq(
+ changed.windowOpenHour,
+ );
+ expect(Number(row.rule_window_duration_hours), "B snapshots the NEW duration").to.eq(
+ changed.windowDurationHours,
+ );
+ });
+ });
+
+ it("schedule A's snapshot is UNCHANGED — frozen against the later edit", () => {
+ snapshotFor(DAY_A, (row) => {
+ expect(row.rule_window_open_hour, "A still the ORIGINAL open hour").to.eq(
+ original.windowOpenHour,
+ );
+ expect(Number(row.rule_window_duration_hours), "A still the ORIGINAL duration").to.eq(
+ original.windowDurationHours,
+ );
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/train_builder_adjust_consist.cy.ts b/e2e/freight/cypress/e2e/flows/train_builder_adjust_consist.cy.ts
new file mode 100644
index 000000000..0e88fc234
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/train_builder_adjust_consist.cy.ts
@@ -0,0 +1,191 @@
+/**
+ * TRAIN-BUILDER — adjust-consist headroom guard:
+ *
+ * `POST /train-scheduling/schedules/:id/adjust-consist` permanently couples
+ * or trims a BUILT train's consist from a live schedule. This spec drives it
+ * against a dedicated small-capacity train (TRN-ADJ-1, see
+ * seed-adjust-consist.sql): 120T-pull locomotives with a 20T overage
+ * tolerance ⇒ a 140T pull cap, starting consist 4 × CW4 (99.2T tare).
+ *
+ * – no wagons passed at all → rejected ("nothing to adjust")
+ * – the same wagon in both add and remove → rejected
+ * – removing a wagon that isn't part of this train → rejected
+ * – adding ONE free spare (→ 124.0T) is within the 140T cap → SUCCEEDS,
+ * schedule.max_wagons follows the new consist size
+ * – adding the SECOND spare too (→ 148.8T) breaches the 140T cap →
+ * REJECTED with the exact gross-weight-over-limit message
+ * – removing a free (unallocated) wagon succeeds, consist shrinks back
+ *
+ * Sequential steps against one schedule — retries off.
+ */
+
+import {
+ apiPost,
+ createImportSchedule,
+ db,
+ departureAt,
+ ensureExportRoute,
+ EXP_DEST,
+ EXP_ORIGIN,
+ resetCorridorDay,
+ superAdmin,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(9);
+
+interface WagonRow {
+ id: string;
+ wagon_number: string;
+ train_id: string | null;
+ status: string;
+}
+
+function wagonByNumber(num: string) {
+ return db(
+ `SELECT id, wagon_number, train_id, status FROM freight.wagons WHERE wagon_number = $1`,
+ [num],
+ ).then(({ rows }) => {
+ expect(rows, `wagon ${num}`).to.have.length(1);
+ return rows[0];
+ });
+}
+
+interface ScheduleRow {
+ id: string;
+ max_wagons: number;
+}
+
+function adjTrainSchedule() {
+ return db(
+ `SELECT ts.id, ts.max_wagons
+ FROM freight.train_schedules ts
+ JOIN freight.train_sets se ON se.id = ts.train_set_id
+ JOIN freight.trains t ON t.id = se.train_id AND t.code = 'TRN-ADJ-1'
+ WHERE ts.deleted_at IS NULL
+ ORDER BY ts.created_at DESC LIMIT 1`,
+ ).then(({ rows }) => {
+ expect(rows, "TRN-ADJ-1 schedule").to.have.length(1);
+ return rows[0];
+ });
+}
+
+function adjustConsist(
+ scheduleId: string,
+ body: { addWagonIds?: string[]; removeWagonIds?: string[] },
+ failOnStatusCode = true,
+) {
+ return apiPost(
+ superAdmin,
+ `/api/train-scheduling/schedules/${scheduleId}/adjust-consist`,
+ body,
+ failOnStatusCode,
+ );
+}
+
+describe("train-builder: adjust-consist headroom guard", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ cy.task("db:seedFile", "seed-adjust-consist.sql");
+ });
+
+ it("operations schedules the dedicated 4-wagon built train (TRN-ADJ-1)", () => {
+ ensureExportRoute();
+ resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
+ createImportSchedule({
+ departure: DEPARTURE,
+ trainCode: "TRN-ADJ-1",
+ maxWagons: 4,
+ kind: "bulk",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ });
+
+ it("rejects an empty adjustment and a wagon listed in both add and remove", () => {
+ adjTrainSchedule().then((s) => {
+ adjustConsist(s.id, {}, false).then((res) => {
+ expect(res.status, "nothing-to-adjust rejected").to.eq(400);
+ expect(JSON.stringify(res.body)).to.include("Nothing to adjust");
+ });
+
+ wagonByNumber("WGN-ADJ-C1").then((w) => {
+ adjustConsist(s.id, { addWagonIds: [w.id], removeWagonIds: [w.id] }, false).then(
+ (res) => {
+ expect(res.status, "add+remove same wagon rejected").to.eq(400);
+ expect(JSON.stringify(res.body)).to.include(
+ "cannot be added and removed in the same adjustment",
+ );
+ },
+ );
+ });
+ });
+ });
+
+ it("rejects removing a wagon that isn't coupled to this train", () => {
+ adjTrainSchedule().then((s) => {
+ wagonByNumber("WGN-ADJ-S1").then((spare) => {
+ adjustConsist(s.id, { removeWagonIds: [spare.id] }, false).then((res) => {
+ expect(res.status, "not-part-of-train rejected").to.eq(404);
+ expect(JSON.stringify(res.body)).to.include("not coupled to train");
+ });
+ });
+ });
+ });
+
+ it("adds one spare within the 140T pull cap (99.2T + 24.8T = 124.0T) — succeeds", () => {
+ adjTrainSchedule().then((s) => {
+ wagonByNumber("WGN-ADJ-S1").then((spare) => {
+ adjustConsist(s.id, { addWagonIds: [spare.id] }).then((res) => {
+ expect(res.status, "add within headroom").to.be.oneOf([200, 201]);
+ });
+ });
+ });
+
+ // Consist grew to 5; the coupled spare now belongs to the train.
+ adjTrainSchedule().then((s) => expect(s.max_wagons, "max_wagons = 5").to.eq(5));
+ wagonByNumber("WGN-ADJ-S1").then((w) => {
+ db<{ code: string }>(
+ `SELECT t.code FROM freight.trains t WHERE t.id = $1`,
+ [w.train_id],
+ ).then(({ rows }) => expect(rows[0]?.code, "S1 now on TRN-ADJ-1").to.eq("TRN-ADJ-1"));
+ });
+ });
+
+ it("adding the second spare breaches the 140T cap (124.0T + 24.8T = 148.8T) — rejected", () => {
+ adjTrainSchedule().then((s) => {
+ wagonByNumber("WGN-ADJ-S2").then((spare) => {
+ adjustConsist(s.id, { addWagonIds: [spare.id] }, false).then((res) => {
+ expect(res.status, "add over headroom rejected").to.eq(400);
+ const body = JSON.stringify(res.body);
+ expect(body).to.include("puts gross weight at 148.8T");
+ expect(body).to.include("over the locomotives' 140T limit incl. tolerance");
+ });
+ });
+ });
+
+ // Rejected add must not have moved anything: still 5 wagons, S2 still free.
+ adjTrainSchedule().then((s) => expect(s.max_wagons, "still 5").to.eq(5));
+ wagonByNumber("WGN-ADJ-S2").then((w) => {
+ expect(w.train_id, "S2 still uncoupled").to.be.null;
+ expect(w.status, "S2 still AVAILABLE").to.eq("AVAILABLE");
+ });
+ });
+
+ it("removes a free coupled wagon — consist shrinks and the wagon returns to the yard", () => {
+ adjTrainSchedule().then((s) => {
+ wagonByNumber("WGN-ADJ-C4").then((w) => {
+ adjustConsist(s.id, { removeWagonIds: [w.id] }).then((res) => {
+ expect(res.status, "remove free wagon").to.be.oneOf([200, 201]);
+ });
+ });
+ });
+
+ adjTrainSchedule().then((s) => expect(s.max_wagons, "max_wagons = 4").to.eq(4));
+ wagonByNumber("WGN-ADJ-C4").then((w) => {
+ expect(w.train_id, "C4 detached").to.be.null;
+ expect(w.status, "C4 back to AVAILABLE").to.eq("AVAILABLE");
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/fixtures/seed-adjust-consist.sql b/e2e/freight/cypress/fixtures/seed-adjust-consist.sql
new file mode 100644
index 000000000..1316358bb
--- /dev/null
+++ b/e2e/freight/cypress/fixtures/seed-adjust-consist.sql
@@ -0,0 +1,83 @@
+-- Arrange-data for flows/train_builder_adjust_consist.cy.ts. Idempotent.
+-- Run AFTER seed-import-corridor.sql (KALITY yard, the KALITY→DJIB_PORT export
+-- route via ensureExportRoute()).
+--
+-- A dedicated small-capacity built train at KALITY so the headroom math is
+-- exact and cheap: LOCO-ADJ-A/B pull 120T each, 20T overage tolerance ⇒ the
+-- consist's pull cap is 120 + 20 = 140T. TRN-ADJ-1 starts with 4 coupled CW4
+-- wagons (4 × 24.8T tare = 99.2T, well under cap) plus 2 free spare CW4
+-- wagons at the same yard (WGN-ADJ-S1/S2) to add:
+-- + one spare -> 124.0T (within the 140T cap) -> adjust-consist SUCCEEDS
+-- + both spares -> 148.8T (over the 140T cap) -> adjust-consist REJECTS
+--
+-- Dedicated codes (LOCO-ADJ-*, TRN-ADJ-1, WGN-ADJ-*) so this spec never
+-- competes with the shared KALITY CW4 export pocket other bulk specs draw
+-- from (see cargo-two-wagon-types-breaks-length-budget.md for what happens
+-- when a spec silently shares fleet stock with another).
+
+-- 1. Locomotives at KALITY: small pull cap, generous length (never binds).
+INSERT INTO freight.locomotives
+ (id, code, max_pull_weight_tons, max_train_length_meters,
+ overage_tolerance_tons, current_yard_id)
+SELECT gen_random_uuid(), v.code, 120, 760, 20, y.id
+FROM (VALUES ('LOCO-ADJ-A'), ('LOCO-ADJ-B')) AS v(code)
+JOIN freight.yards y ON y.code = 'KALITY'
+WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
+
+-- Keep limits stable across re-seeds (in case a prior run's row predates this
+-- fixture's numbers).
+UPDATE freight.locomotives
+SET max_pull_weight_tons = 120, max_train_length_meters = 760, overage_tolerance_tons = 20
+WHERE code IN ('LOCO-ADJ-A', 'LOCO-ADJ-B')
+ AND (max_pull_weight_tons IS DISTINCT FROM 120
+ OR max_train_length_meters IS DISTINCT FROM 760
+ OR overage_tolerance_tons IS DISTINCT FROM 20);
+
+-- 2. The built train, parked at KALITY (must match the export route's origin
+-- yard for a built-train schedule to be creatable on it).
+INSERT INTO freight.trains (id, code, train_name, capacity_tons, current_yard_id)
+SELECT gen_random_uuid(), 'TRN-ADJ-1', 'E2E Adjust-Consist Carrier', 500, y.id
+FROM freight.yards y WHERE y.code = 'KALITY'
+ AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.code = 'TRN-ADJ-1');
+
+-- 3. Couple the locomotive pair.
+INSERT INTO freight.train_locomotives (id, train_id, locomotive_id, sequence_no)
+SELECT gen_random_uuid(), t.id, l.id, v.seq
+FROM (VALUES ('LOCO-ADJ-A', 0), ('LOCO-ADJ-B', 1)) AS v(loco_code, seq)
+JOIN freight.trains t ON t.code = 'TRN-ADJ-1'
+JOIN freight.locomotives l ON l.code = v.loco_code
+WHERE NOT EXISTS (
+ SELECT 1 FROM freight.train_locomotives tl
+ WHERE tl.train_id = t.id AND tl.locomotive_id = l.id
+);
+
+-- 4. Dedicated CW4 wagons at KALITY: 4 coupled onto the train, 2 free spares.
+INSERT INTO freight.wagons (id, wagon_number, wagon_type_id, current_yard_id)
+SELECT gen_random_uuid(), v.num, wt.id, y.id
+FROM (VALUES
+ ('WGN-ADJ-C1', 1), ('WGN-ADJ-C2', 2), ('WGN-ADJ-C3', 3), ('WGN-ADJ-C4', 4),
+ ('WGN-ADJ-S1', NULL), ('WGN-ADJ-S2', NULL)
+ ) AS v(num, seq)
+JOIN freight.wagon_types wt ON wt.code = 'CW4'
+JOIN freight.yards y ON y.code = 'KALITY'
+WHERE NOT EXISTS (SELECT 1 FROM freight.wagons w WHERE w.wagon_number = v.num);
+
+-- Couple the C1..C4 quartet onto the train (idempotent re-couple in case a
+-- prior run's adjust-consist test detached one).
+UPDATE freight.wagons w
+SET train_id = t.id, sequence_number = v.seq, status = 'ASSIGNED', current_yard_id = t.current_yard_id
+FROM freight.trains t,
+ (VALUES ('WGN-ADJ-C1', 1), ('WGN-ADJ-C2', 2), ('WGN-ADJ-C3', 3), ('WGN-ADJ-C4', 4))
+ AS v(num, seq)
+WHERE w.wagon_number = v.num AND t.code = 'TRN-ADJ-1'
+ AND (w.train_id IS DISTINCT FROM t.id OR w.sequence_number IS DISTINCT FROM v.seq);
+
+-- The 2 spares stay loose (train_id NULL), AVAILABLE, at the train's yard —
+-- unconditionally re-assert every seed (runs AFTER seed-import-corridor.sql,
+-- whose own "re-park the export CW4 pocket" step sweeps any loose CW4 whose
+-- number sorts last into NAGAD — 'WGN-ADJ-S%' sorts after every corridor
+-- fixture code, so a prior run's spares are exactly the kind it would steal).
+UPDATE freight.wagons w
+SET train_id = NULL, sequence_number = NULL, status = 'AVAILABLE',
+ current_yard_id = (SELECT id FROM freight.yards WHERE code = 'KALITY')
+WHERE w.wagon_number IN ('WGN-ADJ-S1', 'WGN-ADJ-S2');
diff --git a/e2e/freight/cypress/fixtures/seed-government.sql b/e2e/freight/cypress/fixtures/seed-government.sql
new file mode 100644
index 000000000..dc8631f58
--- /dev/null
+++ b/e2e/freight/cypress/fixtures/seed-government.sql
@@ -0,0 +1,71 @@
+-- Arrange-data for flows/government_preemption.cy.ts. Idempotent.
+--
+-- The real government-company seeder (GovCompaniesSeeder /
+-- gov-companies.data.ts) is NOT wired into app.module.ts (commented out —
+-- an in-progress feature), so the e2e boot never creates it. This fixture
+-- inserts the SAME fixed row (id/tin) as GOV_COMPANIES[0] ("Federal
+-- Government of Ethiopia") — a no-op duplicate-safe insert if that seeder is
+-- ever turned back on, and the only way e2e can book a government booking
+-- today (POST /bookings requires companyId to resolve to a
+-- kind='government', status='active' company + an active company profile).
+
+INSERT INTO freight.companies
+ (id, name, type, kind, status, tin, country, email, phone)
+SELECT '0a1b0001-0000-4000-8000-000000000001'::uuid, 'Federal Government of Ethiopia',
+ 'customer', 'government', 'active', '0000000001', 'Ethiopia',
+ 'procurement@gov.et', '+251111000001'
+WHERE NOT EXISTS (
+ SELECT 1 FROM freight.companies WHERE id = '0a1b0001-0000-4000-8000-000000000001'::uuid
+);
+
+INSERT INTO freight.company_profiles
+ (id, company_id, type, reference, status)
+SELECT '0b1c0001-0000-4000-8000-000000000001'::uuid,
+ '0a1b0001-0000-4000-8000-000000000001'::uuid, 'importer', 'IM-90001', 'active'
+WHERE NOT EXISTS (
+ SELECT 1 FROM freight.company_profiles WHERE id = '0b1c0001-0000-4000-8000-000000000001'::uuid
+);
+
+-- Dedicated 3-wagon BUILT container train at DJIB_PORT (the default import
+-- corridor's origin). A loco-pair schedule's max_wagons is NOT a real cap —
+-- syncScheduleMaxWagons recomputes it from the locomotive's length every fill
+-- pass (54 for a standard-length loco), ignoring maxWagonsPerTrain entirely.
+-- A BUILT train's coupled wagon count IS the cap, immune to that recompute
+-- (see seed-adjust-consist.sql for the same pattern) — the only way to get a
+-- genuinely small, exact-fit train for the preemption scenario below.
+INSERT INTO freight.locomotives
+ (id, code, max_pull_weight_tons, max_train_length_meters, current_yard_id)
+SELECT gen_random_uuid(), v.code, 9000, 760, y.id
+FROM (VALUES ('LOCO-GOV-A'), ('LOCO-GOV-B')) AS v(code)
+JOIN freight.yards y ON y.code = 'DJIB_PORT'
+WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
+
+INSERT INTO freight.trains (id, code, train_name, capacity_tons, current_yard_id)
+SELECT gen_random_uuid(), 'TRN-GOV-1', 'E2E Government Preemption Carrier', 300, y.id
+FROM freight.yards y WHERE y.code = 'DJIB_PORT'
+ AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.code = 'TRN-GOV-1');
+
+INSERT INTO freight.train_locomotives (id, train_id, locomotive_id, sequence_no)
+SELECT gen_random_uuid(), t.id, l.id, v.seq
+FROM (VALUES ('LOCO-GOV-A', 0), ('LOCO-GOV-B', 1)) AS v(loco_code, seq)
+JOIN freight.trains t ON t.code = 'TRN-GOV-1'
+JOIN freight.locomotives l ON l.code = v.loco_code
+WHERE NOT EXISTS (
+ SELECT 1 FROM freight.train_locomotives tl
+ WHERE tl.train_id = t.id AND tl.locomotive_id = l.id
+);
+
+INSERT INTO freight.wagons (id, wagon_number, wagon_type_id, current_yard_id)
+SELECT gen_random_uuid(), v.num, wt.id, y.id
+FROM (VALUES ('WGN-GOV-C1', 1), ('WGN-GOV-C2', 2), ('WGN-GOV-C3', 3)) AS v(num, seq)
+JOIN freight.wagon_types wt ON wt.code = 'NW5'
+JOIN freight.yards y ON y.code = 'DJIB_PORT'
+WHERE NOT EXISTS (SELECT 1 FROM freight.wagons w WHERE w.wagon_number = v.num);
+
+UPDATE freight.wagons w
+SET train_id = t.id, sequence_number = v.seq, status = 'ASSIGNED',
+ current_yard_id = t.current_yard_id
+FROM freight.trains t,
+ (VALUES ('WGN-GOV-C1', 1), ('WGN-GOV-C2', 2), ('WGN-GOV-C3', 3)) AS v(num, seq)
+WHERE w.wagon_number = v.num AND t.code = 'TRN-GOV-1'
+ AND (w.train_id IS DISTINCT FROM t.id OR w.sequence_number IS DISTINCT FROM v.seq);