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);