mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #955 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,6 +33,7 @@ docker-compose.override.yml
|
|||||||
# cypress e2e artifacts
|
# cypress e2e artifacts
|
||||||
e2e/**/cypress/videos/
|
e2e/**/cypress/videos/
|
||||||
e2e/**/cypress/screenshots/
|
e2e/**/cypress/screenshots/
|
||||||
|
e2e/**/cypress/reports/
|
||||||
e2e/**/cypress/downloads/
|
e2e/**/cypress/downloads/
|
||||||
|
|
||||||
# e2e launcher state (ports of the running stack)
|
# e2e launcher state (ports of the running stack)
|
||||||
|
|||||||
440
E2E_TEST_REPORT.md
Normal file
440
E2E_TEST_REPORT.md
Normal file
@@ -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" && (
|
||||||
|
...
|
||||||
|
<Text fz={14} fw={700}>20ft & 40ft containers covered</Text>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 '<p.mantine-Text-root>' 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/<id>/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.
|
||||||
@@ -26,8 +26,23 @@ export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
|||||||
export const TrainSchedulingView = () =>
|
export const TrainSchedulingView = () =>
|
||||||
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
|
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
|
||||||
|
|
||||||
export const TrainSchedulingManage = () =>
|
// Granular train-scheduling actions replace the retired coarse manage:
|
||||||
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
|
// create a schedule, update (assign/consist/loading/finalize/dispatch/arrive…),
|
||||||
|
// cancel a schedule, reschedule (+ maintenance), and manage global rules.
|
||||||
|
export const TrainSchedulingCreate = () =>
|
||||||
|
BookingStaff(FREIGHT_PERMS.trainScheduling.create);
|
||||||
|
|
||||||
|
export const TrainSchedulingUpdate = () =>
|
||||||
|
BookingStaff(FREIGHT_PERMS.trainScheduling.update);
|
||||||
|
|
||||||
|
export const TrainSchedulingCancel = () =>
|
||||||
|
BookingStaff(FREIGHT_PERMS.trainScheduling.cancel);
|
||||||
|
|
||||||
|
export const TrainSchedulingReschedule = () =>
|
||||||
|
BookingStaff(FREIGHT_PERMS.trainScheduling.reschedule);
|
||||||
|
|
||||||
|
export const TrainSchedulingRulesManage = () =>
|
||||||
|
BookingStaff(FREIGHT_PERMS.trainScheduling.rulesManage);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fleet guards take an optional granular per-resource key (locomotives:create,
|
* Fleet guards take an optional granular per-resource key (locomotives:create,
|
||||||
|
|||||||
@@ -13,9 +13,22 @@ export const RuleEngineView = (slug: RuleEngineResourceSlug) =>
|
|||||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
|
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const RuleEngineManage = (slug: RuleEngineResourceSlug) =>
|
// Granular CRUD replaces the retired coarse RuleEngineManage. Each write
|
||||||
|
// endpoint carries the specific action it performs — create on POST-new,
|
||||||
|
// update on PATCH / reorder / move-order, delete on DELETE.
|
||||||
|
export const RuleEngineCreate = (slug: RuleEngineResourceSlug) =>
|
||||||
applyDecorators(
|
applyDecorators(
|
||||||
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])),
|
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.create(slug)])),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const RuleEngineUpdate = (slug: RuleEngineResourceSlug) =>
|
||||||
|
applyDecorators(
|
||||||
|
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.update(slug)])),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const RuleEngineDelete = (slug: RuleEngineResourceSlug) =>
|
||||||
|
applyDecorators(
|
||||||
|
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.delete(slug)])),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -526,6 +526,8 @@ export class ContractsController {
|
|||||||
contractId: view.bookingId,
|
contractId: view.bookingId,
|
||||||
reference: view.reference,
|
reference: view.reference,
|
||||||
status: view.status,
|
status: view.status,
|
||||||
|
// Drives the per-freight-type sign permission on the client.
|
||||||
|
freightType: contract.freightType,
|
||||||
templateKey: view.templateKey,
|
templateKey: view.templateKey,
|
||||||
title: view.template.title,
|
title: view.template.title,
|
||||||
html,
|
html,
|
||||||
@@ -577,19 +579,21 @@ export class ContractsController {
|
|||||||
@Post(':id/contract/sign')
|
@Post(':id/contract/sign')
|
||||||
@UseGuards(JwtGuard)
|
@UseGuards(JwtGuard)
|
||||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
|
||||||
signContract(
|
async signContract(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: SignContractDto,
|
@Body() dto: SignContractDto,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
// Each staff signing role maps to the permission that step already requires;
|
|
||||||
// customers sign their own contract with no permission key.
|
|
||||||
const signRolePermission: Record<string, string> = {
|
|
||||||
STAFF: FREIGHT_PERMS.contracts.signStaff,
|
|
||||||
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
|
||||||
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
|
||||||
};
|
|
||||||
if (dto.role !== 'CUSTOMER') {
|
if (dto.role !== 'CUSTOMER') {
|
||||||
|
// Each staff signing role maps to the permission that step already
|
||||||
|
// requires; the STAFF counter-signature is split per freight type, so a
|
||||||
|
// bulk signer cannot counter-sign a container contract (and vice versa).
|
||||||
|
const contract = await this.contractsService.findById(id);
|
||||||
|
const signRolePermission: Record<string, string> = {
|
||||||
|
STAFF: forFreightType(FREIGHT_PERMS.contracts.signStaff, contract.freightType),
|
||||||
|
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
||||||
|
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
||||||
|
};
|
||||||
assertFreightPermission(user, signRolePermission[dto.role]);
|
assertFreightPermission(user, signRolePermission[dto.role]);
|
||||||
}
|
}
|
||||||
return this.transitionService.sign(id, dto, {
|
return this.transitionService.sign(id, dto, {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
||||||
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
@@ -41,7 +41,7 @@ export class ApprovalRulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('reorder')
|
@Post('reorder')
|
||||||
@RuleEngineManage('approval-rules')
|
@RuleEngineUpdate('approval-rules')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Bulk reorder approval steps within a chain' })
|
@ApiOperation({ summary: 'Bulk reorder approval steps within a chain' })
|
||||||
reorder(@Body() dto: ReorderItemsDto) {
|
reorder(@Body() dto: ReorderItemsDto) {
|
||||||
@@ -49,7 +49,7 @@ export class ApprovalRulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/move-order')
|
@Post(':id/move-order')
|
||||||
@RuleEngineManage('approval-rules')
|
@RuleEngineUpdate('approval-rules')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Move an approval step up or down within its chain' })
|
@ApiOperation({ summary: 'Move an approval step up or down within its chain' })
|
||||||
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
||||||
@@ -64,21 +64,21 @@ export class ApprovalRulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RuleEngineManage('approval-rules')
|
@RuleEngineCreate('approval-rules')
|
||||||
@ApiOperation({ summary: 'Create an approval rule step' })
|
@ApiOperation({ summary: 'Create an approval rule step' })
|
||||||
create(@Body() dto: CreateApprovalRuleDto) {
|
create(@Body() dto: CreateApprovalRuleDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RuleEngineManage('approval-rules')
|
@RuleEngineUpdate('approval-rules')
|
||||||
@ApiOperation({ summary: 'Update an approval rule' })
|
@ApiOperation({ summary: 'Update an approval rule' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RuleEngineManage('approval-rules')
|
@RuleEngineDelete('approval-rules')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete an approval rule' })
|
@ApiOperation({ summary: 'Soft-delete an approval rule' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
|
||||||
import { StaffReference } from '../../../common/booking-guards';
|
import { StaffReference } from '../../../common/booking-guards';
|
||||||
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
||||||
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
@@ -26,7 +26,7 @@ export class CargoTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('reorder')
|
@Post('reorder')
|
||||||
@RuleEngineManage('cargo-types')
|
@RuleEngineUpdate('cargo-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Bulk reorder cargo types by ID list' })
|
@ApiOperation({ summary: 'Bulk reorder cargo types by ID list' })
|
||||||
reorder(@Body() dto: ReorderItemsDto) {
|
reorder(@Body() dto: ReorderItemsDto) {
|
||||||
@@ -34,7 +34,7 @@ export class CargoTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/move-order')
|
@Post(':id/move-order')
|
||||||
@RuleEngineManage('cargo-types')
|
@RuleEngineUpdate('cargo-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Move a cargo type up or down in display order' })
|
@ApiOperation({ summary: 'Move a cargo type up or down in display order' })
|
||||||
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
||||||
@@ -49,21 +49,21 @@ export class CargoTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RuleEngineManage('cargo-types')
|
@RuleEngineCreate('cargo-types')
|
||||||
@ApiOperation({ summary: 'Create a cargo type' })
|
@ApiOperation({ summary: 'Create a cargo type' })
|
||||||
create(@Body() dto: CreateCargoTypeDto) {
|
create(@Body() dto: CreateCargoTypeDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RuleEngineManage('cargo-types')
|
@RuleEngineUpdate('cargo-types')
|
||||||
@ApiOperation({ summary: 'Update a cargo type' })
|
@ApiOperation({ summary: 'Update a cargo type' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoTypeDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoTypeDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RuleEngineManage('cargo-types')
|
@RuleEngineDelete('cargo-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a cargo type' })
|
@ApiOperation({ summary: 'Soft-delete a cargo type' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
|
||||||
import { StaffReference } from '../../../common/booking-guards';
|
import { StaffReference } from '../../../common/booking-guards';
|
||||||
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
||||||
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
@@ -26,7 +26,7 @@ export class ContainerTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('reorder')
|
@Post('reorder')
|
||||||
@RuleEngineManage('container-types')
|
@RuleEngineUpdate('container-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Bulk reorder container types by ID list' })
|
@ApiOperation({ summary: 'Bulk reorder container types by ID list' })
|
||||||
reorder(@Body() dto: ReorderItemsDto) {
|
reorder(@Body() dto: ReorderItemsDto) {
|
||||||
@@ -34,7 +34,7 @@ export class ContainerTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/move-order')
|
@Post(':id/move-order')
|
||||||
@RuleEngineManage('container-types')
|
@RuleEngineUpdate('container-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Move a container type up or down in display order' })
|
@ApiOperation({ summary: 'Move a container type up or down in display order' })
|
||||||
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
||||||
@@ -49,21 +49,21 @@ export class ContainerTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RuleEngineManage('container-types')
|
@RuleEngineCreate('container-types')
|
||||||
@ApiOperation({ summary: 'Create a container type' })
|
@ApiOperation({ summary: 'Create a container type' })
|
||||||
create(@Body() dto: CreateContainerTypeDto) {
|
create(@Body() dto: CreateContainerTypeDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RuleEngineManage('container-types')
|
@RuleEngineUpdate('container-types')
|
||||||
@ApiOperation({ summary: 'Update a container type' })
|
@ApiOperation({ summary: 'Update a container type' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerTypeDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerTypeDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RuleEngineManage('container-types')
|
@RuleEngineDelete('container-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a container type' })
|
@ApiOperation({ summary: 'Soft-delete a container type' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
|
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
|
||||||
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
@@ -49,14 +49,14 @@ export class PriorityConfigsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RuleEngineManage('priority-configs')
|
@RuleEngineCreate('priority-configs')
|
||||||
@ApiOperation({ summary: 'Create a priority config' })
|
@ApiOperation({ summary: 'Create a priority config' })
|
||||||
create(@Body() dto: CreatePriorityConfigDto) {
|
create(@Body() dto: CreatePriorityConfigDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('reorder')
|
@Post('reorder')
|
||||||
@RuleEngineManage('priority-configs')
|
@RuleEngineUpdate('priority-configs')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Bulk reorder priority configs by ID list' })
|
@ApiOperation({ summary: 'Bulk reorder priority configs by ID list' })
|
||||||
reorder(@Body() dto: ReorderItemsDto) {
|
reorder(@Body() dto: ReorderItemsDto) {
|
||||||
@@ -64,7 +64,7 @@ export class PriorityConfigsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/move-order')
|
@Post(':id/move-order')
|
||||||
@RuleEngineManage('priority-configs')
|
@RuleEngineUpdate('priority-configs')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Move a priority config up or down in display order' })
|
@ApiOperation({ summary: 'Move a priority config up or down in display order' })
|
||||||
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
||||||
@@ -72,14 +72,14 @@ export class PriorityConfigsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RuleEngineManage('priority-configs')
|
@RuleEngineUpdate('priority-configs')
|
||||||
@ApiOperation({ summary: 'Update a priority config' })
|
@ApiOperation({ summary: 'Update a priority config' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityConfigDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityConfigDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RuleEngineManage('priority-configs')
|
@RuleEngineDelete('priority-configs')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a priority config' })
|
@ApiOperation({ summary: 'Soft-delete a priority config' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'
|
|||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineCreate, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { isSuperAdmin } from '../../../common/freight-permission.util';
|
import { isSuperAdmin } from '../../../common/freight-permission.util';
|
||||||
import {
|
import {
|
||||||
DecidePriorityRuleChangeDto,
|
DecidePriorityRuleChangeDto,
|
||||||
@@ -32,7 +32,7 @@ export class PriorityRuleChangeRequestsController {
|
|||||||
constructor(private readonly service: PriorityRuleChangeRequestsService) {}
|
constructor(private readonly service: PriorityRuleChangeRequestsService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RuleEngineManage('priority-configs')
|
@RuleEngineCreate('priority-configs')
|
||||||
@ApiOperation({ summary: 'Submit a priority-rule change for approval' })
|
@ApiOperation({ summary: 'Submit a priority-rule change for approval' })
|
||||||
submit(
|
submit(
|
||||||
@Body() dto: SubmitPriorityRuleChangeDto,
|
@Body() dto: SubmitPriorityRuleChangeDto,
|
||||||
@@ -50,7 +50,7 @@ export class PriorityRuleChangeRequestsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/approve')
|
@Post(':id/approve')
|
||||||
@RuleEngineManage('priority-configs')
|
@RuleEngineUpdate('priority-configs')
|
||||||
@ApiOperation({ summary: 'Approve and apply a pending change' })
|
@ApiOperation({ summary: 'Approve and apply a pending change' })
|
||||||
approve(
|
approve(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@@ -63,7 +63,7 @@ export class PriorityRuleChangeRequestsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/reject')
|
@Post(':id/reject')
|
||||||
@RuleEngineManage('priority-configs')
|
@RuleEngineUpdate('priority-configs')
|
||||||
@ApiOperation({ summary: 'Reject a pending change' })
|
@ApiOperation({ summary: 'Reject a pending change' })
|
||||||
reject(
|
reject(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { CurrentUser } from '@edr/api-common';
|
|||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
import { isSuperAdmin } from '../../../common/freight-permission.util';
|
import { isSuperAdmin } from '../../../common/freight-permission.util';
|
||||||
import { RuleEngineApprove, RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineApprove, RuleEngineCreate, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { DecideRateChangeDto, SubmitRateChangeDto } from '../dto/rate-change-request.dto';
|
import { DecideRateChangeDto, SubmitRateChangeDto } from '../dto/rate-change-request.dto';
|
||||||
import { RateChangeStatus } from '../entities/rate-change-request.entity';
|
import { RateChangeStatus } from '../entities/rate-change-request.entity';
|
||||||
import { RateChangeRequestsService } from '../services/rate-change-requests.service';
|
import { RateChangeRequestsService } from '../services/rate-change-requests.service';
|
||||||
@@ -21,7 +21,7 @@ export class RateChangeRequestsController {
|
|||||||
constructor(private readonly service: RateChangeRequestsService) {}
|
constructor(private readonly service: RateChangeRequestsService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RuleEngineManage('rates')
|
@RuleEngineCreate('rates')
|
||||||
@ApiOperation({ summary: 'Propose a change to a LIVE rate' })
|
@ApiOperation({ summary: 'Propose a change to a LIVE rate' })
|
||||||
submit(@Body() dto: SubmitRateChangeDto, @CurrentUser() user: TCurrentUser) {
|
submit(@Body() dto: SubmitRateChangeDto, @CurrentUser() user: TCurrentUser) {
|
||||||
return this.service.submit(dto, user?.id);
|
return this.service.submit(dto, user?.id);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { isSuperAdmin } from '../../../common/freight-permission.util';
|
import { isSuperAdmin } from '../../../common/freight-permission.util';
|
||||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
@@ -44,7 +44,7 @@ export class RatesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RuleEngineManage('rates')
|
@RuleEngineCreate('rates')
|
||||||
@ApiOperation({ summary: 'Create a rate (DRAFT)' })
|
@ApiOperation({ summary: 'Create a rate (DRAFT)' })
|
||||||
create(
|
create(
|
||||||
@Body() dto: CreateRateDto,
|
@Body() dto: CreateRateDto,
|
||||||
@@ -54,21 +54,21 @@ export class RatesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RuleEngineManage('rates')
|
@RuleEngineUpdate('rates')
|
||||||
@ApiOperation({ summary: 'Update a DRAFT rate' })
|
@ApiOperation({ summary: 'Update a DRAFT rate' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRateDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRateDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/submit')
|
@Post(':id/submit')
|
||||||
@RuleEngineManage('rates')
|
@RuleEngineUpdate('rates')
|
||||||
@ApiOperation({ summary: 'Submit rate for CEO approval' })
|
@ApiOperation({ summary: 'Submit rate for CEO approval' })
|
||||||
submit(@Param('id', ParseUUIDPipe) id: string) {
|
submit(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.service.submitForApproval(id);
|
return this.service.submitForApproval(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/approve')
|
@Post(':id/approve')
|
||||||
@RuleEngineManage('rates')
|
@RuleEngineUpdate('rates')
|
||||||
@ApiOperation({ summary: 'CEO approves a rate' })
|
@ApiOperation({ summary: 'CEO approves a rate' })
|
||||||
approve(
|
approve(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@@ -80,7 +80,7 @@ export class RatesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RuleEngineManage('rates')
|
@RuleEngineDelete('rates')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a rate' })
|
@ApiOperation({ summary: 'Soft-delete a rate' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
|
||||||
import { StaffReference } from '../../../common/booking-guards';
|
import { StaffReference } from '../../../common/booking-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
||||||
@@ -26,7 +26,7 @@ export class ServiceTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('reorder')
|
@Post('reorder')
|
||||||
@RuleEngineManage('service-types')
|
@RuleEngineUpdate('service-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Bulk reorder service types by ID list' })
|
@ApiOperation({ summary: 'Bulk reorder service types by ID list' })
|
||||||
reorder(@Body() dto: ReorderItemsDto) {
|
reorder(@Body() dto: ReorderItemsDto) {
|
||||||
@@ -34,7 +34,7 @@ export class ServiceTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/move-order')
|
@Post(':id/move-order')
|
||||||
@RuleEngineManage('service-types')
|
@RuleEngineUpdate('service-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Move a service type up or down in display order' })
|
@ApiOperation({ summary: 'Move a service type up or down in display order' })
|
||||||
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
||||||
@@ -49,21 +49,21 @@ export class ServiceTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RuleEngineManage('service-types')
|
@RuleEngineCreate('service-types')
|
||||||
@ApiOperation({ summary: 'Create a service type' })
|
@ApiOperation({ summary: 'Create a service type' })
|
||||||
create(@Body() dto: CreateServiceTypeDto) {
|
create(@Body() dto: CreateServiceTypeDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RuleEngineManage('service-types')
|
@RuleEngineUpdate('service-types')
|
||||||
@ApiOperation({ summary: 'Update a service type' })
|
@ApiOperation({ summary: 'Update a service type' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateServiceTypeDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateServiceTypeDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RuleEngineManage('service-types')
|
@RuleEngineDelete('service-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a service type' })
|
@ApiOperation({ summary: 'Soft-delete a service type' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
|
||||||
import { StaffReference } from '../../../common/booking-guards';
|
import { StaffReference } from '../../../common/booking-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
|
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
|
||||||
@@ -31,21 +31,21 @@ export class ShippingLinesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RuleEngineManage('shipping-lines')
|
@RuleEngineCreate('shipping-lines')
|
||||||
@ApiOperation({ summary: 'Create a shipping line' })
|
@ApiOperation({ summary: 'Create a shipping line' })
|
||||||
create(@Body() dto: CreateShippingLineDto) {
|
create(@Body() dto: CreateShippingLineDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RuleEngineManage('shipping-lines')
|
@RuleEngineUpdate('shipping-lines')
|
||||||
@ApiOperation({ summary: 'Update a shipping line' })
|
@ApiOperation({ summary: 'Update a shipping line' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateShippingLineDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateShippingLineDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RuleEngineManage('shipping-lines')
|
@RuleEngineDelete('shipping-lines')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a shipping line' })
|
@ApiOperation({ summary: 'Soft-delete a shipping line' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
|
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
|
||||||
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
@@ -30,21 +30,21 @@ export class WeightLimitRulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RuleEngineManage('weight-limit-rules')
|
@RuleEngineCreate('weight-limit-rules')
|
||||||
@ApiOperation({ summary: 'Create a weight limit rule' })
|
@ApiOperation({ summary: 'Create a weight limit rule' })
|
||||||
create(@Body() dto: CreateWeightLimitRuleDto) {
|
create(@Body() dto: CreateWeightLimitRuleDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RuleEngineManage('weight-limit-rules')
|
@RuleEngineUpdate('weight-limit-rules')
|
||||||
@ApiOperation({ summary: 'Update a weight limit rule' })
|
@ApiOperation({ summary: 'Update a weight limit rule' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWeightLimitRuleDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWeightLimitRuleDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RuleEngineManage('weight-limit-rules')
|
@RuleEngineDelete('weight-limit-rules')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a weight limit rule' })
|
@ApiOperation({ summary: 'Soft-delete a weight limit rule' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
|
||||||
import { StaffReference } from '../../../common/booking-guards';
|
import { StaffReference } from '../../../common/booking-guards';
|
||||||
import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto';
|
import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto';
|
||||||
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
@@ -40,21 +40,21 @@ export class YardDistancesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RuleEngineManage('yard-distances')
|
@RuleEngineCreate('yard-distances')
|
||||||
@ApiOperation({ summary: 'Create a yard distance' })
|
@ApiOperation({ summary: 'Create a yard distance' })
|
||||||
create(@Body() dto: CreateYardDistanceDto) {
|
create(@Body() dto: CreateYardDistanceDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RuleEngineManage('yard-distances')
|
@RuleEngineUpdate('yard-distances')
|
||||||
@ApiOperation({ summary: 'Update a yard distance' })
|
@ApiOperation({ summary: 'Update a yard distance' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDistanceDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDistanceDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RuleEngineManage('yard-distances')
|
@RuleEngineDelete('yard-distances')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a yard distance' })
|
@ApiOperation({ summary: 'Soft-delete a yard distance' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate } from '../../../common/rule-engine-guards';
|
||||||
import { StaffReference } from '../../../common/booking-guards';
|
import { StaffReference } from '../../../common/booking-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateYardDto } from '../dto/create-yard.dto';
|
import { CreateYardDto } from '../dto/create-yard.dto';
|
||||||
@@ -28,7 +28,7 @@ export class YardsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('reorder')
|
@Post('reorder')
|
||||||
@RuleEngineManage('yards')
|
@RuleEngineUpdate('yards')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Bulk reorder yards by ID list' })
|
@ApiOperation({ summary: 'Bulk reorder yards by ID list' })
|
||||||
reorder(@Body() dto: ReorderItemsDto) {
|
reorder(@Body() dto: ReorderItemsDto) {
|
||||||
@@ -36,7 +36,7 @@ export class YardsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/move-order')
|
@Post(':id/move-order')
|
||||||
@RuleEngineManage('yards')
|
@RuleEngineUpdate('yards')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Move a yard up or down in display order' })
|
@ApiOperation({ summary: 'Move a yard up or down in display order' })
|
||||||
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
moveOrder(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveOrderDto) {
|
||||||
@@ -51,21 +51,21 @@ export class YardsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RuleEngineManage('yards')
|
@RuleEngineCreate('yards')
|
||||||
@ApiOperation({ summary: 'Create a yard' })
|
@ApiOperation({ summary: 'Create a yard' })
|
||||||
create(@Body() dto: CreateYardDto) {
|
create(@Body() dto: CreateYardDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RuleEngineManage('yards')
|
@RuleEngineUpdate('yards')
|
||||||
@ApiOperation({ summary: 'Update a yard' })
|
@ApiOperation({ summary: 'Update a yard' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDto) {
|
||||||
return this.service.update(id, dto);
|
return this.service.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RuleEngineManage('yards')
|
@RuleEngineDelete('yards')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a yard' })
|
@ApiOperation({ summary: 'Soft-delete a yard' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
|
|||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
|
||||||
import { TrainSchedulingManage } from '../../common/booking-guards';
|
import { TrainSchedulingReschedule } from '../../common/booking-guards';
|
||||||
import {
|
import {
|
||||||
type AuthUserPayload,
|
type AuthUserPayload,
|
||||||
resolveAuthUserId,
|
resolveAuthUserId,
|
||||||
@@ -18,7 +18,7 @@ export class SchedulingRescheduleController {
|
|||||||
constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {}
|
constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {}
|
||||||
|
|
||||||
@Post('preview')
|
@Post('preview')
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingReschedule()
|
||||||
@ApiOperation({ summary: 'Preview reschedule / government preempt plan' })
|
@ApiOperation({ summary: 'Preview reschedule / government preempt plan' })
|
||||||
preview(
|
preview(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@@ -28,7 +28,7 @@ export class SchedulingRescheduleController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('execute')
|
@Post('execute')
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingReschedule()
|
||||||
@ApiOperation({ summary: 'Execute a confirmed reschedule plan' })
|
@ApiOperation({ summary: 'Execute a confirmed reschedule plan' })
|
||||||
execute(
|
execute(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@@ -50,7 +50,7 @@ export class SchedulingMaintenanceController {
|
|||||||
constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {}
|
constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {}
|
||||||
|
|
||||||
@Post('maintenance')
|
@Post('maintenance')
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingReschedule()
|
||||||
@ApiOperation({ summary: 'Reschedule train for maintenance (new departure + rebalance)' })
|
@ApiOperation({ summary: 'Reschedule train for maintenance (new departure + rebalance)' })
|
||||||
maintenance(
|
maintenance(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
ParseUUIDPipe,
|
|
||||||
Patch,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
Res,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { CurrentUser } from "@edr/api-common";
|
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
|
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
|
||||||
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
|
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
TrainSchedulingManage,
|
Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { CurrentUser } from "@edr/api-common";
|
||||||
|
import {
|
||||||
|
TrainSchedulingCancel,
|
||||||
|
TrainSchedulingCreate,
|
||||||
|
TrainSchedulingReschedule,
|
||||||
|
TrainSchedulingRulesManage,
|
||||||
|
TrainSchedulingUpdate,
|
||||||
TrainSchedulingView,
|
TrainSchedulingView,
|
||||||
} from "../../common/booking-guards";
|
} from "../../common/booking-guards";
|
||||||
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
|
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
|
||||||
@@ -114,7 +109,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch("global-rules")
|
@Patch("global-rules")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingRulesManage()
|
||||||
@ApiOperation({ summary: "Update global train scheduling rules (singleton)" })
|
@ApiOperation({ summary: "Update global train scheduling rules (singleton)" })
|
||||||
updateGlobalRules(@Body() dto: UpdateTrainSchedulingGlobalRulesDto) {
|
updateGlobalRules(@Body() dto: UpdateTrainSchedulingGlobalRulesDto) {
|
||||||
return this.trainSchedulingService.updateTrainSchedulingGlobalRules(dto);
|
return this.trainSchedulingService.updateTrainSchedulingGlobalRules(dto);
|
||||||
@@ -181,7 +176,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/adjust-consist")
|
@Post("schedules/:id/adjust-consist")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)",
|
"Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)",
|
||||||
@@ -283,21 +278,21 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("container/schedules")
|
@Post("container/schedules")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingCreate()
|
||||||
@ApiOperation({ summary: "Create a container train schedule" })
|
@ApiOperation({ summary: "Create a container train schedule" })
|
||||||
createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
|
createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
|
||||||
return this.trainSchedulingService.createContainerTrainSchedule(dto);
|
return this.trainSchedulingService.createContainerTrainSchedule(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("bulk/schedules")
|
@Post("bulk/schedules")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingCreate()
|
||||||
@ApiOperation({ summary: "Create a bulk train schedule" })
|
@ApiOperation({ summary: "Create a bulk train schedule" })
|
||||||
createBulkTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
|
createBulkTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
|
||||||
return this.trainSchedulingService.createContainerTrainSchedule(dto);
|
return this.trainSchedulingService.createContainerTrainSchedule(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/assign-bookings")
|
@Post("schedules/:id/assign-bookings")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Assign bookings to a train schedule (mixed-capable)",
|
summary: "Assign bookings to a train schedule (mixed-capable)",
|
||||||
})
|
})
|
||||||
@@ -309,7 +304,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("container/schedules/:id/assign-bookings")
|
@Post("container/schedules/:id/assign-bookings")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({ summary: "Assign container bookings to a train schedule" })
|
@ApiOperation({ summary: "Assign container bookings to a train schedule" })
|
||||||
assignContainerBookings(
|
assignContainerBookings(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -323,7 +318,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("bulk/schedules/:id/assign-bookings")
|
@Post("bulk/schedules/:id/assign-bookings")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({ summary: "Assign bulk bookings to a train schedule" })
|
@ApiOperation({ summary: "Assign bulk bookings to a train schedule" })
|
||||||
assignBulkBookings(
|
assignBulkBookings(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -337,7 +332,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete("schedules/:id/bookings/:bookingId")
|
@Delete("schedules/:id/bookings/:bookingId")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({ summary: "Unassign a booking from a train schedule" })
|
@ApiOperation({ summary: "Unassign a booking from a train schedule" })
|
||||||
unassignBooking(
|
unassignBooking(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -352,7 +347,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete("schedules/:id/wagons/:trainSetWagonId")
|
@Delete("schedules/:id/wagons/:trainSetWagonId")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({ summary: "Remove an empty wagon slot from a train" })
|
@ApiOperation({ summary: "Remove an empty wagon slot from a train" })
|
||||||
removeWagonSlot(
|
removeWagonSlot(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -365,7 +360,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch("schedules/:id/container-items/:itemId")
|
@Patch("schedules/:id/container-items/:itemId")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({ summary: "Update a container number on a wagon slot" })
|
@ApiOperation({ summary: "Update a container number on a wagon slot" })
|
||||||
updateContainerItem(
|
updateContainerItem(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -376,7 +371,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/wagons/:wagonId/move-load")
|
@Post("schedules/:id/wagons/:wagonId/move-load")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)",
|
"Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)",
|
||||||
@@ -397,7 +392,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/assign-unassigned-booking")
|
@Post("schedules/:id/assign-unassigned-booking")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Assign one linked unallocated booking to wagons (preserves existing assignments)",
|
"Assign one linked unallocated booking to wagons (preserves existing assignments)",
|
||||||
@@ -429,7 +424,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch("schedules/:id/import-loading-status")
|
@Patch("schedules/:id/import-loading-status")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)",
|
"Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)",
|
||||||
@@ -442,7 +437,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch("schedules/:id/loading-status")
|
@Patch("schedules/:id/loading-status")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)",
|
"Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)",
|
||||||
@@ -455,21 +450,21 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/pin-wagons")
|
@Post("schedules/:id/pin-wagons")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({ summary: "Pin physical wagons to train set slots" })
|
@ApiOperation({ summary: "Pin physical wagons to train set slots" })
|
||||||
pinWagons(@Param("id", ParseUUIDPipe) id: string, @Body() dto: PinWagonsDto) {
|
pinWagons(@Param("id", ParseUUIDPipe) id: string, @Body() dto: PinWagonsDto) {
|
||||||
return this.trainSchedulingService.pinWagons(id, dto);
|
return this.trainSchedulingService.pinWagons(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/finalize")
|
@Post("schedules/:id/finalize")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({ summary: "Finalize a draft train schedule" })
|
@ApiOperation({ summary: "Finalize a draft train schedule" })
|
||||||
finalizeSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
finalizeSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.trainSchedulingService.finalizeSchedule(id);
|
return this.trainSchedulingService.finalizeSchedule(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/dispatch")
|
@Post("schedules/:id/dispatch")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({ summary: "Dispatch a scheduled train" })
|
@ApiOperation({ summary: "Dispatch a scheduled train" })
|
||||||
dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.trainSchedulingService.dispatchSchedule(id);
|
return this.trainSchedulingService.dispatchSchedule(id);
|
||||||
@@ -496,7 +491,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/intercity/accept")
|
@Post("schedules/:id/intercity/accept")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)",
|
"Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)",
|
||||||
@@ -519,7 +514,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/bookings/:bookingId/load")
|
@Post("schedules/:id/bookings/:bookingId/load")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)",
|
"Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)",
|
||||||
@@ -532,7 +527,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/bookings/:bookingId/unload")
|
@Post("schedules/:id/bookings/:bookingId/unload")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival",
|
"Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival",
|
||||||
@@ -545,7 +540,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/intercity/:bookingId/load")
|
@Post("schedules/:id/intercity/:bookingId/load")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)",
|
summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)",
|
||||||
})
|
})
|
||||||
@@ -557,7 +552,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/intercity/:bookingId/unload")
|
@Post("schedules/:id/intercity/:bookingId/unload")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)",
|
"Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)",
|
||||||
@@ -577,7 +572,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/import-djibouti/documents")
|
@Post("schedules/:id/import-djibouti/documents")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({ summary: "Upload/check an import Djibouti-side document" })
|
@ApiOperation({ summary: "Upload/check an import Djibouti-side document" })
|
||||||
uploadImportDjiboutiDocument(
|
uploadImportDjiboutiDocument(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -587,7 +582,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/import-djibouti/gatepass-granted")
|
@Post("schedules/:id/import-djibouti/gatepass-granted")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({ summary: "Mark import Djibouti gatepass permission granted" })
|
@ApiOperation({ summary: "Mark import Djibouti gatepass permission granted" })
|
||||||
grantImportDjiboutiGatepass(
|
grantImportDjiboutiGatepass(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -597,7 +592,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/import-djibouti/ready-for-loading")
|
@Post("schedules/:id/import-djibouti/ready-for-loading")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({ summary: "Mark import train ready for loading at Djibouti" })
|
@ApiOperation({ summary: "Mark import train ready for loading at Djibouti" })
|
||||||
markImportReadyForLoading(
|
markImportReadyForLoading(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -607,7 +602,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/import-djibouti/loaded-on-train")
|
@Post("schedules/:id/import-djibouti/loaded-on-train")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({ summary: "Confirm import cargo loaded on train at Djibouti" })
|
@ApiOperation({ summary: "Confirm import cargo loaded on train at Djibouti" })
|
||||||
confirmImportLoadedOnTrain(
|
confirmImportLoadedOnTrain(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -617,7 +612,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/confirm-loading")
|
@Post("schedules/:id/confirm-loading")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)",
|
"Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)",
|
||||||
@@ -630,7 +625,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/import-djibouti/depart")
|
@Post("schedules/:id/import-djibouti/depart")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({ summary: "Depart loaded import train from Djibouti" })
|
@ApiOperation({ summary: "Depart loaded import train from Djibouti" })
|
||||||
departImportFromDjibouti(
|
departImportFromDjibouti(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -640,7 +635,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/import-djibouti/load-list")
|
@Post("schedules/:id/import-djibouti/load-list")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({ summary: "Generate import load list / marshalling document summary" })
|
@ApiOperation({ summary: "Generate import load list / marshalling document summary" })
|
||||||
generateImportLoadList(
|
generateImportLoadList(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -680,7 +675,7 @@ export class TrainSchedulingController {
|
|||||||
// ---- batch / booking-window staff actions ----
|
// ---- batch / booking-window staff actions ----
|
||||||
|
|
||||||
@Post("schedules/:id/run-batch")
|
@Post("schedules/:id/run-batch")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({ summary: "Manually run the batch fill for a schedule" })
|
@ApiOperation({ summary: "Manually run the batch fill for a schedule" })
|
||||||
async runBatch(@Param("id", ParseUUIDPipe) id: string) {
|
async runBatch(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
await this.bookingBatchService.fillSchedule(id);
|
await this.bookingBatchService.fillSchedule(id);
|
||||||
@@ -688,7 +683,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/run-allocation")
|
@Post("schedules/:id/run-allocation")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Run wagon-level allocation for all eligible linked bookings",
|
summary: "Run wagon-level allocation for all eligible linked bookings",
|
||||||
})
|
})
|
||||||
@@ -697,7 +692,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch("schedules/:id/booking-window")
|
@Patch("schedules/:id/booking-window")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({ summary: "Open or close a schedule booking window" })
|
@ApiOperation({ summary: "Open or close a schedule booking window" })
|
||||||
async setBookingWindow(
|
async setBookingWindow(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@@ -711,7 +706,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch("schedules/:id/window-rule")
|
@Patch("schedules/:id/window-rule")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens",
|
"Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens",
|
||||||
@@ -725,7 +720,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch("schedules/:id/schedule-date")
|
@Patch("schedules/:id/schedule-date")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window",
|
"Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window",
|
||||||
@@ -739,7 +734,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/maintenance")
|
@Post("schedules/:id/maintenance")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingReschedule()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged",
|
"Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged",
|
||||||
@@ -753,7 +748,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/doc-review-complete")
|
@Post("schedules/:id/doc-review-complete")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)",
|
"Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)",
|
||||||
@@ -764,7 +759,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("bookings/:bookingId/mark-paid")
|
@Post("bookings/:bookingId/mark-paid")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Staff: mark a reserved booking paid and allocate it now",
|
summary: "Staff: mark a reserved booking paid and allocate it now",
|
||||||
})
|
})
|
||||||
@@ -774,7 +769,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("bookings/:bookingId/expire")
|
@Post("bookings/:bookingId/expire")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Staff: expire a reservation and free its capacity",
|
summary: "Staff: expire a reservation and free its capacity",
|
||||||
})
|
})
|
||||||
@@ -784,7 +779,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("bookings/:bookingId/move-schedule")
|
@Post("bookings/:bookingId/move-schedule")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Re-point a booking to another OPEN same-route schedule",
|
summary: "Re-point a booking to another OPEN same-route schedule",
|
||||||
})
|
})
|
||||||
@@ -806,7 +801,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/checkpoints")
|
@Post("schedules/:id/checkpoints")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Log the train passing a station (final station triggers arrival)",
|
summary: "Log the train passing a station (final station triggers arrival)",
|
||||||
})
|
})
|
||||||
@@ -818,7 +813,7 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/arrive")
|
@Post("schedules/:id/arrive")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Mark a dispatched train arrived (move assets to destination yard, free assets)",
|
"Mark a dispatched train arrived (move assets to destination yard, free assets)",
|
||||||
@@ -856,14 +851,14 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("container/schedules/:id/cancel")
|
@Post("container/schedules/:id/cancel")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingCancel()
|
||||||
@ApiOperation({ summary: "Cancel container train schedule" })
|
@ApiOperation({ summary: "Cancel container train schedule" })
|
||||||
cancelTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
cancelTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.trainSchedulingService.cancelTrainSchedule(id);
|
return this.trainSchedulingService.cancelTrainSchedule(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bulk/schedules/:id/cancel')
|
@Post('bulk/schedules/:id/cancel')
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingCancel()
|
||||||
@ApiOperation({ summary: "Cancel bulk train schedule" })
|
@ApiOperation({ summary: "Cancel bulk train schedule" })
|
||||||
cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.trainSchedulingService.cancelTrainSchedule(id);
|
return this.trainSchedulingService.cancelTrainSchedule(id);
|
||||||
|
|||||||
@@ -2303,14 +2303,19 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
if (direction !== 'EXPORT') return;
|
if (direction !== 'EXPORT') return;
|
||||||
|
|
||||||
|
// Only bookings boarding at the schedule's ORIGIN station gate dispatch —
|
||||||
|
// a mid-corridor boarder (origin B on an A→B→C→D run) is loaded when the
|
||||||
|
// train reaches its yard, so its warehouse state says nothing at departure.
|
||||||
const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query(
|
const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query(
|
||||||
`WITH ${SCHEDULE_BOOKINGS_CTE}
|
`WITH ${SCHEDULE_BOOKINGS_CTE}
|
||||||
SELECT DISTINCT b.reference AS "reference", inv.status AS "status"
|
SELECT DISTINCT b.reference AS "reference", inv.status AS "status"
|
||||||
FROM sched_bookings sb
|
FROM sched_bookings sb
|
||||||
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
|
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
|
||||||
|
JOIN freight.train_schedules ts ON ts.id = sb.schedule_id
|
||||||
JOIN freight.warehouse_inventory inv
|
JOIN freight.warehouse_inventory inv
|
||||||
ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||||
WHERE sb.schedule_id = $1
|
WHERE sb.schedule_id = $1
|
||||||
|
AND b.origin_yard_id = ts.origin_station_id
|
||||||
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`,
|
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`,
|
||||||
[scheduleId],
|
[scheduleId],
|
||||||
);
|
);
|
||||||
@@ -5625,9 +5630,12 @@ export class TrainSchedulingService {
|
|||||||
// --- validate additions: AVAILABLE, loose, standing in the train's yard ---
|
// --- validate additions: AVAILABLE, loose, standing in the train's yard ---
|
||||||
const added: Wagon[] = [];
|
const added: Wagon[] = [];
|
||||||
for (const wagonId of addWagonIds) {
|
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({
|
const wagon = await manager.getRepository(Wagon).findOne({
|
||||||
where: { id: wagonId },
|
where: { id: wagonId },
|
||||||
relations: { wagonType: true },
|
|
||||||
lock: { mode: 'pessimistic_write' },
|
lock: { mode: 'pessimistic_write' },
|
||||||
});
|
});
|
||||||
if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`);
|
if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`);
|
||||||
@@ -5644,6 +5652,10 @@ export class TrainSchedulingService {
|
|||||||
`Wagon ${wagon.wagonNumber} is not in the train's yard — only wagons in the same yard can be coupled`,
|
`Wagon ${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);
|
added.push(wagon);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../common/rule-engine-guards';
|
import {
|
||||||
|
RuleEngineCreate,
|
||||||
|
RuleEngineDelete,
|
||||||
|
RuleEngineUpdate,
|
||||||
|
RuleEngineView,
|
||||||
|
} from '../../common/rule-engine-guards';
|
||||||
|
|
||||||
import { CreateTruckTypeDto } from './dto/create-truck-type.dto';
|
import { CreateTruckTypeDto } from './dto/create-truck-type.dto';
|
||||||
import { UpdateTruckTypeDto } from './dto/update-truck-type.dto';
|
import { UpdateTruckTypeDto } from './dto/update-truck-type.dto';
|
||||||
@@ -51,21 +56,21 @@ export class TruckTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RuleEngineManage('truck-types')
|
@RuleEngineCreate('truck-types')
|
||||||
@ApiOperation({ summary: 'Create a truck type' })
|
@ApiOperation({ summary: 'Create a truck type' })
|
||||||
create(@Body() dto: CreateTruckTypeDto) {
|
create(@Body() dto: CreateTruckTypeDto) {
|
||||||
return this.truckTypesService.create(dto);
|
return this.truckTypesService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RuleEngineManage('truck-types')
|
@RuleEngineUpdate('truck-types')
|
||||||
@ApiOperation({ summary: 'Update a truck type' })
|
@ApiOperation({ summary: 'Update a truck type' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTruckTypeDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTruckTypeDto) {
|
||||||
return this.truckTypesService.update(id, dto);
|
return this.truckTypesService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RuleEngineManage('truck-types')
|
@RuleEngineDelete('truck-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a truck type' })
|
@ApiOperation({ summary: 'Soft-delete a truck type' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../common/rule-engine-guards';
|
import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../common/rule-engine-guards';
|
||||||
|
|
||||||
import { CreateWagonTypeDto } from './dto/create-wagon-type.dto';
|
import { CreateWagonTypeDto } from './dto/create-wagon-type.dto';
|
||||||
import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
|
import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
|
||||||
@@ -51,21 +51,21 @@ export class WagonTypesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RuleEngineManage('wagon-types')
|
@RuleEngineCreate('wagon-types')
|
||||||
@ApiOperation({ summary: 'Create a wagon type' })
|
@ApiOperation({ summary: 'Create a wagon type' })
|
||||||
create(@Body() dto: CreateWagonTypeDto) {
|
create(@Body() dto: CreateWagonTypeDto) {
|
||||||
return this.wagonTypesService.create(dto);
|
return this.wagonTypesService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@RuleEngineManage('wagon-types')
|
@RuleEngineUpdate('wagon-types')
|
||||||
@ApiOperation({ summary: 'Update a wagon type' })
|
@ApiOperation({ summary: 'Update a wagon type' })
|
||||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonTypeDto) {
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonTypeDto) {
|
||||||
return this.wagonTypesService.update(id, dto);
|
return this.wagonTypesService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RuleEngineManage('wagon-types')
|
@RuleEngineDelete('wagon-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Soft-delete a wagon type' })
|
@ApiOperation({ summary: 'Soft-delete a wagon type' })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -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()
|
@Injectable()
|
||||||
export class FileUploadSettingsSeeder {
|
export class FileUploadSettingsSeeder {
|
||||||
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
|
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
|
||||||
@@ -633,6 +646,11 @@ export class FileUploadSettingsSeeder {
|
|||||||
description:
|
description:
|
||||||
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
|
"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) => ({
|
...INTERCITY_DOCUMENT_SETTINGS.map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
|
|||||||
'cargo-types',
|
'cargo-types',
|
||||||
'container-types',
|
'container-types',
|
||||||
'wagon-types',
|
'wagon-types',
|
||||||
'truck-types',
|
|
||||||
'service-types',
|
'service-types',
|
||||||
'yards',
|
'yards',
|
||||||
'shipping-lines',
|
'shipping-lines',
|
||||||
@@ -20,6 +19,9 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
|
|||||||
'rates',
|
'rates',
|
||||||
'approval-rules',
|
'approval-rules',
|
||||||
'yard-distances',
|
'yard-distances',
|
||||||
|
// Keep new slugs at the END: ruleEngineCrudId derives ids from list index,
|
||||||
|
// so a mid-list insert would shift ids already seeded for later slugs.
|
||||||
|
'truck-types',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number];
|
export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number];
|
||||||
@@ -58,7 +60,6 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('a1000001-0001-4000-8000-000000000021', 'edr_freight_app:bookings:upload_clearance_output', 'Upload customs output documents'),
|
perm('a1000001-0001-4000-8000-000000000021', 'edr_freight_app:bookings:upload_clearance_output', 'Upload customs output documents'),
|
||||||
perm('a1000001-0001-4000-8000-000000000022', 'edr_freight_app:bookings:finalize_clearance', 'Finalize document clearance'),
|
perm('a1000001-0001-4000-8000-000000000022', 'edr_freight_app:bookings:finalize_clearance', 'Finalize document clearance'),
|
||||||
perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'),
|
perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'),
|
||||||
perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'),
|
|
||||||
perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'),
|
perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'),
|
||||||
perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'),
|
perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'),
|
||||||
perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'),
|
perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'),
|
||||||
@@ -84,7 +85,10 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('a3000001-0001-4000-8000-000000000006', 'edr_freight_app:contracts:approve_director', 'Approve contract as director'),
|
perm('a3000001-0001-4000-8000-000000000006', 'edr_freight_app:contracts:approve_director', 'Approve contract as director'),
|
||||||
perm('a3000001-0001-4000-8000-000000000007', 'edr_freight_app:contracts:approve_ceo', 'Approve contract as CEO'),
|
perm('a3000001-0001-4000-8000-000000000007', 'edr_freight_app:contracts:approve_ceo', 'Approve contract as CEO'),
|
||||||
perm('a3000001-0001-4000-8000-000000000008', 'edr_freight_app:contracts:generate_contract', 'Generate contract document'),
|
perm('a3000001-0001-4000-8000-000000000008', 'edr_freight_app:contracts:generate_contract', 'Generate contract document'),
|
||||||
perm('a3000001-0001-4000-8000-000000000009', 'edr_freight_app:contracts:sign_staff', 'Staff contract signature'),
|
// Staff counter-signature is split per freight type too — fresh ids for the
|
||||||
|
// same reason as the intake keys above.
|
||||||
|
perm('a3000001-0001-4000-8000-000000000017', 'edr_freight_app:contracts:sign_staff:bulk', 'Staff contract signature: bulk'),
|
||||||
|
perm('a3000001-0001-4000-8000-000000000018', 'edr_freight_app:contracts:sign_staff:container', 'Staff contract signature: container'),
|
||||||
perm('a3000001-0001-4000-8000-00000000000a', 'edr_freight_app:contracts:clearance_review', 'Review pre-booking clearance docs'),
|
perm('a3000001-0001-4000-8000-00000000000a', 'edr_freight_app:contracts:clearance_review', 'Review pre-booking clearance docs'),
|
||||||
perm('a3000001-0001-4000-8000-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'),
|
perm('a3000001-0001-4000-8000-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'),
|
||||||
perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'),
|
perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'),
|
||||||
@@ -94,25 +98,43 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
perm('a3000001-0001-4000-8000-000000000010', 'edr_freight_app:contracts:clearance_duty_advise', 'Advise contract duty/tax'),
|
perm('a3000001-0001-4000-8000-000000000010', 'edr_freight_app:contracts:clearance_duty_advise', 'Advise contract duty/tax'),
|
||||||
];
|
];
|
||||||
|
|
||||||
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
|
// Existing per-slug view ids are kept as-is: position-type grants reference
|
||||||
'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' },
|
// them by id, so re-minting would orphan those rows.
|
||||||
'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' },
|
const RULE_ENGINE_VIEW_IDS: Record<RuleEngineResourceSlug, string> = {
|
||||||
'wagon-types': { view: 'b2000001-0001-4000-8000-000000000015', manage: 'b2000001-0001-4000-8000-000000000016' },
|
'cargo-types': 'b2000001-0001-4000-8000-000000000001',
|
||||||
'truck-types': { view: 'b2000001-0001-4000-8000-00000000001a', manage: 'b2000001-0001-4000-8000-00000000001b' },
|
'container-types': 'b2000001-0001-4000-8000-000000000003',
|
||||||
'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' },
|
'wagon-types': 'b2000001-0001-4000-8000-000000000015',
|
||||||
yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' },
|
'truck-types': 'b2000001-0001-4000-8000-00000000001a',
|
||||||
'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },
|
'service-types': 'b2000001-0001-4000-8000-000000000005',
|
||||||
'weight-limit-rules': { view: 'b2000001-0001-4000-8000-00000000000b', manage: 'b2000001-0001-4000-8000-00000000000c' },
|
yards: 'b2000001-0001-4000-8000-000000000007',
|
||||||
'priority-configs': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' },
|
'shipping-lines': 'b2000001-0001-4000-8000-000000000009',
|
||||||
rates: { view: 'b2000001-0001-4000-8000-000000000011', manage: 'b2000001-0001-4000-8000-000000000012' },
|
'weight-limit-rules': 'b2000001-0001-4000-8000-00000000000b',
|
||||||
'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' },
|
'priority-configs': 'b2000001-0001-4000-8000-00000000000f',
|
||||||
'yard-distances': { view: 'b2000001-0001-4000-8000-000000000018', manage: 'b2000001-0001-4000-8000-000000000019' },
|
rates: 'b2000001-0001-4000-8000-000000000011',
|
||||||
|
'approval-rules': 'b2000001-0001-4000-8000-000000000013',
|
||||||
|
'yard-distances': 'b2000001-0001-4000-8000-000000000018',
|
||||||
|
};
|
||||||
|
|
||||||
|
// CRUD replaces the retired coarse `:manage`. New ids live in a fresh block
|
||||||
|
// (b2000002-…) so a stale `:manage` grant can never silently confer a CRUD
|
||||||
|
// action — the migration re-grants create/update/delete explicitly.
|
||||||
|
const RULE_ENGINE_CRUD_ACTIONS = ['create', 'update', 'delete'] as const;
|
||||||
|
type RuleEngineCrudAction = (typeof RULE_ENGINE_CRUD_ACTIONS)[number];
|
||||||
|
const ruleEngineCrudId = (
|
||||||
|
slug: RuleEngineResourceSlug,
|
||||||
|
action: RuleEngineCrudAction,
|
||||||
|
): string => {
|
||||||
|
const n =
|
||||||
|
RULE_ENGINE_RESOURCE_SLUGS.indexOf(slug) * 3 +
|
||||||
|
RULE_ENGINE_CRUD_ACTIONS.indexOf(action) +
|
||||||
|
1; // 1..36
|
||||||
|
return `b2000002-0001-4000-8000-${n.toString(16).padStart(12, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Slugs whose changes go through a separate approver. `manage` lets a staff
|
* Slugs whose changes go through a separate approver. CRUD lets a staff member
|
||||||
* member propose a change; only `approve` lets someone put it into effect.
|
* propose a change; only `approve` lets someone put it into effect. Only listed
|
||||||
* Only listed slugs get the permission — the rest are manage-only.
|
* slugs get the permission.
|
||||||
*/
|
*/
|
||||||
const RULE_ENGINE_APPROVE_PERMISSION_IDS: Partial<Record<RuleEngineResourceSlug, string>> = {
|
const RULE_ENGINE_APPROVE_PERMISSION_IDS: Partial<Record<RuleEngineResourceSlug, string>> = {
|
||||||
rates: 'b2000001-0001-4000-8000-000000000017',
|
rates: 'b2000001-0001-4000-8000-000000000017',
|
||||||
@@ -123,11 +145,12 @@ export type RuleEngineApprovableSlug = 'rates';
|
|||||||
export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap(
|
export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap(
|
||||||
(slug) => {
|
(slug) => {
|
||||||
const resource = slugToResourceKey(slug);
|
const resource = slugToResourceKey(slug);
|
||||||
const ids = RULE_ENGINE_PERMISSION_IDS[slug];
|
|
||||||
const approveId = RULE_ENGINE_APPROVE_PERMISSION_IDS[slug];
|
const approveId = RULE_ENGINE_APPROVE_PERMISSION_IDS[slug];
|
||||||
return [
|
return [
|
||||||
perm(ids.view, `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`),
|
perm(RULE_ENGINE_VIEW_IDS[slug], `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`),
|
||||||
perm(ids.manage, `edr_freight_app:rule_engine:${resource}:manage`, `Manage ${slug}`),
|
perm(ruleEngineCrudId(slug, 'create'), `edr_freight_app:rule_engine:${resource}:create`, `Create ${slug}`),
|
||||||
|
perm(ruleEngineCrudId(slug, 'update'), `edr_freight_app:rule_engine:${resource}:update`, `Update ${slug}`),
|
||||||
|
perm(ruleEngineCrudId(slug, 'delete'), `edr_freight_app:rule_engine:${resource}:delete`, `Delete ${slug}`),
|
||||||
...(approveId
|
...(approveId
|
||||||
? [perm(approveId, `edr_freight_app:rule_engine:${resource}:approve`, `Approve ${slug} changes`)]
|
? [perm(approveId, `edr_freight_app:rule_engine:${resource}:approve`, `Approve ${slug} changes`)]
|
||||||
: []),
|
: []),
|
||||||
@@ -397,7 +420,10 @@ export const FREIGHT_PERMS = {
|
|||||||
approveDirector: 'edr_freight_app:contracts:approve_director',
|
approveDirector: 'edr_freight_app:contracts:approve_director',
|
||||||
approveCeo: 'edr_freight_app:contracts:approve_ceo',
|
approveCeo: 'edr_freight_app:contracts:approve_ceo',
|
||||||
generateContract: 'edr_freight_app:contracts:generate_contract',
|
generateContract: 'edr_freight_app:contracts:generate_contract',
|
||||||
signStaff: 'edr_freight_app:contracts:sign_staff',
|
signStaff: {
|
||||||
|
bulk: 'edr_freight_app:contracts:sign_staff:bulk',
|
||||||
|
container: 'edr_freight_app:contracts:sign_staff:container',
|
||||||
|
},
|
||||||
clearanceReview: 'edr_freight_app:contracts:clearance_review',
|
clearanceReview: 'edr_freight_app:contracts:clearance_review',
|
||||||
finalizeClearance: 'edr_freight_app:contracts:finalize_clearance',
|
finalizeClearance: 'edr_freight_app:contracts:finalize_clearance',
|
||||||
createBooking: 'edr_freight_app:contracts:create_booking',
|
createBooking: 'edr_freight_app:contracts:create_booking',
|
||||||
@@ -408,7 +434,6 @@ export const FREIGHT_PERMS = {
|
|||||||
},
|
},
|
||||||
trainScheduling: {
|
trainScheduling: {
|
||||||
view: 'edr_freight_app:train_scheduling:view',
|
view: 'edr_freight_app:train_scheduling:view',
|
||||||
manage: 'edr_freight_app:train_scheduling:manage',
|
|
||||||
create: 'edr_freight_app:train_scheduling:create',
|
create: 'edr_freight_app:train_scheduling:create',
|
||||||
update: 'edr_freight_app:train_scheduling:update',
|
update: 'edr_freight_app:train_scheduling:update',
|
||||||
cancel: 'edr_freight_app:train_scheduling:cancel',
|
cancel: 'edr_freight_app:train_scheduling:cancel',
|
||||||
@@ -423,8 +448,12 @@ export const FREIGHT_PERMS = {
|
|||||||
ruleEngine: {
|
ruleEngine: {
|
||||||
view: (slug: RuleEngineResourceSlug) =>
|
view: (slug: RuleEngineResourceSlug) =>
|
||||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`,
|
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`,
|
||||||
manage: (slug: RuleEngineResourceSlug) =>
|
create: (slug: RuleEngineResourceSlug) =>
|
||||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`,
|
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:create`,
|
||||||
|
update: (slug: RuleEngineResourceSlug) =>
|
||||||
|
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:update`,
|
||||||
|
delete: (slug: RuleEngineResourceSlug) =>
|
||||||
|
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:delete`,
|
||||||
approve: (slug: RuleEngineApprovableSlug) =>
|
approve: (slug: RuleEngineApprovableSlug) =>
|
||||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`,
|
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`,
|
||||||
},
|
},
|
||||||
@@ -753,7 +782,11 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.bookings.view,
|
FREIGHT_PERMS.bookings.view,
|
||||||
FREIGHT_PERMS.bookings.operations,
|
FREIGHT_PERMS.bookings.operations,
|
||||||
FREIGHT_PERMS.trainScheduling.view,
|
FREIGHT_PERMS.trainScheduling.view,
|
||||||
FREIGHT_PERMS.trainScheduling.manage,
|
FREIGHT_PERMS.trainScheduling.create,
|
||||||
|
FREIGHT_PERMS.trainScheduling.update,
|
||||||
|
FREIGHT_PERMS.trainScheduling.cancel,
|
||||||
|
FREIGHT_PERMS.trainScheduling.reschedule,
|
||||||
|
FREIGHT_PERMS.trainScheduling.rulesManage,
|
||||||
FREIGHT_PERMS.fleet.view,
|
FREIGHT_PERMS.fleet.view,
|
||||||
FREIGHT_PERMS.fleet.manage,
|
FREIGHT_PERMS.fleet.manage,
|
||||||
...FLEET_GRANULAR_KEYS,
|
...FLEET_GRANULAR_KEYS,
|
||||||
@@ -838,7 +871,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
...bothFreightTypes(FREIGHT_PERMS.contracts.reject),
|
...bothFreightTypes(FREIGHT_PERMS.contracts.reject),
|
||||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||||
FREIGHT_PERMS.contracts.generateContract,
|
FREIGHT_PERMS.contracts.generateContract,
|
||||||
FREIGHT_PERMS.contracts.signStaff,
|
...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff),
|
||||||
],
|
],
|
||||||
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
|
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Flag,
|
||||||
|
MapPin,
|
||||||
|
PackageCheck,
|
||||||
|
TrainFront,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type { TrackStation, YardWorkBookingRow } from "@/types/trainScheduling";
|
||||||
|
|
||||||
|
const parseError = (error: unknown, fallback: string) => {
|
||||||
|
const message = (error as { response?: { data?: { message?: string | string[] } } })
|
||||||
|
?.response?.data?.message;
|
||||||
|
if (Array.isArray(message)) return message.join("; ");
|
||||||
|
return message || (error as Error)?.message || fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtDate = (iso: string) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIRECTION_COLORS: Record<string, string> = {
|
||||||
|
IMPORT: "blue",
|
||||||
|
EXPORT: "teal",
|
||||||
|
DOMESTIC: "violet",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */
|
||||||
|
const DIRECTION_LABELS: Record<string, string> = Freight.TRADE_DIRECTION_LABELS;
|
||||||
|
|
||||||
|
function DirectionChip({ direction }: { direction: string }) {
|
||||||
|
return (
|
||||||
|
<Badge size="sm" variant="light" color={DIRECTION_COLORS[direction] ?? "gray"}>
|
||||||
|
{DIRECTION_LABELS[direction] ?? direction}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionLabel({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
count,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
count: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Group gap={8} align="center">
|
||||||
|
<ThemeIcon size={26} radius="md" variant="light" color="edr-green">
|
||||||
|
{icon}
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={700} size="sm">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Badge size="sm" variant="light" color="gray" radius="sm">
|
||||||
|
{count}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yard-work modal for the track page's "Log pass" step.
|
||||||
|
*
|
||||||
|
* A train runs A→B→C→D and bookings board/alight at any stop, so logging the
|
||||||
|
* pass at a yard is the moment its yard work happens: bookings destined here
|
||||||
|
* flip to ARRIVED (import/export) or COMPLETED (intercity) automatically the
|
||||||
|
* instant the pass is logged, and bookings boarding here become loadable —
|
||||||
|
* the server only accepts a load while the train's latest checkpoint is this
|
||||||
|
* yard. The modal therefore drives the sequence: log the pass first, then
|
||||||
|
* load anything that boards here (including cargo the operator forgot — it
|
||||||
|
* stays loadable until the next pass is logged).
|
||||||
|
*/
|
||||||
|
export function LogPassYardWorkModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
scheduleId,
|
||||||
|
station,
|
||||||
|
isFinal,
|
||||||
|
alreadyLogged,
|
||||||
|
}: {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
scheduleId: string;
|
||||||
|
station: TrackStation | null;
|
||||||
|
isFinal: boolean;
|
||||||
|
/** True when opened for the current station (pass already logged). */
|
||||||
|
alreadyLogged: boolean;
|
||||||
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [justLogged, setJustLogged] = useState(false);
|
||||||
|
useEffect(() => setJustLogged(false), [station?.sequenceNo, opened]);
|
||||||
|
const logged = alreadyLogged || justLogged;
|
||||||
|
|
||||||
|
const yardWorkQuery = useQuery(
|
||||||
|
api.trainScheduling.yardWork.queryOptions({
|
||||||
|
input: { scheduleId },
|
||||||
|
enabled: opened && Boolean(scheduleId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const recordCheckpoint = useMutation(
|
||||||
|
api.trainScheduling.recordCheckpoint.mutationOptions(),
|
||||||
|
);
|
||||||
|
const load = useMutation(api.trainScheduling.loadScheduleBooking.mutationOptions());
|
||||||
|
|
||||||
|
const yard = yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId);
|
||||||
|
const boarders: YardWorkBookingRow[] = yard?.toLoad ?? [];
|
||||||
|
const arrivals: YardWorkBookingRow[] = yard?.toUnload ?? [];
|
||||||
|
const pendingBoarders = boarders.filter((r) => !r.loadedAt);
|
||||||
|
|
||||||
|
const doLogPass = () => {
|
||||||
|
if (!station) return;
|
||||||
|
recordCheckpoint.mutate(
|
||||||
|
{ id: scheduleId, payload: { sequenceNo: station.sequenceNo } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setJustLogged(true);
|
||||||
|
toast({
|
||||||
|
title: isFinal
|
||||||
|
? "Train arrived — remaining bookings marked arrived, assets freed"
|
||||||
|
: `Pass logged at ${station.label}`,
|
||||||
|
description: isFinal
|
||||||
|
? undefined
|
||||||
|
: arrivals.some((r) => r.canUnload)
|
||||||
|
? "Bookings arriving here have been marked arrived."
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
void yardWorkQuery.refetch();
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast({
|
||||||
|
title: "Could not log checkpoint",
|
||||||
|
description: parseError(err, "Please try again"),
|
||||||
|
variant: "destructive",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doLoad = (row: YardWorkBookingRow) => {
|
||||||
|
load.mutate(
|
||||||
|
{ scheduleId, bookingId: row.id },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: `${row.reference ?? "Booking"} loaded`,
|
||||||
|
description: `Cargo boarded the train at ${station?.label ?? "this yard"}.`,
|
||||||
|
});
|
||||||
|
void yardWorkQuery.refetch();
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast({
|
||||||
|
title: "Could not load booking",
|
||||||
|
description: parseError(err, "Please try again"),
|
||||||
|
variant: "destructive",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasWork = boarders.length > 0 || arrivals.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
size="xl"
|
||||||
|
radius="lg"
|
||||||
|
title={
|
||||||
|
<Group gap={8}>
|
||||||
|
{isFinal ? <Flag size={18} /> : <MapPin size={18} />}
|
||||||
|
<Text fw={700}>
|
||||||
|
{isFinal ? "Arrival" : "Yard work"} — {station?.label ?? ""}
|
||||||
|
</Text>
|
||||||
|
{logged ? (
|
||||||
|
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||||
|
{isFinal ? "Arrived" : "Pass logged"}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{yardWorkQuery.isLoading ? (
|
||||||
|
<Group justify="center" py="lg">
|
||||||
|
<Loader size="sm" color="edr-green" />
|
||||||
|
</Group>
|
||||||
|
) : !hasWork ? (
|
||||||
|
<Alert color="gray" variant="light" radius="md" icon={<MapPin size={16} />}>
|
||||||
|
No bookings board or alight at this station.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ── Arriving here ─────────────────────────────────────────── */}
|
||||||
|
{arrivals.length > 0 ? (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<SectionLabel
|
||||||
|
icon={<Flag size={14} />}
|
||||||
|
title="Arriving at this yard"
|
||||||
|
count={arrivals.length}
|
||||||
|
/>
|
||||||
|
{!logged ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Logging the pass marks the loaded bookings below as Arrived
|
||||||
|
(import/export) or Completed (intercity) automatically.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Table.ScrollContainer minWidth={620}>
|
||||||
|
<Table verticalSpacing="xs" highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Booking</Table.Th>
|
||||||
|
<Table.Th>Customer</Table.Th>
|
||||||
|
<Table.Th>Direction</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>Arrived</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{arrivals.map((row) => (
|
||||||
|
<Table.Tr key={row.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{row.reference ?? row.id.slice(0, 8)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{row.customer}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<DirectionChip direction={row.tradeDirection} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<BookingStatusBadge status={row.status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{row.arrivedAt ? fmtDate(row.arrivedAt) : "—"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{arrivals.length > 0 && boarders.length > 0 ? <Divider /> : null}
|
||||||
|
|
||||||
|
{/* ── Boarding here ─────────────────────────────────────────── */}
|
||||||
|
{boarders.length > 0 ? (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<SectionLabel
|
||||||
|
icon={<TrainFront size={14} />}
|
||||||
|
title="Boarding at this yard"
|
||||||
|
count={boarders.length}
|
||||||
|
/>
|
||||||
|
{!logged && pendingBoarders.length > 0 ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Log the pass first — the train must be at {station?.label} before
|
||||||
|
cargo can be loaded.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Table.ScrollContainer minWidth={620}>
|
||||||
|
<Table verticalSpacing="xs" highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Booking</Table.Th>
|
||||||
|
<Table.Th>Customer</Table.Th>
|
||||||
|
<Table.Th>Direction</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>Loaded</Table.Th>
|
||||||
|
<Table.Th />
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{boarders.map((row) => (
|
||||||
|
<Table.Tr key={row.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{row.reference ?? row.id.slice(0, 8)}
|
||||||
|
</Text>
|
||||||
|
{row.isGovernment ? (
|
||||||
|
<Badge size="xs" variant="light" color="grape">
|
||||||
|
GOV
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{row.customer}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<DirectionChip direction={row.tradeDirection} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<BookingStatusBadge status={row.status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{row.loadedAt ? (
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<CheckCircle2
|
||||||
|
size={13}
|
||||||
|
color="var(--mantine-color-edr-green-7)"
|
||||||
|
/>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{fmtDate(row.loadedAt)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Not loaded
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{!row.loadedAt ? (
|
||||||
|
<Tooltip
|
||||||
|
label={
|
||||||
|
!logged
|
||||||
|
? "Log the pass first — the train must be at this yard"
|
||||||
|
: !row.canLoad
|
||||||
|
? "Booking is not ready to load (payment pending)"
|
||||||
|
: "Confirm cargo loaded onto the train"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<PackageCheck size={13} />}
|
||||||
|
disabled={!logged || !row.canLoad}
|
||||||
|
loading={
|
||||||
|
load.isPending && load.variables?.bookingId === row.id
|
||||||
|
}
|
||||||
|
onClick={() => doLoad(row)}
|
||||||
|
>
|
||||||
|
Load
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between" mt="xs">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{logged && pendingBoarders.length > 0
|
||||||
|
? `${pendingBoarders.length} booking${pendingBoarders.length === 1 ? "" : "s"} still to load before the next station.`
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
{!logged ? (
|
||||||
|
<Button
|
||||||
|
color={isFinal ? "teal" : "edr-green"}
|
||||||
|
leftSection={isFinal ? <Flag size={15} /> : <MapPin size={15} />}
|
||||||
|
loading={recordCheckpoint.isPending}
|
||||||
|
onClick={doLogPass}
|
||||||
|
>
|
||||||
|
{isFinal
|
||||||
|
? `Mark arrived at ${station?.label ?? "destination"}`
|
||||||
|
: `Log pass at ${station?.label ?? "station"}`}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -166,9 +166,6 @@ export function ScheduleWorkspacePanel({
|
|||||||
const setLoading = useMutation(
|
const setLoading = useMutation(
|
||||||
api.trainScheduling.setLoadingStatus.mutationOptions(),
|
api.trainScheduling.setLoadingStatus.mutationOptions(),
|
||||||
);
|
);
|
||||||
const confirmLoading = useMutation(
|
|
||||||
api.trainScheduling.confirmLoading.mutationOptions(),
|
|
||||||
);
|
|
||||||
const moveSchedule = useMutation(
|
const moveSchedule = useMutation(
|
||||||
api.trainScheduling.moveBookingSchedule.mutationOptions(),
|
api.trainScheduling.moveBookingSchedule.mutationOptions(),
|
||||||
);
|
);
|
||||||
@@ -301,25 +298,6 @@ export function ScheduleWorkspacePanel({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const doConfirmLoading = () => {
|
|
||||||
confirmLoading
|
|
||||||
.mutateAsync({ id: schedule.id })
|
|
||||||
.then(() => {
|
|
||||||
toast({ title: "Loading confirmed", description: "The train is cleared to dispatch." });
|
|
||||||
onChanged();
|
|
||||||
})
|
|
||||||
.catch((error) =>
|
|
||||||
toast({
|
|
||||||
title: "Could not confirm loading",
|
|
||||||
description: apiErrorMessage(
|
|
||||||
error,
|
|
||||||
"Grant the Djibouti gatepass first, then confirm loading.",
|
|
||||||
),
|
|
||||||
variant: "destructive",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Point the pool booking at the chosen same-day train, then put it on wagons.
|
// Point the pool booking at the chosen same-day train, then put it on wagons.
|
||||||
// If the wagon step fails (that train is short too) the booking stays paid &
|
// If the wagon step fails (that train is short too) the booking stays paid &
|
||||||
// unassigned in the pool — nothing is lost, staff just pick another train.
|
// unassigned in the pool — nothing is lost, staff just pick another train.
|
||||||
@@ -460,53 +438,8 @@ export function ScheduleWorkspacePanel({
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Loading confirmation — required before dispatch for import-Djibouti
|
{/* Loading confirmation gate removed: bookings can board mid-corridor,
|
||||||
trains; shown for every direction so staff have one place to confirm. */}
|
so per-yard loading happens from the track page's log-pass flow. */}
|
||||||
{canManage ? (
|
|
||||||
<Group
|
|
||||||
gap={10}
|
|
||||||
p="sm"
|
|
||||||
wrap="nowrap"
|
|
||||||
align="center"
|
|
||||||
justify="space-between"
|
|
||||||
style={{
|
|
||||||
borderRadius: 10,
|
|
||||||
background: schedule.loadingConfirmed
|
|
||||||
? "var(--mantine-color-edr-green-0)"
|
|
||||||
: "var(--mantine-color-yellow-0)",
|
|
||||||
border: `1px solid ${
|
|
||||||
schedule.loadingConfirmed
|
|
||||||
? "var(--mantine-color-edr-green-2)"
|
|
||||||
: "var(--mantine-color-yellow-3)"
|
|
||||||
}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Group gap={8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
|
|
||||||
{schedule.loadingConfirmed ? (
|
|
||||||
<CheckCircle2 size={18} color="var(--mantine-color-edr-green-7)" />
|
|
||||||
) : (
|
|
||||||
<PackageCheck size={18} color="#B7791F" />
|
|
||||||
)}
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{schedule.loadingConfirmed
|
|
||||||
? "Loading confirmed — cleared to dispatch"
|
|
||||||
: "Confirm loading before dispatching this train"}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
{!schedule.loadingConfirmed ? (
|
|
||||||
<Button
|
|
||||||
size="compact-sm"
|
|
||||||
color="edr-green"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<CheckCircle2 size={14} />}
|
|
||||||
loading={confirmLoading.isPending}
|
|
||||||
onClick={doConfirmLoading}
|
|
||||||
>
|
|
||||||
Confirm loading
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</Group>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Two-panel board */}
|
{/* Two-panel board */}
|
||||||
<Group align="stretch" gap="lg" grow wrap="wrap">
|
<Group align="stretch" gap="lg" grow wrap="wrap">
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
|||||||
complete: FREIGHT_PERMS.bookings.operations,
|
complete: FREIGHT_PERMS.bookings.operations,
|
||||||
operationAccept: FREIGHT_PERMS.bookings.operations,
|
operationAccept: FREIGHT_PERMS.bookings.operations,
|
||||||
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
|
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
|
||||||
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
|
allocateBooking: FREIGHT_PERMS.trainScheduling.update,
|
||||||
cancel: FREIGHT_PERMS.bookings.cancel,
|
cancel: FREIGHT_PERMS.bookings.cancel,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ export const FREIGHT_PERMS = {
|
|||||||
approveDirector: "edr_freight_app:contracts:approve_director",
|
approveDirector: "edr_freight_app:contracts:approve_director",
|
||||||
approveCeo: "edr_freight_app:contracts:approve_ceo",
|
approveCeo: "edr_freight_app:contracts:approve_ceo",
|
||||||
generateContract: "edr_freight_app:contracts:generate_contract",
|
generateContract: "edr_freight_app:contracts:generate_contract",
|
||||||
signStaff: "edr_freight_app:contracts:sign_staff",
|
signStaff: {
|
||||||
|
bulk: "edr_freight_app:contracts:sign_staff:bulk",
|
||||||
|
container: "edr_freight_app:contracts:sign_staff:container",
|
||||||
|
},
|
||||||
clearanceReview: "edr_freight_app:contracts:clearance_review",
|
clearanceReview: "edr_freight_app:contracts:clearance_review",
|
||||||
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
|
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
|
||||||
createBooking: "edr_freight_app:contracts:create_booking",
|
createBooking: "edr_freight_app:contracts:create_booking",
|
||||||
@@ -57,7 +60,6 @@ export const FREIGHT_PERMS = {
|
|||||||
},
|
},
|
||||||
trainScheduling: {
|
trainScheduling: {
|
||||||
view: "edr_freight_app:train_scheduling:view",
|
view: "edr_freight_app:train_scheduling:view",
|
||||||
manage: "edr_freight_app:train_scheduling:manage",
|
|
||||||
create: "edr_freight_app:train_scheduling:create",
|
create: "edr_freight_app:train_scheduling:create",
|
||||||
update: "edr_freight_app:train_scheduling:update",
|
update: "edr_freight_app:train_scheduling:update",
|
||||||
cancel: "edr_freight_app:train_scheduling:cancel",
|
cancel: "edr_freight_app:train_scheduling:cancel",
|
||||||
@@ -509,8 +511,18 @@ export function canViewScheduling(user: AuthUser | null | undefined): boolean {
|
|||||||
return hasPermission(user, FREIGHT_PERMS.trainScheduling.view);
|
return hasPermission(user, FREIGHT_PERMS.trainScheduling.view);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Any train-scheduling write action (create / update / cancel / reschedule). */
|
||||||
export function canManageScheduling(user: AuthUser | null | undefined): boolean {
|
export function canManageScheduling(user: AuthUser | null | undefined): boolean {
|
||||||
return hasPermission(user, FREIGHT_PERMS.trainScheduling.manage);
|
return (
|
||||||
|
hasPermission(user, FREIGHT_PERMS.trainScheduling.create) ||
|
||||||
|
hasPermission(user, FREIGHT_PERMS.trainScheduling.update) ||
|
||||||
|
hasPermission(user, FREIGHT_PERMS.trainScheduling.cancel) ||
|
||||||
|
hasPermission(user, FREIGHT_PERMS.trainScheduling.reschedule)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canCreateSchedule(user: AuthUser | null | undefined): boolean {
|
||||||
|
return hasPermission(user, FREIGHT_PERMS.trainScheduling.create);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canViewFleet(user: AuthUser | null | undefined): boolean {
|
export function canViewFleet(user: AuthUser | null | undefined): boolean {
|
||||||
@@ -546,12 +558,17 @@ export function isFreightAdmin(user: AuthUser | null | undefined): boolean {
|
|||||||
return hasPermission(user, FREIGHT_PERMS.admin);
|
return hasPermission(user, FREIGHT_PERMS.admin);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string {
|
export type RuleEngineAction = "view" | "create" | "update" | "delete";
|
||||||
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`;
|
|
||||||
|
export function ruleEngineActionKey(
|
||||||
|
slug: RuleEngineResourceSlug,
|
||||||
|
action: RuleEngineAction,
|
||||||
|
): string {
|
||||||
|
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:${action}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ruleEngineManageKey(slug: RuleEngineResourceSlug): string {
|
export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string {
|
||||||
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`;
|
return ruleEngineActionKey(slug, "view");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -572,10 +589,19 @@ export function canApproveRuleEngineChange(
|
|||||||
export function canAccessRuleEngineResource(
|
export function canAccessRuleEngineResource(
|
||||||
user: AuthUser | null | undefined,
|
user: AuthUser | null | undefined,
|
||||||
slug: RuleEngineResourceSlug,
|
slug: RuleEngineResourceSlug,
|
||||||
mode: "view" | "manage",
|
mode: RuleEngineAction,
|
||||||
): boolean {
|
): boolean {
|
||||||
const key = mode === "manage" ? ruleEngineManageKey(slug) : ruleEngineViewKey(slug);
|
return hasPermission(user, ruleEngineActionKey(slug, mode));
|
||||||
return hasPermission(user, key);
|
}
|
||||||
|
|
||||||
|
/** Holds any write action on the resource — for surfaces gated on "can edit at all". */
|
||||||
|
export function canWriteRuleEngineResource(
|
||||||
|
user: AuthUser | null | undefined,
|
||||||
|
slug: RuleEngineResourceSlug,
|
||||||
|
): boolean {
|
||||||
|
return (["create", "update", "delete"] as const).some((a) =>
|
||||||
|
canAccessRuleEngineResource(user, slug, a),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canAccessAnyRuleEngineView(
|
export function canAccessAnyRuleEngineView(
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuc
|
|||||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Staff contract preview + sign. Staff must open and read the generated
|
* Staff contract preview + sign. Staff must open and read the generated
|
||||||
@@ -30,6 +32,7 @@ export default function ContractViewPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [signOpen, setSignOpen] = useState(false);
|
const [signOpen, setSignOpen] = useState(false);
|
||||||
const [successOpen, setSuccessOpen] = useState(false);
|
const [successOpen, setSuccessOpen] = useState(false);
|
||||||
@@ -116,6 +119,15 @@ export default function ContractViewPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Counter-signature permission is split per freight type — a bulk signer must
|
||||||
|
// not sign a container contract (API enforces the same on POST /contract/sign).
|
||||||
|
const maySign = hasPermission(
|
||||||
|
user,
|
||||||
|
FREIGHT_PERMS.contracts.signStaff[
|
||||||
|
data.freightType === "BULK" ? "bulk" : "container"
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box p={{ base: "md", md: "xl" }}>
|
<Box p={{ base: "md", md: "xl" }}>
|
||||||
<Box maw={920} mx="auto">
|
<Box maw={920} mx="auto">
|
||||||
@@ -145,7 +157,7 @@ export default function ContractViewPage() {
|
|||||||
>
|
>
|
||||||
Download PDF
|
Download PDF
|
||||||
</Button>
|
</Button>
|
||||||
{data.canSignStaff && (
|
{data.canSignStaff && maySign && (
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
leftSection={<FileSignature size={16} />}
|
leftSection={<FileSignature size={16} />}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
|||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { canFleetAction } from "@/lib/permissions";
|
import { canFleetAction, hasPermission, FREIGHT_PERMS } from "@/lib/permissions";
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import { Inbox, Plus, Warehouse } from "lucide-react";
|
import { Inbox, Plus, Warehouse } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
@@ -45,6 +45,12 @@ const FleetResourcePage = () => {
|
|||||||
const canCreate = canFleetAction(user, slug, "create");
|
const canCreate = canFleetAction(user, slug, "create");
|
||||||
const canUpdate = canFleetAction(user, slug, "update");
|
const canUpdate = canFleetAction(user, slug, "update");
|
||||||
const canDelete = canFleetAction(user, slug, "delete");
|
const canDelete = canFleetAction(user, slug, "delete");
|
||||||
|
// Wagon transfer workspace: shown only to holders of a transfer capability
|
||||||
|
// (raise a request, fulfill one, or see the cross-yard history).
|
||||||
|
const canTransfer =
|
||||||
|
hasPermission(user, FREIGHT_PERMS.wagons.transferRequest) ||
|
||||||
|
hasPermission(user, FREIGHT_PERMS.wagons.transferFulfill) ||
|
||||||
|
hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll);
|
||||||
|
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -442,15 +448,17 @@ const FleetResourcePage = () => {
|
|||||||
Yard Workspace
|
Yard Workspace
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
{canTransfer ? (
|
||||||
variant="light"
|
<Button
|
||||||
color="grape"
|
variant="light"
|
||||||
leftSection={<Inbox size={16} />}
|
color="grape"
|
||||||
styles={{ label: { fontWeight: 500 } }}
|
leftSection={<Inbox size={16} />}
|
||||||
onClick={() => setTransferRequestsOpen(true)}
|
styles={{ label: { fontWeight: 500 } }}
|
||||||
>
|
onClick={() => setTransferRequestsOpen(true)}
|
||||||
Transfer Requests
|
>
|
||||||
</Button>
|
Transfer Requests
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{canCreate ? (
|
{canCreate ? (
|
||||||
|
|||||||
@@ -114,7 +114,9 @@ const CargoTypesPage = () => {
|
|||||||
const config = getRuleEngineResource(CARGO_SLUG);
|
const config = getRuleEngineResource(CARGO_SLUG);
|
||||||
|
|
||||||
const canView = canAccessRuleEngineResource(user, CARGO_SLUG, "view");
|
const canView = canAccessRuleEngineResource(user, CARGO_SLUG, "view");
|
||||||
const canManage = canAccessRuleEngineResource(user, CARGO_SLUG, "manage");
|
const canCreate = canAccessRuleEngineResource(user, CARGO_SLUG, "create");
|
||||||
|
const canUpdate = canAccessRuleEngineResource(user, CARGO_SLUG, "update");
|
||||||
|
const canDelete = canAccessRuleEngineResource(user, CARGO_SLUG, "delete");
|
||||||
|
|
||||||
// One fetch of the whole (small) set — page-walked because the API caps
|
// One fetch of the whole (small) set — page-walked because the API caps
|
||||||
// pageSize at 100; the tree, ancestry and each level are derived client-side
|
// pageSize at 100; the tree, ancestry and each level are derived client-side
|
||||||
@@ -128,7 +130,7 @@ const CargoTypesPage = () => {
|
|||||||
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
|
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
|
||||||
|
|
||||||
// Wagon-type options for the "Wagon types" picker (bulk cargo → allowed list).
|
// Wagon-type options for the "Wagon types" picker (bulk cargo → allowed list).
|
||||||
const { data: wagonTypeOptions } = useWagonTypeOptions(canManage);
|
const { data: wagonTypeOptions } = useWagonTypeOptions(canCreate || canUpdate);
|
||||||
const formFields = useMemo<FormFieldDef[]>(
|
const formFields = useMemo<FormFieldDef[]>(
|
||||||
() =>
|
() =>
|
||||||
FORM_FIELDS.map((field) =>
|
FORM_FIELDS.map((field) =>
|
||||||
@@ -291,7 +293,7 @@ const CargoTypesPage = () => {
|
|||||||
leftSection={<Search size={16} />}
|
leftSection={<Search size={16} />}
|
||||||
w={240}
|
w={240}
|
||||||
/>
|
/>
|
||||||
{canManage && (
|
{canCreate && (
|
||||||
<Button
|
<Button
|
||||||
color="teal"
|
color="teal"
|
||||||
leftSection={<Plus size={16} />}
|
leftSection={<Plus size={16} />}
|
||||||
@@ -335,7 +337,7 @@ const CargoTypesPage = () => {
|
|||||||
? "No cargo categories yet"
|
? "No cargo categories yet"
|
||||||
: `No cargo types under “${str(current?.cargoTypeName)}” yet`}
|
: `No cargo types under “${str(current?.cargoTypeName)}” yet`}
|
||||||
</Text>
|
</Text>
|
||||||
{!term && canManage && (
|
{!term && canCreate && (
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
color="teal"
|
color="teal"
|
||||||
@@ -354,7 +356,8 @@ const CargoTypesPage = () => {
|
|||||||
node={node}
|
node={node}
|
||||||
childCount={(childrenOf.get(node.id) ?? []).length}
|
childCount={(childrenOf.get(node.id) ?? []).length}
|
||||||
topBorder={i > 0}
|
topBorder={i > 0}
|
||||||
canManage={canManage}
|
canUpdate={canUpdate}
|
||||||
|
canDelete={canDelete}
|
||||||
onOpen={() => navigate(`${BASE_PATH}/${node.id}`)}
|
onOpen={() => navigate(`${BASE_PATH}/${node.id}`)}
|
||||||
onEdit={() => setFormMode({ kind: "edit", record: node })}
|
onEdit={() => setFormMode({ kind: "edit", record: node })}
|
||||||
onDelete={() => setDeleteTarget(node)}
|
onDelete={() => setDeleteTarget(node)}
|
||||||
@@ -444,7 +447,8 @@ interface CargoRowProps {
|
|||||||
node: CargoNode;
|
node: CargoNode;
|
||||||
childCount: number;
|
childCount: number;
|
||||||
topBorder: boolean;
|
topBorder: boolean;
|
||||||
canManage: boolean;
|
canUpdate: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
@@ -454,7 +458,8 @@ function CargoRow({
|
|||||||
node,
|
node,
|
||||||
childCount,
|
childCount,
|
||||||
topBorder,
|
topBorder,
|
||||||
canManage,
|
canUpdate,
|
||||||
|
canDelete,
|
||||||
onOpen,
|
onOpen,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -529,19 +534,19 @@ function CargoRow({
|
|||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
|
|
||||||
<Group gap={4} wrap="nowrap">
|
<Group gap={4} wrap="nowrap">
|
||||||
{canManage && (
|
{canUpdate && (
|
||||||
<>
|
<Tooltip label="Edit" withArrow>
|
||||||
<Tooltip label="Edit" withArrow>
|
<Button size="compact-sm" variant="subtle" color="gray" onClick={onEdit} px={8}>
|
||||||
<Button size="compact-sm" variant="subtle" color="gray" onClick={onEdit} px={8}>
|
<Pencil size={15} />
|
||||||
<Pencil size={15} />
|
</Button>
|
||||||
</Button>
|
</Tooltip>
|
||||||
</Tooltip>
|
)}
|
||||||
<Tooltip label="Delete" withArrow>
|
{canDelete && (
|
||||||
<Button size="compact-sm" variant="subtle" color="red" onClick={onDelete} px={8}>
|
<Tooltip label="Delete" withArrow>
|
||||||
<Trash2 size={15} />
|
<Button size="compact-sm" variant="subtle" color="red" onClick={onDelete} px={8}>
|
||||||
</Button>
|
<Trash2 size={15} />
|
||||||
</Tooltip>
|
</Button>
|
||||||
</>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<Tooltip label="Open" withArrow>
|
<Tooltip label="Open" withArrow>
|
||||||
<Button size="compact-sm" variant="subtle" color="teal" onClick={onOpen} px={8}>
|
<Button size="compact-sm" variant="subtle" color="teal" onClick={onOpen} px={8}>
|
||||||
|
|||||||
@@ -150,9 +150,20 @@ const RuleEngineResourcePage = () => {
|
|||||||
const canView = Boolean(
|
const canView = Boolean(
|
||||||
config && canAccessRuleEngineResource(user, config.slug, "view"),
|
config && canAccessRuleEngineResource(user, config.slug, "view"),
|
||||||
);
|
);
|
||||||
const canManage = Boolean(
|
// Per-action gates replace the retired coarse "manage": Add shows only with
|
||||||
config && canAccessRuleEngineResource(user, config.slug, "manage"),
|
// create, row Edit with update, row Delete with delete.
|
||||||
|
const canCreate = Boolean(
|
||||||
|
config && canAccessRuleEngineResource(user, config.slug, "create"),
|
||||||
);
|
);
|
||||||
|
const canUpdate = Boolean(
|
||||||
|
config && canAccessRuleEngineResource(user, config.slug, "update"),
|
||||||
|
);
|
||||||
|
const canDelete = Boolean(
|
||||||
|
config && canAccessRuleEngineResource(user, config.slug, "delete"),
|
||||||
|
);
|
||||||
|
// Update-class controls (reorder, rate submit/approve, approval-rule decide)
|
||||||
|
// all map to the update permission — the matching endpoints now require it.
|
||||||
|
const canUpdateControls = canUpdate;
|
||||||
|
|
||||||
const listParams = useMemo(
|
const listParams = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -480,7 +491,7 @@ const RuleEngineResourcePage = () => {
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
|
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
|
||||||
<Group gap="xs" wrap="nowrap" justify="flex-end">
|
<Group gap="xs" wrap="nowrap" justify="flex-end">
|
||||||
{config.orderConfig && canManage ? (
|
{config.orderConfig && canUpdateControls ? (
|
||||||
<RuleEngineOrderControls
|
<RuleEngineOrderControls
|
||||||
record={row.original}
|
record={row.original}
|
||||||
orderConfig={config.orderConfig}
|
orderConfig={config.orderConfig}
|
||||||
@@ -493,7 +504,7 @@ const RuleEngineResourcePage = () => {
|
|||||||
record={row.original}
|
record={row.original}
|
||||||
config={config}
|
config={config}
|
||||||
layout="row"
|
layout="row"
|
||||||
readOnly={!canManage}
|
readOnly={!canUpdateControls}
|
||||||
onEdit={(record) => {
|
onEdit={(record) => {
|
||||||
setEditing(record);
|
setEditing(record);
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
@@ -504,8 +515,8 @@ const RuleEngineResourcePage = () => {
|
|||||||
? () => setChainOpen(true)
|
? () => setChainOpen(true)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
onSubmitRate={canUpdateControls ? (id) => submit.mutate(id) : undefined}
|
||||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
onApproveRate={canUpdateControls ? handleApproveRate : undefined}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
@@ -514,7 +525,7 @@ const RuleEngineResourcePage = () => {
|
|||||||
|
|
||||||
return base;
|
return base;
|
||||||
}, [
|
}, [
|
||||||
canManage,
|
canUpdateControls,
|
||||||
config,
|
config,
|
||||||
isRates,
|
isRates,
|
||||||
pendingByRateId,
|
pendingByRateId,
|
||||||
@@ -642,7 +653,7 @@ const RuleEngineResourcePage = () => {
|
|||||||
title={config.label}
|
title={config.label}
|
||||||
subtitle={config.subtitle}
|
subtitle={config.subtitle}
|
||||||
action={
|
action={
|
||||||
canManage && config.slug !== "container-types" ? (
|
canCreate && config.slug !== "container-types" ? (
|
||||||
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
|
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
|
||||||
{addLabel}
|
{addLabel}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -653,7 +664,7 @@ const RuleEngineResourcePage = () => {
|
|||||||
{isPriorityRules ? (
|
{isPriorityRules ? (
|
||||||
<PriorityRuleApprovalsSection
|
<PriorityRuleApprovalsSection
|
||||||
requests={priorityWorkflow.pending.data ?? []}
|
requests={priorityWorkflow.pending.data ?? []}
|
||||||
canDecide={canManage}
|
canDecide={canUpdateControls}
|
||||||
approve={priorityWorkflow.approve}
|
approve={priorityWorkflow.approve}
|
||||||
reject={priorityWorkflow.reject}
|
reject={priorityWorkflow.reject}
|
||||||
/>
|
/>
|
||||||
@@ -742,7 +753,7 @@ const RuleEngineResourcePage = () => {
|
|||||||
showSearch={Boolean(config.supportsSearch)}
|
showSearch={Boolean(config.supportsSearch)}
|
||||||
searchPlaceholder={config.searchPlaceholder}
|
searchPlaceholder={config.searchPlaceholder}
|
||||||
onManageOrder={
|
onManageOrder={
|
||||||
canManage && config.orderConfig
|
canUpdateControls && config.orderConfig
|
||||||
? () => setOrderDialogOpen(true)
|
? () => setOrderDialogOpen(true)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -806,16 +817,16 @@ const RuleEngineResourcePage = () => {
|
|||||||
pageCount={pageCount}
|
pageCount={pageCount}
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
onPaginationChange={setPagination}
|
onPaginationChange={setPagination}
|
||||||
readOnly={!canManage}
|
readOnly={!canUpdate && !canDelete}
|
||||||
onEdit={canManage ? openEdit : undefined}
|
onEdit={canUpdate ? openEdit : undefined}
|
||||||
onDelete={canManage ? setDeleteTarget : undefined}
|
onDelete={canDelete ? setDeleteTarget : undefined}
|
||||||
onViewChain={
|
onViewChain={
|
||||||
config.slug === "approval-rules"
|
config.slug === "approval-rules"
|
||||||
? () => setChainOpen(true)
|
? () => setChainOpen(true)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
onSubmitRate={canUpdateControls ? (id) => submit.mutate(id) : undefined}
|
||||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
onApproveRate={canUpdateControls ? handleApproveRate : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
@@ -7,9 +8,11 @@ import {
|
|||||||
Flag,
|
Flag,
|
||||||
MapPin,
|
MapPin,
|
||||||
Navigation,
|
Navigation,
|
||||||
|
PackageCheck,
|
||||||
Train,
|
Train,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -25,7 +28,9 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
||||||
import { PageContainer } from "@/components/page";
|
import { PageContainer } from "@/components/page";
|
||||||
|
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
|
||||||
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
|
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
|
||||||
|
import type { TrackStation } from "@/types/trainScheduling";
|
||||||
import {
|
import {
|
||||||
RouteCorridor,
|
RouteCorridor,
|
||||||
StatusPill,
|
StatusPill,
|
||||||
@@ -148,6 +153,18 @@ export default function TrainScheduleTrackPage() {
|
|||||||
const recordCheckpoint = useMutation(
|
const recordCheckpoint = useMutation(
|
||||||
api.trainScheduling.recordCheckpoint.mutationOptions(),
|
api.trainScheduling.recordCheckpoint.mutationOptions(),
|
||||||
);
|
);
|
||||||
|
// Yard work drives the log-pass modal: which bookings board/alight per stop.
|
||||||
|
const yardWorkQuery = useQuery(
|
||||||
|
api.trainScheduling.yardWork.queryOptions({
|
||||||
|
input: { scheduleId: scheduleId ?? "" },
|
||||||
|
enabled: Boolean(scheduleId) && trackQuery.data?.status === "DISPATCHED",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const [yardModal, setYardModal] = useState<{
|
||||||
|
station: TrackStation;
|
||||||
|
isFinal: boolean;
|
||||||
|
alreadyLogged: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
if (trackQuery.isLoading) {
|
if (trackQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -181,8 +198,26 @@ export default function TrainScheduleTrackPage() {
|
|||||||
const inTransit = track.status === "DISPATCHED";
|
const inTransit = track.status === "DISPATCHED";
|
||||||
const arrived = track.status === "ARRIVED";
|
const arrived = track.status === "ARRIVED";
|
||||||
|
|
||||||
|
// Yard work at a station: boarders not yet loaded, and loaded bookings that
|
||||||
|
// alight there. When either exists, logging the pass goes through the modal
|
||||||
|
// so the operator sees (and can act on) both lists; empty yards log directly.
|
||||||
|
const yardWorkFor = (station: TrackStation | undefined) =>
|
||||||
|
yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId);
|
||||||
|
const stationHasWork = (station: TrackStation | undefined) => {
|
||||||
|
const yard = yardWorkFor(station);
|
||||||
|
return Boolean(
|
||||||
|
yard &&
|
||||||
|
(yard.toLoad.some((r) => !r.loadedAt) || yard.toUnload.some((r) => r.canUnload)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleLog = (sequenceNo: number) => {
|
const handleLog = (sequenceNo: number) => {
|
||||||
|
const station = track.stations.find((s) => s.sequenceNo === sequenceNo);
|
||||||
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
|
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
|
||||||
|
if (station && stationHasWork(station)) {
|
||||||
|
setYardModal({ station, isFinal, alreadyLogged: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
recordCheckpoint.mutate(
|
recordCheckpoint.mutate(
|
||||||
{ id: scheduleId, payload: { sequenceNo } },
|
{ id: scheduleId, payload: { sequenceNo } },
|
||||||
{
|
{
|
||||||
@@ -203,6 +238,15 @@ export default function TrainScheduleTrackPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// "Forgot to load" catch: while the train sits at the current station, any
|
||||||
|
// boarder there that is still unloaded can be loaded until the next pass.
|
||||||
|
const currentStationObj = track.stations.find(
|
||||||
|
(s) => s.sequenceNo === track.currentSequenceNo,
|
||||||
|
);
|
||||||
|
const currentYard = canLog ? yardWorkFor(currentStationObj) : undefined;
|
||||||
|
const forgottenBoarders =
|
||||||
|
currentYard?.toLoad.filter((r) => !r.loadedAt) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<Button
|
<Button
|
||||||
@@ -416,6 +460,43 @@ export default function TrainScheduleTrackPage() {
|
|||||||
}
|
}
|
||||||
onLogCheckpoint={handleLog}
|
onLogCheckpoint={handleLog}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Cargo the operator forgot: boarders at the CURRENT station stay
|
||||||
|
loadable until the next pass is logged. */}
|
||||||
|
{currentStationObj && forgottenBoarders.length > 0 ? (
|
||||||
|
<Alert
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<PackageCheck size={16} />}
|
||||||
|
title={`${forgottenBoarders.length} booking${
|
||||||
|
forgottenBoarders.length === 1 ? "" : "s"
|
||||||
|
} at ${currentStationObj.label} not loaded yet`}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
|
||||||
|
<Text size="sm">
|
||||||
|
The train is at {currentStationObj.label} — cargo boarding here can
|
||||||
|
still be loaded before the next station is logged.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
variant="light"
|
||||||
|
color="yellow"
|
||||||
|
onClick={() =>
|
||||||
|
setYardModal({
|
||||||
|
station: currentStationObj,
|
||||||
|
isFinal:
|
||||||
|
currentStationObj.sequenceNo ===
|
||||||
|
track.stations[totalStations - 1]?.sequenceNo,
|
||||||
|
alreadyLogged: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Open yard work
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
@@ -505,6 +586,15 @@ export default function TrainScheduleTrackPage() {
|
|||||||
</Timeline>
|
</Timeline>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
<LogPassYardWorkModal
|
||||||
|
opened={yardModal !== null}
|
||||||
|
onClose={() => setYardModal(null)}
|
||||||
|
scheduleId={scheduleId}
|
||||||
|
station={yardModal?.station ?? null}
|
||||||
|
isFinal={yardModal?.isFinal ?? false}
|
||||||
|
alreadyLogged={yardModal?.alreadyLogged ?? false}
|
||||||
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -398,11 +398,8 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
!b.loadedAt &&
|
!b.loadedAt &&
|
||||||
!["IN_TRANSIT", "COMPLETED"].includes(b.status ?? ""),
|
!["IN_TRANSIT", "COMPLETED"].includes(b.status ?? ""),
|
||||||
).length;
|
).length;
|
||||||
// Import-Djibouti trains are HARD-blocked from dispatch until loading is
|
// No loading hard-block: bookings may board mid-corridor, so loading happens
|
||||||
// confirmed in the workspace — surface it as a blocker, not just a warning.
|
// per yard from the track page's log-pass flow. Everything below is advisory.
|
||||||
const loadingBlocksDispatch =
|
|
||||||
schedule.requiresLoadingConfirmation === true &&
|
|
||||||
schedule.loadingConfirmed !== true;
|
|
||||||
const hasDispatchWarnings =
|
const hasDispatchWarnings =
|
||||||
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
|
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
|
||||||
|
|
||||||
@@ -1271,22 +1268,6 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
undone.
|
undone.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{loadingBlocksDispatch ? (
|
|
||||||
<Alert
|
|
||||||
color="red"
|
|
||||||
variant="light"
|
|
||||||
radius="md"
|
|
||||||
icon={<AlertTriangle size={18} />}
|
|
||||||
title="Loading not confirmed"
|
|
||||||
>
|
|
||||||
This import train cannot depart until loading is confirmed. Use{" "}
|
|
||||||
<Text span fw={700}>
|
|
||||||
Confirm loading
|
|
||||||
</Text>{" "}
|
|
||||||
in the Workspace tab first.
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{hasDispatchWarnings ? (
|
{hasDispatchWarnings ? (
|
||||||
<Alert
|
<Alert
|
||||||
color="orange"
|
color="orange"
|
||||||
@@ -1309,8 +1290,9 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
<Text span fw={700}>
|
<Text span fw={700}>
|
||||||
{unloadedCount}
|
{unloadedCount}
|
||||||
</Text>{" "}
|
</Text>{" "}
|
||||||
wagon-assigned booking{unloadedCount === 1 ? "" : "s"} still marked
|
wagon-assigned booking{unloadedCount === 1 ? "" : "s"} not loaded
|
||||||
unloaded
|
yet — mid-route boarders load from the track page when the train
|
||||||
|
reaches their yard
|
||||||
</List.Item>
|
</List.Item>
|
||||||
) : null}
|
) : null}
|
||||||
{intercityNotLoadedCount > 0 ? (
|
{intercityNotLoadedCount > 0 ? (
|
||||||
@@ -1350,7 +1332,6 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
color="edr-green"
|
color="edr-green"
|
||||||
leftSection={<Send size={16} />}
|
leftSection={<Send size={16} />}
|
||||||
loading={dispatch.isPending}
|
loading={dispatch.isPending}
|
||||||
disabled={loadingBlocksDispatch}
|
|
||||||
onClick={() => void runDispatch()}
|
onClick={() => void runDispatch()}
|
||||||
>
|
>
|
||||||
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}
|
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { formatRouteLabel } from "@/services/routes.service";
|
import { formatRouteLabel } from "@/services/routes.service";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { canCreateSchedule } from "@/lib/permissions";
|
||||||
import type {
|
import type {
|
||||||
FreightType,
|
FreightType,
|
||||||
TrainScheduleListFilters,
|
TrainScheduleListFilters,
|
||||||
@@ -99,6 +101,8 @@ const parseError = (error: unknown, fallback: string) => {
|
|||||||
export default function TrainScheduleV2ListPage() {
|
export default function TrainScheduleV2ListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canCreate = canCreateSchedule(user);
|
||||||
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
|
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -550,9 +554,11 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
title="Train Schedules"
|
title="Train Schedules"
|
||||||
subtitle="Operational train scheduling with full allocation workflow."
|
subtitle="Operational train scheduling with full allocation workflow."
|
||||||
action={
|
action={
|
||||||
<Button leftSection={<Train size={18} />} onClick={() => setCreateOpen(true)}>
|
canCreate ? (
|
||||||
New schedule
|
<Button leftSection={<Train size={18} />} onClick={() => setCreateOpen(true)}>
|
||||||
</Button>
|
New schedule
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -658,7 +658,16 @@ export const api = {
|
|||||||
"finalize-schedule",
|
"finalize-schedule",
|
||||||
(id) => trainSchedulingService.finalizeSchedule(id),
|
(id) => trainSchedulingService.finalizeSchedule(id),
|
||||||
undefined,
|
undefined,
|
||||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
// Finalize only flips statuses (schedule DRAFT→SCHEDULED + each booking's
|
||||||
|
// schedulingStatus) — it never touches allocation or loading. Refresh only
|
||||||
|
// the schedule detail/list + booking views instead of the broad
|
||||||
|
// train-scheduling ROOT, which refired the eligible-bookings / pool /
|
||||||
|
// yard-work queries and made the booking lists visibly reload.
|
||||||
|
() => [
|
||||||
|
["train-scheduling", "schedule"],
|
||||||
|
["train-scheduling", "schedules"],
|
||||||
|
QUERY_KEYS.BOOKINGS.ROOT,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
dispatchSchedule: endpoint<string, TrainScheduleDetail>(
|
dispatchSchedule: endpoint<string, TrainScheduleDetail>(
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export interface ContractView {
|
|||||||
contractId: string;
|
contractId: string;
|
||||||
reference: string;
|
reference: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
freightType: "BULK" | "CONTAINER";
|
||||||
templateKey: string;
|
templateKey: string;
|
||||||
title: string;
|
title: string;
|
||||||
html: string;
|
html: string;
|
||||||
|
|||||||
@@ -257,6 +257,20 @@ function usePlacesSearch(): PlacesSearch | null {
|
|||||||
}, [placesLib]);
|
}, [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. */
|
/** Recenters the map imperatively when the pinned coordinate changes. */
|
||||||
function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) {
|
function MapRecenter({ lat, lng }: { lat: number | null; lng: number | null }) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
@@ -290,12 +304,17 @@ export interface LocationPickerProps {
|
|||||||
* Reports the resolved address and coordinates up via `onChange`.
|
* Reports the resolved address and coordinates up via `onChange`.
|
||||||
*/
|
*/
|
||||||
export function LocationPicker(props: LocationPickerProps) {
|
export function LocationPicker(props: LocationPickerProps) {
|
||||||
|
const value: LocationValue = {
|
||||||
|
...props.value,
|
||||||
|
lat: toFiniteNumber(props.value.lat),
|
||||||
|
lng: toFiniteNumber(props.value.lng),
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
|
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
|
||||||
{props.variant === "modal" ? (
|
{props.variant === "modal" ? (
|
||||||
<LocationPickerModal {...props} />
|
<LocationPickerModal {...props} value={value} />
|
||||||
) : (
|
) : (
|
||||||
<LocationPickerInline {...props} />
|
<LocationPickerInline {...props} value={value} />
|
||||||
)}
|
)}
|
||||||
</APIProvider>
|
</APIProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,21 +42,27 @@ export function Step2ServiceType({
|
|||||||
const lastMileEnabled = form.watch("lastMile.enabled");
|
const lastMileEnabled = form.watch("lastMile.enabled");
|
||||||
|
|
||||||
const prevServiceType = useRef(serviceType);
|
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(() => {
|
useEffect(() => {
|
||||||
|
if (includesFirstMile) return;
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"firstMile",
|
"firstMile",
|
||||||
{ enabled: false, pickUpAddress: "", lat: null, lng: null },
|
{ enabled: false, pickUpAddress: "", lat: null, lng: null },
|
||||||
{ shouldValidate: true },
|
{ shouldValidate: true },
|
||||||
);
|
);
|
||||||
}, [includesFirstMile]);
|
}, [includesFirstMile, form]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (includesLastMile) return;
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"lastMile",
|
"lastMile",
|
||||||
{ enabled: false, deliveryAddress: "", lat: null, lng: null },
|
{ enabled: false, deliveryAddress: "", lat: null, lng: null },
|
||||||
{ shouldValidate: true },
|
{ shouldValidate: true },
|
||||||
);
|
);
|
||||||
}, [includesLastMile]);
|
}, [includesLastMile, form]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prev = prevServiceType.current;
|
const prev = prevServiceType.current;
|
||||||
@@ -82,6 +88,10 @@ export function Step2ServiceType({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
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");
|
const currentId = form.getValues("serviceTypeId");
|
||||||
if (!currentId) return;
|
if (!currentId) return;
|
||||||
if (!bookableServices.some((s) => s.id === currentId)) {
|
if (!bookableServices.some((s) => s.id === currentId)) {
|
||||||
|
|||||||
@@ -100,12 +100,17 @@ export function Step4Route({
|
|||||||
// here — e.g. switching import→export flips which end must be in Ethiopia.
|
// 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
|
// Clear any selection that no longer matches the operation's required country
|
||||||
// so the customer can't submit a route that contradicts the operation.
|
// 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(() => {
|
useEffect(() => {
|
||||||
|
if (!form.formState.isDirty) return;
|
||||||
if (originCountry && origin && origin.country !== originCountry) {
|
if (originCountry && origin && origin.country !== originCountry) {
|
||||||
form.setValue("originYard", "");
|
form.setValue("originYard", "");
|
||||||
}
|
}
|
||||||
}, [originCountry, origin, form]);
|
}, [originCountry, origin, form]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!form.formState.isDirty) return;
|
||||||
if (destinationCountry && dest && dest.country !== destinationCountry) {
|
if (destinationCountry && dest && dest.country !== destinationCountry) {
|
||||||
form.setValue("destinationYard", "");
|
form.setValue("destinationYard", "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,6 +335,11 @@ export function Step2ServiceType({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
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");
|
const currentId = form.getValues("serviceTypeId");
|
||||||
if (!currentId) return;
|
if (!currentId) return;
|
||||||
if (!standaloneServices.some((s) => s.id === currentId)) {
|
if (!standaloneServices.some((s) => s.id === currentId)) {
|
||||||
|
|||||||
@@ -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 { 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 {
|
import {
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { SmartFileInput } from "@edr/ui-common";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
import { api } from "@/services/api";
|
||||||
import {
|
import {
|
||||||
CONTAINER_SIZES,
|
CONTAINER_SIZES,
|
||||||
ContractFormInputValues,
|
ContractFormInputValues,
|
||||||
|
type ContractDocuments,
|
||||||
type ContractFormValues,
|
type ContractFormValues,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
import { operationToTradeDirection } from "./helpers";
|
||||||
import { fieldStyles, SelectField, StepLabel } from "./shared";
|
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 = [
|
const CARGO_TYPE_OPTIONS = [
|
||||||
{ value: "container", label: "Containerized (20ft / 40ft)" },
|
{ value: "container", label: "Containerized (20ft / 40ft)" },
|
||||||
{ value: "bulk", label: "General / Bulk cargo" },
|
{ value: "bulk", label: "General / Bulk cargo" },
|
||||||
@@ -51,6 +72,101 @@ export function Step3CargoScope({
|
|||||||
const cargoTypePath = form.watch("cargoTypePath") ?? [];
|
const cargoTypePath = form.watch("cargoTypePath") ?? [];
|
||||||
const parentId = cargoTypePath[0];
|
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<ContractDocuments>({});
|
||||||
|
const [hazardErrors, setHazardErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
/** 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.
|
// Reset the commodity child only when the parent group really changes.
|
||||||
const prevParentIdRef = useRef<string | undefined>(parentId);
|
const prevParentIdRef = useRef<string | undefined>(parentId);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -233,40 +349,55 @@ export function Step3CargoScope({
|
|||||||
<Box>
|
<Box>
|
||||||
<StepLabel>Cargo handling</StepLabel>
|
<StepLabel>Cargo handling</StepLabel>
|
||||||
<Stack gap={12} mt={12}>
|
<Stack gap={12} mt={12}>
|
||||||
<Controller
|
{/* Hazardous cargo is only carried under a one-time contract, and
|
||||||
name="isHazardous"
|
enabling it requires the configured hazard documents up front. */}
|
||||||
control={form.control}
|
{isOneTime && (
|
||||||
render={({ field }) => (
|
<Controller
|
||||||
<ToggleRow
|
name="isHazardous"
|
||||||
icon={<Flame size={18} />}
|
control={form.control}
|
||||||
iconBg="#FBEAE7"
|
render={({ field }) => (
|
||||||
iconColor="#C0392B"
|
<ToggleRow
|
||||||
title="Hazardous Material"
|
icon={<Flame size={18} />}
|
||||||
description="Applies a hazard surcharge as a per-container unit rate."
|
iconBg="#FBEAE7"
|
||||||
checked={field.value ?? false}
|
iconColor="#C0392B"
|
||||||
onChange={(v) => field.onChange(v)}
|
title="Hazardous Material"
|
||||||
/>
|
description="Applies a hazard surcharge as a per-container unit rate. Requires hazard documents."
|
||||||
)}
|
checked={field.value ?? false}
|
||||||
/>
|
onChange={(v) => {
|
||||||
<Controller
|
if (v) {
|
||||||
name="isRefrigerated"
|
openHazardModal();
|
||||||
control={form.control}
|
return;
|
||||||
render={({ field }) => (
|
}
|
||||||
<ToggleRow
|
field.onChange(false);
|
||||||
icon={<Snowflake size={18} />}
|
clearHazardDocs();
|
||||||
iconBg="#E9F0F8"
|
}}
|
||||||
iconColor="#2E5B96"
|
/>
|
||||||
title="Refrigerated Cargo"
|
)}
|
||||||
description="Temperature-controlled transport applies a reefer surcharge."
|
/>
|
||||||
checked={field.value ?? false}
|
)}
|
||||||
onChange={(v) => field.onChange(v)}
|
{/* Refrigerated cargo is hidden for now — import-only when re-enabled.
|
||||||
/>
|
The effect above keeps isRefrigerated false while it's off.
|
||||||
)}
|
{isImport && (
|
||||||
/>
|
<Controller
|
||||||
{/* Empty-container return is a container-only service. Like hazardous,
|
name="isRefrigerated"
|
||||||
enabling it here adds the return surcharge as a unit rate; at
|
control={form.control}
|
||||||
booking time the customer/GL sets how many containers return. */}
|
render={({ field }) => (
|
||||||
{cargoType === "container" && (
|
<ToggleRow
|
||||||
|
icon={<Snowflake size={18} />}
|
||||||
|
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" && (
|
||||||
<Controller
|
<Controller
|
||||||
name="equipmentReturn"
|
name="equipmentReturn"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -287,6 +418,54 @@ export function Step3CargoScope({
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={hazardModalOpen}
|
||||||
|
onClose={() => setHazardModalOpen(false)}
|
||||||
|
title="Hazardous cargo documents"
|
||||||
|
size="lg"
|
||||||
|
centered
|
||||||
|
radius={14}
|
||||||
|
>
|
||||||
|
<Stack gap={16}>
|
||||||
|
<Text fz={13} c="#6B7C8E">
|
||||||
|
Hazardous cargo can only move once the documents below are attached
|
||||||
|
to the contract.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{hazardSettingQuery.isLoading ? (
|
||||||
|
<Group justify="center" py="lg">
|
||||||
|
<Loader size="sm" color="edr-green" />
|
||||||
|
</Group>
|
||||||
|
) : hazardFields.length > 0 && hazardSettingQuery.data ? (
|
||||||
|
<SmartFileInput
|
||||||
|
file={hazardSettingQuery.data}
|
||||||
|
value={hazardDraft}
|
||||||
|
onChange={(v) => setHazardDraft(v)}
|
||||||
|
errors={hazardErrors}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text fz={13} c="#6B7C8E">
|
||||||
|
No hazardous document requirements are configured yet. You can
|
||||||
|
still flag the cargo as hazardous — EDR will request the paperwork
|
||||||
|
during review.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap={10}>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius={10}
|
||||||
|
onClick={() => setHazardModalOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="edr-green" radius={10} onClick={confirmHazardDocs}>
|
||||||
|
Save & mark hazardous
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,12 +72,17 @@ export function Step4Route({
|
|||||||
const direction = getRouteDirection(origin, dest);
|
const direction = getRouteDirection(origin, dest);
|
||||||
|
|
||||||
// Clear yards that no longer match the operation's required country.
|
// 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(() => {
|
useEffect(() => {
|
||||||
|
if (!form.formState.isDirty) return;
|
||||||
if (originCountry && origin && origin.country !== originCountry) {
|
if (originCountry && origin && origin.country !== originCountry) {
|
||||||
form.setValue("originYard", "");
|
form.setValue("originYard", "");
|
||||||
}
|
}
|
||||||
}, [originCountry, origin, form]);
|
}, [originCountry, origin, form]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!form.formState.isDirty) return;
|
||||||
if (destinationCountry && dest && dest.country !== destinationCountry) {
|
if (destinationCountry && dest && dest.country !== destinationCountry) {
|
||||||
form.setValue("destinationYard", "");
|
form.setValue("destinationYard", "");
|
||||||
}
|
}
|
||||||
|
|||||||
134
e2e/freight/cypress/e2e/flows/booking_cancel.cy.ts
Normal file
134
e2e/freight/cypress/e2e/flows/booking_cancel.cy.ts
Normal file
@@ -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 {};
|
||||||
141
e2e/freight/cypress/e2e/flows/booking_clearance_query.cy.ts
Normal file
141
e2e/freight/cypress/e2e/flows/booking_clearance_query.cy.ts
Normal file
@@ -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<ReviewRow>(
|
||||||
|
`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 {};
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* Contract creation → finalization, spanning portal + backoffice:
|
* Contract creation → finalization, spanning portal + backoffice:
|
||||||
*
|
*
|
||||||
* 1. portal (user@gmail.com, company seeded active by seed-company.sql):
|
* 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
|
* 2. backoffice marketer: "Accept for approval" (validity) + approve the
|
||||||
* LINE_STAFF step
|
* LINE_STAFF step
|
||||||
* 3. backoffice director: approve the DIRECTOR step → PDF → CONTRACT_READY
|
* 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.mantineSelect(/^Payment Currency/, /^ETB/);
|
||||||
cy.contains("button", "Continue").click({ force: true });
|
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.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(/^Origin Yard/, "Djibouti Port Terminal");
|
||||||
cy.mantineSelect(/^Destination Yard/, "Mojo Dry Port");
|
cy.mantineSelect(/^Destination Yard/, "Mojo Dry Port");
|
||||||
cy.contains("button", "Continue").click({ force: true });
|
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.contains("button", "Approve & submit").click();
|
||||||
|
|
||||||
cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts");
|
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 }) => {
|
dbContract().then(({ rows }) => {
|
||||||
expect(rows, "contract row").to.have.length(1);
|
expect(rows, "contract row").to.have.length(1);
|
||||||
|
|||||||
197
e2e/freight/cypress/e2e/flows/contract_rejection_send_back.cy.ts
Normal file
197
e2e/freight/cypress/e2e/flows/contract_rejection_send_back.cy.ts
Normal file
@@ -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<string> {
|
||||||
|
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<ContractRow>(`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<StepRow>(
|
||||||
|
`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 {};
|
||||||
449
e2e/freight/cypress/e2e/flows/export_ledger_day.cy.ts
Normal file
449
e2e/freight/cypress/e2e/flows/export_ledger_day.cy.ts
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
/**
|
||||||
|
* EXPORT "LEDGER DAY" (D+19) — one departure day, THREE trains of three
|
||||||
|
* different consist styles, ~22 bookings of every flavour (ONE_TIME +
|
||||||
|
* GENERAL, container + bulk, USD + ETB), every one of them driven to a known
|
||||||
|
* final fate — and a generated day-ledger REPORT at the end.
|
||||||
|
*
|
||||||
|
* GRAIN built train TRN-LEDGER-PW2 — 37 × PW2 box wagons (the physical
|
||||||
|
* consist IS the cap; the only built-train schedule in the suite)
|
||||||
|
* → bulk only, 37/37 = 2 590 T
|
||||||
|
* BOX loco pair, 54 × NW5 → containers only, 54/54
|
||||||
|
* MIX loco pair, 54 slots → 24w containers (NW5) + 30w bulk (CW4)
|
||||||
|
*
|
||||||
|
* 145 slots, filled FCFS in wave order (export reserves at ACCEPT):
|
||||||
|
* – bulk wave fills GRAIN exactly; containers then cascade PAST the full
|
||||||
|
* PW2 train onto BOX; the mixed wave lands on MIX
|
||||||
|
* – ONE booking per train never pays → all three EXPIRE in one sweep
|
||||||
|
* – 2 late bookings are REFUSED at create (export never queues)
|
||||||
|
* – 1 refused customer rebooks GRAIN's freed 5 wagons, pays, rides
|
||||||
|
*
|
||||||
|
* The final test classifies every booking of the day and writes
|
||||||
|
* `cypress/reports/export-ledger-<day>.json`.
|
||||||
|
*
|
||||||
|
* Sequential steps of one journey — retries off.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
acceptExport,
|
||||||
|
bookBulk,
|
||||||
|
bookContainers,
|
||||||
|
clearGeneralBooking,
|
||||||
|
createImportSchedule,
|
||||||
|
db,
|
||||||
|
dayLedger,
|
||||||
|
departureAt,
|
||||||
|
eatDayStr,
|
||||||
|
ensureExportRoute,
|
||||||
|
EXP_DEST,
|
||||||
|
EXP_ORIGIN,
|
||||||
|
expectWagonType,
|
||||||
|
forceReservationExpiry,
|
||||||
|
forceWindowOpen,
|
||||||
|
markPaid,
|
||||||
|
pollAllocations,
|
||||||
|
pollBookingStatus,
|
||||||
|
pollDb,
|
||||||
|
resetCorridorDay,
|
||||||
|
seedImportContract,
|
||||||
|
withBooking,
|
||||||
|
writeLedgerReport,
|
||||||
|
type LedgerRow,
|
||||||
|
type ScheduleRow,
|
||||||
|
} from "./import-utils";
|
||||||
|
|
||||||
|
const GRAIN_AT = departureAt(19);
|
||||||
|
const BOX_AT = new Date(GRAIN_AT.getTime() + 2 * 3_600_000);
|
||||||
|
const MIX_AT = new Date(GRAIN_AT.getTime() + 4 * 3_600_000);
|
||||||
|
const DAY = eatDayStr(GRAIN_AT);
|
||||||
|
|
||||||
|
const stamp = String(Date.now());
|
||||||
|
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||||||
|
|
||||||
|
type Kind = "ONE_TIME" | "GENERAL";
|
||||||
|
interface Line {
|
||||||
|
suffix: string;
|
||||||
|
kind: Kind;
|
||||||
|
currency: "ETB" | "USD";
|
||||||
|
freight: "CONTAINER" | "BULK";
|
||||||
|
twenty?: number;
|
||||||
|
tons?: number;
|
||||||
|
wagons: number;
|
||||||
|
wagonType: "PW2" | "NW5" | "CW4";
|
||||||
|
pays: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wave 1 — fills the built 37 × PW2 GRAIN train exactly. */
|
||||||
|
const GRAIN_LINES: Line[] = [
|
||||||
|
{ suffix: "LG1", kind: "ONE_TIME", currency: "USD", freight: "BULK", tons: 700, wagons: 10, wagonType: "PW2", pays: true },
|
||||||
|
{ suffix: "LG2", kind: "GENERAL", currency: "ETB", freight: "BULK", tons: 560, wagons: 8, wagonType: "PW2", pays: true },
|
||||||
|
{ suffix: "LG3", kind: "ONE_TIME", currency: "ETB", freight: "BULK", tons: 560, wagons: 8, wagonType: "PW2", pays: true },
|
||||||
|
{ suffix: "LG4", kind: "ONE_TIME", currency: "USD", freight: "BULK", tons: 420, wagons: 6, wagonType: "PW2", pays: true },
|
||||||
|
{ suffix: "LG5", kind: "ONE_TIME", currency: "ETB", freight: "BULK", tons: 350, wagons: 5, wagonType: "PW2", pays: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Wave 2 — 54 × NW5 on the pure container train. */
|
||||||
|
const BOX_LINES: Line[] = [
|
||||||
|
{ suffix: "LB1", kind: "ONE_TIME", currency: "USD", freight: "CONTAINER", twenty: 40, wagons: 20, wagonType: "NW5", pays: true },
|
||||||
|
{ suffix: "LB2", kind: "ONE_TIME", currency: "ETB", freight: "CONTAINER", twenty: 24, wagons: 12, wagonType: "NW5", pays: true },
|
||||||
|
{ suffix: "LB3", kind: "GENERAL", currency: "USD", freight: "CONTAINER", twenty: 16, wagons: 8, wagonType: "NW5", pays: true },
|
||||||
|
{ suffix: "LB4", kind: "ONE_TIME", currency: "ETB", freight: "CONTAINER", twenty: 12, wagons: 6, wagonType: "NW5", pays: true },
|
||||||
|
{ suffix: "LB5", kind: "ONE_TIME", currency: "USD", freight: "CONTAINER", twenty: 8, wagons: 4, wagonType: "NW5", pays: true },
|
||||||
|
{ suffix: "LB6", kind: "GENERAL", currency: "ETB", freight: "CONTAINER", twenty: 4, wagons: 2, wagonType: "NW5", pays: true },
|
||||||
|
{ suffix: "LB7", kind: "ONE_TIME", currency: "USD", freight: "CONTAINER", twenty: 2, wagons: 1, wagonType: "NW5", pays: true },
|
||||||
|
{ suffix: "LB8", kind: "ONE_TIME", currency: "ETB", freight: "CONTAINER", twenty: 2, wagons: 1, wagonType: "NW5", pays: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Wave 3 — 24w containers + 30w bulk share the MIX train. */
|
||||||
|
const MIX_LINES: Line[] = [
|
||||||
|
{ suffix: "LM1", kind: "ONE_TIME", currency: "USD", freight: "CONTAINER", twenty: 24, wagons: 12, wagonType: "NW5", pays: true },
|
||||||
|
{ suffix: "LM2", kind: "GENERAL", currency: "ETB", freight: "CONTAINER", twenty: 16, wagons: 8, wagonType: "NW5", pays: true },
|
||||||
|
{ suffix: "LM3", kind: "ONE_TIME", currency: "ETB", freight: "CONTAINER", twenty: 8, wagons: 4, wagonType: "NW5", pays: true },
|
||||||
|
{ suffix: "LM4", kind: "ONE_TIME", currency: "USD", freight: "BULK", tons: 700, wagons: 10, wagonType: "CW4", pays: true },
|
||||||
|
{ suffix: "LM5", kind: "GENERAL", currency: "ETB", freight: "BULK", tons: 700, wagons: 10, wagonType: "CW4", pays: true },
|
||||||
|
{ suffix: "LM6", kind: "ONE_TIME", currency: "ETB", freight: "BULK", tons: 700, wagons: 10, wagonType: "CW4", pays: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL_LINES = [...GRAIN_LINES, ...BOX_LINES, ...MIX_LINES];
|
||||||
|
const PAID = ALL_LINES.filter((l) => l.pays);
|
||||||
|
const UNPAID = ALL_LINES.filter((l) => !l.pays);
|
||||||
|
/** Refused at create once all 145 slots are held; LR2 later redeems itself. */
|
||||||
|
const REJECTED = ["LR1", "LR2"];
|
||||||
|
|
||||||
|
let isoSeed = 20_000;
|
||||||
|
|
||||||
|
/** Book one line and take it to its FCFS reservation. */
|
||||||
|
function bookAndAccept(line: Line) {
|
||||||
|
if (line.freight === "CONTAINER") {
|
||||||
|
bookContainers({
|
||||||
|
suffix: line.suffix,
|
||||||
|
runStamp: stamp,
|
||||||
|
isoSeed,
|
||||||
|
twenty: line.twenty,
|
||||||
|
scheduledDate: DAY,
|
||||||
|
});
|
||||||
|
isoSeed += (line.twenty ?? 0) + 5;
|
||||||
|
} else {
|
||||||
|
// PW2 bulk rides the built GRAIN consist → GRAINS cargo; CW4 bulk rides the
|
||||||
|
// loco-pair MIX train → WHEAT. One wagon length per cargo keeps the
|
||||||
|
// loco-pair length budget deterministic (see seed 5b3).
|
||||||
|
bookBulk({
|
||||||
|
suffix: line.suffix,
|
||||||
|
tons: line.tons!,
|
||||||
|
scheduledDate: DAY,
|
||||||
|
cargoCode: line.wagonType === "PW2" ? "E2E_IMP_GRAINS" : "E2E_IMP_WHEAT",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (line.kind === "GENERAL") clearGeneralBooking(line.suffix, DAY, "export");
|
||||||
|
else acceptExport(line.suffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedLine(line: Line) {
|
||||||
|
seedImportContract({
|
||||||
|
suffix: line.suffix,
|
||||||
|
reference: stampedRef(line.suffix),
|
||||||
|
kind: line.kind,
|
||||||
|
currency: line.currency,
|
||||||
|
freight: line.freight,
|
||||||
|
direction: "EXPORT",
|
||||||
|
originCode: EXP_ORIGIN,
|
||||||
|
destCode: EXP_DEST,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DaySchedule {
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
max_wagons: number;
|
||||||
|
booking_window_status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function daySchedule(at: Date) {
|
||||||
|
return db<DaySchedule>(
|
||||||
|
`SELECT ts.id, ts.reference, ts.max_wagons, ts.booking_window_status
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1
|
||||||
|
JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2
|
||||||
|
WHERE ts.deleted_at IS NULL
|
||||||
|
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < 3600
|
||||||
|
ORDER BY ts.created_at DESC LIMIT 1`,
|
||||||
|
[EXP_ORIGIN, EXP_DEST, at.toISOString()],
|
||||||
|
).then(({ rows }) => {
|
||||||
|
expect(rows, `schedule departing ${at.toISOString()}`).to.have.length(1);
|
||||||
|
return cy.wrap(rows[0], { log: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("export ledger day: three trains, every fate, one report", { retries: 0 }, () => {
|
||||||
|
before(() => {
|
||||||
|
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||||||
|
ALL_LINES.forEach(seedLine);
|
||||||
|
seedLine({
|
||||||
|
suffix: "LR1",
|
||||||
|
kind: "ONE_TIME",
|
||||||
|
currency: "USD",
|
||||||
|
freight: "CONTAINER",
|
||||||
|
twenty: 2,
|
||||||
|
wagons: 1,
|
||||||
|
wagonType: "NW5",
|
||||||
|
pays: false,
|
||||||
|
});
|
||||||
|
seedLine({
|
||||||
|
suffix: "LR2",
|
||||||
|
kind: "ONE_TIME",
|
||||||
|
currency: "ETB",
|
||||||
|
freight: "BULK",
|
||||||
|
tons: 350,
|
||||||
|
wagons: 5,
|
||||||
|
wagonType: "PW2",
|
||||||
|
pays: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("operations schedules THREE trains for one day: 37×PW2 built GRAIN, 54×NW5 BOX, 54 MIX", () => {
|
||||||
|
ensureExportRoute();
|
||||||
|
resetCorridorDay(GRAIN_AT, EXP_DEST, EXP_ORIGIN);
|
||||||
|
|
||||||
|
// The built train's coupled consist IS its capacity — 37, not the
|
||||||
|
// loco-length-derived 54 every other schedule in the suite gets.
|
||||||
|
createImportSchedule({
|
||||||
|
departure: GRAIN_AT,
|
||||||
|
trainCode: "TRN-LEDGER-PW2",
|
||||||
|
maxWagons: 37,
|
||||||
|
kind: "bulk",
|
||||||
|
originCode: EXP_ORIGIN,
|
||||||
|
destCode: EXP_DEST,
|
||||||
|
});
|
||||||
|
createImportSchedule({
|
||||||
|
departure: BOX_AT,
|
||||||
|
locoPair: ["LOCO-LED-3", "LOCO-LED-4"],
|
||||||
|
originCode: EXP_ORIGIN,
|
||||||
|
destCode: EXP_DEST,
|
||||||
|
});
|
||||||
|
createImportSchedule({
|
||||||
|
departure: MIX_AT,
|
||||||
|
locoPair: ["LOCO-LED-5", "LOCO-LED-6"],
|
||||||
|
originCode: EXP_ORIGIN,
|
||||||
|
destCode: EXP_DEST,
|
||||||
|
});
|
||||||
|
|
||||||
|
[GRAIN_AT, BOX_AT, MIX_AT].forEach((at) =>
|
||||||
|
daySchedule(at).then((s) => forceWindowOpen(s.id, 120)),
|
||||||
|
);
|
||||||
|
daySchedule(GRAIN_AT).then((s) =>
|
||||||
|
expect(s.max_wagons, "built consist = 37 PW2").to.eq(37),
|
||||||
|
);
|
||||||
|
daySchedule(BOX_AT).then((s) => expect(s.max_wagons, "BOX = 54").to.eq(54));
|
||||||
|
daySchedule(MIX_AT).then((s) => expect(s.max_wagons, "MIX = 54").to.eq(54));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wave 1 — five bulk bookings fill the built PW2 train exactly (37/37 reserved)", () => {
|
||||||
|
GRAIN_LINES.forEach(bookAndAccept);
|
||||||
|
daySchedule(GRAIN_AT).then((s) => {
|
||||||
|
GRAIN_LINES.forEach((l) =>
|
||||||
|
withBooking(l.suffix, (b) =>
|
||||||
|
expect(b.train_schedule_id, `${l.suffix} on GRAIN`).to.eq(s.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ONE_TIME is single-slot; GENERAL draws down again but is stopped at the FCFS accept", () => {
|
||||||
|
// A second booking on the live ONE_TIME contract LG1 is refused outright…
|
||||||
|
bookBulk({
|
||||||
|
suffix: "LG1",
|
||||||
|
tons: 70,
|
||||||
|
scheduledDate: DAY,
|
||||||
|
cargoCode: "E2E_IMP_GRAINS",
|
||||||
|
expectFailure: "already has an active booking",
|
||||||
|
});
|
||||||
|
|
||||||
|
// …while the GENERAL neighbour LG2 draws again freely. Engine truth: a
|
||||||
|
// GENERAL booking clears PER BOOKING, so it SKIPS the create-time
|
||||||
|
// window/space gate that refused the ONE_TIME latecomers above — a full
|
||||||
|
// day stops it only later, when staff try to accept it onto a train.
|
||||||
|
bookBulk({ suffix: "LG2", tons: 70, scheduledDate: DAY, cargoCode: "E2E_IMP_GRAINS" });
|
||||||
|
withBooking("LG2", (probe) => {
|
||||||
|
expect(probe.status, "GENERAL drawdown accepted at create").to.eq(
|
||||||
|
"AWAITING_DOCUMENTS",
|
||||||
|
);
|
||||||
|
// Dispose of the probe so the day's ledger keeps exactly one live
|
||||||
|
// booking per contract (every lookup skips soft-deleted rows).
|
||||||
|
db(`UPDATE freight.bookings SET deleted_at = now() WHERE id = $1`, [probe.id]);
|
||||||
|
});
|
||||||
|
withBooking("LG2", (b) =>
|
||||||
|
expect(b.status, "LG2's real reservation is untouched").to.be.oneOf([
|
||||||
|
"SELECTED_FOR_BATCH",
|
||||||
|
"AWAITING_PAYMENT",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wave 2 — containers cascade PAST the full PW2 train onto BOX (54/54 reserved)", () => {
|
||||||
|
BOX_LINES.forEach(bookAndAccept);
|
||||||
|
daySchedule(BOX_AT).then((s) => {
|
||||||
|
BOX_LINES.forEach((l) =>
|
||||||
|
withBooking(l.suffix, (b) =>
|
||||||
|
expect(b.train_schedule_id, `${l.suffix} on BOX`).to.eq(s.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wave 3 — containers and bulk share MIX (24w + 30w = 54/54 reserved)", () => {
|
||||||
|
MIX_LINES.forEach(bookAndAccept);
|
||||||
|
daySchedule(MIX_AT).then((s) => {
|
||||||
|
MIX_LINES.forEach((l) =>
|
||||||
|
withBooking(l.suffix, (b) =>
|
||||||
|
expect(b.train_schedule_id, `${l.suffix} on MIX`).to.eq(s.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every reservation's pay deadline is clamped to its OWN train's window close", () => {
|
||||||
|
const check = (at: Date, lines: Line[]) =>
|
||||||
|
daySchedule(at).then((s) => {
|
||||||
|
db<{ window_closes_at: string }>(
|
||||||
|
`SELECT window_closes_at FROM freight.train_schedules WHERE id = $1`,
|
||||||
|
[s.id],
|
||||||
|
).then(({ rows }) => {
|
||||||
|
lines.forEach((l) =>
|
||||||
|
withBooking(l.suffix, (b) => {
|
||||||
|
expect(
|
||||||
|
new Date(b.payment_deadline!).getTime(),
|
||||||
|
`${l.suffix} clamped`,
|
||||||
|
).to.be.at.most(new Date(rows[0].window_closes_at).getTime());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
check(GRAIN_AT, GRAIN_LINES);
|
||||||
|
check(BOX_AT, BOX_LINES);
|
||||||
|
check(MIX_AT, MIX_LINES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all 145 slots are held — two late bookings are REFUSED (export never queues)", () => {
|
||||||
|
bookContainers({
|
||||||
|
suffix: "LR1",
|
||||||
|
runStamp: stamp,
|
||||||
|
isoSeed: 29_000,
|
||||||
|
twenty: 2,
|
||||||
|
scheduledDate: DAY,
|
||||||
|
expectFailure: /space|window/i,
|
||||||
|
});
|
||||||
|
bookBulk({
|
||||||
|
suffix: "LR2",
|
||||||
|
tons: 350,
|
||||||
|
scheduledDate: DAY,
|
||||||
|
cargoCode: "E2E_IMP_GRAINS",
|
||||||
|
expectFailure: /space|window/i,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("16 of the 19 pay — typed allocation on all three trains", () => {
|
||||||
|
PAID.forEach((l) => {
|
||||||
|
markPaid(l.suffix);
|
||||||
|
pollAllocations(l.suffix, l.wagons);
|
||||||
|
expectWagonType(l.suffix, l.wagonType, l.wagons);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the three unpaid — one per train — expire in one sweep", () => {
|
||||||
|
UNPAID.forEach((l) => forceReservationExpiry(l.suffix));
|
||||||
|
UNPAID.forEach((l) => pollBookingStatus(l.suffix, "EXPIRED"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redemption: the refused bulk customer takes GRAIN's freed 5 PW2 wagons and pays", () => {
|
||||||
|
bookBulk({ suffix: "LR2", tons: 350, scheduledDate: DAY, cargoCode: "E2E_IMP_GRAINS" });
|
||||||
|
acceptExport("LR2");
|
||||||
|
markPaid("LR2");
|
||||||
|
pollAllocations("LR2", 5);
|
||||||
|
expectWagonType("LR2", "PW2", 5);
|
||||||
|
daySchedule(GRAIN_AT).then((s) =>
|
||||||
|
withBooking("LR2", (b) =>
|
||||||
|
expect(b.train_schedule_id, "LR2 rides GRAIN").to.eq(s.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("per-train totals: 37 PW2 + 54 NW5 + 54 mixed, links match bookings", () => {
|
||||||
|
const totals: Array<[Date, string, number, number]> = [
|
||||||
|
// [departure, label, wagons, bookings]
|
||||||
|
[GRAIN_AT, "GRAIN", 37, 5], // 4 paid + the redeemed LR2
|
||||||
|
[BOX_AT, "BOX", 53, 7], // one booking expired: 54 − 1
|
||||||
|
[MIX_AT, "MIX", 44, 5], // one 10w booking expired: 54 − 10
|
||||||
|
];
|
||||||
|
totals.forEach(([at, label, wagons, bookings]) => {
|
||||||
|
daySchedule(at).then((s) => {
|
||||||
|
pollDb<{ n: string }>(
|
||||||
|
`${label} carries ${bookings} bookings`,
|
||||||
|
`SELECT count(*) AS n FROM freight.train_schedule_bookings
|
||||||
|
WHERE train_schedule_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[s.id],
|
||||||
|
(row) => Number(row?.n) === bookings,
|
||||||
|
15,
|
||||||
|
);
|
||||||
|
db<{ n: string }>(
|
||||||
|
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
|
||||||
|
FROM freight.wagon_booking_allocations wba
|
||||||
|
JOIN freight.train_schedule_bookings tsb
|
||||||
|
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||||||
|
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||||||
|
[s.id],
|
||||||
|
).then(({ rows }) =>
|
||||||
|
expect(Number(rows[0].n), `${label} wagons`).to.eq(wagons),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("THE LEDGER — classify every booking of the day and write the report", () => {
|
||||||
|
dayLedger(stamp, { rejected: REJECTED, redeemed: ["LR2"] }).then((rows) => {
|
||||||
|
const ledger = rows as LedgerRow[];
|
||||||
|
const byFate = (fate: string) => ledger.filter((r) => r.fate === fate);
|
||||||
|
|
||||||
|
// Class counts: 16 riding + 1 redeemed + 3 expired + 2 refusals.
|
||||||
|
expect(byFate("PAID_RIDING").length, "paid & riding").to.eq(PAID.length);
|
||||||
|
expect(byFate("REBOOKED_PAID").length, "refused then rebooked").to.eq(1);
|
||||||
|
expect(byFate("EXPIRED_UNPAID").length, "reserved but never paid").to.eq(
|
||||||
|
UNPAID.length,
|
||||||
|
);
|
||||||
|
expect(byFate("REJECTED_NO_SPACE").length, "refused at create").to.eq(
|
||||||
|
REJECTED.length,
|
||||||
|
);
|
||||||
|
expect(byFate("RESERVED_UNPAID").length, "nothing left hanging").to.eq(0);
|
||||||
|
|
||||||
|
// Every engineered line landed in its expected class.
|
||||||
|
PAID.forEach((l) => {
|
||||||
|
const row = ledger.find((r) => r.suffix === l.suffix && r.fate === "PAID_RIDING");
|
||||||
|
expect(row, `${l.suffix} rides`).to.exist;
|
||||||
|
expect(row!.kind, `${l.suffix} kind`).to.eq(l.kind);
|
||||||
|
expect(row!.currency, `${l.suffix} currency`).to.eq(l.currency);
|
||||||
|
expect(row!.wagon_type, `${l.suffix} wagon type`).to.eq(l.wagonType);
|
||||||
|
expect(row!.wagons, `${l.suffix} wagons`).to.eq(l.wagons);
|
||||||
|
});
|
||||||
|
UNPAID.forEach((l) =>
|
||||||
|
expect(
|
||||||
|
ledger.find((r) => r.suffix === l.suffix && r.fate === "EXPIRED_UNPAID"),
|
||||||
|
`${l.suffix} expired`,
|
||||||
|
).to.exist,
|
||||||
|
);
|
||||||
|
// LR2 appears TWICE: the refusal and the redemption.
|
||||||
|
expect(
|
||||||
|
ledger.filter((r) => r.suffix === "LR2").length,
|
||||||
|
"LR2 refused then rebooked",
|
||||||
|
).to.eq(2);
|
||||||
|
|
||||||
|
// Currency split of the riding cargo (USD vs ETB invoices on one day).
|
||||||
|
const riding = [...byFate("PAID_RIDING"), ...byFate("REBOOKED_PAID")];
|
||||||
|
expect(riding.filter((r) => r.currency === "USD").length, "USD riders").to.be.greaterThan(0);
|
||||||
|
expect(riding.filter((r) => r.currency === "ETB").length, "ETB riders").to.be.greaterThan(0);
|
||||||
|
// Every riding booking sits on a train, every expired one does not ride.
|
||||||
|
riding.forEach((r) => expect(r.train, `${r.suffix} has a train`).to.be.a("string"));
|
||||||
|
|
||||||
|
writeLedgerReport(`export-ledger-${DAY}`, ledger);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -387,10 +387,9 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
|
|||||||
cy.mantineSelect(/^Payment Currency/, /^ETB/);
|
cy.mantineSelect(/^Payment Currency/, /^ETB/);
|
||||||
cy.contains("button", "Continue").click({ force: true });
|
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.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(/^Origin Yard/, ORIGIN_YARD);
|
||||||
cy.mantineSelect(/^Destination Yard/, PORT_YARD);
|
cy.mantineSelect(/^Destination Yard/, PORT_YARD);
|
||||||
cy.contains("button", "Continue").click({ force: true });
|
cy.contains("button", "Continue").click({ force: true });
|
||||||
|
|||||||
227
e2e/freight/cypress/e2e/flows/fleet_wagon_transfer.cy.ts
Normal file
227
e2e/freight/cypress/e2e/flows/fleet_wagon_transfer.cy.ts
Normal file
@@ -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<Ids>(
|
||||||
|
`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 {};
|
||||||
254
e2e/freight/cypress/e2e/flows/government_preemption.cy.ts
Normal file
254
e2e/freight/cypress/e2e/flows/government_preemption.cy.ts
Normal file
@@ -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<GovBookingRow>(
|
||||||
|
`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 {};
|
||||||
@@ -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). */
|
/** Poll a 1-row query until `check` passes (10s window tick ⇒ 3s cadence). */
|
||||||
export function pollDb<T = Row>(
|
export function pollDb<T = Row>(
|
||||||
label: string,
|
label: string,
|
||||||
@@ -271,7 +299,7 @@ export function dbBooking(suffix: string) {
|
|||||||
b.is_split, b.contract_id
|
b.is_split, b.contract_id
|
||||||
FROM freight.bookings b
|
FROM freight.bookings b
|
||||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
|
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND b.deleted_at IS NULL
|
||||||
ORDER BY b.created_at DESC LIMIT 1`,
|
ORDER BY b.created_at DESC LIMIT 1`,
|
||||||
[suffix],
|
[suffix],
|
||||||
);
|
);
|
||||||
@@ -297,7 +325,7 @@ export function pollBookingStatus(suffix: string, status: string | string[], att
|
|||||||
`${suffix} → ${want.join("|")}`,
|
`${suffix} → ${want.join("|")}`,
|
||||||
`SELECT b.status FROM freight.bookings b
|
`SELECT b.status FROM freight.bookings b
|
||||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
|
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND b.deleted_at IS NULL
|
||||||
ORDER BY b.created_at DESC LIMIT 1`,
|
ORDER BY b.created_at DESC LIMIT 1`,
|
||||||
[suffix],
|
[suffix],
|
||||||
(row) => !!row && want.includes(row.status as string),
|
(row) => !!row && want.includes(row.status as string),
|
||||||
@@ -389,15 +417,17 @@ export function bookBulk(opts: {
|
|||||||
suffix: string;
|
suffix: string;
|
||||||
tons: number;
|
tons: number;
|
||||||
scheduledDate?: string; // omit for DOMESTIC (intercity)
|
scheduledDate?: string; // omit for DOMESTIC (intercity)
|
||||||
|
/** Cargo code — WHEAT rides CW4, GRAINS rides PW2. Defaults to wheat. */
|
||||||
|
cargoCode?: "E2E_IMP_WHEAT" | "E2E_IMP_GRAINS";
|
||||||
expectFailure?: string | RegExp;
|
expectFailure?: string | RegExp;
|
||||||
}) {
|
}) {
|
||||||
db<{ id: string; customs_clearing_enabled: boolean; cargo_type_id: string }>(
|
db<{ id: string; customs_clearing_enabled: boolean; cargo_type_id: string }>(
|
||||||
`SELECT ct.id, ct.customs_clearing_enabled,
|
`SELECT ct.id, ct.customs_clearing_enabled,
|
||||||
(SELECT t.id FROM freight.cargo_types t WHERE t.code = 'E2E_IMP_WHEAT') AS cargo_type_id
|
(SELECT t.id FROM freight.cargo_types t WHERE t.code = $2) AS cargo_type_id
|
||||||
FROM freight.contracts ct
|
FROM freight.contracts ct
|
||||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
|
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
|
||||||
ORDER BY ct.created_at DESC LIMIT 1`,
|
ORDER BY ct.created_at DESC LIMIT 1`,
|
||||||
[opts.suffix],
|
[opts.suffix, opts.cargoCode ?? "E2E_IMP_WHEAT"],
|
||||||
).then(({ rows }) => {
|
).then(({ rows }) => {
|
||||||
expect(rows, `seeded contract *-${opts.suffix}`).to.have.length(1);
|
expect(rows, `seeded contract *-${opts.suffix}`).to.have.length(1);
|
||||||
const actor = rows[0].customs_clearing_enabled ? superAdmin : customer;
|
const actor = rows[0].customs_clearing_enabled ? superAdmin : customer;
|
||||||
@@ -433,7 +463,12 @@ export function bookBulk(opts: {
|
|||||||
* seed configures no required documents, so one ad-hoc doc satisfies the
|
* seed configures no required documents, so one ad-hoc doc satisfies the
|
||||||
* 100%-approved gate.
|
* 100%-approved gate.
|
||||||
*/
|
*/
|
||||||
export function clearGeneralBooking(suffix: string, scheduledDate: string) {
|
export function clearGeneralBooking(
|
||||||
|
suffix: string,
|
||||||
|
scheduledDate: string,
|
||||||
|
/** EXPORT ends at acceptExport (FCFS reserves on accept), not the pool. */
|
||||||
|
mode: "import" | "export" = "import",
|
||||||
|
) {
|
||||||
withBooking(suffix, (b) => {
|
withBooking(suffix, (b) => {
|
||||||
expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS");
|
expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS");
|
||||||
glUpload(`/api/bookings/${b.id}/clearance/documents`, {}, "custom_e2e");
|
glUpload(`/api/bookings/${b.id}/clearance/documents`, {}, "custom_e2e");
|
||||||
@@ -450,7 +485,8 @@ export function clearGeneralBooking(suffix: string, scheduledDate: string) {
|
|||||||
.its("status")
|
.its("status")
|
||||||
.should("be.oneOf", [200, 201]);
|
.should("be.oneOf", [200, 201]);
|
||||||
});
|
});
|
||||||
acceptOperation(suffix);
|
if (mode === "export") acceptExport(suffix);
|
||||||
|
else acceptOperation(suffix);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ops accepts the operation request → FULLY_EXECUTED (enters the day pool). */
|
/** Ops accepts the operation request → FULLY_EXECUTED (enters the day pool). */
|
||||||
@@ -473,6 +509,28 @@ export function setPriority(suffix: string, order: number) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customs bookings pay their clearance service fee ON the booking invoice —
|
||||||
|
* there is no separate prepaid clearance invoice. Asserts the booking's
|
||||||
|
* invoice carries a CUSTOMS_CLEARANCE line.
|
||||||
|
*/
|
||||||
|
export function expectClearanceOnBookingInvoice(suffix: string) {
|
||||||
|
pollDb<{ n: string }>(
|
||||||
|
`${suffix} clearance fee on booking invoice`,
|
||||||
|
`SELECT COUNT(*)::text AS n
|
||||||
|
FROM freight.invoice_lines l
|
||||||
|
JOIN freight.invoices i ON i.id = l.invoice_id
|
||||||
|
JOIN freight.bookings b ON b.id::text = i.source_id::text
|
||||||
|
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||||
|
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND b.deleted_at IS NULL
|
||||||
|
AND i.source = 'booking'
|
||||||
|
AND l.charge_type LIKE 'CUSTOMS_CLEARANCE%'`,
|
||||||
|
[suffix],
|
||||||
|
(row) => Number(row?.n ?? 0) > 0,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Staff force-pay; polls PAID + SCHEDULED. */
|
/** Staff force-pay; polls PAID + SCHEDULED. */
|
||||||
export function markPaid(suffix: string) {
|
export function markPaid(suffix: string) {
|
||||||
withBooking(suffix, (b) => {
|
withBooking(suffix, (b) => {
|
||||||
@@ -484,7 +542,7 @@ export function markPaid(suffix: string) {
|
|||||||
`${suffix} PAID+SCHEDULED`,
|
`${suffix} PAID+SCHEDULED`,
|
||||||
`SELECT b.status, b.scheduling_status FROM freight.bookings b
|
`SELECT b.status, b.scheduling_status FROM freight.bookings b
|
||||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
|
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND b.deleted_at IS NULL
|
||||||
ORDER BY b.created_at DESC LIMIT 1`,
|
ORDER BY b.created_at DESC LIMIT 1`,
|
||||||
[suffix],
|
[suffix],
|
||||||
(row) => row?.status === "PAID" && row?.scheduling_status === "SCHEDULED",
|
(row) => row?.status === "PAID" && row?.scheduling_status === "SCHEDULED",
|
||||||
@@ -557,7 +615,7 @@ export function settleViaGateway(suffix: string) {
|
|||||||
`${suffix} PAID via gateway`,
|
`${suffix} PAID via gateway`,
|
||||||
`SELECT b.status FROM freight.bookings b
|
`SELECT b.status FROM freight.bookings b
|
||||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||||
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
|
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND b.deleted_at IS NULL
|
||||||
ORDER BY b.created_at DESC LIMIT 1`,
|
ORDER BY b.created_at DESC LIMIT 1`,
|
||||||
[suffix],
|
[suffix],
|
||||||
(row) => row?.status === "PAID",
|
(row) => row?.status === "PAID",
|
||||||
@@ -808,7 +866,13 @@ export function withExportSchedule(departure: Date, fn: (s: ScheduleRow) => void
|
|||||||
*/
|
*/
|
||||||
export function createImportSchedule(opts: {
|
export function createImportSchedule(opts: {
|
||||||
departure: Date;
|
departure: Date;
|
||||||
locoPair: [string, string];
|
/** Loco-pair mode — capacity comes from maxWagonsPerTrain. */
|
||||||
|
locoPair?: [string, string];
|
||||||
|
/**
|
||||||
|
* Built-train mode — the Train-Builder consist IS the capacity (its coupled
|
||||||
|
* wagon count), immune to the loco-length slot recompute.
|
||||||
|
*/
|
||||||
|
trainCode?: string;
|
||||||
maxWagons?: number;
|
maxWagons?: number;
|
||||||
kind?: "container" | "bulk";
|
kind?: "container" | "bulk";
|
||||||
originCode?: string;
|
originCode?: string;
|
||||||
@@ -816,16 +880,32 @@ export function createImportSchedule(opts: {
|
|||||||
}) {
|
}) {
|
||||||
const originCode = opts.originCode ?? ORIGIN;
|
const originCode = opts.originCode ?? ORIGIN;
|
||||||
const destCode = opts.destCode ?? DEST;
|
const destCode = opts.destCode ?? DEST;
|
||||||
|
const endpoint = `/api/train-scheduling/${opts.kind ?? "container"}/schedules`;
|
||||||
dbSchedule(opts.departure, destCode, originCode).then(({ rows }) => {
|
dbSchedule(opts.departure, destCode, originCode).then(({ rows }) => {
|
||||||
if (rows.length > 0) return;
|
if (rows.length > 0) return;
|
||||||
dbRouteId(originCode, destCode).then(({ rows: routes }) => {
|
dbRouteId(originCode, destCode).then(({ rows: routes }) => {
|
||||||
expect(routes, "corridor route").to.have.length(1);
|
expect(routes, "corridor route").to.have.length(1);
|
||||||
|
if (opts.trainCode) {
|
||||||
|
db<{ id: string }>(`SELECT id FROM freight.trains WHERE code = $1`, [
|
||||||
|
opts.trainCode,
|
||||||
|
]).then(({ rows: trains }) => {
|
||||||
|
expect(trains, `built train ${opts.trainCode}`).to.have.length(1);
|
||||||
|
apiPost(opsStaff, endpoint, {
|
||||||
|
routeId: routes[0].id,
|
||||||
|
scheduleDate: opts.departure.toISOString(),
|
||||||
|
trainId: trains[0].id,
|
||||||
|
})
|
||||||
|
.its("status")
|
||||||
|
.should("be.oneOf", [200, 201]);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
db<{ id: string }>(
|
db<{ id: string }>(
|
||||||
`SELECT id FROM freight.locomotives WHERE code = ANY($1::text[]) ORDER BY code`,
|
`SELECT id FROM freight.locomotives WHERE code = ANY($1::text[]) ORDER BY code`,
|
||||||
[opts.locoPair],
|
[opts.locoPair ?? []],
|
||||||
).then(({ rows: locos }) => {
|
).then(({ rows: locos }) => {
|
||||||
expect(locos, `locomotives ${opts.locoPair.join(",")}`).to.have.length(2);
|
expect(locos, `locomotives ${(opts.locoPair ?? []).join(",")}`).to.have.length(2);
|
||||||
apiPost(opsStaff, `/api/train-scheduling/${opts.kind ?? "container"}/schedules`, {
|
apiPost(opsStaff, endpoint, {
|
||||||
routeId: routes[0].id,
|
routeId: routes[0].id,
|
||||||
scheduleDate: opts.departure.toISOString(),
|
scheduleDate: opts.departure.toISOString(),
|
||||||
locomotiveIds: locos.map((l) => l.id),
|
locomotiveIds: locos.map((l) => l.id),
|
||||||
@@ -838,7 +918,111 @@ export function createImportSchedule(opts: {
|
|||||||
});
|
});
|
||||||
dbSchedule(opts.departure, destCode, originCode).then(({ rows }) => {
|
dbSchedule(opts.departure, destCode, originCode).then(({ rows }) => {
|
||||||
expect(rows, "created schedule").to.have.length(1);
|
expect(rows, "created schedule").to.have.length(1);
|
||||||
expect(rows[0].max_wagons, "54-wagon consist").to.eq(opts.maxWagons ?? 54);
|
expect(rows[0].max_wagons, "consist size").to.eq(opts.maxWagons ?? 54);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// day ledger — who booked, who rides, who expired, who was refused
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface LedgerRow {
|
||||||
|
suffix: string;
|
||||||
|
reference: string;
|
||||||
|
kind: string;
|
||||||
|
freight: string;
|
||||||
|
currency: string;
|
||||||
|
status: string;
|
||||||
|
wagons: number;
|
||||||
|
wagon_type: string | null;
|
||||||
|
train: string | null;
|
||||||
|
fate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify every booking made under this run's stamped contracts into its
|
||||||
|
* final fate. Bookings the engine REFUSED at create never exist as rows —
|
||||||
|
* the caller passes those suffixes in (they are only observable as 4xx at
|
||||||
|
* request time), plus any suffix that was refused and later rebooked.
|
||||||
|
*/
|
||||||
|
export function dayLedger(
|
||||||
|
runStamp: string,
|
||||||
|
opts: { rejected?: string[]; redeemed?: string[] } = {},
|
||||||
|
) {
|
||||||
|
return db<LedgerRow>(
|
||||||
|
`SELECT split_part(ct.reference, '-', 4) AS suffix,
|
||||||
|
b.reference,
|
||||||
|
ct.contract_kind AS kind,
|
||||||
|
b.freight_type AS freight,
|
||||||
|
b.payment_currency AS currency,
|
||||||
|
b.status,
|
||||||
|
COALESCE(a.wagons, 0)::int AS wagons,
|
||||||
|
a.wagon_type,
|
||||||
|
ts.reference AS train,
|
||||||
|
CASE
|
||||||
|
WHEN b.status IN ('PAID','IN_TRANSIT','ARRIVED','COMPLETED') THEN 'PAID_RIDING'
|
||||||
|
WHEN b.status = 'EXPIRED' THEN 'EXPIRED_UNPAID'
|
||||||
|
WHEN b.status IN ('SELECTED_FOR_BATCH','AWAITING_PAYMENT') THEN 'RESERVED_UNPAID'
|
||||||
|
ELSE 'PENDING_' || b.status
|
||||||
|
END AS fate
|
||||||
|
FROM freight.bookings b
|
||||||
|
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||||
|
LEFT JOIN freight.train_schedules ts ON ts.id = b.train_schedule_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT count(*)::int AS wagons, max(wt.code) AS wagon_type
|
||||||
|
FROM freight.wagon_booking_allocations wba
|
||||||
|
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||||
|
JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
||||||
|
WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL
|
||||||
|
) a ON true
|
||||||
|
WHERE ct.reference LIKE 'CTR-IMP-' || $1 || '-%' AND b.deleted_at IS NULL
|
||||||
|
ORDER BY b.created_at`,
|
||||||
|
[runStamp],
|
||||||
|
).then(({ rows }) => {
|
||||||
|
const rejected = (opts.rejected ?? []).map((suffix) => ({
|
||||||
|
suffix,
|
||||||
|
reference: "—",
|
||||||
|
kind: "—",
|
||||||
|
freight: "—",
|
||||||
|
currency: "—",
|
||||||
|
status: "NOT_CREATED",
|
||||||
|
wagons: 0,
|
||||||
|
wagon_type: null,
|
||||||
|
train: null,
|
||||||
|
fate: "REJECTED_NO_SPACE",
|
||||||
|
})) as LedgerRow[];
|
||||||
|
// A refused customer who later rebooked shows BOTH lines: the refusal
|
||||||
|
// above and the live booking here, re-labelled.
|
||||||
|
const ledger = [
|
||||||
|
...rows.map((r) =>
|
||||||
|
(opts.redeemed ?? []).includes(r.suffix) && r.fate === "PAID_RIDING"
|
||||||
|
? { ...r, fate: "REBOOKED_PAID" }
|
||||||
|
: r,
|
||||||
|
),
|
||||||
|
...rejected,
|
||||||
|
];
|
||||||
|
return cy.wrap(ledger, { log: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Print the ledger as a table into the Cypress log and write a JSON artifact. */
|
||||||
|
export function writeLedgerReport(name: string, ledger: LedgerRow[]) {
|
||||||
|
const counts = ledger.reduce<Record<string, number>>((acc, r) => {
|
||||||
|
acc[r.fate] = (acc[r.fate] ?? 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
cy.log(`**LEDGER ${name}** — ${JSON.stringify(counts)}`);
|
||||||
|
ledger.forEach((r) =>
|
||||||
|
cy.log(
|
||||||
|
`${r.suffix.padEnd(8)} ${r.kind.padEnd(9)} ${r.freight.padEnd(9)} ` +
|
||||||
|
`${r.currency.padEnd(4)} ${String(r.wagons).padStart(2)}w ` +
|
||||||
|
`${(r.wagon_type ?? "-").padEnd(4)} ${(r.train ?? "-").padEnd(14)} ${r.fate}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
cy.writeFile(`cypress/reports/${name}.json`, {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
counts,
|
||||||
|
bookings: ledger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
withBooking,
|
withBooking,
|
||||||
withSchedule,
|
withSchedule,
|
||||||
type ScheduleRow,
|
type ScheduleRow,
|
||||||
|
expectClearanceOnBookingInvoice,
|
||||||
} from "./import-utils";
|
} from "./import-utils";
|
||||||
|
|
||||||
const DEPARTURE = departureAt(4);
|
const DEPARTURE = departureAt(4);
|
||||||
@@ -147,6 +148,9 @@ describe("import: six bookings fill the 54-wagon corridor train", { retries: 0 }
|
|||||||
|
|
||||||
it("all six pay — allocated onto the train, 54/54 wagons, window FULL and schedule finalized", () => {
|
it("all six pay — allocated onto the train, 54/54 wagons, window FULL and schedule finalized", () => {
|
||||||
BOOKINGS.forEach((b) => {
|
BOOKINGS.forEach((b) => {
|
||||||
|
// Customs bookings carry their clearance service fee ON the booking
|
||||||
|
// invoice (no prepaid clearance invoice) — assert before settling.
|
||||||
|
if (b.customs) expectClearanceOnBookingInvoice(b.suffix);
|
||||||
markPaid(b.suffix);
|
markPaid(b.suffix);
|
||||||
pollAllocations(b.suffix, b.wagons);
|
pollAllocations(b.suffix, b.wagons);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -149,12 +149,10 @@ function createIntercityContract() {
|
|||||||
cy.mantineSelect(/^Payment Currency/, /^ETB/);
|
cy.mantineSelect(/^Payment Currency/, /^ETB/);
|
||||||
cy.contains("button", "Continue").click({ force: true });
|
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.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(/^Origin Yard/, ORIGIN_YARD);
|
||||||
cy.mantineSelect(/^Destination Yard/, DEST_YARD);
|
cy.mantineSelect(/^Destination Yard/, DEST_YARD);
|
||||||
cy.contains("button", "Continue").click({ force: true });
|
cy.contains("button", "Continue").click({ force: true });
|
||||||
|
|||||||
229
e2e/freight/cypress/e2e/flows/rate_change_mid_window.cy.ts
Normal file
229
e2e/freight/cypress/e2e/flows/rate_change_mid_window.cy.ts
Normal file
@@ -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<RateRow>(
|
||||||
|
`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<ChangeRequestRow>(
|
||||||
|
`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 {};
|
||||||
@@ -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<SnapshotRow>(
|
||||||
|
`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 {};
|
||||||
191
e2e/freight/cypress/e2e/flows/train_builder_adjust_consist.cy.ts
Normal file
191
e2e/freight/cypress/e2e/flows/train_builder_adjust_consist.cy.ts
Normal file
@@ -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<WagonRow>(
|
||||||
|
`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<ScheduleRow>(
|
||||||
|
`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 {};
|
||||||
83
e2e/freight/cypress/fixtures/seed-adjust-consist.sql
Normal file
83
e2e/freight/cypress/fixtures/seed-adjust-consist.sql
Normal file
@@ -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');
|
||||||
71
e2e/freight/cypress/fixtures/seed-government.sql
Normal file
71
e2e/freight/cypress/fixtures/seed-government.sql
Normal file
@@ -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);
|
||||||
@@ -26,6 +26,24 @@
|
|||||||
-- missing production migration.
|
-- missing production migration.
|
||||||
DROP INDEX IF EXISTS freight.uq_one_active_booking_per_one_time_contract;
|
DROP INDEX IF EXISTS freight.uq_one_active_booking_per_one_time_contract;
|
||||||
|
|
||||||
|
-- 0b. Schema drift guard: CUSTOMS_CLEARANCE / WITH_RETURN rates became
|
||||||
|
-- route-scoped in migrations 2820 + 2830, but an e2e image built before them
|
||||||
|
-- still carries the old CK_rates_yard_scope (yards allowed only for ALWAYS
|
||||||
|
-- BULK/CONTAINER/INTERCITY rows). Re-state the post-2830 shape — a no-op on an
|
||||||
|
-- up-to-date image, and what lets the customs-clearance lane rates below load.
|
||||||
|
ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";
|
||||||
|
ALTER TABLE freight.rates
|
||||||
|
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||||
|
deleted_at IS NOT NULL
|
||||||
|
OR status = 'SUPERSEDED'
|
||||||
|
OR CASE
|
||||||
|
WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
|
||||||
|
OR "trigger" IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN')
|
||||||
|
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||||
|
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||||
|
END
|
||||||
|
);
|
||||||
|
|
||||||
-- 1a. Extra Ethiopian mid-corridor yard.
|
-- 1a. Extra Ethiopian mid-corridor yard.
|
||||||
INSERT INTO freight.yards (id, code, label, country, is_active, display_order)
|
INSERT INTO freight.yards (id, code, label, country, is_active, display_order)
|
||||||
SELECT gen_random_uuid(), 'E2E_AWASH', 'E2E Awash Yard', 'Ethiopia', true, 50
|
SELECT gen_random_uuid(), 'E2E_AWASH', 'E2E Awash Yard', 'Ethiopia', true, 50
|
||||||
@@ -124,7 +142,7 @@ FROM (
|
|||||||
JOIN freight.yards y ON y.id = w2.current_yard_id AND y.code = 'DJIB_PORT'
|
JOIN freight.yards y ON y.id = w2.current_yard_id AND y.code = 'DJIB_PORT'
|
||||||
WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
|
WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
|
||||||
ORDER BY w2.wagon_number ASC
|
ORDER BY w2.wagon_number ASC
|
||||||
LIMIT 120
|
LIMIT 200
|
||||||
) pick
|
) pick
|
||||||
WHERE w.id = pick.id;
|
WHERE w.id = pick.id;
|
||||||
|
|
||||||
@@ -158,22 +176,35 @@ WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
|
|||||||
INSERT INTO freight.wagons (id, wagon_number, wagon_type_id, current_yard_id)
|
INSERT INTO freight.wagons (id, wagon_number, wagon_type_id, current_yard_id)
|
||||||
SELECT gen_random_uuid(), 'ECW' || lpad(g::text, 4, '0'), wt.id,
|
SELECT gen_random_uuid(), 'ECW' || lpad(g::text, 4, '0'), wt.id,
|
||||||
(SELECT id FROM freight.yards
|
(SELECT id FROM freight.yards
|
||||||
WHERE code = CASE WHEN g <= 80 THEN 'KALITY' ELSE 'DIRE_DAWA' END)
|
WHERE code = CASE WHEN g <= 120 THEN 'KALITY' ELSE 'DIRE_DAWA' END)
|
||||||
FROM generate_series(1, 100) AS g
|
FROM generate_series(1, 160) AS g
|
||||||
JOIN freight.wagon_types wt ON wt.code = 'CW4'
|
JOIN freight.wagon_types wt ON wt.code = 'CW4'
|
||||||
WHERE NOT EXISTS (
|
WHERE NOT EXISTS (
|
||||||
SELECT 1 FROM freight.wagons w WHERE w.wagon_number = 'ECW' || lpad(g::text, 4, '0')
|
SELECT 1 FROM freight.wagons w WHERE w.wagon_number = 'ECW' || lpad(g::text, 4, '0')
|
||||||
);
|
);
|
||||||
|
|
||||||
-- 5b3. Ledger-day rolling stock: wheat may also ride PW2 box wagons, and the
|
-- 5b3. Ledger-day rolling stock: GRAINS ride PW2 box wagons on a BUILT
|
||||||
-- GRAIN train is a BUILT Train-Builder consist — 37 PW2 wagons coupled at
|
-- Train-Builder consist — 37 PW2 wagons coupled at KALITY behind two dedicated
|
||||||
-- KALITY behind two dedicated locos. A built train's physical wagon count IS
|
-- locos. A built train's physical wagon count IS its schedule capacity (37),
|
||||||
-- its schedule capacity (37), immune to the loco-length slot recompute.
|
-- immune to the loco-length slot recompute.
|
||||||
|
--
|
||||||
|
-- GRAINS gets PW2 (17.066 m), WHEAT gets CW4 (13.976 m) below — kept on SEPARATE
|
||||||
|
-- cargo types on purpose. `dimsFor`/`needFor` size a bulk booking off its cargo
|
||||||
|
-- type's FIRST wagon type, so a cargo carrying two wagon-lengths sizes
|
||||||
|
-- inconsistently on loco-pair export trains (one booking as CW4, another as PW2)
|
||||||
|
-- and the length budget never matches the 54-wagon CW4 board the export specs
|
||||||
|
-- expect. One cargo → one wagon length keeps that math deterministic.
|
||||||
|
DELETE FROM freight.cargo_type_wagon_types x
|
||||||
|
USING freight.cargo_types ct, freight.wagon_types wt
|
||||||
|
WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id
|
||||||
|
AND ((ct.code = 'E2E_IMP_WHEAT' AND wt.code = 'PW2')
|
||||||
|
OR (ct.code = 'E2E_IMP_GRAINS' AND wt.code = 'CW4'));
|
||||||
|
|
||||||
INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id)
|
INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id)
|
||||||
SELECT ct.id, wt.id
|
SELECT ct.id, wt.id
|
||||||
FROM freight.cargo_types ct
|
FROM freight.cargo_types ct
|
||||||
JOIN freight.wagon_types wt ON wt.code = 'PW2'
|
JOIN freight.wagon_types wt ON wt.code = 'PW2'
|
||||||
WHERE ct.code IN ('E2E_IMP_GRAINS', 'E2E_IMP_WHEAT')
|
WHERE ct.code = 'E2E_IMP_GRAINS'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM freight.cargo_type_wagon_types x
|
SELECT 1 FROM freight.cargo_type_wagon_types x
|
||||||
WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id
|
WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id
|
||||||
@@ -182,7 +213,8 @@ WHERE ct.code IN ('E2E_IMP_GRAINS', 'E2E_IMP_WHEAT')
|
|||||||
INSERT INTO freight.locomotives
|
INSERT INTO freight.locomotives
|
||||||
(id, code, max_pull_weight_tons, max_train_length_meters, current_yard_id)
|
(id, code, max_pull_weight_tons, max_train_length_meters, current_yard_id)
|
||||||
SELECT gen_random_uuid(), v.code, 9000, 760, y.id
|
SELECT gen_random_uuid(), v.code, 9000, 760, y.id
|
||||||
FROM (VALUES ('LOCO-LED-1'), ('LOCO-LED-2')) AS v(code)
|
FROM (VALUES ('LOCO-LED-1'), ('LOCO-LED-2'), ('LOCO-LED-3'), ('LOCO-LED-4'),
|
||||||
|
('LOCO-LED-5'), ('LOCO-LED-6')) AS v(code)
|
||||||
JOIN freight.yards y ON y.code = 'KALITY'
|
JOIN freight.yards y ON y.code = 'KALITY'
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
|
WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
|
||||||
|
|
||||||
@@ -269,7 +301,7 @@ INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id)
|
|||||||
SELECT ct.id, wt.id
|
SELECT ct.id, wt.id
|
||||||
FROM freight.cargo_types ct
|
FROM freight.cargo_types ct
|
||||||
JOIN freight.wagon_types wt ON wt.code = 'CW4'
|
JOIN freight.wagon_types wt ON wt.code = 'CW4'
|
||||||
WHERE ct.code IN ('E2E_IMP_GRAINS', 'E2E_IMP_WHEAT')
|
WHERE ct.code = 'E2E_IMP_WHEAT'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM freight.cargo_type_wagon_types x
|
SELECT 1 FROM freight.cargo_type_wagon_types x
|
||||||
WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id
|
WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id
|
||||||
@@ -289,7 +321,7 @@ WHERE wt.id = w.wagon_type_id AND wt.code = 'CW4'
|
|||||||
-- Re-park the export CW4 pocket every seed (the mint above is insert-guarded).
|
-- Re-park the export CW4 pocket every seed (the mint above is insert-guarded).
|
||||||
UPDATE freight.wagons w
|
UPDATE freight.wagons w
|
||||||
SET current_yard_id = (SELECT id FROM freight.yards
|
SET current_yard_id = (SELECT id FROM freight.yards
|
||||||
WHERE code = CASE WHEN substring(w.wagon_number FROM 4)::int <= 80
|
WHERE code = CASE WHEN substring(w.wagon_number FROM 4)::int <= 120
|
||||||
THEN 'KALITY' ELSE 'DIRE_DAWA' END)
|
THEN 'KALITY' ELSE 'DIRE_DAWA' END)
|
||||||
FROM freight.wagon_types wt
|
FROM freight.wagon_types wt
|
||||||
WHERE wt.id = w.wagon_type_id AND wt.code = 'CW4'
|
WHERE wt.id = w.wagon_type_id AND wt.code = 'CW4'
|
||||||
@@ -357,3 +389,62 @@ WHERE NOT EXISTS (
|
|||||||
AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
|
AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
|
||||||
AND r.deleted_at IS NULL
|
AND r.deleted_at IS NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- 7b. Customs clearance service fees — billed on the booking invoice together
|
||||||
|
-- with the freight. Sold per direction + route + cargo kind: container fees
|
||||||
|
-- per container type (one row per active 20/40ft type so whichever type the
|
||||||
|
-- server resolves for a size always matches), bulk fees per commodity.
|
||||||
|
-- Without these, every customs booking hard-blocks at pricing.
|
||||||
|
INSERT INTO freight.rates
|
||||||
|
(id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status,
|
||||||
|
trade_direction, container_type_id, origin_yard_id, destination_yard_id,
|
||||||
|
proposed_by_staff_id)
|
||||||
|
SELECT gen_random_uuid(), 'CUSTOMS_CLEARANCE', 'OTHER', 'CUSTOMS_CLEARANCE',
|
||||||
|
'USD', 50, 'PER_CONTAINER', 'LIVE', v.direction, ct.id, a.id, b.id, u.id
|
||||||
|
FROM (VALUES
|
||||||
|
('IMPORT', 'DJIB_PORT', 'KALITY'),
|
||||||
|
('IMPORT', 'DJIB_PORT', 'MOJO'),
|
||||||
|
('IMPORT', 'NAGAD', 'KALITY'),
|
||||||
|
('IMPORT', 'NAGAD', 'MOJO'),
|
||||||
|
('EXPORT', 'KALITY', 'DJIB_PORT'),
|
||||||
|
('EXPORT', 'MOJO', 'DJIB_PORT'),
|
||||||
|
('EXPORT', 'DIRE_DAWA', 'DJIB_PORT')
|
||||||
|
) AS v(direction, from_code, to_code)
|
||||||
|
JOIN freight.yards a ON a.code = v.from_code
|
||||||
|
JOIN freight.yards b ON b.code = v.to_code
|
||||||
|
JOIN freight.container_types ct ON ct.size_ft IN (20, 40) AND ct.is_active
|
||||||
|
JOIN iam.users u ON u.email = 'operation@edr.local'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.rates r
|
||||||
|
WHERE r.rate_type = 'CUSTOMS_CLEARANCE'
|
||||||
|
AND r.container_type_id = ct.id
|
||||||
|
AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO freight.rates
|
||||||
|
(id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status,
|
||||||
|
trade_direction, cargo_type_id, origin_yard_id, destination_yard_id,
|
||||||
|
proposed_by_staff_id)
|
||||||
|
SELECT gen_random_uuid(), 'CUSTOMS_CLEARANCE', 'OTHER', 'CUSTOMS_CLEARANCE',
|
||||||
|
'USD', 2, 'PER_TON', 'LIVE', v.direction, cgt.id, a.id, b.id, u.id
|
||||||
|
FROM (VALUES
|
||||||
|
('IMPORT', 'DJIB_PORT', 'KALITY'),
|
||||||
|
('IMPORT', 'DJIB_PORT', 'MOJO'),
|
||||||
|
('IMPORT', 'NAGAD', 'KALITY'),
|
||||||
|
('IMPORT', 'NAGAD', 'MOJO'),
|
||||||
|
('EXPORT', 'KALITY', 'DJIB_PORT'),
|
||||||
|
('EXPORT', 'MOJO', 'DJIB_PORT'),
|
||||||
|
('EXPORT', 'DIRE_DAWA', 'DJIB_PORT')
|
||||||
|
) AS v(direction, from_code, to_code)
|
||||||
|
JOIN freight.yards a ON a.code = v.from_code
|
||||||
|
JOIN freight.yards b ON b.code = v.to_code
|
||||||
|
JOIN freight.cargo_types cgt ON cgt.code = 'E2E_IMP_WHEAT'
|
||||||
|
JOIN iam.users u ON u.email = 'operation@edr.local'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.rates r
|
||||||
|
WHERE r.rate_type = 'CUSTOMS_CLEARANCE'
|
||||||
|
AND r.cargo_type_id = cgt.id
|
||||||
|
AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
|
);
|
||||||
|
|||||||
17043
edr_freight_dump.sql
Normal file
17043
edr_freight_dump.sql
Normal file
File diff suppressed because one or more lines are too long
@@ -178,7 +178,7 @@ export enum InvoiceSource {
|
|||||||
Demurrage = "demurrage",
|
Demurrage = "demurrage",
|
||||||
FirstMile = "firstmile",
|
FirstMile = "firstmile",
|
||||||
LastMile = "lastmile",
|
LastMile = "lastmile",
|
||||||
/** Customs clearance service fee, prepaid before clearance work begins. */
|
/** Customs clearance service fee — billed on the booking invoice with the freight. */
|
||||||
Clearance = "clearance"
|
Clearance = "clearance"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user