add permissions and fix issues

This commit is contained in:
Marshal
2026-07-23 20:24:20 +00:00
parent 40f16f3cec
commit 668b5e1c9d
40 changed files with 18634 additions and 342 deletions

View File

@@ -0,0 +1,449 @@
/**
* EXPORT "LEDGER DAY" (D+19) — one departure day, THREE trains of three
* different consist styles, ~22 bookings of every flavour (ONE_TIME +
* GENERAL, container + bulk, USD + ETB), every one of them driven to a known
* final fate — and a generated day-ledger REPORT at the end.
*
* GRAIN built train TRN-LEDGER-PW2 — 37 × PW2 box wagons (the physical
* consist IS the cap; the only built-train schedule in the suite)
* → bulk only, 37/37 = 2 590 T
* BOX loco pair, 54 × NW5 → containers only, 54/54
* MIX loco pair, 54 slots → 24w containers (NW5) + 30w bulk (CW4)
*
* 145 slots, filled FCFS in wave order (export reserves at ACCEPT):
* bulk wave fills GRAIN exactly; containers then cascade PAST the full
* PW2 train onto BOX; the mixed wave lands on MIX
* ONE booking per train never pays → all three EXPIRE in one sweep
* 2 late bookings are REFUSED at create (export never queues)
* 1 refused customer rebooks GRAIN's freed 5 wagons, pays, rides
*
* The final test classifies every booking of the day and writes
* `cypress/reports/export-ledger-<day>.json`.
*
* Sequential steps of one journey — retries off.
*/
import {
acceptExport,
bookBulk,
bookContainers,
clearGeneralBooking,
createImportSchedule,
db,
dayLedger,
departureAt,
eatDayStr,
ensureExportRoute,
EXP_DEST,
EXP_ORIGIN,
expectWagonType,
forceReservationExpiry,
forceWindowOpen,
markPaid,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
withBooking,
writeLedgerReport,
type LedgerRow,
type ScheduleRow,
} from "./import-utils";
const GRAIN_AT = departureAt(19);
const BOX_AT = new Date(GRAIN_AT.getTime() + 2 * 3_600_000);
const MIX_AT = new Date(GRAIN_AT.getTime() + 4 * 3_600_000);
const DAY = eatDayStr(GRAIN_AT);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
type Kind = "ONE_TIME" | "GENERAL";
interface Line {
suffix: string;
kind: Kind;
currency: "ETB" | "USD";
freight: "CONTAINER" | "BULK";
twenty?: number;
tons?: number;
wagons: number;
wagonType: "PW2" | "NW5" | "CW4";
pays: boolean;
}
/** Wave 1 — fills the built 37 × PW2 GRAIN train exactly. */
const GRAIN_LINES: Line[] = [
{ suffix: "LG1", kind: "ONE_TIME", currency: "USD", freight: "BULK", tons: 700, wagons: 10, wagonType: "PW2", pays: true },
{ suffix: "LG2", kind: "GENERAL", currency: "ETB", freight: "BULK", tons: 560, wagons: 8, wagonType: "PW2", pays: true },
{ suffix: "LG3", kind: "ONE_TIME", currency: "ETB", freight: "BULK", tons: 560, wagons: 8, wagonType: "PW2", pays: true },
{ suffix: "LG4", kind: "ONE_TIME", currency: "USD", freight: "BULK", tons: 420, wagons: 6, wagonType: "PW2", pays: true },
{ suffix: "LG5", kind: "ONE_TIME", currency: "ETB", freight: "BULK", tons: 350, wagons: 5, wagonType: "PW2", pays: false },
];
/** Wave 2 — 54 × NW5 on the pure container train. */
const BOX_LINES: Line[] = [
{ suffix: "LB1", kind: "ONE_TIME", currency: "USD", freight: "CONTAINER", twenty: 40, wagons: 20, wagonType: "NW5", pays: true },
{ suffix: "LB2", kind: "ONE_TIME", currency: "ETB", freight: "CONTAINER", twenty: 24, wagons: 12, wagonType: "NW5", pays: true },
{ suffix: "LB3", kind: "GENERAL", currency: "USD", freight: "CONTAINER", twenty: 16, wagons: 8, wagonType: "NW5", pays: true },
{ suffix: "LB4", kind: "ONE_TIME", currency: "ETB", freight: "CONTAINER", twenty: 12, wagons: 6, wagonType: "NW5", pays: true },
{ suffix: "LB5", kind: "ONE_TIME", currency: "USD", freight: "CONTAINER", twenty: 8, wagons: 4, wagonType: "NW5", pays: true },
{ suffix: "LB6", kind: "GENERAL", currency: "ETB", freight: "CONTAINER", twenty: 4, wagons: 2, wagonType: "NW5", pays: true },
{ suffix: "LB7", kind: "ONE_TIME", currency: "USD", freight: "CONTAINER", twenty: 2, wagons: 1, wagonType: "NW5", pays: true },
{ suffix: "LB8", kind: "ONE_TIME", currency: "ETB", freight: "CONTAINER", twenty: 2, wagons: 1, wagonType: "NW5", pays: false },
];
/** Wave 3 — 24w containers + 30w bulk share the MIX train. */
const MIX_LINES: Line[] = [
{ suffix: "LM1", kind: "ONE_TIME", currency: "USD", freight: "CONTAINER", twenty: 24, wagons: 12, wagonType: "NW5", pays: true },
{ suffix: "LM2", kind: "GENERAL", currency: "ETB", freight: "CONTAINER", twenty: 16, wagons: 8, wagonType: "NW5", pays: true },
{ suffix: "LM3", kind: "ONE_TIME", currency: "ETB", freight: "CONTAINER", twenty: 8, wagons: 4, wagonType: "NW5", pays: true },
{ suffix: "LM4", kind: "ONE_TIME", currency: "USD", freight: "BULK", tons: 700, wagons: 10, wagonType: "CW4", pays: true },
{ suffix: "LM5", kind: "GENERAL", currency: "ETB", freight: "BULK", tons: 700, wagons: 10, wagonType: "CW4", pays: true },
{ suffix: "LM6", kind: "ONE_TIME", currency: "ETB", freight: "BULK", tons: 700, wagons: 10, wagonType: "CW4", pays: false },
];
const ALL_LINES = [...GRAIN_LINES, ...BOX_LINES, ...MIX_LINES];
const PAID = ALL_LINES.filter((l) => l.pays);
const UNPAID = ALL_LINES.filter((l) => !l.pays);
/** Refused at create once all 145 slots are held; LR2 later redeems itself. */
const REJECTED = ["LR1", "LR2"];
let isoSeed = 20_000;
/** Book one line and take it to its FCFS reservation. */
function bookAndAccept(line: Line) {
if (line.freight === "CONTAINER") {
bookContainers({
suffix: line.suffix,
runStamp: stamp,
isoSeed,
twenty: line.twenty,
scheduledDate: DAY,
});
isoSeed += (line.twenty ?? 0) + 5;
} else {
// PW2 bulk rides the built GRAIN consist → GRAINS cargo; CW4 bulk rides the
// loco-pair MIX train → WHEAT. One wagon length per cargo keeps the
// loco-pair length budget deterministic (see seed 5b3).
bookBulk({
suffix: line.suffix,
tons: line.tons!,
scheduledDate: DAY,
cargoCode: line.wagonType === "PW2" ? "E2E_IMP_GRAINS" : "E2E_IMP_WHEAT",
});
}
if (line.kind === "GENERAL") clearGeneralBooking(line.suffix, DAY, "export");
else acceptExport(line.suffix);
}
function seedLine(line: Line) {
seedImportContract({
suffix: line.suffix,
reference: stampedRef(line.suffix),
kind: line.kind,
currency: line.currency,
freight: line.freight,
direction: "EXPORT",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
}
interface DaySchedule {
id: string;
reference: string;
max_wagons: number;
booking_window_status: string;
}
function daySchedule(at: Date) {
return db<DaySchedule>(
`SELECT ts.id, ts.reference, ts.max_wagons, ts.booking_window_status
FROM freight.train_schedules ts
JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1
JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2
WHERE ts.deleted_at IS NULL
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < 3600
ORDER BY ts.created_at DESC LIMIT 1`,
[EXP_ORIGIN, EXP_DEST, at.toISOString()],
).then(({ rows }) => {
expect(rows, `schedule departing ${at.toISOString()}`).to.have.length(1);
return cy.wrap(rows[0], { log: false });
});
}
describe("export ledger day: three trains, every fate, one report", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
ALL_LINES.forEach(seedLine);
seedLine({
suffix: "LR1",
kind: "ONE_TIME",
currency: "USD",
freight: "CONTAINER",
twenty: 2,
wagons: 1,
wagonType: "NW5",
pays: false,
});
seedLine({
suffix: "LR2",
kind: "ONE_TIME",
currency: "ETB",
freight: "BULK",
tons: 350,
wagons: 5,
wagonType: "PW2",
pays: false,
});
});
it("operations schedules THREE trains for one day: 37×PW2 built GRAIN, 54×NW5 BOX, 54 MIX", () => {
ensureExportRoute();
resetCorridorDay(GRAIN_AT, EXP_DEST, EXP_ORIGIN);
// The built train's coupled consist IS its capacity — 37, not the
// loco-length-derived 54 every other schedule in the suite gets.
createImportSchedule({
departure: GRAIN_AT,
trainCode: "TRN-LEDGER-PW2",
maxWagons: 37,
kind: "bulk",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
createImportSchedule({
departure: BOX_AT,
locoPair: ["LOCO-LED-3", "LOCO-LED-4"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
createImportSchedule({
departure: MIX_AT,
locoPair: ["LOCO-LED-5", "LOCO-LED-6"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
[GRAIN_AT, BOX_AT, MIX_AT].forEach((at) =>
daySchedule(at).then((s) => forceWindowOpen(s.id, 120)),
);
daySchedule(GRAIN_AT).then((s) =>
expect(s.max_wagons, "built consist = 37 PW2").to.eq(37),
);
daySchedule(BOX_AT).then((s) => expect(s.max_wagons, "BOX = 54").to.eq(54));
daySchedule(MIX_AT).then((s) => expect(s.max_wagons, "MIX = 54").to.eq(54));
});
it("wave 1 — five bulk bookings fill the built PW2 train exactly (37/37 reserved)", () => {
GRAIN_LINES.forEach(bookAndAccept);
daySchedule(GRAIN_AT).then((s) => {
GRAIN_LINES.forEach((l) =>
withBooking(l.suffix, (b) =>
expect(b.train_schedule_id, `${l.suffix} on GRAIN`).to.eq(s.id),
),
);
});
});
it("ONE_TIME is single-slot; GENERAL draws down again but is stopped at the FCFS accept", () => {
// A second booking on the live ONE_TIME contract LG1 is refused outright…
bookBulk({
suffix: "LG1",
tons: 70,
scheduledDate: DAY,
cargoCode: "E2E_IMP_GRAINS",
expectFailure: "already has an active booking",
});
// …while the GENERAL neighbour LG2 draws again freely. Engine truth: a
// GENERAL booking clears PER BOOKING, so it SKIPS the create-time
// window/space gate that refused the ONE_TIME latecomers above — a full
// day stops it only later, when staff try to accept it onto a train.
bookBulk({ suffix: "LG2", tons: 70, scheduledDate: DAY, cargoCode: "E2E_IMP_GRAINS" });
withBooking("LG2", (probe) => {
expect(probe.status, "GENERAL drawdown accepted at create").to.eq(
"AWAITING_DOCUMENTS",
);
// Dispose of the probe so the day's ledger keeps exactly one live
// booking per contract (every lookup skips soft-deleted rows).
db(`UPDATE freight.bookings SET deleted_at = now() WHERE id = $1`, [probe.id]);
});
withBooking("LG2", (b) =>
expect(b.status, "LG2's real reservation is untouched").to.be.oneOf([
"SELECTED_FOR_BATCH",
"AWAITING_PAYMENT",
]),
);
});
it("wave 2 — containers cascade PAST the full PW2 train onto BOX (54/54 reserved)", () => {
BOX_LINES.forEach(bookAndAccept);
daySchedule(BOX_AT).then((s) => {
BOX_LINES.forEach((l) =>
withBooking(l.suffix, (b) =>
expect(b.train_schedule_id, `${l.suffix} on BOX`).to.eq(s.id),
),
);
});
});
it("wave 3 — containers and bulk share MIX (24w + 30w = 54/54 reserved)", () => {
MIX_LINES.forEach(bookAndAccept);
daySchedule(MIX_AT).then((s) => {
MIX_LINES.forEach((l) =>
withBooking(l.suffix, (b) =>
expect(b.train_schedule_id, `${l.suffix} on MIX`).to.eq(s.id),
),
);
});
});
it("every reservation's pay deadline is clamped to its OWN train's window close", () => {
const check = (at: Date, lines: Line[]) =>
daySchedule(at).then((s) => {
db<{ window_closes_at: string }>(
`SELECT window_closes_at FROM freight.train_schedules WHERE id = $1`,
[s.id],
).then(({ rows }) => {
lines.forEach((l) =>
withBooking(l.suffix, (b) => {
expect(
new Date(b.payment_deadline!).getTime(),
`${l.suffix} clamped`,
).to.be.at.most(new Date(rows[0].window_closes_at).getTime());
}),
);
});
});
check(GRAIN_AT, GRAIN_LINES);
check(BOX_AT, BOX_LINES);
check(MIX_AT, MIX_LINES);
});
it("all 145 slots are held — two late bookings are REFUSED (export never queues)", () => {
bookContainers({
suffix: "LR1",
runStamp: stamp,
isoSeed: 29_000,
twenty: 2,
scheduledDate: DAY,
expectFailure: /space|window/i,
});
bookBulk({
suffix: "LR2",
tons: 350,
scheduledDate: DAY,
cargoCode: "E2E_IMP_GRAINS",
expectFailure: /space|window/i,
});
});
it("16 of the 19 pay — typed allocation on all three trains", () => {
PAID.forEach((l) => {
markPaid(l.suffix);
pollAllocations(l.suffix, l.wagons);
expectWagonType(l.suffix, l.wagonType, l.wagons);
});
});
it("the three unpaid — one per train — expire in one sweep", () => {
UNPAID.forEach((l) => forceReservationExpiry(l.suffix));
UNPAID.forEach((l) => pollBookingStatus(l.suffix, "EXPIRED"));
});
it("redemption: the refused bulk customer takes GRAIN's freed 5 PW2 wagons and pays", () => {
bookBulk({ suffix: "LR2", tons: 350, scheduledDate: DAY, cargoCode: "E2E_IMP_GRAINS" });
acceptExport("LR2");
markPaid("LR2");
pollAllocations("LR2", 5);
expectWagonType("LR2", "PW2", 5);
daySchedule(GRAIN_AT).then((s) =>
withBooking("LR2", (b) =>
expect(b.train_schedule_id, "LR2 rides GRAIN").to.eq(s.id),
),
);
});
it("per-train totals: 37 PW2 + 54 NW5 + 54 mixed, links match bookings", () => {
const totals: Array<[Date, string, number, number]> = [
// [departure, label, wagons, bookings]
[GRAIN_AT, "GRAIN", 37, 5], // 4 paid + the redeemed LR2
[BOX_AT, "BOX", 53, 7], // one booking expired: 54 1
[MIX_AT, "MIX", 44, 5], // one 10w booking expired: 54 10
];
totals.forEach(([at, label, wagons, bookings]) => {
daySchedule(at).then((s) => {
pollDb<{ n: string }>(
`${label} carries ${bookings} bookings`,
`SELECT count(*) AS n FROM freight.train_schedule_bookings
WHERE train_schedule_id = $1 AND deleted_at IS NULL`,
[s.id],
(row) => Number(row?.n) === bookings,
15,
);
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`,
[s.id],
).then(({ rows }) =>
expect(Number(rows[0].n), `${label} wagons`).to.eq(wagons),
);
});
});
});
it("THE LEDGER — classify every booking of the day and write the report", () => {
dayLedger(stamp, { rejected: REJECTED, redeemed: ["LR2"] }).then((rows) => {
const ledger = rows as LedgerRow[];
const byFate = (fate: string) => ledger.filter((r) => r.fate === fate);
// Class counts: 16 riding + 1 redeemed + 3 expired + 2 refusals.
expect(byFate("PAID_RIDING").length, "paid & riding").to.eq(PAID.length);
expect(byFate("REBOOKED_PAID").length, "refused then rebooked").to.eq(1);
expect(byFate("EXPIRED_UNPAID").length, "reserved but never paid").to.eq(
UNPAID.length,
);
expect(byFate("REJECTED_NO_SPACE").length, "refused at create").to.eq(
REJECTED.length,
);
expect(byFate("RESERVED_UNPAID").length, "nothing left hanging").to.eq(0);
// Every engineered line landed in its expected class.
PAID.forEach((l) => {
const row = ledger.find((r) => r.suffix === l.suffix && r.fate === "PAID_RIDING");
expect(row, `${l.suffix} rides`).to.exist;
expect(row!.kind, `${l.suffix} kind`).to.eq(l.kind);
expect(row!.currency, `${l.suffix} currency`).to.eq(l.currency);
expect(row!.wagon_type, `${l.suffix} wagon type`).to.eq(l.wagonType);
expect(row!.wagons, `${l.suffix} wagons`).to.eq(l.wagons);
});
UNPAID.forEach((l) =>
expect(
ledger.find((r) => r.suffix === l.suffix && r.fate === "EXPIRED_UNPAID"),
`${l.suffix} expired`,
).to.exist,
);
// LR2 appears TWICE: the refusal and the redemption.
expect(
ledger.filter((r) => r.suffix === "LR2").length,
"LR2 refused then rebooked",
).to.eq(2);
// Currency split of the riding cargo (USD vs ETB invoices on one day).
const riding = [...byFate("PAID_RIDING"), ...byFate("REBOOKED_PAID")];
expect(riding.filter((r) => r.currency === "USD").length, "USD riders").to.be.greaterThan(0);
expect(riding.filter((r) => r.currency === "ETB").length, "ETB riders").to.be.greaterThan(0);
// Every riding booking sits on a train, every expired one does not ride.
riding.forEach((r) => expect(r.train, `${r.suffix} has a train`).to.be.a("string"));
writeLedgerReport(`export-ledger-${DAY}`, ledger);
});
});
});
export {};

View File

@@ -271,7 +271,7 @@ export function dbBooking(suffix: string) {
b.is_split, b.contract_id
FROM freight.bookings b
JOIN freight.contracts ct ON ct.id = b.contract_id
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND b.deleted_at IS NULL
ORDER BY b.created_at DESC LIMIT 1`,
[suffix],
);
@@ -297,7 +297,7 @@ export function pollBookingStatus(suffix: string, status: string | string[], att
`${suffix}${want.join("|")}`,
`SELECT b.status FROM freight.bookings b
JOIN freight.contracts ct ON ct.id = b.contract_id
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND b.deleted_at IS NULL
ORDER BY b.created_at DESC LIMIT 1`,
[suffix],
(row) => !!row && want.includes(row.status as string),
@@ -389,15 +389,17 @@ export function bookBulk(opts: {
suffix: string;
tons: number;
scheduledDate?: string; // omit for DOMESTIC (intercity)
/** Cargo code — WHEAT rides CW4, GRAINS rides PW2. Defaults to wheat. */
cargoCode?: "E2E_IMP_WHEAT" | "E2E_IMP_GRAINS";
expectFailure?: string | RegExp;
}) {
db<{ id: string; customs_clearing_enabled: boolean; cargo_type_id: string }>(
`SELECT ct.id, ct.customs_clearing_enabled,
(SELECT t.id FROM freight.cargo_types t WHERE t.code = 'E2E_IMP_WHEAT') AS cargo_type_id
(SELECT t.id FROM freight.cargo_types t WHERE t.code = $2) AS cargo_type_id
FROM freight.contracts ct
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
ORDER BY ct.created_at DESC LIMIT 1`,
[opts.suffix],
[opts.suffix, opts.cargoCode ?? "E2E_IMP_WHEAT"],
).then(({ rows }) => {
expect(rows, `seeded contract *-${opts.suffix}`).to.have.length(1);
const actor = rows[0].customs_clearing_enabled ? superAdmin : customer;
@@ -433,7 +435,12 @@ export function bookBulk(opts: {
* seed configures no required documents, so one ad-hoc doc satisfies the
* 100%-approved gate.
*/
export function clearGeneralBooking(suffix: string, scheduledDate: string) {
export function clearGeneralBooking(
suffix: string,
scheduledDate: string,
/** EXPORT ends at acceptExport (FCFS reserves on accept), not the pool. */
mode: "import" | "export" = "import",
) {
withBooking(suffix, (b) => {
expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS");
glUpload(`/api/bookings/${b.id}/clearance/documents`, {}, "custom_e2e");
@@ -450,7 +457,8 @@ export function clearGeneralBooking(suffix: string, scheduledDate: string) {
.its("status")
.should("be.oneOf", [200, 201]);
});
acceptOperation(suffix);
if (mode === "export") acceptExport(suffix);
else acceptOperation(suffix);
}
/** Ops accepts the operation request → FULLY_EXECUTED (enters the day pool). */
@@ -473,6 +481,28 @@ export function setPriority(suffix: string, order: number) {
);
}
/**
* Customs bookings pay their clearance service fee ON the booking invoice —
* there is no separate prepaid clearance invoice. Asserts the booking's
* invoice carries a CUSTOMS_CLEARANCE line.
*/
export function expectClearanceOnBookingInvoice(suffix: string) {
pollDb<{ n: string }>(
`${suffix} clearance fee on booking invoice`,
`SELECT COUNT(*)::text AS n
FROM freight.invoice_lines l
JOIN freight.invoices i ON i.id = l.invoice_id
JOIN freight.bookings b ON b.id::text = i.source_id::text
JOIN freight.contracts ct ON ct.id = b.contract_id
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND b.deleted_at IS NULL
AND i.source = 'booking'
AND l.charge_type LIKE 'CUSTOMS_CLEARANCE%'`,
[suffix],
(row) => Number(row?.n ?? 0) > 0,
10,
);
}
/** Staff force-pay; polls PAID + SCHEDULED. */
export function markPaid(suffix: string) {
withBooking(suffix, (b) => {
@@ -484,7 +514,7 @@ export function markPaid(suffix: string) {
`${suffix} PAID+SCHEDULED`,
`SELECT b.status, b.scheduling_status FROM freight.bookings b
JOIN freight.contracts ct ON ct.id = b.contract_id
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND b.deleted_at IS NULL
ORDER BY b.created_at DESC LIMIT 1`,
[suffix],
(row) => row?.status === "PAID" && row?.scheduling_status === "SCHEDULED",
@@ -557,7 +587,7 @@ export function settleViaGateway(suffix: string) {
`${suffix} PAID via gateway`,
`SELECT b.status FROM freight.bookings b
JOIN freight.contracts ct ON ct.id = b.contract_id
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND b.deleted_at IS NULL
ORDER BY b.created_at DESC LIMIT 1`,
[suffix],
(row) => row?.status === "PAID",
@@ -808,7 +838,13 @@ export function withExportSchedule(departure: Date, fn: (s: ScheduleRow) => void
*/
export function createImportSchedule(opts: {
departure: Date;
locoPair: [string, string];
/** Loco-pair mode — capacity comes from maxWagonsPerTrain. */
locoPair?: [string, string];
/**
* Built-train mode — the Train-Builder consist IS the capacity (its coupled
* wagon count), immune to the loco-length slot recompute.
*/
trainCode?: string;
maxWagons?: number;
kind?: "container" | "bulk";
originCode?: string;
@@ -816,16 +852,32 @@ export function createImportSchedule(opts: {
}) {
const originCode = opts.originCode ?? ORIGIN;
const destCode = opts.destCode ?? DEST;
const endpoint = `/api/train-scheduling/${opts.kind ?? "container"}/schedules`;
dbSchedule(opts.departure, destCode, originCode).then(({ rows }) => {
if (rows.length > 0) return;
dbRouteId(originCode, destCode).then(({ rows: routes }) => {
expect(routes, "corridor route").to.have.length(1);
if (opts.trainCode) {
db<{ id: string }>(`SELECT id FROM freight.trains WHERE code = $1`, [
opts.trainCode,
]).then(({ rows: trains }) => {
expect(trains, `built train ${opts.trainCode}`).to.have.length(1);
apiPost(opsStaff, endpoint, {
routeId: routes[0].id,
scheduleDate: opts.departure.toISOString(),
trainId: trains[0].id,
})
.its("status")
.should("be.oneOf", [200, 201]);
});
return;
}
db<{ id: string }>(
`SELECT id FROM freight.locomotives WHERE code = ANY($1::text[]) ORDER BY code`,
[opts.locoPair],
[opts.locoPair ?? []],
).then(({ rows: locos }) => {
expect(locos, `locomotives ${opts.locoPair.join(",")}`).to.have.length(2);
apiPost(opsStaff, `/api/train-scheduling/${opts.kind ?? "container"}/schedules`, {
expect(locos, `locomotives ${(opts.locoPair ?? []).join(",")}`).to.have.length(2);
apiPost(opsStaff, endpoint, {
routeId: routes[0].id,
scheduleDate: opts.departure.toISOString(),
locomotiveIds: locos.map((l) => l.id),
@@ -838,7 +890,111 @@ export function createImportSchedule(opts: {
});
dbSchedule(opts.departure, destCode, originCode).then(({ rows }) => {
expect(rows, "created schedule").to.have.length(1);
expect(rows[0].max_wagons, "54-wagon consist").to.eq(opts.maxWagons ?? 54);
expect(rows[0].max_wagons, "consist size").to.eq(opts.maxWagons ?? 54);
});
}
// ---------------------------------------------------------------------------
// day ledger — who booked, who rides, who expired, who was refused
// ---------------------------------------------------------------------------
export interface LedgerRow {
suffix: string;
reference: string;
kind: string;
freight: string;
currency: string;
status: string;
wagons: number;
wagon_type: string | null;
train: string | null;
fate: string;
}
/**
* Classify every booking made under this run's stamped contracts into its
* final fate. Bookings the engine REFUSED at create never exist as rows —
* the caller passes those suffixes in (they are only observable as 4xx at
* request time), plus any suffix that was refused and later rebooked.
*/
export function dayLedger(
runStamp: string,
opts: { rejected?: string[]; redeemed?: string[] } = {},
) {
return db<LedgerRow>(
`SELECT split_part(ct.reference, '-', 4) AS suffix,
b.reference,
ct.contract_kind AS kind,
b.freight_type AS freight,
b.payment_currency AS currency,
b.status,
COALESCE(a.wagons, 0)::int AS wagons,
a.wagon_type,
ts.reference AS train,
CASE
WHEN b.status IN ('PAID','IN_TRANSIT','ARRIVED','COMPLETED') THEN 'PAID_RIDING'
WHEN b.status = 'EXPIRED' THEN 'EXPIRED_UNPAID'
WHEN b.status IN ('SELECTED_FOR_BATCH','AWAITING_PAYMENT') THEN 'RESERVED_UNPAID'
ELSE 'PENDING_' || b.status
END AS fate
FROM freight.bookings b
JOIN freight.contracts ct ON ct.id = b.contract_id
LEFT JOIN freight.train_schedules ts ON ts.id = b.train_schedule_id
LEFT JOIN LATERAL (
SELECT count(*)::int AS wagons, max(wt.code) AS wagon_type
FROM freight.wagon_booking_allocations wba
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL
) a ON true
WHERE ct.reference LIKE 'CTR-IMP-' || $1 || '-%' AND b.deleted_at IS NULL
ORDER BY b.created_at`,
[runStamp],
).then(({ rows }) => {
const rejected = (opts.rejected ?? []).map((suffix) => ({
suffix,
reference: "—",
kind: "—",
freight: "—",
currency: "—",
status: "NOT_CREATED",
wagons: 0,
wagon_type: null,
train: null,
fate: "REJECTED_NO_SPACE",
})) as LedgerRow[];
// A refused customer who later rebooked shows BOTH lines: the refusal
// above and the live booking here, re-labelled.
const ledger = [
...rows.map((r) =>
(opts.redeemed ?? []).includes(r.suffix) && r.fate === "PAID_RIDING"
? { ...r, fate: "REBOOKED_PAID" }
: r,
),
...rejected,
];
return cy.wrap(ledger, { log: false });
});
}
/** Print the ledger as a table into the Cypress log and write a JSON artifact. */
export function writeLedgerReport(name: string, ledger: LedgerRow[]) {
const counts = ledger.reduce<Record<string, number>>((acc, r) => {
acc[r.fate] = (acc[r.fate] ?? 0) + 1;
return acc;
}, {});
cy.log(`**LEDGER ${name}** — ${JSON.stringify(counts)}`);
ledger.forEach((r) =>
cy.log(
`${r.suffix.padEnd(8)} ${r.kind.padEnd(9)} ${r.freight.padEnd(9)} ` +
`${r.currency.padEnd(4)} ${String(r.wagons).padStart(2)}w ` +
`${(r.wagon_type ?? "-").padEnd(4)} ${(r.train ?? "-").padEnd(14)} ${r.fate}`,
),
);
cy.writeFile(`cypress/reports/${name}.json`, {
generatedAt: new Date().toISOString(),
counts,
bookings: ledger,
});
}

View File

@@ -43,6 +43,7 @@ import {
withBooking,
withSchedule,
type ScheduleRow,
expectClearanceOnBookingInvoice,
} from "./import-utils";
const DEPARTURE = departureAt(4);
@@ -147,6 +148,9 @@ describe("import: six bookings fill the 54-wagon corridor train", { retries: 0 }
it("all six pay — allocated onto the train, 54/54 wagons, window FULL and schedule finalized", () => {
BOOKINGS.forEach((b) => {
// Customs bookings carry their clearance service fee ON the booking
// invoice (no prepaid clearance invoice) — assert before settling.
if (b.customs) expectClearanceOnBookingInvoice(b.suffix);
markPaid(b.suffix);
pollAllocations(b.suffix, b.wagons);
});

View File

@@ -26,6 +26,24 @@
-- missing production migration.
DROP INDEX IF EXISTS freight.uq_one_active_booking_per_one_time_contract;
-- 0b. Schema drift guard: CUSTOMS_CLEARANCE / WITH_RETURN rates became
-- route-scoped in migrations 2820 + 2830, but an e2e image built before them
-- still carries the old CK_rates_yard_scope (yards allowed only for ALWAYS
-- BULK/CONTAINER/INTERCITY rows). Re-state the post-2830 shape — a no-op on an
-- up-to-date image, and what lets the customs-clearance lane rates below load.
ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";
ALTER TABLE freight.rates
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL
OR status = 'SUPERSEDED'
OR CASE
WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR "trigger" IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
);
-- 1a. Extra Ethiopian mid-corridor yard.
INSERT INTO freight.yards (id, code, label, country, is_active, display_order)
SELECT gen_random_uuid(), 'E2E_AWASH', 'E2E Awash Yard', 'Ethiopia', true, 50
@@ -124,7 +142,7 @@ FROM (
JOIN freight.yards y ON y.id = w2.current_yard_id AND y.code = 'DJIB_PORT'
WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
ORDER BY w2.wagon_number ASC
LIMIT 120
LIMIT 200
) pick
WHERE w.id = pick.id;
@@ -158,22 +176,35 @@ WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
INSERT INTO freight.wagons (id, wagon_number, wagon_type_id, current_yard_id)
SELECT gen_random_uuid(), 'ECW' || lpad(g::text, 4, '0'), wt.id,
(SELECT id FROM freight.yards
WHERE code = CASE WHEN g <= 80 THEN 'KALITY' ELSE 'DIRE_DAWA' END)
FROM generate_series(1, 100) AS g
WHERE code = CASE WHEN g <= 120 THEN 'KALITY' ELSE 'DIRE_DAWA' END)
FROM generate_series(1, 160) AS g
JOIN freight.wagon_types wt ON wt.code = 'CW4'
WHERE NOT EXISTS (
SELECT 1 FROM freight.wagons w WHERE w.wagon_number = 'ECW' || lpad(g::text, 4, '0')
);
-- 5b3. Ledger-day rolling stock: wheat may also ride PW2 box wagons, and the
-- GRAIN train is a BUILT Train-Builder consist — 37 PW2 wagons coupled at
-- KALITY behind two dedicated locos. A built train's physical wagon count IS
-- its schedule capacity (37), immune to the loco-length slot recompute.
-- 5b3. Ledger-day rolling stock: GRAINS ride PW2 box wagons on a BUILT
-- Train-Builder consist — 37 PW2 wagons coupled at KALITY behind two dedicated
-- locos. A built train's physical wagon count IS its schedule capacity (37),
-- immune to the loco-length slot recompute.
--
-- GRAINS gets PW2 (17.066 m), WHEAT gets CW4 (13.976 m) below — kept on SEPARATE
-- cargo types on purpose. `dimsFor`/`needFor` size a bulk booking off its cargo
-- type's FIRST wagon type, so a cargo carrying two wagon-lengths sizes
-- inconsistently on loco-pair export trains (one booking as CW4, another as PW2)
-- and the length budget never matches the 54-wagon CW4 board the export specs
-- expect. One cargo → one wagon length keeps that math deterministic.
DELETE FROM freight.cargo_type_wagon_types x
USING freight.cargo_types ct, freight.wagon_types wt
WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id
AND ((ct.code = 'E2E_IMP_WHEAT' AND wt.code = 'PW2')
OR (ct.code = 'E2E_IMP_GRAINS' AND wt.code = 'CW4'));
INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id)
SELECT ct.id, wt.id
FROM freight.cargo_types ct
JOIN freight.wagon_types wt ON wt.code = 'PW2'
WHERE ct.code IN ('E2E_IMP_GRAINS', 'E2E_IMP_WHEAT')
WHERE ct.code = 'E2E_IMP_GRAINS'
AND NOT EXISTS (
SELECT 1 FROM freight.cargo_type_wagon_types x
WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id
@@ -182,7 +213,8 @@ WHERE ct.code IN ('E2E_IMP_GRAINS', 'E2E_IMP_WHEAT')
INSERT INTO freight.locomotives
(id, code, max_pull_weight_tons, max_train_length_meters, current_yard_id)
SELECT gen_random_uuid(), v.code, 9000, 760, y.id
FROM (VALUES ('LOCO-LED-1'), ('LOCO-LED-2')) AS v(code)
FROM (VALUES ('LOCO-LED-1'), ('LOCO-LED-2'), ('LOCO-LED-3'), ('LOCO-LED-4'),
('LOCO-LED-5'), ('LOCO-LED-6')) AS v(code)
JOIN freight.yards y ON y.code = 'KALITY'
WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
@@ -269,7 +301,7 @@ INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id)
SELECT ct.id, wt.id
FROM freight.cargo_types ct
JOIN freight.wagon_types wt ON wt.code = 'CW4'
WHERE ct.code IN ('E2E_IMP_GRAINS', 'E2E_IMP_WHEAT')
WHERE ct.code = 'E2E_IMP_WHEAT'
AND NOT EXISTS (
SELECT 1 FROM freight.cargo_type_wagon_types x
WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id
@@ -289,7 +321,7 @@ WHERE wt.id = w.wagon_type_id AND wt.code = 'CW4'
-- Re-park the export CW4 pocket every seed (the mint above is insert-guarded).
UPDATE freight.wagons w
SET current_yard_id = (SELECT id FROM freight.yards
WHERE code = CASE WHEN substring(w.wagon_number FROM 4)::int <= 80
WHERE code = CASE WHEN substring(w.wagon_number FROM 4)::int <= 120
THEN 'KALITY' ELSE 'DIRE_DAWA' END)
FROM freight.wagon_types wt
WHERE wt.id = w.wagon_type_id AND wt.code = 'CW4'
@@ -357,3 +389,62 @@ WHERE NOT EXISTS (
AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
AND r.deleted_at IS NULL
);
-- 7b. Customs clearance service fees — billed on the booking invoice together
-- with the freight. Sold per direction + route + cargo kind: container fees
-- per container type (one row per active 20/40ft type so whichever type the
-- server resolves for a size always matches), bulk fees per commodity.
-- Without these, every customs booking hard-blocks at pricing.
INSERT INTO freight.rates
(id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status,
trade_direction, container_type_id, origin_yard_id, destination_yard_id,
proposed_by_staff_id)
SELECT gen_random_uuid(), 'CUSTOMS_CLEARANCE', 'OTHER', 'CUSTOMS_CLEARANCE',
'USD', 50, 'PER_CONTAINER', 'LIVE', v.direction, ct.id, a.id, b.id, u.id
FROM (VALUES
('IMPORT', 'DJIB_PORT', 'KALITY'),
('IMPORT', 'DJIB_PORT', 'MOJO'),
('IMPORT', 'NAGAD', 'KALITY'),
('IMPORT', 'NAGAD', 'MOJO'),
('EXPORT', 'KALITY', 'DJIB_PORT'),
('EXPORT', 'MOJO', 'DJIB_PORT'),
('EXPORT', 'DIRE_DAWA', 'DJIB_PORT')
) AS v(direction, from_code, to_code)
JOIN freight.yards a ON a.code = v.from_code
JOIN freight.yards b ON b.code = v.to_code
JOIN freight.container_types ct ON ct.size_ft IN (20, 40) AND ct.is_active
JOIN iam.users u ON u.email = 'operation@edr.local'
WHERE NOT EXISTS (
SELECT 1 FROM freight.rates r
WHERE r.rate_type = 'CUSTOMS_CLEARANCE'
AND r.container_type_id = ct.id
AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
AND r.deleted_at IS NULL
);
INSERT INTO freight.rates
(id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status,
trade_direction, cargo_type_id, origin_yard_id, destination_yard_id,
proposed_by_staff_id)
SELECT gen_random_uuid(), 'CUSTOMS_CLEARANCE', 'OTHER', 'CUSTOMS_CLEARANCE',
'USD', 2, 'PER_TON', 'LIVE', v.direction, cgt.id, a.id, b.id, u.id
FROM (VALUES
('IMPORT', 'DJIB_PORT', 'KALITY'),
('IMPORT', 'DJIB_PORT', 'MOJO'),
('IMPORT', 'NAGAD', 'KALITY'),
('IMPORT', 'NAGAD', 'MOJO'),
('EXPORT', 'KALITY', 'DJIB_PORT'),
('EXPORT', 'MOJO', 'DJIB_PORT'),
('EXPORT', 'DIRE_DAWA', 'DJIB_PORT')
) AS v(direction, from_code, to_code)
JOIN freight.yards a ON a.code = v.from_code
JOIN freight.yards b ON b.code = v.to_code
JOIN freight.cargo_types cgt ON cgt.code = 'E2E_IMP_WHEAT'
JOIN iam.users u ON u.email = 'operation@edr.local'
WHERE NOT EXISTS (
SELECT 1 FROM freight.rates r
WHERE r.rate_type = 'CUSTOMS_CLEARANCE'
AND r.cargo_type_id = cgt.id
AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
AND r.deleted_at IS NULL
);