mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
255 lines
9.3 KiB
TypeScript
255 lines
9.3 KiB
TypeScript
/**
|
||
* BATCH — government preemption:
|
||
*
|
||
* A government booking that fits nowhere on an already-committed train
|
||
* displaces the LOWEST-priority commercial reservation first (never the
|
||
* higher-priority one), then allocates directly — no pay window, since
|
||
* government rides unpaid (see booking-batch.service.ts `preemptForGovernment`
|
||
* + `allocate(..., "gov")`).
|
||
*
|
||
* Setup: a 3-wagon import train. CGA (priority 1, higher) and CGB (priority
|
||
* 2, lower) each book one 40ft container and reserve via the normal batch —
|
||
* 2/3 wagons used, 1 free. A standalone government booking (2×40ft = 2
|
||
* wagons) then needs more than the 1 free slot: it doesn't fit, so the
|
||
* engine preempts CGB (the lower-priority reservation) to free the second
|
||
* wagon, then allocates the government booking. CGA is untouched throughout.
|
||
*
|
||
* Government bookings don't go through the customer contract wizard —
|
||
* they're created directly via `POST /bookings` (isGovernment: true, billed
|
||
* to a real government company/profile — see seed-government.sql) and
|
||
* promoted with `POST /bookings/:id/government-expedite`.
|
||
*
|
||
* Sequential steps of one scenario — retries off.
|
||
*/
|
||
|
||
import {
|
||
acceptOperation,
|
||
apiPost,
|
||
bookContainers,
|
||
closeBookingWindow,
|
||
completeDocReview,
|
||
createImportSchedule,
|
||
db,
|
||
departureAt,
|
||
DEST,
|
||
eatDayStr,
|
||
ensureCorridorRoute,
|
||
forceWindowOpen,
|
||
ORIGIN,
|
||
pollBookingStatus,
|
||
pollDb,
|
||
resetCorridorDay,
|
||
seedImportContract,
|
||
setPriority,
|
||
superAdmin,
|
||
withBooking,
|
||
withSchedule,
|
||
type ScheduleRow,
|
||
} from "./import-utils";
|
||
|
||
const DEPARTURE = departureAt(27);
|
||
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||
const stamp = String(Date.now());
|
||
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
|
||
|
||
const GOV_COMPANY_ID = "0a1b0001-0000-4000-8000-000000000001";
|
||
const GOV_PROFILE_ID = "0b1c0001-0000-4000-8000-000000000001";
|
||
|
||
function withScheduleId(fn: (id: string, s: ScheduleRow) => void) {
|
||
withSchedule(DEPARTURE, (s) => fn(s.id, s));
|
||
}
|
||
|
||
interface GovBookingRow {
|
||
id: string;
|
||
status: string;
|
||
scheduling_status: string;
|
||
is_government: boolean;
|
||
train_schedule_id: string | null;
|
||
}
|
||
|
||
/** This run's government booking — always re-queried, never trusted from a
|
||
* create-response shape (only one exists per run: newest for this gov company). */
|
||
function withGovBooking(fn: (b: GovBookingRow) => void) {
|
||
db<GovBookingRow>(
|
||
`SELECT id, status, scheduling_status, is_government, train_schedule_id
|
||
FROM freight.bookings
|
||
WHERE company_id = $1 AND is_government = true AND deleted_at IS NULL
|
||
ORDER BY created_at DESC LIMIT 1`,
|
||
[GOV_COMPANY_ID],
|
||
).then(({ rows }) => {
|
||
expect(rows, "this run's government booking").to.have.length(1);
|
||
fn(rows[0]);
|
||
});
|
||
}
|
||
|
||
describe("batch: government booking preempts the lowest-priority commercial reservation", { retries: 0 }, () => {
|
||
before(() => {
|
||
cy.task("db:seedFile", "seed-import-corridor.sql");
|
||
cy.task("db:seedFile", "seed-government.sql");
|
||
["CGA", "CGB"].forEach((suffix) =>
|
||
seedImportContract({ suffix, reference: stampedRef(suffix) }),
|
||
);
|
||
});
|
||
|
||
it("operations schedules the dedicated 3-wagon built train (TRN-GOV-1) — window forced open", () => {
|
||
ensureCorridorRoute();
|
||
resetCorridorDay(DEPARTURE);
|
||
// A loco-pair schedule's max_wagons is NOT a real cap — it gets recomputed
|
||
// from the locomotive's length every fill pass (54 for a standard loco),
|
||
// ignoring maxWagonsPerTrain. A BUILT train's coupled count IS the cap.
|
||
createImportSchedule({
|
||
departure: DEPARTURE,
|
||
trainCode: "TRN-GOV-1",
|
||
maxWagons: 3,
|
||
});
|
||
withScheduleId((id) => forceWindowOpen(id, 45));
|
||
});
|
||
|
||
it("CGA and CGB each book one 40ft and reserve — 2/3 wagons used, 1 free", () => {
|
||
bookContainers({
|
||
suffix: "CGA",
|
||
runStamp: stamp,
|
||
isoSeed: 0,
|
||
forty: 1,
|
||
scheduledDate: BOOKING_DAY,
|
||
});
|
||
acceptOperation("CGA");
|
||
bookContainers({
|
||
suffix: "CGB",
|
||
runStamp: stamp,
|
||
isoSeed: 10,
|
||
forty: 1,
|
||
scheduledDate: BOOKING_DAY,
|
||
});
|
||
acceptOperation("CGB");
|
||
setPriority("CGA", 1); // higher priority — must survive
|
||
setPriority("CGB", 2); // lower priority — the preemption target
|
||
|
||
withScheduleId((id) => {
|
||
closeBookingWindow(id);
|
||
completeDocReview(id);
|
||
});
|
||
|
||
withBooking("CGA", (b) =>
|
||
expect(b.status, "CGA reserved").to.be.oneOf(["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
|
||
);
|
||
withBooking("CGB", (b) =>
|
||
expect(b.status, "CGB reserved").to.be.oneOf(["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
|
||
);
|
||
});
|
||
|
||
it("a government booking (2×40ft) is created and expedited to PAID, then pinned to the train", () => {
|
||
db<{ id: string }>(
|
||
`SELECT id FROM freight.yards WHERE code = $1`,
|
||
[ORIGIN],
|
||
).then(({ rows: o }) => {
|
||
db<{ id: string }>(`SELECT id FROM freight.yards WHERE code = $1`, [DEST]).then(
|
||
({ rows: d }) => {
|
||
db<{ id: string }>(
|
||
`SELECT id FROM freight.service_types ORDER BY created_at LIMIT 1`,
|
||
).then(({ rows: st }) => {
|
||
db<{ id: string }>(
|
||
`SELECT id FROM freight.container_types WHERE size_ft = 40 AND is_active LIMIT 1`,
|
||
).then(({ rows: ct }) => {
|
||
apiPost(superAdmin, "/api/bookings", {
|
||
isGovernment: true,
|
||
companyId: GOV_COMPANY_ID,
|
||
companyProfileId: GOV_PROFILE_ID,
|
||
contractType: "NEW",
|
||
serviceTypeId: st[0].id,
|
||
equipmentReturn: "WITHOUT_RETURN",
|
||
originYardId: o[0].id,
|
||
destinationYardId: d[0].id,
|
||
tradeDirection: "IMPORT",
|
||
freightType: "CONTAINER",
|
||
containers: [{ containerTypeId: ct[0].id, quantity: 2, vgmPerUnitTons: 10 }],
|
||
cargoTotalWeightVgm: 20,
|
||
paymentCurrency: "USD",
|
||
}).then((res) => {
|
||
expect(res.status, "government booking created").to.be.oneOf([200, 201]);
|
||
|
||
withGovBooking((created) => {
|
||
apiPost(
|
||
superAdmin,
|
||
`/api/bookings/${created.id}/government-expedite`,
|
||
).then((expRes) => {
|
||
expect(expRes.status, "expedited").to.be.oneOf([200, 201]);
|
||
});
|
||
});
|
||
withGovBooking((b) => {
|
||
expect(b.status, "PAID after expedite").to.eq("PAID");
|
||
expect(b.is_government, "flagged government").to.eq(true);
|
||
|
||
// Staff pin onto this schedule (the customer-facing OPEN-window
|
||
// pin can't be used here — the window already closed when
|
||
// completeDocReview ran). Mirrors a staff manual assign.
|
||
withScheduleId((scheduleId) => {
|
||
db(
|
||
`UPDATE freight.bookings SET train_schedule_id = $1 WHERE id = $2`,
|
||
[scheduleId, b.id],
|
||
);
|
||
});
|
||
});
|
||
});
|
||
});
|
||
});
|
||
},
|
||
);
|
||
});
|
||
});
|
||
|
||
it("run-batch: the government booking preempts CGB (lower priority), CGA is untouched", () => {
|
||
withScheduleId((scheduleId) => {
|
||
apiPost(superAdmin, `/api/train-scheduling/schedules/${scheduleId}/run-batch`).then(
|
||
(res) => expect(res.status, "run-batch").to.be.oneOf([200, 201]),
|
||
);
|
||
});
|
||
|
||
// CGB displaced: EXPIRED, back to ELIGIBLE, no pay window, invoice expired.
|
||
pollBookingStatus("CGB", "EXPIRED");
|
||
withBooking("CGB", (b) => {
|
||
expect(b.scheduling_status, "CGB back to ELIGIBLE").to.eq("ELIGIBLE");
|
||
expect(b.payment_deadline, "CGB pay window cleared").to.be.null;
|
||
db<{ n: string }>(
|
||
`SELECT count(*) AS n FROM freight.invoices
|
||
WHERE source = 'booking' AND source_id = $1
|
||
AND status NOT IN ('EXPIRED','CANCELLED','PAID') AND deleted_at IS NULL`,
|
||
[b.id],
|
||
).then(({ rows }) => expect(Number(rows[0].n), "CGB invoice expired").to.eq(0));
|
||
});
|
||
|
||
// CGA survives untouched — still reserved on the same train.
|
||
withScheduleId((scheduleId) => {
|
||
withBooking("CGA", (b) => {
|
||
expect(b.status, "CGA still reserved").to.be.oneOf([
|
||
"SELECTED_FOR_BATCH",
|
||
"AWAITING_PAYMENT",
|
||
]);
|
||
expect(b.train_schedule_id, "CGA still on this train").to.eq(scheduleId);
|
||
});
|
||
});
|
||
|
||
// Government booking allocated: SCHEDULED + linked via the schedule-bookings
|
||
// table (allocate() never sets the train_schedule_id column for gov — only
|
||
// the link row — so the pin from the previous step is what carries it).
|
||
withGovBooking((b) => {
|
||
expect(b.scheduling_status, "gov SCHEDULED").to.eq("SCHEDULED");
|
||
withScheduleId((scheduleId) => {
|
||
db<{ n: string }>(
|
||
`SELECT count(*) AS n FROM freight.train_schedule_bookings
|
||
WHERE booking_id = $1 AND train_schedule_id = $2 AND deleted_at IS NULL`,
|
||
[b.id, scheduleId],
|
||
).then(({ rows }) => expect(Number(rows[0].n), "gov linked to schedule").to.eq(1));
|
||
});
|
||
pollDb<{ n: string }>(
|
||
"gov booking allocated 2 wagons",
|
||
`SELECT count(*) AS n FROM freight.wagon_booking_allocations WHERE booking_id = $1`,
|
||
[b.id],
|
||
(row) => Number(row?.n ?? 0) >= 2,
|
||
);
|
||
});
|
||
});
|
||
});
|
||
|
||
export {};
|