mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
- 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.
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
/**
|
||
* GROUP 9 · S38–S39 — 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 {};
|