mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
695 lines
27 KiB
TypeScript
695 lines
27 KiB
TypeScript
/**
|
|
* Shared helpers for the GROUP 1 scenario specs (g1_s1 … g1_s8).
|
|
*
|
|
* These specs are the "visual" variant of the corridor suite: the fleet
|
|
* configuration phase (wagons → locomotives → train consist → schedule) and
|
|
* every capacity verdict are driven and asserted through the BACKOFFICE UI,
|
|
* while the bulk of the cargo (50+ wagons ≈ 100+ ISO container inputs per
|
|
* scenario) is still created through the API. See `bookOneVisually` below for
|
|
* where the line is drawn and why.
|
|
*
|
|
* Everything here builds on ./import-utils — the corridor route, contract
|
|
* seeding, window choreography and polling are unchanged. This module adds
|
|
* only what Group 1 needs on top:
|
|
*
|
|
* - a 53-WAGON BUILT TRAIN (seed-g1-train.sql). Group 1's arithmetic is
|
|
* written for 53 slots; a loco-pair schedule cannot hold that number
|
|
* because syncScheduleMaxWagons recomputes max_wagons from locomotive
|
|
* length (floor(760 / 13.966) = 54 on this corridor). A built train's
|
|
* physical consist wins outright — booking-batch.service.ts:4152:
|
|
* const maxWagons = physicalWagons ?? capacityLimits(loco).base.wagons
|
|
* so the consist staff marshal IS the capacity, and it survives the tick.
|
|
*
|
|
* - the FULL / NOT FULL verdict helpers, which every scenario ends on.
|
|
*
|
|
* No module-level mutable state: Cypress re-evaluates the spec bundle on every
|
|
* cross-origin visit, so helpers resolve rows by stamped-reference suffix and
|
|
* newest-row, never by a captured id. (Same rule as import-utils.)
|
|
*/
|
|
|
|
import {
|
|
acceptExport,
|
|
acceptOperation,
|
|
apiPost,
|
|
bookContainers,
|
|
clearToOperationRequestPending,
|
|
closeBookingWindow,
|
|
db,
|
|
dbSchedule,
|
|
forceWindowOpen,
|
|
opsStaff,
|
|
ORIGIN,
|
|
pollDb,
|
|
withBooking,
|
|
withSchedule,
|
|
type ScheduleRow,
|
|
} from "./import-utils";
|
|
|
|
/** The Group 1 built train's consist size — see seed-g1-train.sql. */
|
|
export const G1_WAGONS = 53;
|
|
export const G1_TRAIN = "TRN-G1-1";
|
|
/** Second identical train, for the multi-schedule scenarios. */
|
|
export const G1_TRAIN_2 = "TRN-G1-2";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// wagon arithmetic — the number every scenario is written in
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Escape a DB-sourced string for use inside a RegExp. Yard and train labels go
|
|
* straight into `cy.contains(new RegExp(...))` selectors, and a label carrying
|
|
* a metacharacter (a "." or "(" in a yard name) would otherwise silently match
|
|
* the wrong option — or nothing at all.
|
|
*/
|
|
export function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
/**
|
|
* A Date as the unzoned "YYYY-MM-DDTHH:mm" wall-clock string that an
|
|
* `<input type="datetime-local">` accepts (the create-schedule form's Departure
|
|
* date field).
|
|
*
|
|
* The value MUST be in the BROWSER's local zone, not EAT. The input carries no
|
|
* offset, so whatever is typed is read as local time and converted on submit —
|
|
* pre-shifting to EAT on a UTC browser files the departure three hours late,
|
|
* which put it outside dbSchedule's ±1h lookup window and made a successfully
|
|
* created schedule look like it had never been created at all.
|
|
*
|
|
* Built from the local getters rather than toISOString for exactly that reason.
|
|
*/
|
|
export function localDateTime(d: Date): string {
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
return (
|
|
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
|
`T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Wagons a container booking needs: 20ft containers pair up two-per-wagon,
|
|
* 40ft take a whole wagon each. An ODD 20ft count still costs a whole wagon
|
|
* (and the portal form blocks submitting one — `hasOdd20ft`), so callers
|
|
* should keep 20ft quantities even.
|
|
*/
|
|
export function wagonsFor(twenty: number, forty: number): number {
|
|
return Math.ceil(twenty / 2) + forty;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// the built-train schedule
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create the Group 1 schedule on the corridor from the BUILT 53-wagon train.
|
|
*
|
|
* Deliberately NOT `createImportSchedule({ locoPair })`: that path derives
|
|
* capacity from locomotive length and would give 54 slots. Passing the train
|
|
* makes the coupled consist the cap (see module header).
|
|
*/
|
|
export function createG1Schedule(opts: {
|
|
departure: Date;
|
|
trainCode?: string;
|
|
routeId: string;
|
|
}) {
|
|
const trainCode = opts.trainCode ?? G1_TRAIN;
|
|
dbSchedule(opts.departure).then(({ rows }) => {
|
|
if (rows.length > 0) return;
|
|
db<{ id: string }>(`SELECT id FROM freight.trains WHERE code = $1`, [trainCode]).then(
|
|
({ rows: trains }) => {
|
|
expect(trains, `built train ${trainCode}`).to.have.length(1);
|
|
apiPost(opsStaff, "/api/train-scheduling/container/schedules", {
|
|
routeId: opts.routeId,
|
|
scheduleDate: opts.departure.toISOString(),
|
|
trainId: trains[0].id,
|
|
})
|
|
.its("status")
|
|
.should("be.oneOf", [200, 201]);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The visual configuration phase, shared by every Group 1 scenario: operations
|
|
* schedules the built train on the corridor through the REAL create form
|
|
* (Route → Departure date → Train), then the window is opened.
|
|
*
|
|
* The form is built-train only — there is no locomotive-pair option and no
|
|
* max-wagons field in it (the pair path lives in AllocateBookingWizard), which
|
|
* is exactly what this suite wants: the consist chosen here IS the capacity.
|
|
*
|
|
* Leaves the schedule OPEN with `closesInMinutes` of window left.
|
|
*/
|
|
export function configureAndOpenSchedule(opts: {
|
|
departure: Date;
|
|
trainCode?: string;
|
|
originCode?: string;
|
|
closesInMinutes?: number;
|
|
wagons?: number;
|
|
}) {
|
|
const trainCode = opts.trainCode ?? G1_TRAIN;
|
|
const originCode = opts.originCode ?? ORIGIN;
|
|
|
|
cy.loginBackoffice(opsStaff);
|
|
cy.visit("/dashboard/operations/train-scheduling-v2");
|
|
cy.contains("button", "New schedule", { timeout: 120000 }).click();
|
|
cy.contains("Create train schedule", { timeout: 120000 }).should("be.visible");
|
|
|
|
// Route options are composed by formatRouteLabel, which renders yard LABELS
|
|
// ("Djibouti Port"), never codes — so resolve the label for this corridor.
|
|
db<{ label: string }>(`SELECT label FROM freight.yards WHERE code = $1`, [
|
|
originCode,
|
|
]).then(({ rows }) => {
|
|
expect(rows, `origin yard ${originCode}`).to.have.length(1);
|
|
cy.mantineSelect("Route", new RegExp(escapeRegExp(rows[0].label)));
|
|
});
|
|
// datetime-local takes an unzoned "YYYY-MM-DDTHH:mm" wall-clock string.
|
|
cy.get('input[type="datetime-local"]').type(localDateTime(opts.departure), {
|
|
force: true,
|
|
});
|
|
// Option text is composed: "TRN-G1-1 — E2E Group-1 … · 53 wagons".
|
|
cy.mantineSelect("Train", new RegExp(escapeRegExp(trainCode)));
|
|
cy.get(".mantine-Modal-content").contains("button", "Create").click();
|
|
cy.get(".mantine-Modal-content", { timeout: 120000 }).should("not.exist");
|
|
|
|
expectCapacity(opts.departure, opts.wagons ?? G1_WAGONS);
|
|
withSchedule(opts.departure, (s) => forceWindowOpen(s.id, opts.closesInMinutes ?? 45));
|
|
}
|
|
|
|
/**
|
|
* Close the window and run the batch from the board's own button — the one
|
|
* manual phase action the app exposes (closing itself is time-driven; there is
|
|
* no "close window" control anywhere in the UI).
|
|
*
|
|
* Asserts the phase actually advanced: a button that silently no-ops would
|
|
* otherwise leave every downstream assertion to time out far from the cause.
|
|
*/
|
|
export function closeWindowAndRunBatch(departure: Date) {
|
|
withSchedule(departure, (s) => closeBookingWindow(s.id));
|
|
|
|
cy.loginBackoffice(opsStaff);
|
|
withSchedule(departure, (s) => cy.visit(`/dashboard/operations/batch-board/${s.id}`));
|
|
cy.contains("Doc review", { timeout: 120000 }).should("exist");
|
|
cy.contains("button", "Doc review complete — run batch", { timeout: 120000 }).click();
|
|
|
|
withSchedule(departure, (s) =>
|
|
pollDb<ScheduleRow>(
|
|
"batch ran — window phase advanced",
|
|
`SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
|
|
[s.id],
|
|
// DONE when the batch reserved nobody — itself a scenario outcome.
|
|
(row) => !!row && ["PAYMENT", "DONE"].includes(row.window_phase as string),
|
|
20,
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Read the Priority Tracking board: the lane counts and the capacity divider.
|
|
* Pass only what the scenario cares about.
|
|
*
|
|
* The divider is ONE text node — `Capacity line · 53/53 wagons · FULL` — so
|
|
* the FULL suffix cannot be asserted separately from the ratio.
|
|
*/
|
|
export function expectBoard(
|
|
departure: Date,
|
|
opts: {
|
|
inBatch?: number;
|
|
waiting?: number;
|
|
expired?: number;
|
|
capacity?: { used: number; max?: number; full?: boolean };
|
|
},
|
|
) {
|
|
cy.loginBackoffice(opsStaff);
|
|
withSchedule(departure, (s) => cy.visit(`/dashboard/operations/batch-board/${s.id}`));
|
|
cy.contains(/Priority Tracking/, { timeout: 120000 }).click();
|
|
cy.contains("Priority ranking", { timeout: 120000 }).should("be.visible");
|
|
|
|
if (opts.inBatch !== undefined) {
|
|
cy.contains(new RegExp(`In the batch\\s*\\(${opts.inBatch}\\)`), {
|
|
timeout: 120000,
|
|
}).should("exist");
|
|
}
|
|
if (opts.waiting !== undefined) {
|
|
if (opts.waiting === 0) cy.contains(/Waiting list/).should("not.exist");
|
|
else cy.contains(new RegExp(`Waiting list\\s*\\(${opts.waiting}\\)`)).should("exist");
|
|
}
|
|
if (opts.expired !== undefined) {
|
|
if (opts.expired === 0) cy.contains(/Expired\s*\(/).should("not.exist");
|
|
else cy.contains(new RegExp(`Expired\\s*\\(${opts.expired}\\)`)).should("exist");
|
|
}
|
|
if (opts.capacity) {
|
|
const max = opts.capacity.max ?? G1_WAGONS;
|
|
const suffix = opts.capacity.full ? " · FULL" : "";
|
|
cy.contains(`Capacity line · ${opts.capacity.used}/${max} wagons${suffix}`).should(
|
|
"exist",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Assert the schedule's capacity is the built consist, not the loco-derived
|
|
* 54. Worth asserting explicitly in every scenario's config phase: if a future
|
|
* change lets the length recompute win again, EVERY Group 1 expectation shifts
|
|
* by one slot and the exact-fit cases (S1, S2) would fail somewhere far from
|
|
* the cause.
|
|
*/
|
|
export function expectCapacity(departure: Date, wagons = G1_WAGONS) {
|
|
dbSchedule(departure).then(({ rows }) => {
|
|
expect(rows, "G1 schedule").to.have.length(1);
|
|
expect(rows[0].max_wagons, `consist capacity = ${wagons}`).to.eq(wagons);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// the verdict — every scenario ends naming its binding axis
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Distinct wagon slots actually allocated to bookings on a schedule. */
|
|
export function allocatedWagons(scheduleId: string) {
|
|
return 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`,
|
|
[scheduleId],
|
|
).then(({ rows }) => Number(rows[0].n));
|
|
}
|
|
|
|
/**
|
|
* The scenario's closing verdict: how many of the train's slots ended up
|
|
* filled, and whether the engine agrees it is FULL.
|
|
*
|
|
* `booking_window_status` is the engine's own word (FULL / OPEN / CLOSED) —
|
|
* asserting the slot count alone would pass on a train that is physically full
|
|
* but which the window state machine never marked, which is exactly the bug
|
|
* class these scenarios exist to catch.
|
|
*/
|
|
export function expectVerdict(
|
|
departure: Date,
|
|
expected: { wagons: number; full: boolean; capacity?: number },
|
|
) {
|
|
const capacity = expected.capacity ?? G1_WAGONS;
|
|
dbSchedule(departure).then(({ rows }) => {
|
|
expect(rows, "G1 schedule").to.have.length(1);
|
|
const schedule = rows[0];
|
|
allocatedWagons(schedule.id).then((n) => {
|
|
expect(n, `${expected.wagons}/${capacity} wagons allocated`).to.eq(expected.wagons);
|
|
});
|
|
if (expected.full) {
|
|
pollDb<ScheduleRow>(
|
|
`window FULL (${expected.wagons}/${capacity})`,
|
|
`SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
|
|
[schedule.id],
|
|
(row) => row?.booking_window_status === "FULL",
|
|
20,
|
|
);
|
|
} else {
|
|
db<ScheduleRow>(
|
|
`SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
|
|
[schedule.id],
|
|
).then(({ rows: after }) => {
|
|
expect(
|
|
after[0].booking_window_status,
|
|
`not FULL (${expected.wagons}/${capacity})`,
|
|
).to.not.eq("FULL");
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// booking → clearance gate → operations queue
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Book containers and walk the booking all the way to the operations pool.
|
|
*
|
|
* EVERY contract booking is now born in the clearance gate — see
|
|
* contract-booking.service.ts:211, "EVERY contract booking clears per booking
|
|
* now — both contract kinds, both paths, intercity included". A booking is
|
|
* created in AWAITING_DOCUMENTS regardless of whether customs clearance is
|
|
* enabled, so calling `acceptOperation` straight after `bookContainers` always
|
|
* 409s with:
|
|
*
|
|
* Cannot perform this action on status "AWAITING_DOCUMENTS".
|
|
* Allowed: OPERATION_REQUEST_PENDING
|
|
*
|
|
* The gate is: upload a document → GL approves it → finalize → the customer
|
|
* proceeds with the shipment day. `clearToOperationRequestPending` runs that
|
|
* whole chain (the e2e seed configures no required documents, so one ad-hoc
|
|
* doc satisfies the 100%-approved rule).
|
|
*
|
|
* Use this instead of bookContainers + acceptOperation anywhere a booking has
|
|
* to reach the day pool.
|
|
*/
|
|
export function bookAndClear(opts: {
|
|
suffix: string;
|
|
runStamp: string;
|
|
isoSeed: number;
|
|
twenty?: number;
|
|
forty?: number;
|
|
scheduledDate: string;
|
|
vgmTons?: number;
|
|
/** EXPORT reserves on accept (FCFS) rather than entering the batch pool. */
|
|
mode?: "import" | "export";
|
|
}) {
|
|
bookContainers({
|
|
suffix: opts.suffix,
|
|
runStamp: opts.runStamp,
|
|
isoSeed: opts.isoSeed,
|
|
twenty: opts.twenty,
|
|
forty: opts.forty,
|
|
scheduledDate: opts.scheduledDate,
|
|
vgmTons: opts.vgmTons,
|
|
});
|
|
clearToOperationRequestPending(opts.suffix, opts.scheduledDate);
|
|
if (opts.mode === "export") acceptExport(opts.suffix);
|
|
else acceptOperation(opts.suffix);
|
|
}
|
|
|
|
/**
|
|
* The clearance half alone, for a booking created some other way — e.g. the
|
|
* portal form (`bookContainersVisually`), which leaves the booking sitting in
|
|
* the same AWAITING_DOCUMENTS gate.
|
|
*/
|
|
export function clearAndAccept(opts: {
|
|
suffix: string;
|
|
scheduledDate: string;
|
|
mode?: "import" | "export";
|
|
}) {
|
|
clearToOperationRequestPending(opts.suffix, opts.scheduledDate);
|
|
if (opts.mode === "export") acceptExport(opts.suffix);
|
|
else acceptOperation(opts.suffix);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// booking through the real portal form
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Book containers the way a customer actually does: the portal's New Shipment
|
|
* form, end to end. Reserved for the SMALL booking in each scenario — one
|
|
* container is one ISO input, so a 30-wagon booking would mean 30-60 of them.
|
|
*
|
|
* The three traps this navigates (all learned from export_one_time.cy.ts and
|
|
* the form source):
|
|
* 1. size editors render in CONTRACT-SCOPE order, not 20ft-then-40ft, so
|
|
* each is addressed by its "20ft containers" heading;
|
|
* 2. the shipment-day calendar does not render until the cargo quantities
|
|
* are valid — "available days depend on the wagons your cargo needs";
|
|
* 3. a blank cargo description aborts the submit SILENTLY (no modal, no
|
|
* toast, no request) — cy.fillCargoDescription covers it.
|
|
*
|
|
* Leaves the browser on /bookings/:id, the page the form redirects to.
|
|
*/
|
|
export function bookContainersVisually(opts: {
|
|
contractId: string;
|
|
twenty?: number;
|
|
forty?: number;
|
|
/** Day to pick in the inline calendar — must be a bookable (enabled) day. */
|
|
shipmentDay: Date;
|
|
/** Distinct ISO prefixes keep container numbers unique across scenarios. */
|
|
isoPrefix?: string;
|
|
/**
|
|
* Per-run stamp, same one the spec passes to the API path. Without it every
|
|
* run typed the identical SEXU1000000… block and the second run against a
|
|
* warm DB was rejected — the container number is already booked.
|
|
*/
|
|
runStamp?: string;
|
|
vgmTons?: number;
|
|
}) {
|
|
const twenty = opts.twenty ?? 0;
|
|
const forty = opts.forty ?? 0;
|
|
const total = twenty + forty;
|
|
expect(total, "at least one container").to.be.greaterThan(0);
|
|
// The form blocks an odd 20ft count (a lone 20ft cannot be paired onto a
|
|
// wagon) — "Review price & book" would stay disabled and the spec would
|
|
// fail on a timeout rather than on this, the real reason.
|
|
expect(twenty % 2, "20ft quantity must be even").to.eq(0);
|
|
|
|
cy.visitPortal(`/contracts/${opts.contractId}/bookings/new`);
|
|
cy.contains("New Shipment Booking", { timeout: 120000 }).should("be.visible");
|
|
|
|
// BOTH size cards must be given a quantity, including the unused one.
|
|
//
|
|
// The form renders a ContainerLineEditor per size in the contract's cargo
|
|
// scope, and an untouched editor keeps one blank unit row. The zod schema
|
|
// requires a valid ISO number AND a VGM on EVERY unit row
|
|
// (new-shipment-form/schema.ts:39-50), so that blank row fails validation and
|
|
// handleSubmit aborts SILENTLY — no modal, no toast, no request. Typing 0
|
|
// truncates the card's units to none (syncUnits: `next.length = max(0, qty)`)
|
|
// and takes it out of validation.
|
|
fillSizeQuantity("20ft", String(twenty));
|
|
fillSizeQuantity("40ft", String(forty));
|
|
|
|
// One ISO row per container, then the VGM on each.
|
|
//
|
|
// Scoped PER SIZE CARD, not globally: the form renders a ContainerLineEditor
|
|
// for every size in the contract's cargo scope, and an editor left at
|
|
// quantity 0 still renders one blank unit row. A global
|
|
// `input[placeholder*="MSCU"]` therefore counts the other card's row too —
|
|
// "Found 7, expected 6" — and the numbers land in the wrong card.
|
|
const prefix = opts.isoPrefix ?? "MSCU";
|
|
// 7 digits: 5 of run stamp + 2 of unit index. Keeps every run's block
|
|
// distinct while staying inside the ISO field width (max 99 units/booking).
|
|
const runBlock = Number((opts.runStamp ?? String(Date.now())).slice(-5));
|
|
let unit = 0;
|
|
const fillUnits = (size: "20ft" | "40ft", count: number) => {
|
|
if (!count) return;
|
|
cy.contains(`${size} containers`, { timeout: 120000 })
|
|
.closest("div.rounded-xl")
|
|
.within(() => {
|
|
cy.get('input[placeholder*="MSCU"]', { timeout: 120000 }).should(
|
|
"have.length",
|
|
count,
|
|
);
|
|
for (let i = 0; i < count; i += 1) {
|
|
const iso = `${prefix}${String(runBlock).padStart(5, "0")}${String(unit + i).padStart(2, "0")}`;
|
|
cy.get('input[placeholder*="MSCU"]')
|
|
.eq(i)
|
|
.clear({ force: true })
|
|
.type(iso, { force: true });
|
|
}
|
|
cy.get('input[placeholder*="24.5"]').each(($input) => {
|
|
cy.wrap($input)
|
|
.clear({ force: true })
|
|
.type(String(opts.vgmTons ?? 10), { force: true });
|
|
});
|
|
})
|
|
.then(() => {
|
|
unit += count;
|
|
});
|
|
};
|
|
fillUnits("20ft", twenty);
|
|
fillUnits("40ft", forty);
|
|
|
|
pickShipmentDay(opts.shipmentDay);
|
|
cy.fillCargoDescription();
|
|
|
|
cy.contains("button", "Review price & book").should("not.be.disabled").click();
|
|
cy.contains("Confirm shipment price", { timeout: 120000 }).should("be.visible");
|
|
cy.contains("button", "Confirm & book").click();
|
|
cy.location("pathname", { timeout: 120000 }).should("match", /^\/bookings\/.+/);
|
|
}
|
|
|
|
/**
|
|
* One container-size line's quantity, addressed by its heading rather than by
|
|
* position — the cards render in CONTRACT-SCOPE order, not 20ft-then-40ft.
|
|
*
|
|
* A no-op when the contract does not scope this size, so callers can always
|
|
* set both (see the note about blank unit rows in bookContainersVisually).
|
|
*/
|
|
function fillSizeQuantity(size: "20ft" | "40ft", value: string) {
|
|
cy.get("body").then(($body) => {
|
|
if (!$body.text().includes(`${size} containers`)) return;
|
|
cy.contains(`${size} containers`, { timeout: 120000 })
|
|
.closest("div.rounded-xl")
|
|
.find('input[type="number"]')
|
|
.first()
|
|
.clear({ force: true })
|
|
.type(value, { force: true });
|
|
});
|
|
}
|
|
|
|
/** Pick a day on the Schedule card's inline, cargo-aware calendar. */
|
|
function pickShipmentDay(day: Date) {
|
|
cy.contains(/available day/, { timeout: 120000 }).should("exist");
|
|
// Day cells are plain buttons in a grid; only bookable days are enabled
|
|
// (out-of-month duplicates and unscheduled days stay disabled).
|
|
const eatDay = new Date(day.getTime() + 3 * 3_600_000).getUTCDate();
|
|
cy.get("button:not(:disabled)", { timeout: 120000 })
|
|
.contains(new RegExp(`^${eatDay}$`))
|
|
.click({ force: true });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// visual assertions on the backoffice schedule board
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Open the schedule's detail page as operations staff. Every scenario does
|
|
* this at least twice — once after configuring the train (to SEE the empty
|
|
* 53-slot consist) and once at the end (to SEE the verdict).
|
|
*/
|
|
export function visitSchedule(departure: Date) {
|
|
cy.loginBackoffice(opsStaff);
|
|
dbSchedule(departure).then(({ rows }) => {
|
|
expect(rows, "G1 schedule").to.have.length(1);
|
|
cy.visit(`/dashboard/operations/train-scheduling-v2/${rows[0].id}`);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Every container on the train mapped to a wagon slot, with a real container
|
|
* number on it.
|
|
*
|
|
* Wagon-slot counts alone cannot catch a half-done allocation: a booking whose
|
|
* wagons were reserved but whose units were never placed still reads as a full
|
|
* train on the board. The units live in `wagon_allocation_container_items`
|
|
* (one row per container, `position_on_wagon` + `container_number`), hanging
|
|
* off `wagon_booking_allocations`.
|
|
*/
|
|
export function expectContainersPlaced(scheduleId: string, containers: number) {
|
|
pollDb<{ n: string }>(
|
|
`${containers} containers mapped to wagon slots`,
|
|
`SELECT count(*) AS n
|
|
FROM freight.wagon_allocation_container_items ci
|
|
JOIN freight.wagon_booking_allocations wba
|
|
ON wba.id = ci.wagon_booking_allocation_id
|
|
JOIN freight.train_schedule_bookings tsb
|
|
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
|
WHERE ci.deleted_at IS NULL AND wba.deleted_at IS NULL
|
|
AND tsb.deleted_at IS NULL`,
|
|
[scheduleId],
|
|
(row) => Number(row?.n ?? 0) === containers,
|
|
25,
|
|
);
|
|
// A placed unit with no number would be an empty slot wearing a container's
|
|
// name — the marshalling sheet is generated from exactly this column.
|
|
db<{ n: string }>(
|
|
`SELECT count(*) AS n
|
|
FROM freight.wagon_allocation_container_items ci
|
|
JOIN freight.wagon_booking_allocations wba
|
|
ON wba.id = ci.wagon_booking_allocation_id
|
|
JOIN freight.train_schedule_bookings tsb
|
|
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
|
WHERE ci.deleted_at IS NULL AND wba.deleted_at IS NULL
|
|
AND tsb.deleted_at IS NULL
|
|
AND (ci.container_number IS NULL OR ci.container_number = '')`,
|
|
[scheduleId],
|
|
).then(({ rows }) =>
|
|
expect(Number(rows[0].n), "no slot left without a container number").to.eq(0),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// split offers — S4, S6, S7, S8
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Poll until the batch has raised a partial (split) offer on a booking.
|
|
* The engine offers rather than reserves when a booking cannot fit whole but
|
|
* some room remains — sizePartialOfferWagons budgets that room against the
|
|
* BASE caps only, never the locomotive's overage tolerance (see S11).
|
|
*/
|
|
export function expectSplitOffer(suffix: string) {
|
|
withBooking(suffix, (b) => {
|
|
pollDb<{ status: string }>(
|
|
`${suffix} open partial offer`,
|
|
`SELECT status FROM freight.booking_batch_offers
|
|
WHERE booking_id = $1 AND deleted_at IS NULL
|
|
ORDER BY created_at DESC LIMIT 1`,
|
|
[b.id],
|
|
(row) => row?.status === "OFFERED",
|
|
15,
|
|
);
|
|
});
|
|
}
|
|
|
|
/** Assert NO split offer was raised — the whole-or-nothing cases. */
|
|
export function expectNoSplitOffer(suffix: string) {
|
|
withBooking(suffix, (b) => {
|
|
db<{ n: string }>(
|
|
`SELECT count(*) AS n FROM freight.booking_batch_offers
|
|
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
|
[b.id],
|
|
).then(({ rows }) => expect(Number(rows[0].n), `${suffix} has no split offer`).to.eq(0));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Let a split offer lapse rather than paying it (S6). The offer expires with
|
|
* the booking's pay deadline, so pushing the deadline into the past and
|
|
* letting the 10s tick run is the same thing the wall clock would do.
|
|
*/
|
|
export function forceOfferLapse(suffix: string) {
|
|
withBooking(suffix, (b) =>
|
|
db(
|
|
`UPDATE freight.bookings SET payment_deadline = now() - interval '1 second'
|
|
WHERE id = $1`,
|
|
[b.id],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// waiting list — S1, S5, S24
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* A booking that lost the batch sits at FULLY_EXECUTED with no schedule — it
|
|
* is on the day's waiting list, not rejected. Promotion happens when capacity
|
|
* frees up (fillFromWaitingList loops up to 10 rounds, so one expiry can
|
|
* cascade into several promotions — see S5).
|
|
*/
|
|
export function expectWaitlisted(suffix: string) {
|
|
withBooking(suffix, (b) => {
|
|
expect(b.status, `${suffix} waitlisted`).to.eq("FULLY_EXECUTED");
|
|
expect(b.train_schedule_id, `${suffix} holds no seat`).to.be.null;
|
|
});
|
|
}
|
|
|
|
/** Poll until a waitlisted booking has been promoted into a pay window. */
|
|
export function expectPromoted(suffix: string) {
|
|
pollDb<{ status: string; payment_deadline: string | null }>(
|
|
`${suffix} promoted from the waiting list`,
|
|
`SELECT b.status, b.payment_deadline FROM freight.bookings b
|
|
JOIN freight.contracts ct ON ct.id = b.contract_id
|
|
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND b.deleted_at IS NULL
|
|
ORDER BY b.created_at DESC LIMIT 1`,
|
|
[suffix],
|
|
(row) =>
|
|
!!row &&
|
|
["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"].includes(row.status) &&
|
|
row.payment_deadline !== null,
|
|
30,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// recoverability — S1's tail
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* An EXPIRED booking is recoverable without re-approval: its contract is still
|
|
* FULLY_EXECUTED, so the customer can book again onto a later day. Asserting
|
|
* the CONTRACT state (not just the booking's) is the point — a bug that also
|
|
* retired the contract would strand the customer.
|
|
*/
|
|
export function expectRecoverable(suffix: string) {
|
|
withBooking(suffix, (b) => {
|
|
expect(b.status, `${suffix} expired`).to.eq("EXPIRED");
|
|
db<{ status: string }>(`SELECT status FROM freight.contracts WHERE id = $1`, [
|
|
b.contract_id,
|
|
]).then(({ rows }) =>
|
|
expect(rows[0].status, `${suffix} contract still bookable`).to.be.oneOf([
|
|
"FULLY_EXECUTED",
|
|
"CONTRACT_ACTIVE",
|
|
]),
|
|
);
|
|
});
|
|
}
|