enhance shipment form and booking process

This commit is contained in:
Marshal
2026-07-22 17:25:01 +00:00
parent ef840241c0
commit 5709801590
26 changed files with 1413 additions and 330 deletions

View 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 (817 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 {};

View 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 (MojoDire 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)
);