mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
enhance shipment form and booking process
This commit is contained in:
634
e2e/freight/cypress/e2e/flows/segment_weight.cy.ts
Normal file
634
e2e/freight/cypress/e2e/flows/segment_weight.cy.ts
Normal file
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* Segment weight & tolerance journeys — per-edge capacity on one corridor
|
||||
* (Mojo Dry Port → Dire Dawa Yard → Nagad Terminal), two dedicated
|
||||
* trains departing the same day (seed-segment-weight.sql):
|
||||
*
|
||||
* TRN-SEG-W "tolerance train" — 240T pull; one loco carries a 90T overage
|
||||
* tolerance, the second has NONE CONFIGURED (the S-2026-00024 regression
|
||||
* pair: the set's tolerance must stay 90, not collapse to 0).
|
||||
*
|
||||
* 1. 130T wheat Mojo→Djibouti → 179.6T gross (2 CW4) on both legs
|
||||
* 2. intercity 2×20ft VGM 24 Mojo→Dire → 70.4T gross (1 NW5); the shared
|
||||
* Mojo→Dire leg hits 250T — OVER the 240T base, inside the 330T
|
||||
* ceiling. Wagon allocation must succeed (allocation is what silently
|
||||
* failed on S-2026-00024: PAID + SCHEDULED, zero allocations).
|
||||
* 3. a second identical ride-along → 320.4T, still inside the ceiling —
|
||||
* the tolerance admits whole bookings repeatedly until spent
|
||||
* 4. the schedule detail strip shows the per-leg gross vs the ceiling
|
||||
*
|
||||
* TRN-SEG-F "border-full train" — 200T pull, no tolerance, 4 NW5.
|
||||
*
|
||||
* 5. 8×20ft export boarding at DIRE (sub-corridor) commits all 4 wagons
|
||||
* on the border edge (W's border edge only has 2 free, so FCFS lands
|
||||
* it on F) → the window goes FULL for the trade direction
|
||||
* 6. the FULL train still accepts an intercity ride-along Mojo→Dire on
|
||||
* its free home leg (the old whole-train sum — 169.6 + 70.4 = 240T >
|
||||
* 200T pull — rejected the accept outright; per-edge math admits it).
|
||||
* Wagon ALLOCATION of that shared wagon is a known gap: physical
|
||||
* pinning is slot-exclusive, so the test asserts accept + link only.
|
||||
*
|
||||
* Contracts are seeded FULLY_EXECUTED straight into SQL (stamped references,
|
||||
* re-runnable) — contract lifecycle is covered by the other flow specs; this
|
||||
* spec is about the scheduling engine. Run against a fresh e2e stack: the
|
||||
* trains' capacity math assumes empty consists.
|
||||
*
|
||||
* Sequential steps of one journey — retries off (steps are not idempotent).
|
||||
*/
|
||||
|
||||
const customer = "user@gmail.com";
|
||||
const companyTin = "0102030405"; // seed-company.sql
|
||||
const opsStaff = "operation@edr.local";
|
||||
|
||||
// Own corridor (…→ Nagad, not Djibouti Port): other specs schedule TRN-E2E-1
|
||||
// on the Djibouti Port route, and an earlier-departing same-day train there
|
||||
// would steal these FCFS bookings.
|
||||
const ORIGIN_YARD = "Mojo Dry Port";
|
||||
const MID_YARD = "Dire Dawa Yard";
|
||||
const PORT_YARD = "Nagad Terminal, Djibouti";
|
||||
const TRAIN_W = "TRN-SEG-W";
|
||||
const TRAIN_F = "TRN-SEG-F";
|
||||
|
||||
const stamp = String(Date.now());
|
||||
const isoNumber = (prefix: string, offset: number) =>
|
||||
`${prefix}${String(Number(stamp.slice(-7)) + offset).padStart(7, "0")}`;
|
||||
|
||||
// A ONE_TIME contract is spent after one booking, so every run seeds a fresh
|
||||
// set with stamped references. Lookups go by SUFFIX + newest row: Cypress
|
||||
// re-evaluates the spec bundle on cross-origin reloads, so a module-scope
|
||||
// stamp drifts between tests and must never key a lookup.
|
||||
const REF = {
|
||||
exportMojo: "EXP1",
|
||||
exportDire: "EXP2",
|
||||
ic1: "IC1",
|
||||
ic2: "IC2",
|
||||
ic3: "IC3",
|
||||
} as const;
|
||||
const stampedRef = (suffix: string) => `CTR-SEG-${stamp}-${suffix}`;
|
||||
|
||||
/** Both trains depart just past the 24h export lead — windows open in minutes. */
|
||||
const DEPART_W = new Date(Date.now() + 24 * 3_600_000 + 4 * 60_000);
|
||||
const DEPART_F = new Date(Date.now() + 24 * 3_600_000 + 7 * 60_000);
|
||||
|
||||
const apiUrl = () => Cypress.env("apiUrl") as string;
|
||||
|
||||
/** Seed one FULLY_EXECUTED ONE_TIME contract (contract + route + cargo scope). */
|
||||
function seedContract(opts: {
|
||||
suffix: string;
|
||||
reference: string;
|
||||
direction: "EXPORT" | "DOMESTIC";
|
||||
freight: "CONTAINER" | "BULK";
|
||||
originCode: string;
|
||||
destCode: string;
|
||||
}) {
|
||||
cy.task("db:query", {
|
||||
sql: `WITH c AS (
|
||||
INSERT INTO freight.contracts
|
||||
(reference, company_id, company_profile_id, contract_kind,
|
||||
trade_direction, freight_type, service_type_id, payment_currency,
|
||||
status, fully_executed_at, contract_valid_from,
|
||||
contract_valid_until, contract_summary)
|
||||
SELECT $1, comp.id,
|
||||
-- bookings.company_profile_id is NOT NULL and inherits from
|
||||
-- the contract: exporter profile for exports, any active
|
||||
-- profile otherwise.
|
||||
(SELECT p.id FROM freight.company_profiles p
|
||||
WHERE p.company_id = comp.id AND p.deleted_at IS NULL
|
||||
ORDER BY CASE
|
||||
WHEN $2 = 'EXPORT' AND p.type = 'exporter' THEN 0
|
||||
ELSE 1
|
||||
END
|
||||
LIMIT 1),
|
||||
'ONE_TIME', $2, $3,
|
||||
(SELECT st.id FROM freight.service_types st ORDER BY st.created_at LIMIT 1),
|
||||
'ETB', 'FULLY_EXECUTED', now(), now() - interval '1 day',
|
||||
now() + interval '60 days', 'E2E segment-weight fixture contract'
|
||||
FROM freight.companies comp
|
||||
WHERE comp.tin = $4
|
||||
-- before() re-runs on Cypress reloads: skip when this run
|
||||
-- already seeded a fresh, still-unbooked contract for the
|
||||
-- suffix, so lookups keep pointing at one stable row.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.contracts c2
|
||||
WHERE c2.reference LIKE 'CTR-SEG-%-' || $7
|
||||
AND c2.deleted_at IS NULL
|
||||
AND c2.created_at > now() - interval '15 minutes'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.bookings b2 WHERE b2.contract_id = c2.id
|
||||
)
|
||||
)
|
||||
RETURNING id
|
||||
), r AS (
|
||||
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 = $5
|
||||
JOIN freight.yards d ON d.code = $6
|
||||
RETURNING id
|
||||
)
|
||||
INSERT INTO freight.contract_cargo_scope
|
||||
(contract_id, container_size, cargo_type_id, cargo_free_text)
|
||||
SELECT c.id,
|
||||
CASE WHEN $3 = 'CONTAINER' THEN '20ft' END,
|
||||
CASE WHEN $3 = 'BULK' THEN
|
||||
(SELECT ct.id FROM freight.cargo_types ct WHERE ct.code = 'E2E_WHEAT' LIMIT 1)
|
||||
END,
|
||||
'E2E segment-weight cargo'
|
||||
FROM c`,
|
||||
params: [
|
||||
opts.reference,
|
||||
opts.direction,
|
||||
opts.freight,
|
||||
companyTin,
|
||||
opts.originCode,
|
||||
opts.destCode,
|
||||
opts.suffix,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** Newest seeded contract for a suffix — stamp-agnostic (see REF). */
|
||||
function dbContractId(suffix: string) {
|
||||
return cy
|
||||
.task<{ rows: Array<{ id: string }> }>("db:query", {
|
||||
sql: `SELECT id FROM freight.contracts
|
||||
WHERE reference LIKE 'CTR-SEG-%-' || $1
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
params: [suffix],
|
||||
})
|
||||
.then(({ rows }) => {
|
||||
expect(rows, `seeded contract *-${suffix}`).to.have.length(1);
|
||||
return cy.wrap(rows[0].id, { log: false });
|
||||
});
|
||||
}
|
||||
|
||||
type BookingRow = {
|
||||
id: string;
|
||||
reference: string;
|
||||
status: string;
|
||||
scheduling_status: string;
|
||||
train_schedule_id: string | null;
|
||||
payment_deadline: string | null;
|
||||
};
|
||||
|
||||
/** The (only) booking under this run's seeded contract for a suffix. */
|
||||
function withBooking(suffix: string, fn: (b: BookingRow) => void) {
|
||||
cy.task<{ rows: BookingRow[] }>("db:query", {
|
||||
sql: `SELECT b.id, b.reference, b.status, b.scheduling_status,
|
||||
b.train_schedule_id, b.payment_deadline
|
||||
FROM freight.bookings b
|
||||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||||
WHERE ct.reference LIKE 'CTR-SEG-%-' || $1
|
||||
ORDER BY b.created_at DESC LIMIT 1`,
|
||||
params: [suffix],
|
||||
}).then(({ rows }) => {
|
||||
expect(rows, `booking under *-${suffix}`).to.have.length(1);
|
||||
fn(rows[0]);
|
||||
});
|
||||
}
|
||||
|
||||
type ScheduleRow = {
|
||||
id: string;
|
||||
booking_window_status: string;
|
||||
window_closes_at: string;
|
||||
};
|
||||
|
||||
/** The live schedule riding a given built train on the NAGAD corridor. */
|
||||
function dbScheduleFor(trainCode: string) {
|
||||
return cy.task<{ rows: ScheduleRow[] }>("db:query", {
|
||||
sql: `SELECT ts.id, ts.booking_window_status, ts.window_closes_at
|
||||
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
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id
|
||||
WHERE t.code = $1 AND d.code = 'NAGAD' AND ts.deleted_at IS NULL
|
||||
ORDER BY ts.created_at DESC LIMIT 1`,
|
||||
params: [trainCode],
|
||||
});
|
||||
}
|
||||
|
||||
function withSchedule(trainCode: string, fn: (s: ScheduleRow) => void) {
|
||||
dbScheduleFor(trainCode).then(({ rows }) => {
|
||||
expect(rows, `schedule for ${trainCode}`).to.have.length(1);
|
||||
fn(rows[0]);
|
||||
});
|
||||
}
|
||||
|
||||
function fill(label: string | RegExp, value: string) {
|
||||
cy.contains("label", label)
|
||||
.invoke("attr", "for")
|
||||
.then((id) => {
|
||||
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** Pick the departure day on the booking form's inline calendar. */
|
||||
function pickShipmentDay(date: Date) {
|
||||
cy.contains(/available day/, { timeout: 30000 }).should("exist");
|
||||
const day = String(date.getDate());
|
||||
cy.get("button:not(:disabled)", { timeout: 15000 })
|
||||
.contains(new RegExp(`^${day}$`))
|
||||
.click({ force: true });
|
||||
}
|
||||
|
||||
/** Create one schedule from a built train, departing at the given moment. */
|
||||
function createSchedule(trainCode: string, departure: Date) {
|
||||
cy.visit("/dashboard/operations/train-scheduling-v2");
|
||||
cy.contains("button", "New schedule", { timeout: 20000 }).click();
|
||||
cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible");
|
||||
cy.mantineSelect(/^Route$/, /Nagad/);
|
||||
const local = new Date(departure.getTime() - departure.getTimezoneOffset() * 60000)
|
||||
.toISOString()
|
||||
.slice(0, 16);
|
||||
cy.get('.mantine-Modal-content input[type="datetime-local"]')
|
||||
.clear({ force: true })
|
||||
.type(local, { force: true });
|
||||
cy.mantineSelect(/^Train$/, new RegExp(trainCode));
|
||||
cy.get(".mantine-Modal-content").contains("button", "Create").click();
|
||||
cy.location("pathname", { timeout: 30000 }).should(
|
||||
"match",
|
||||
/\/dashboard\/operations\/train-scheduling-v2\/.+/,
|
||||
);
|
||||
}
|
||||
|
||||
/** Ops accepts a booking's operation request from the booking-requests page. */
|
||||
function acceptOperationRequest(contractRef: string) {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
withBooking(contractRef, (b) => cy.visit(`/dashboard/booking-requests/${b.id}`));
|
||||
cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click();
|
||||
cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible");
|
||||
cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click();
|
||||
cy.get(".mantine-Modal-content", { timeout: 30000 }).should("not.exist");
|
||||
}
|
||||
|
||||
/** Book 2×20ft VGM 24T each under a seeded DOMESTIC contract (48T cargo, 1 NW5). */
|
||||
function bookIntercityPair(contractRef: string, isoOffset: number) {
|
||||
cy.loginPortal(customer);
|
||||
dbContractId(contractRef).then((id) => cy.visitPortal(`/contracts/${id}/bookings/new`));
|
||||
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
|
||||
|
||||
fill(/^Quantity/, "2");
|
||||
cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 2);
|
||||
cy.get('input[placeholder*="MSCU"]').eq(0).type(isoNumber("MSCU", isoOffset));
|
||||
cy.get('input[placeholder*="MSCU"]').eq(1).type(isoNumber("TCLU", isoOffset + 1));
|
||||
cy.get('input[placeholder*="24.5"]').each(($input) => {
|
||||
cy.wrap($input).clear({ force: true }).type("24", { force: true });
|
||||
});
|
||||
cy.contains("Shipment day").should("not.exist");
|
||||
|
||||
cy.contains("button", "Review price & book").should("not.be.disabled").click();
|
||||
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
|
||||
cy.contains("button", "Confirm & book").click();
|
||||
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
|
||||
}
|
||||
|
||||
/** Accept a waiting intercity booking from a schedule's ride-along panel. */
|
||||
function acceptRideAlong(trainCode: string, contractRef: string) {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
withSchedule(trainCode, (s) =>
|
||||
cy.visit(`/dashboard/operations/train-scheduling-v2/${s.id}`),
|
||||
);
|
||||
cy.contains('[role="tab"]', "Workspace", { timeout: 30000 }).click();
|
||||
cy.contains("Intercity ride-along", { timeout: 30000 }).should("exist");
|
||||
withBooking(contractRef, (b) => {
|
||||
cy.contains("tr", b.reference, { timeout: 30000 })
|
||||
.find('input[type="checkbox"]')
|
||||
.check({ force: true });
|
||||
cy.contains("button", /Accept .*onto this train/).click();
|
||||
cy.contains("Awaiting payment", { timeout: 30000 }).should("exist");
|
||||
});
|
||||
}
|
||||
|
||||
/** Staff mark-paid (no mounted UI button), then wait for wagon allocation. */
|
||||
function markPaidAndAssertAllocated(contractRef: string) {
|
||||
withBooking(contractRef, (b) => {
|
||||
cy.apiLogin(opsStaff).then(({ token }) => {
|
||||
cy.request({
|
||||
method: "POST",
|
||||
url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
});
|
||||
withBooking(contractRef, (b) => {
|
||||
expect(b.status).to.eq("PAID");
|
||||
expect(b.scheduling_status).to.eq("SCHEDULED");
|
||||
|
||||
// Wagon allocation runs async after allocate() — poll for its rows.
|
||||
// Zero allocations with a PAID/SCHEDULED booking is exactly the
|
||||
// S-2026-00024 failure shape this spec guards against.
|
||||
const waitForAllocation = (attempt: number) => {
|
||||
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
|
||||
sql: `SELECT count(*) AS n FROM freight.wagon_booking_allocations
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
params: [b.id],
|
||||
}).then(({ rows }) => {
|
||||
if (Number(rows[0].n) > 0) return;
|
||||
expect(attempt, `wagon allocations for ${contractRef}`).to.be.lessThan(20);
|
||||
cy.wait(2000).then(() => waitForAllocation(attempt + 1));
|
||||
});
|
||||
};
|
||||
waitForAllocation(0);
|
||||
});
|
||||
}
|
||||
|
||||
describe(
|
||||
"segment weight: per-edge caps, loco tolerance, directional FULL",
|
||||
{ retries: 0 },
|
||||
() => {
|
||||
before(() => {
|
||||
cy.task("db:seedFile", "seed-intercity.sql");
|
||||
cy.task("db:seedFile", "seed-export.sql");
|
||||
cy.task("db:seedFile", "seed-segment-weight.sql");
|
||||
seedContract({
|
||||
suffix: REF.exportMojo,
|
||||
reference: stampedRef(REF.exportMojo),
|
||||
direction: "EXPORT",
|
||||
freight: "BULK",
|
||||
originCode: "MOJO",
|
||||
destCode: "NAGAD",
|
||||
});
|
||||
seedContract({
|
||||
suffix: REF.exportDire,
|
||||
reference: stampedRef(REF.exportDire),
|
||||
direction: "EXPORT",
|
||||
freight: "CONTAINER",
|
||||
originCode: "DIRE_DAWA",
|
||||
destCode: "NAGAD",
|
||||
});
|
||||
for (const ref of [REF.ic1, REF.ic2, REF.ic3]) {
|
||||
seedContract({
|
||||
suffix: ref,
|
||||
reference: stampedRef(ref),
|
||||
direction: "DOMESTIC",
|
||||
freight: "CONTAINER",
|
||||
originCode: "MOJO",
|
||||
destCode: "DIRE_DAWA",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── Infrastructure ───────────────────────────────────────────────────────
|
||||
|
||||
it("operations ensures the export route exists", () => {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
|
||||
sql: `SELECT count(*) AS n
|
||||
FROM freight.routes r
|
||||
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
|
||||
JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'NAGAD'
|
||||
WHERE r.deleted_at IS NULL`,
|
||||
}).then(({ rows }) => {
|
||||
if (Number(rows[0].n) > 0) return;
|
||||
|
||||
cy.visit("/dashboard/routes");
|
||||
cy.contains("button", "Add route", { timeout: 20000 }).click();
|
||||
cy.contains("Add Route", { timeout: 15000 }).should("be.visible");
|
||||
cy.get(".mantine-Modal-content").contains("button", "Add milestone").click();
|
||||
const pickYard = (index: number, yard: string) => {
|
||||
cy.get('.mantine-Modal-content input[placeholder="Select yard"]')
|
||||
.eq(index)
|
||||
.click({ force: true });
|
||||
cy.get('[role="option"]:visible').contains(yard).click();
|
||||
};
|
||||
pickYard(0, ORIGIN_YARD);
|
||||
pickYard(1, MID_YARD);
|
||||
pickYard(2, PORT_YARD);
|
||||
cy.get(".mantine-Modal-content").contains("button", "Save").click();
|
||||
});
|
||||
});
|
||||
|
||||
it("operations schedules both segment trains — same route, same day", () => {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
// Two different physical trains may share a route+day (the guard only
|
||||
// blocks the SAME train twice); export windows are per-schedule.
|
||||
dbScheduleFor(TRAIN_W).then(({ rows }) => {
|
||||
if (rows.length === 0) createSchedule(TRAIN_W, DEPART_W);
|
||||
});
|
||||
dbScheduleFor(TRAIN_F).then(({ rows }) => {
|
||||
if (rows.length === 0) createSchedule(TRAIN_F, DEPART_F);
|
||||
});
|
||||
|
||||
// The export window opens at departure − 24h CLAMPED into the booking
|
||||
// desk hours (8–17 EAT), and the engine can take minutes to advance a
|
||||
// second same-day train. Force both windows OPEN directly — the spec
|
||||
// arranges window state, it does not test the window engine.
|
||||
for (const trainCode of [TRAIN_W, TRAIN_F]) {
|
||||
dbScheduleFor(trainCode).then(({ rows }) => {
|
||||
expect(rows, `schedule for ${trainCode}`).to.have.length(1);
|
||||
cy.task("db:query", {
|
||||
sql: `UPDATE freight.train_schedules
|
||||
SET window_opens_at = LEAST(window_opens_at, now()),
|
||||
window_phase = 'OPEN',
|
||||
booking_window_status = 'OPEN'
|
||||
WHERE id = $1 AND booking_window_status <> 'FULL'`,
|
||||
params: [rows[0].id],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Belt-and-braces: confirm the engine keeps them OPEN.
|
||||
const waitForOpen = (trainCode: string, attempt: number) => {
|
||||
dbScheduleFor(trainCode).then(({ rows }) => {
|
||||
expect(rows, `schedule for ${trainCode}`).to.have.length(1);
|
||||
if (rows[0].booking_window_status === "OPEN") return;
|
||||
expect(attempt, `${trainCode} window OPEN`).to.be.lessThan(60);
|
||||
cy.wait(10000).then(() => waitForOpen(trainCode, attempt + 1));
|
||||
});
|
||||
};
|
||||
waitForOpen(TRAIN_W, 0);
|
||||
waitForOpen(TRAIN_F, 0);
|
||||
});
|
||||
|
||||
// ── Tolerance train (TRN-SEG-W) ──────────────────────────────────────────
|
||||
|
||||
it("customer books 130T of wheat Mojo→Djibouti", () => {
|
||||
cy.loginPortal(customer);
|
||||
dbContractId(REF.exportMojo).then((id) =>
|
||||
cy.visitPortal(`/contracts/${id}/bookings/new`),
|
||||
);
|
||||
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
|
||||
fill(/^Quantity \(tons\)/, "130");
|
||||
pickShipmentDay(DEPART_W);
|
||||
cy.contains("button", "Review price & book").should("not.be.disabled").click();
|
||||
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
|
||||
cy.contains("button", "Confirm & book").click();
|
||||
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
|
||||
});
|
||||
|
||||
it("the wheat lands on the tolerance train and allocates", () => {
|
||||
acceptOperationRequest(REF.exportMojo);
|
||||
|
||||
// FCFS picks the earliest fitting train of the day — the W train.
|
||||
withSchedule(TRAIN_W, (s) => {
|
||||
withBooking(REF.exportMojo, (b) => {
|
||||
expect(b.status).to.eq("SELECTED_FOR_BATCH");
|
||||
expect(b.train_schedule_id, "reserved on the tolerance train").to.eq(s.id);
|
||||
});
|
||||
});
|
||||
markPaidAndAssertAllocated(REF.exportMojo);
|
||||
});
|
||||
|
||||
it("customer books the first intercity pair Mojo→Dire", () => {
|
||||
bookIntercityPair(REF.ic1, 0);
|
||||
});
|
||||
|
||||
it("the ride-along boards the shared leg through the overage tolerance", () => {
|
||||
// Mojo→Dire now carries 179.6T (wheat). Adding 70.4T (2×20ft VGM 24 on
|
||||
// one NW5) puts the leg at 250T — over the 240T base, inside 240+90.
|
||||
// Before the tolerance fix the second loco's NULL tolerance zeroed the
|
||||
// set and this exact allocation failed at "3500" scale.
|
||||
acceptOperationRequest(REF.ic1);
|
||||
acceptRideAlong(TRAIN_W, REF.ic1);
|
||||
|
||||
withSchedule(TRAIN_W, (s) => {
|
||||
withBooking(REF.ic1, (b) => {
|
||||
expect(b.status).to.eq("SELECTED_FOR_BATCH");
|
||||
expect(b.train_schedule_id).to.eq(s.id);
|
||||
expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
|
||||
new Date(s.window_closes_at).getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
markPaidAndAssertAllocated(REF.ic1);
|
||||
});
|
||||
|
||||
it("customer books the second intercity pair Mojo→Dire", () => {
|
||||
bookIntercityPair(REF.ic2, 2);
|
||||
});
|
||||
|
||||
it("a second ride-along still fits whole — tolerance spends per booking, not once", () => {
|
||||
// 250T + 70.4T = 320.4T on Mojo→Dire — still under the 330T ceiling.
|
||||
acceptOperationRequest(REF.ic2);
|
||||
acceptRideAlong(TRAIN_W, REF.ic2);
|
||||
markPaidAndAssertAllocated(REF.ic2);
|
||||
|
||||
// The border edge (Dire→Djibouti) still has room, so the tolerance
|
||||
// train's window must NOT be FULL — fullness is directional.
|
||||
withSchedule(TRAIN_W, (s) => {
|
||||
expect(s.booking_window_status, "W window stays open").to.eq("OPEN");
|
||||
});
|
||||
});
|
||||
|
||||
it("the schedule detail strip shows per-leg gross against the tolerance ceiling", () => {
|
||||
cy.loginBackoffice(opsStaff);
|
||||
withSchedule(TRAIN_W, (s) =>
|
||||
cy.visit(`/dashboard/operations/train-scheduling-v2/${s.id}`),
|
||||
);
|
||||
// Mojo→Dire: 179.6 (wheat) + 70.4 + 70.4 (ride-alongs) = 320.4T gross;
|
||||
// ceiling = 240 base + 90 tolerance = 330T (the weakest CONFIGURED
|
||||
// tolerance governs — the second loco has none set).
|
||||
cy.contains("320.4 / 330 T gross", { timeout: 30000 }).should("exist");
|
||||
// Border leg carries only the wheat.
|
||||
cy.contains("179.6 / 330 T gross").should("exist");
|
||||
});
|
||||
|
||||
// ── Border-full train (TRN-SEG-F) ────────────────────────────────────────
|
||||
|
||||
it("customer books an 8×20ft export from the MID yard", () => {
|
||||
cy.loginPortal(customer);
|
||||
dbContractId(REF.exportDire).then((id) =>
|
||||
cy.visitPortal(`/contracts/${id}/bookings/new`),
|
||||
);
|
||||
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
|
||||
fill(/^Quantity/, "8");
|
||||
cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 8);
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
cy.get('input[placeholder*="MSCU"]').eq(i).type(isoNumber("MSCU", 10 + i));
|
||||
}
|
||||
cy.get('input[placeholder*="24.5"]').each(($input) => {
|
||||
cy.wrap($input).clear({ force: true }).type("10", { force: true });
|
||||
});
|
||||
pickShipmentDay(DEPART_F);
|
||||
cy.contains("button", "Review price & book").should("not.be.disabled").click();
|
||||
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
|
||||
cy.contains("button", "Confirm & book").click();
|
||||
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
|
||||
});
|
||||
|
||||
it("the export fills the border edge — the window goes FULL for the direction", () => {
|
||||
acceptOperationRequest(REF.exportDire);
|
||||
|
||||
// 8×20ft = 4 wagons. W's border edge has only 2 wagons free (the wheat
|
||||
// holds the other 2), so FCFS lands this Dire→Djibouti sub-corridor
|
||||
// booking on the F train — all 4 of its wagons, but only past Dire.
|
||||
withSchedule(TRAIN_F, (s) => {
|
||||
withBooking(REF.exportDire, (b) => {
|
||||
expect(b.status).to.eq("SELECTED_FOR_BATCH");
|
||||
expect(b.train_schedule_id, "reserved on the border-full train").to.eq(s.id);
|
||||
});
|
||||
});
|
||||
markPaidAndAssertAllocated(REF.exportDire);
|
||||
|
||||
// Every wagon on the border edge is committed: the train is FULL for
|
||||
// its trade direction even though Mojo→Dire runs completely empty.
|
||||
const waitForFull = (attempt: number) => {
|
||||
dbScheduleFor(TRAIN_F).then(({ rows }) => {
|
||||
if (rows[0]?.booking_window_status === "FULL") return;
|
||||
expect(attempt, "F window FULL").to.be.lessThan(20);
|
||||
cy.wait(3000).then(() => waitForFull(attempt + 1));
|
||||
});
|
||||
};
|
||||
waitForFull(0);
|
||||
});
|
||||
|
||||
it("customer books the third intercity pair Mojo→Dire", () => {
|
||||
bookIntercityPair(REF.ic3, 4);
|
||||
});
|
||||
|
||||
it("the FULL train still accepts and allocates an intercity ride-along on its free leg", () => {
|
||||
// Mojo→Dire on the F train is empty (the export boards at Dire): the
|
||||
// ride-along uses the SAME wagons there and alights before they load.
|
||||
// The old whole-train sum — 169.6 + 70.4 = 240T > 200T pull — rejected
|
||||
// this; per-edge math sees 70.4T on Mojo→Dire and 169.6T on the border,
|
||||
// both within the cap. The FULL flag closes the export window only.
|
||||
acceptOperationRequest(REF.ic3);
|
||||
acceptRideAlong(TRAIN_F, REF.ic3);
|
||||
|
||||
withSchedule(TRAIN_F, (s) => {
|
||||
withBooking(REF.ic3, (b) => {
|
||||
expect(b.status).to.eq("SELECTED_FOR_BATCH");
|
||||
expect(b.train_schedule_id, "accepted onto the FULL train").to.eq(s.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Mark paid: PAID + SCHEDULED + linked. Wagon allocation is asserted
|
||||
// only as far as today's model supports: physical wagon pinning is
|
||||
// slot-exclusive (one wagon serves ONE slot), so the same wagon cannot
|
||||
// yet be pinned to the intercity's Mojo→Dire slot AND the export's
|
||||
// Dire→Nagad slot even though the per-edge budget admits both. KNOWN
|
||||
// GAP — when wagon↔slot pinning becomes leg-aware, restore
|
||||
// markPaidAndAssertAllocated(REF.ic3) here.
|
||||
withBooking(REF.ic3, (b) => {
|
||||
cy.apiLogin(opsStaff).then(({ token }) => {
|
||||
cy.request({
|
||||
method: "POST",
|
||||
url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [200, 201]);
|
||||
});
|
||||
});
|
||||
withBooking(REF.ic3, (b) => {
|
||||
expect(b.status).to.eq("PAID");
|
||||
expect(b.scheduling_status).to.eq("SCHEDULED");
|
||||
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
|
||||
sql: `SELECT count(*) AS n FROM freight.train_schedule_bookings
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
params: [b.id],
|
||||
}).then(({ rows }) => {
|
||||
expect(Number(rows[0].n), "train_schedule_bookings link").to.eq(1);
|
||||
});
|
||||
});
|
||||
|
||||
// The ride-along never reopens the export window.
|
||||
withSchedule(TRAIN_F, (s) => {
|
||||
expect(s.booking_window_status, "F window stays FULL").to.eq("FULL");
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export {};
|
||||
175
e2e/freight/cypress/fixtures/seed-segment-weight.sql
Normal file
175
e2e/freight/cypress/fixtures/seed-segment-weight.sql
Normal file
@@ -0,0 +1,175 @@
|
||||
-- Arrange-data for flows/segment_weight.cy.ts. Idempotent.
|
||||
-- Run AFTER seed-intercity.sql + seed-export.sql (container types, E2E_WHEAT
|
||||
-- cargo, Mojo→Djibouti rates, yard distances come from those).
|
||||
--
|
||||
-- Two dedicated trains on the Mojo → Dire Dawa → Djibouti Port corridor:
|
||||
--
|
||||
-- TRN-SEG-W ("tolerance train") — locos 240T pull; LOCO-SEG-A carries a 90T
|
||||
-- overage tolerance, LOCO-SEG-B has NONE CONFIGURED (null). The S-2026-00024
|
||||
-- regression pair: min-across-locos must keep the 90, not zero it.
|
||||
-- Consist: 2 CW4 (bulk) + 2 NW5 (containers).
|
||||
--
|
||||
-- TRN-SEG-F ("border-full train") — locos 200T pull, no tolerance.
|
||||
-- Consist: 4 NW5. An 8×20ft export boarding at Dire Dawa commits every
|
||||
-- wagon on the border edge → the train goes FULL for its trade direction
|
||||
-- while the Mojo→Dire leg stays free: an intercity ride-along boards the
|
||||
-- SAME wagons there and alights before the export loads them.
|
||||
--
|
||||
-- Wagons are dedicated inserts (WGN-SEG-*) so the fixture never competes with
|
||||
-- other specs for free fleet stock, and the corridor targets NAGAD (not
|
||||
-- DJIB_PORT) so no other spec's same-day schedule can steal FCFS bookings.
|
||||
|
||||
-- 0. Reset any PREVIOUS segment-weight run (namespaced: TRN-SEG-* trains,
|
||||
-- CTR-SEG-* contracts, WGN-SEG-* wagons) so the spec re-runs on a warm DB.
|
||||
-- Everything is age-guarded (45 min): Cypress re-runs the spec's before()
|
||||
-- hook on cross-origin reloads, and an unguarded reset would soft-delete the
|
||||
-- CURRENT run's own schedules and contracts mid-flight. Consequence: rerun
|
||||
-- the spec no sooner than 45 minutes after a crashed run (or restack).
|
||||
UPDATE freight.train_schedules ts
|
||||
SET deleted_at = now(), booking_window_status = 'CLOSED'
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.created_at < now() - interval '45 minutes'
|
||||
AND ts.train_set_id IN (
|
||||
SELECT se.id FROM freight.train_sets se
|
||||
JOIN freight.trains t ON t.id = se.train_id
|
||||
WHERE t.code LIKE 'TRN-SEG-%'
|
||||
);
|
||||
|
||||
UPDATE freight.wagon_booking_allocations a
|
||||
SET deleted_at = now()
|
||||
WHERE a.deleted_at IS NULL
|
||||
AND a.booking_id IN (
|
||||
SELECT b.id FROM freight.bookings b
|
||||
JOIN freight.contracts c ON c.id = b.contract_id
|
||||
WHERE c.reference LIKE 'CTR-SEG-%'
|
||||
AND c.created_at < now() - interval '45 minutes'
|
||||
);
|
||||
|
||||
UPDATE freight.bookings b
|
||||
SET deleted_at = now()
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND b.contract_id IN (
|
||||
SELECT id FROM freight.contracts
|
||||
WHERE reference LIKE 'CTR-SEG-%'
|
||||
AND created_at < now() - interval '45 minutes'
|
||||
);
|
||||
|
||||
UPDATE freight.contracts
|
||||
SET deleted_at = now()
|
||||
WHERE deleted_at IS NULL
|
||||
AND reference LIKE 'CTR-SEG-%'
|
||||
AND created_at < now() - interval '45 minutes';
|
||||
|
||||
-- Un-pin only wagons whose pin points at a dead schedule — live pins from the
|
||||
-- current run must survive a mid-run re-seed.
|
||||
UPDATE freight.wagons w
|
||||
SET current_train_schedule_id = NULL,
|
||||
train_set_wagon_id = NULL,
|
||||
current_yard_id = (SELECT id FROM freight.yards WHERE code = 'MOJO')
|
||||
WHERE w.wagon_number LIKE 'WGN-SEG-%'
|
||||
AND w.current_train_schedule_id IS NOT NULL
|
||||
AND w.current_train_schedule_id IN (
|
||||
SELECT id FROM freight.train_schedules WHERE deleted_at IS NOT NULL
|
||||
);
|
||||
|
||||
-- 1. Locomotives at Mojo.
|
||||
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, v.pull, 760, v.tol, y.id
|
||||
FROM (VALUES
|
||||
('LOCO-SEG-A', 240, 90),
|
||||
('LOCO-SEG-B', 240, NULL),
|
||||
('LOCO-SEG-C', 200, NULL),
|
||||
('LOCO-SEG-D', 200, NULL)
|
||||
) AS v(code, pull, tol)
|
||||
JOIN freight.yards y ON y.code = 'MOJO'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
|
||||
|
||||
-- 2. Built trains at Mojo.
|
||||
INSERT INTO freight.trains (id, code, train_name, capacity_tons, current_yard_id)
|
||||
SELECT gen_random_uuid(), v.code, v.name, 2000, y.id
|
||||
FROM (VALUES
|
||||
('TRN-SEG-W', 'E2E Tolerance Carrier'),
|
||||
('TRN-SEG-F', 'E2E Border-Full Carrier')
|
||||
) AS v(code, name)
|
||||
JOIN freight.yards y ON y.code = 'MOJO'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.code = v.code);
|
||||
|
||||
-- 3. Couple the locomotive pairs (schedulable trains need >= 2 locos).
|
||||
INSERT INTO freight.train_locomotives (id, train_id, locomotive_id, sequence_no)
|
||||
SELECT gen_random_uuid(), t.id, l.id, v.seq
|
||||
FROM (VALUES
|
||||
('TRN-SEG-W', 'LOCO-SEG-A', 0),
|
||||
('TRN-SEG-W', 'LOCO-SEG-B', 1),
|
||||
('TRN-SEG-F', 'LOCO-SEG-C', 0),
|
||||
('TRN-SEG-F', 'LOCO-SEG-D', 1)
|
||||
) AS v(train_code, loco_code, seq)
|
||||
JOIN freight.trains t ON t.code = v.train_code
|
||||
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 wagons, parked at Mojo.
|
||||
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-SEG-C1', 'CW4'), ('WGN-SEG-C2', 'CW4'),
|
||||
('WGN-SEG-N1', 'NW5'), ('WGN-SEG-N2', 'NW5'),
|
||||
('WGN-SEG-N3', 'NW5'), ('WGN-SEG-N4', 'NW5'),
|
||||
('WGN-SEG-N5', 'NW5'), ('WGN-SEG-N6', 'NW5')
|
||||
) AS v(num, wt_code)
|
||||
JOIN freight.wagon_types wt ON wt.code = v.wt_code
|
||||
JOIN freight.yards y ON y.code = 'MOJO'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.wagons w WHERE w.wagon_number = v.num);
|
||||
|
||||
-- 5. Couple them: W = 2 CW4 + 2 NW5, F = 4 NW5.
|
||||
UPDATE freight.wagons w
|
||||
SET train_id = t.id, sequence_number = v.seq
|
||||
FROM freight.trains t,
|
||||
(VALUES
|
||||
('WGN-SEG-C1', 'TRN-SEG-W', 1), ('WGN-SEG-C2', 'TRN-SEG-W', 2),
|
||||
('WGN-SEG-N1', 'TRN-SEG-W', 3), ('WGN-SEG-N2', 'TRN-SEG-W', 4),
|
||||
('WGN-SEG-N3', 'TRN-SEG-F', 1), ('WGN-SEG-N4', 'TRN-SEG-F', 2),
|
||||
('WGN-SEG-N5', 'TRN-SEG-F', 3), ('WGN-SEG-N6', 'TRN-SEG-F', 4)
|
||||
) AS v(num, train_code, seq)
|
||||
WHERE w.wagon_number = v.num
|
||||
AND t.code = v.train_code
|
||||
AND w.train_id IS DISTINCT FROM t.id;
|
||||
|
||||
-- 6. LIVE export rates for the NAGAD corridor: bulk from Mojo (tolerance
|
||||
-- train's wheat) and container from Dire Dawa (border-full scenario's
|
||||
-- mid-route export).
|
||||
INSERT INTO freight.rates
|
||||
(id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status,
|
||||
origin_yard_id, destination_yard_id, proposed_by_staff_id)
|
||||
SELECT gen_random_uuid(), v.rate_type, v.applies_to, 'ALWAYS', 'USD', v.value,
|
||||
v.unit, 'LIVE', a.id, b.id, u.id
|
||||
FROM (VALUES
|
||||
('BULK_EXPORT', 'BULK', 'MOJO', 25, 'PER_TON'),
|
||||
('CONTAINER_EXPORT', 'CONTAINER', 'DIRE_DAWA', 600, 'PER_CONTAINER')
|
||||
) AS v(rate_type, applies_to, origin_code, value, unit)
|
||||
JOIN freight.yards a ON a.code = v.origin_code
|
||||
JOIN freight.yards b ON b.code = 'NAGAD'
|
||||
JOIN iam.users u ON u.email = 'operation@edr.local'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.rates r
|
||||
WHERE r.rate_type = v.rate_type
|
||||
AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
|
||||
AND r.deleted_at IS NULL
|
||||
);
|
||||
|
||||
-- 7. Segment distance for the new corridor's border leg (Mojo–Dire comes
|
||||
-- from seed-intercity.sql).
|
||||
INSERT INTO freight.yard_distances (id, from_yard_id, to_yard_id, distance_km)
|
||||
SELECT gen_random_uuid(), a.id, b.id, 460
|
||||
FROM freight.yards a
|
||||
JOIN freight.yards b ON b.code = 'NAGAD'
|
||||
WHERE a.code = 'DIRE_DAWA'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.yard_distances d
|
||||
WHERE (d.from_yard_id = a.id AND d.to_yard_id = b.id)
|
||||
OR (d.from_yard_id = b.id AND d.to_yard_id = a.id)
|
||||
);
|
||||
Reference in New Issue
Block a user