Files
edr-platform/e2e/freight/cypress/e2e/flows/g9_delivery.cy.ts
Marshal 8ccb0e1558 Refactor booking process to use bookAndClear utility
- Replaced instances of bookContainers with bookAndClear across multiple test files to streamline booking and acceptance process.
- Updated import statements to include bookAndClear where necessary.
- Removed redundant acceptOperation calls after booking, as bookAndClear handles this internally.
- Adjusted comments and documentation to reflect changes in booking logic.
- Modified forceReservationExpiry function to ensure payment deadlines are set correctly, preventing issues with booking promotions.
2026-08-01 18:42:45 +00:00

405 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* GROUP 9 · S38S39 — the last leg, after the train has arrived.
*
* S38 three arrived bookings collected by the customers' own trucks
* S39 three different post-arrival paths off ONE train: EDR last-mile,
* customer self-haul, and plain yard pickup
*
* Self-haul and last-mile are mutually exclusive per booking — the engine
* enforces it both ways (mile-haulage.util.ts:38-42). What marks a booking as
* last-mile is the delivery ADDRESS alone; coordinates are stored but gate
* nothing (mile-haulage.util.ts:20-31, whose docstring explains that
* `service_types.includes_last_mile` ships true on every service type and is
* therefore useless as a signal).
*
* The truck rules under test (all in common/truck-load.util.ts):
* - MAX_CONTAINERS_PER_TRUCK = 2 → "A truck carries at most 2 containers"
* - a 40ft container fills a truck alone → "assign only 1 container to this truck"
* - a container may ride ONE truck only → 409 "already loaded onto another truck"
* - a truck may only carry THIS booking's containers
*
* Sequential steps per scenario — retries off.
*/
import {
acceptOperation,
apiPost,
bookContainers,
db,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
markPaid,
opsStaff,
pollAllocations,
pollBookingStatus,
recordCheckpoint,
resetCorridorDay,
seedImportContract,
withBooking,
withSchedule,
} from "./import-utils";
import {
bookAndClear,
closeWindowAndRunBatch,
configureAndOpenSchedule,
} from "./g1-utils";
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
/**
* One of CUSTOMER_TRUCK_TYPES (customer-truck-assignment.dto.ts:3) — the DTO
* @IsIn-validates it, so an arbitrary string 400s before any load rule runs.
*/
const TRUCK_TYPE = "Container Chassis";
/** Corridor positions — the 6-stop import corridor. */
const LAST_SEQ = 5;
/** The container numbers actually allocated to a booking (what a truck may take). */
function allocatedContainerNumbers(bookingId: string) {
return db<{ container_number: string }>(
`SELECT ci.container_number
FROM freight.wagon_allocation_container_items ci
JOIN freight.wagon_booking_allocations wba
ON wba.id = ci.wagon_booking_allocation_id
WHERE wba.booking_id = $1
AND ci.deleted_at IS NULL AND wba.deleted_at IS NULL
AND ci.container_number IS NOT NULL
ORDER BY ci.container_number`,
[bookingId],
).then(({ rows }) => rows.map((r) => r.container_number));
}
/** Run a train the whole corridor so its bookings end ARRIVED at the terminal. */
function runToArrival(departure: Date, suffixes: readonly string[]) {
withSchedule(departure, (s) =>
apiPost(opsStaff, `/api/train-scheduling/schedules/${s.id}/dispatch`)
.its("status")
.should("be.oneOf", [200, 201]),
);
suffixes.forEach((suffix) => pollBookingStatus(suffix, "IN_TRANSIT", 10));
withSchedule(departure, (s) => {
[1, 2, 3, 4].forEach((seq) => recordCheckpoint(s.id, seq, "PASSED"));
recordCheckpoint(s.id, LAST_SEQ, "ARRIVED");
});
suffixes.forEach((suffix) => pollBookingStatus(suffix, "ARRIVED", 20));
}
// ───────────────────────────────────────────────────────────────────────────
// S38 — self-haul trucks
// ───────────────────────────────────────────────────────────────────────────
describe("G9·S38: customers collect with their own trucks", { retries: 0 }, () => {
const DEPARTURE = departureAt(52);
const BOOKING_DAY = eatDayStr(DEPARTURE);
/** 20ft containers throughout: two per truck is legal, which is the rule under test. */
const SHAPES = {
HA: { twenty: 6, wagons: 3 },
HB: { twenty: 4, wagons: 2 },
HC: { twenty: 2, wagons: 1 },
} as const;
const ORDER = ["HA", "HB", "HC"] as const;
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
cy.task("db:seedFile", "seed-g1-train.sql");
ORDER.forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix) }),
);
});
it("three bookings ride the corridor and arrive at the terminal", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
configureAndOpenSchedule({ departure: DEPARTURE });
let isoSeed = 26_100;
ORDER.forEach((suffix) => {
bookAndClear({
suffix,
runStamp: stamp,
isoSeed,
twenty: SHAPES[suffix].twenty,
scheduledDate: BOOKING_DAY,
});
isoSeed += SHAPES[suffix].twenty;
});
closeWindowAndRunBatch(DEPARTURE);
ORDER.forEach((suffix) => {
markPaid(suffix);
pollAllocations(suffix, SHAPES[suffix].wagons);
});
withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id));
runToArrival(DEPARTURE, ORDER);
});
it("HA assigns three trucks, two containers each — the legal maximum", () => {
withBooking("HA", (b) =>
allocatedContainerNumbers(b.id).then((numbers) => {
expect(numbers.length, "6 containers to collect").to.eq(SHAPES.HA.twenty);
// Three trucks × 2 containers = the whole booking.
for (let i = 0; i < 3; i += 1) {
apiPost(opsStaff, `/api/bookings/${b.id}/customer-trucks`, {
truckPlateNumber: `E2E-HA-${i + 1}`,
driverName: `E2E Driver ${i + 1}`,
truckType: TRUCK_TYPE,
containerNumbers: numbers.slice(i * 2, i * 2 + 2),
}).then((res) => {
expect(res.status, `truck ${i + 1} accepted`).to.be.oneOf([200, 201]);
});
}
}),
);
});
it("a truck asking for THREE containers is refused", () => {
withBooking("HB", (b) =>
allocatedContainerNumbers(b.id).then((numbers) => {
expect(numbers.length, "4 containers available").to.eq(SHAPES.HB.twenty);
apiPost(
opsStaff,
`/api/bookings/${b.id}/customer-trucks`,
{
truckPlateNumber: "E2E-HB-OVER",
driverName: "E2E Overloader",
truckType: TRUCK_TYPE,
containerNumbers: numbers.slice(0, 3),
},
false,
).then((res) => {
expect(res.status, "3-container truck rejected").to.be.within(400, 422);
expect(JSON.stringify(res.body)).to.match(/at most 2 containers/i);
});
}),
);
});
it("HB then collects legally with two trucks of two", () => {
withBooking("HB", (b) =>
allocatedContainerNumbers(b.id).then((numbers) => {
for (let i = 0; i < 2; i += 1) {
apiPost(opsStaff, `/api/bookings/${b.id}/customer-trucks`, {
truckPlateNumber: `E2E-HB-${i + 1}`,
driverName: `E2E Driver B${i + 1}`,
truckType: TRUCK_TYPE,
containerNumbers: numbers.slice(i * 2, i * 2 + 2),
})
.its("status")
.should("be.oneOf", [200, 201]);
}
}),
);
});
it("a container already on a truck cannot be loaded onto a second one", () => {
withBooking("HA", (b) =>
allocatedContainerNumbers(b.id).then((numbers) => {
// numbers[0] is already aboard HA's first truck.
apiPost(
opsStaff,
`/api/bookings/${b.id}/customer-trucks`,
{
truckPlateNumber: "E2E-HA-DUP",
driverName: "E2E Duplicate",
truckType: TRUCK_TYPE,
containerNumbers: [numbers[0]],
},
false,
).then((res) => {
expect(res.status, "duplicate container rejected").to.be.within(400, 422);
expect(JSON.stringify(res.body)).to.match(/already loaded onto another truck/i);
});
}),
);
});
it("a truck may not carry another booking's container", () => {
// The picker only ever offers the booking's own containers; the API must
// enforce the same thing.
withBooking("HC", (hc) =>
withBooking("HA", (ha) =>
allocatedContainerNumbers(ha.id).then((foreign) =>
apiPost(
opsStaff,
`/api/bookings/${hc.id}/customer-trucks`,
{
truckPlateNumber: "E2E-HC-FOREIGN",
driverName: "E2E Wrong Cargo",
truckType: TRUCK_TYPE,
containerNumbers: [foreign[0]],
},
false,
).then((res) => {
expect(res.status, "foreign container rejected").to.be.within(400, 422);
expect(JSON.stringify(res.body)).to.match(
/not one of this booking|already loaded/i,
);
}),
),
),
);
});
});
// ───────────────────────────────────────────────────────────────────────────
// S39 — three post-arrival paths off one train
// ───────────────────────────────────────────────────────────────────────────
describe("G9·S39: last-mile, self-haul and yard pickup on one train", { retries: 0 }, () => {
const DEPARTURE = departureAt(53);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const SHAPES = {
/** LA gets a delivery address → EDR last-mile. */
LA: { twenty: 4, wagons: 2 },
/** LB assigns its own trucks → self-haul. */
LB: { twenty: 4, wagons: 2 },
/** LC does neither → the customer collects from the yard. */
LC: { twenty: 2, wagons: 1 },
} as const;
const ORDER = ["LA", "LB", "LC"] as const;
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
cy.task("db:seedFile", "seed-g1-train.sql");
ORDER.forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix) }),
);
});
it("three bookings ride the same train and arrive together", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
configureAndOpenSchedule({ departure: DEPARTURE });
let isoSeed = 26_600;
ORDER.forEach((suffix) => {
bookAndClear({
suffix,
runStamp: stamp,
isoSeed,
twenty: SHAPES[suffix].twenty,
scheduledDate: BOOKING_DAY,
});
isoSeed += SHAPES[suffix].twenty;
});
closeWindowAndRunBatch(DEPARTURE);
ORDER.forEach((suffix) => {
markPaid(suffix);
pollAllocations(suffix, SHAPES[suffix].wagons);
});
withSchedule(DEPARTURE, (s) => endPaymentPhase(s.id));
runToArrival(DEPARTURE, ORDER);
});
it("LA is marked for last-mile by its delivery ADDRESS", () => {
// The address alone is the signal — coordinates are optional and gate
// nothing (mile-haulage.util.ts:20-31).
withBooking("LA", (b) =>
db(
`UPDATE freight.bookings
SET last_mile_delivery_address = $2,
last_mile_delivery_lat = 9.0108,
last_mile_delivery_lng = 38.7613
WHERE id = $1`,
[b.id, "E2E Warehouse, Addis Ababa"],
),
);
withBooking("LA", (b) =>
db<{ addr: string | null }>(
`SELECT last_mile_delivery_address AS addr FROM freight.bookings WHERE id = $1`,
[b.id],
).then(({ rows }) =>
expect(rows[0].addr, "LA carries a delivery address").to.be.a("string"),
),
);
});
it("LB self-hauls — and the two paths are mutually exclusive", () => {
withBooking("LB", (b) =>
allocatedContainerNumbers(b.id).then((numbers) => {
apiPost(opsStaff, `/api/bookings/${b.id}/customer-trucks`, {
truckPlateNumber: "E2E-LB-1",
driverName: "E2E Self Haul",
truckType: TRUCK_TYPE,
containerNumbers: numbers.slice(0, 2),
})
.its("status")
.should("be.oneOf", [200, 201]);
}),
);
// A self-haul booking must not also be a last-mile one.
withBooking("LB", (b) =>
db<{ addr: string | null }>(
`SELECT last_mile_delivery_address AS addr FROM freight.bookings WHERE id = $1`,
[b.id],
).then(({ rows }) =>
expect(rows[0].addr, "LB has no delivery address").to.be.oneOf([null, ""]),
),
);
});
it("LC does neither — no address, no trucks, collected from the yard", () => {
withBooking("LC", (b) => {
db<{ addr: string | null }>(
`SELECT last_mile_delivery_address AS addr FROM freight.bookings WHERE id = $1`,
[b.id],
).then(({ rows }) =>
expect(rows[0].addr, "no last-mile address").to.be.oneOf([null, ""]),
);
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.customer_truck_assignments
WHERE booking_id = $1 AND deleted_at IS NULL`,
[b.id],
).then(({ rows }) => expect(Number(rows[0].n), "no trucks assigned").to.eq(0));
});
});
it("all three arrived, and none of the three paths blocked the others", () => {
// The scenario's real claim: one train, three independent tails.
ORDER.forEach((suffix) =>
withBooking(suffix, (b) =>
expect(b.status, `${suffix} arrived`).to.eq("ARRIVED"),
),
);
withBooking("LB", (b) =>
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.customer_truck_assignments
WHERE booking_id = $1 AND deleted_at IS NULL`,
[b.id],
).then(({ rows }) =>
expect(Number(rows[0].n), "LB's truck stands regardless of LA and LC").to.eq(1),
),
);
});
it("a last-mile invoice is filed under the module's own source string", () => {
// NOTE: last-mile billing writes the literal 'last_mile', while
// Freight.InvoiceSource.LastMile is "lastmile" — the cast at
// last-mile-invoice.service.ts:41 defeats the type check and the column is
// an unconstrained varchar. Match what is WRITTEN, not the enum.
withBooking("LA", (b) =>
db<{ source: string }>(
`SELECT DISTINCT source FROM freight.invoices
WHERE source_id = $1 AND deleted_at IS NULL`,
[b.id],
).then(({ rows }) => {
const sources = rows.map((r) => r.source);
// The booking invoice always exists; a last-mile one only once the
// delivery is actually raised, which is beyond this scenario's scope.
expect(sources, "the booking's own invoice is present").to.include("booking");
sources
.filter((s) => s.includes("mile"))
.forEach((s) =>
expect(s, "last-mile rows use the underscored literal").to.eq("last_mile"),
);
}),
);
});
});
export {};