Enhance booking and signature functionalities

- Updated MySignaturePage title to Signature
This commit is contained in:
Marshal
2026-08-01 22:03:18 +00:00
parent ea9df9abbe
commit 53d8655dc3
26 changed files with 1050 additions and 19 deletions

View File

@@ -0,0 +1,101 @@
# Bulk scenario catalog — BS1BS40
Real-world bulk (PER_TON) and break-bulk (PER_ITEM) scenarios on the e2e
corridor `DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY` and its
export inverse. Companion to `SCENARIO_ENGINE_NOTES.md` (containers, S1S40).
**Fleet facts** (dev + e2e DB, verified 2026-08-01):
| Wagon | Capacity | Length | Tare |
| --- | --- | --- | --- |
| CW4 (wheat, autos, machinery) | 70 T | 13.976 m | 24.8 T |
| PW2 (grains) | 70 T | 17.066 m | 25.2 T |
Standard bulk train: **54 CW4 wagons / 3 780 T** cargo. Per-item math
(`train-capacity.util.ts`): items per wagon = min(floor(capacity ÷ per-item
tons), configured floor from `cargo_types.items_per_wagon_map`); items never
split across wagons; bulk partial offers are **whole wagons at full rated
payload only** (`sizePartialOfferWagons fullWagonsOnly`).
E2E cargo codes: `E2E_IMP_WHEAT` (PER_TON, CW4), `E2E_IMP_GRAINS` (PER_TON,
PW2), `E2E_IMP_AUTO` (PER_ITEM, CW4, floor 4/wagon — `seed-bulk-items.sql`),
`E2E_IMP_MACHINE` (PER_ITEM, CW4, no floor → tonnage-only).
Coverage column: spec that runs it, or **doc-only** (same engine path already
proven by the named spec — a bulk twin adds no new engine coverage), or
**gap** (engine contradicts the expectation — ticket, not a test).
`bulk_b1_*`, `bulk_b2_*`, `bulk_b3_*` are **authored but not yet run**
first execution may need assertion tuning.
---
## A. Fill · payment · waitlist (import wheat, PER_TON)
| # | Scenario | Expected | Coverage |
| --- | --- | --- | --- |
| BS1 | Six wheat bookings (560+420+420+420+1540+420 T = 54 wagons) fill the CW4 train in the first window; mixed USD/ETB, customs/self | All selected, all pay, 54/54 allocated, window FULL, schedule finalized | `bulk_import_full_train` |
| BS2 | Staff priority ordering: 1 960 T + 1 400 T + 700 T (58 w > 54); staff put the 700 T relief cargo first | Priority order wins: 700 T + 1 960 T reserved whole, 1 400 T gets a whole-wagon offer for the leftover 16 w | `bulk_b1_priority_expiry_refill` |
| BS3 | Reserved giant misses the 1 h pay window | EXPIRED; its 28 wagons return; refill round promotes the offered booking WHOLE (offer superseded) | `bulk_b1_priority_expiry_refill` |
| BS4 | Waitlisted wheat promoted on expiry | Waiting-list booking selected in the freed space, pays, rides | `bulk_import_waiting_expiry` |
| BS5 | Partial offer accepted → split | `is_split = true`, offered wagons allocated, remainder must rebook | `bulk_import_split_promote` |
| BS6 | Window reopens after under-fill | Second cycle opens; late bookings enter cycle 2 | `bulk_import_window_reopen` |
| BS7 | Booking on a day with no open window | Rejected at creation ("booking window") | `bulk_critical_matrix` |
| BS8 | Currency per booking: USD and ETB invoices on one train | Each invoice carries its booking's currency | `bulk_import_full_train` |
## B. Capacity axes · wagon types · giants (PER_TON)
| # | Scenario | Expected | Coverage |
| --- | --- | --- | --- |
| BS9 | 4 000 T giant alone on a 54-wagon train | Full-consist offer 3 780 T, gateway settle applies split, train FULL from one booking | `bulk_critical_matrix` |
| BS10 | Giant's 220 T remainder rebooks | 100 T attempt rejected ("must take the whole"); exactly 220 T accepted | `bulk_critical_matrix` |
| BS11 | Wheat rides CW4 only, grains ride PW2 only | `expectWagonType` CW4 for wheat / PW2 for grains on their trains | `export_ledger_day` + `bulk_b1` (CW4 assert) |
| BS12 | PW2 length tax: 17.066 m wagons on a 760 m board | 44 slots by length vs CW4's 54 — same tonnage needs a longer consist | doc-only (`export_ledger_day` runs the 37-wagon PW2 board) |
| BS13 | Gross weight = tare + cargo (PW2: 37 × 95.2 = 3 522 T ≈ the 3 500 T pull limit) | Weight axis binds before slots; overbooking by tare fraction impossible | unit-tested in `train-capacity.util` + `export_ledger_day` |
| BS14 | Tolerance spent only on a whole booking, never sizing a split | Split offers budget against base caps | doc-only (`booking-batch.service.ts:4109`; container twin g2) |
| BS15 | Two bulk bookings, one over-weight last wagon | Batch trims to whole wagons at full payload — no part-loaded squeeze into leftover pull weight | doc-only (`sizePartialOfferWagons fullWagonsOnly`) |
| BS16 | Sub-corridor 700 T (NAGAD→MOJO) shares the through-train with a 1 400 T DJIB_PORT→KALITY booking | Both allocated to the same schedule | `bulk_critical_matrix` |
## C. Break-bulk PER_ITEM (autos, machinery — NEW)
| # | Scenario | Expected | Coverage |
| --- | --- | --- | --- |
| BS17 | 16 automobiles @ 2.5 T (40 T). Tonnage alone says 1 wagon; floor says 4/wagon | **4 wagons** allocated — physical floor binds, rated capacity rides empty | `bulk_b2_per_item_floor` |
| BS18 | 12 machines @ 20 T (240 T), no configured floor | floor(70/20) = 3 per wagon → **4 wagons** — tonnage fallback binds | `bulk_b2_per_item_floor` |
| BS19 | 216 automobiles (540 T) — exactly 54 wagons | Whole booking fits, no split; train FULL from one break-bulk booking | `bulk_b2_per_item_floor` |
| BS20 | Giant: 240 automobiles (60 wagons) on a 54-wagon train | Whole-wagon offer of 216 autos; `is_split = true`; 54/54; FULL | `bulk_b3_per_item_giant_quantities` |
| BS21 | Giant's remainder: 24 autos outstanding | 10-auto attempt rejected ("must take the whole"); exactly 24 accepted | `bulk_b3_per_item_giant_quantities` |
| BS22 | hazardousQuantity 12 on a 10-item line | **Engine CLAMPS to 10, returns 201 — no rejection** (same gap as container S40). Spec asserts the clamp so the gap is visible | `bulk_b3_per_item_giant_quantities` |
| BS23 | reeferQuantity 3 on an 8-item line | Stored on the booking (`bulk_reefer_quantity = 3`), reefer surcharge applies | `bulk_b3_per_item_giant_quantities` |
| BS24 | One item heavier than a whole wagon (80 T machine on a 70 T CW4) | Engine still charges 1 wagon per item (`ponytail:` note in `bulkItemWagonsRequired`) — creation-time rejection does NOT exist | **gap** — ticket, not a test |
## D. Export bulk (KALITY → DJIB_PORT)
| # | Scenario | Expected | Coverage |
| --- | --- | --- | --- |
| BS25 | Sesame/grain export fills the PW2 board | Whole-booking placement, board FULL | `bulk_export_full_train` |
| BS26 | Export FCFS: space taken by earlier holds | Later booking sees reduced space | `bulk_export_fcfs_space` |
| BS27 | Export pay-or-lose: hold lapses at deadline | Space returns, next customer takes it | `bulk_export_pay_or_lose` |
| BS28 | Export whole-or-nothing (no split) | Oversized booking 409s with a sized message | `bulk_export_matrix` |
| BS29 | Export matrix: currencies × customs | Per-combination invoice + clearance behavior | `bulk_export_matrix` |
| BS30 | "Export never splits" is flag-dependent | Assert `FREIGHT_EXPORT_SPLIT !== "true"` or the suite is vacuous | noted in `SCENARIO_ENGINE_NOTES.md` |
| BS31 | Export day ledger: who booked/rode/expired/refused | Ledger report written per day | `export_ledger_day` |
## E. Corridor ops · intercity · disruptions
| # | Scenario | Expected | Coverage |
| --- | --- | --- | --- |
| BS32 | Bulk intercity ride-along (MOJO→KALITY, DOMESTIC, dateless) accepted onto the import train's free leg | Pay window opens on accept; paid + linked | `bulk_critical_matrix` |
| BS33 | Checkpoint-by-checkpoint corridor run; bookings ARRIVED at terminal | Statuses walk IN_TRANSIT → ARRIVED; ≥54 wagon-movement ledger rows | `bulk_import_full_train` |
| BS34 | Mid-corridor auto-unload: booking destined MOJO on a KALITY train | Checkpoint at MOJO auto-unloads it (`booking-journey.service.ts:261`) | container twin `g6_corridor` — same engine path |
| BS35 | Schedule cancelled after bulk allocation | Frozen `wagon_allocation_snapshot`, bookings re-pool still PAID | container twin `g7_disruptions` — same engine path |
| BS36 | Paid bulk booking, yard short of CW4 → transfer request | WAITING_FOR_WAGON → PARTIALLY_FULFILLED → FULFILLED → placed | `fleet_wagon_transfer` |
## F. Customs tail · validation
| # | Scenario | Expected | Coverage |
| --- | --- | --- | --- |
| BS37 | Customs bookings: gatepass → T1 → dispatch → T1 close → risk → second duty → release → final invoice | Full milestone tail COMPLETED in order | `bulk_import_full_train` |
| BS38 | Self-clearance bookings arrive with NO customs tail | Zero T1_CLOSED milestones | `bulk_import_full_train` |
| BS39 | `importReleaseGranted` not gated on second duty | Release grantable with SECOND_DUTY_PAID pending — **known gap** | `SCENARIO_ENGINE_NOTES.md` §S36 |
| BS40 | Out-of-order checkpoint relocates rolling stock silently | No sequence guard in `recordCheckpoint`**known bug** | `SCENARIO_ENGINE_NOTES.md` §S29 |

View File

@@ -0,0 +1,61 @@
/**
* Shared helper for the PER_ITEM break-bulk specs (bulk_b2 / bulk_b3).
* Cargo types come from fixtures/seed-bulk-items.sql.
*/
import { apiPost, customer, db } from "./import-utils";
/** Book a PER_ITEM break-bulk line under the suffix's seeded contract. */
export function bookBulkItems(opts: {
suffix: string;
cargoCode: "E2E_IMP_AUTO" | "E2E_IMP_MACHINE";
items: number;
tons: number;
scheduledDate?: string;
hazardousQuantity?: number;
reeferQuantity?: number;
expectFailure?: string | RegExp;
}) {
db<{ id: string; cargo_type_id: string }>(
`SELECT ct.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.cargoCode],
).then(({ rows }) => {
expect(rows, `seeded contract *-${opts.suffix}`).to.have.length(1);
expect(rows[0].cargo_type_id, `${opts.cargoCode} seeded`).to.be.a("string");
apiPost(
customer,
`/api/contracts/${rows[0].id}/bookings`,
{
...(opts.scheduledDate ? { scheduledDate: opts.scheduledDate } : {}),
bulkLines: [
{
cargoTypeId: rows[0].cargo_type_id,
itemCount: opts.items,
cargoWeightTons: opts.tons,
...(opts.hazardousQuantity != null
? { hazardousQuantity: opts.hazardousQuantity }
: {}),
...(opts.reeferQuantity != null ? { reeferQuantity: opts.reeferQuantity } : {}),
},
],
cargoFreeText: `E2E break-bulk ${opts.cargoCode}`,
},
!opts.expectFailure,
).then((res) => {
if (opts.expectFailure) {
expect(res.status, `${opts.suffix} booking rejected`).to.be.within(400, 422);
if (opts.expectFailure instanceof RegExp) {
expect(JSON.stringify(res.body)).to.match(opts.expectFailure);
} else {
expect(JSON.stringify(res.body)).to.include(opts.expectFailure);
}
} else {
expect(res.status, `${opts.suffix} booking created`).to.be.oneOf([200, 201]);
}
});
});
}

View File

@@ -0,0 +1,122 @@
/**
* BULK B1 — priority ordering + expiry refill (BS2/BS3 in BULK_SCENARIOS.md).
*
* Day D+28, 54-wagon CW4 wheat train, three bookings that cannot all fit:
*
* BP1 1 960 T = 28 wagons (commercial giant)
* BP2 1 400 T = 20 wagons (commercial)
* BP3 700 T = 10 wagons (relief cargo — staff put it FIRST)
*
* 58 wagons chase 54. With BP3 forced to the top of the order the batch
* reserves BP3 + BP1 whole (38 w) and leaves BP2 a whole-wagon offer for the
* remaining 16. Then BP1 misses its pay window: its 28 wagons return and the
* refill round must promote BP2 WHOLE — the 16-wagon offer is superseded.
*
* ⚠ Authored, not yet run — see BULK_SCENARIOS.md.
*/
import {
acceptOperation,
bookBulk,
clearToOperationRequestPending,
closeBookingWindow,
completeDocReview,
createImportSchedule,
departureAt,
eatDayStr,
ensureCorridorRoute,
expectWagonType,
forceReservationExpiry,
forceWindowOpen,
markPaid,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
setPriority,
withBooking,
withSchedule,
} from "./import-utils";
const DEPARTURE = departureAt(28);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
describe("bulk b1: staff priority decides who rides; expiry refill promotes the offered booking whole", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
(["BP1", "BP2", "BP3"] as const).forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix), freight: "BULK" }),
);
});
it("operations prepares the D+28 wheat train with an open window", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"],
kind: "bulk",
});
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
});
it("three wheat bookings (28+20+10 wagons) enter the window; staff rank the relief cargo first", () => {
bookBulk({ suffix: "BP1", tons: 1960, scheduledDate: BOOKING_DAY });
clearToOperationRequestPending("BP1", BOOKING_DAY);
acceptOperation("BP1");
bookBulk({ suffix: "BP2", tons: 1400, scheduledDate: BOOKING_DAY });
clearToOperationRequestPending("BP2", BOOKING_DAY);
acceptOperation("BP2");
bookBulk({ suffix: "BP3", tons: 700, scheduledDate: BOOKING_DAY });
clearToOperationRequestPending("BP3", BOOKING_DAY);
acceptOperation("BP3");
setPriority("BP3", 1);
setPriority("BP1", 2);
setPriority("BP2", 3);
});
it("batch reserves BP3 + BP1 whole; BP2 gets a whole-wagon offer for the 16-wagon leftover", () => {
withSchedule(DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
pollBookingStatus("BP3", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
pollBookingStatus("BP1", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
withBooking("BP2", (b) => {
pollDb<{ status: string; offered_wagons: string }>(
"BP2 open partial offer",
`SELECT status, offered_wagons 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" && Number(row?.offered_wagons) === 16,
15,
);
});
});
it("BP3 pays and rides CW4; BP1 misses the pay window and EXPIRES", () => {
markPaid("BP3");
pollAllocations("BP3", 10);
expectWagonType("BP3", "CW4", 10);
forceReservationExpiry("BP1");
pollBookingStatus("BP1", "EXPIRED", 20);
});
it("refill round promotes BP2 WHOLE into the freed 28 wagons — the 16-wagon offer is superseded", () => {
pollBookingStatus("BP2", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 30);
markPaid("BP2");
pollAllocations("BP2", 20);
withBooking("BP2", (b) => {
expect(b.is_split, "BP2 rides whole, not split").to.not.eq(true);
});
});
});
export {};

View File

@@ -0,0 +1,147 @@
/**
* BULK B2 — break-bulk PER_ITEM wagon math (BS17BS19 in BULK_SCENARIOS.md).
*
* Cargo from seed-bulk-items.sql, riding the CW4 fleet (70 T / 24.8 T tare):
*
* E2E_IMP_AUTO automobiles, items_per_wagon_map floor = 4 per CW4
* E2E_IMP_MACHINE machinery, NO floor → tonnage-only fallback
*
* Three verdicts of bulkItemWagonsRequired, end to end:
* BA1 16 autos @2.5 T (40 T) → floor binds: 4 wagons (tonnage said 1)
* BA2 12 machines @20 T (240 T) → tonnage binds: floor(70/20)=3/wagon → 4 wagons
* BA3 216 autos (540 T) → exactly 54 wagons — FULL from one booking
*
* ⚠ Authored, not yet run — see BULK_SCENARIOS.md.
*/
import { bookBulkItems } from "./bulk-items-utils";
import {
acceptOperation,
clearToOperationRequestPending,
closeBookingWindow,
completeDocReview,
createImportSchedule,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
expectWagonType,
forceWindowOpen,
markPaid,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
withBooking,
withSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(30);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const FULL_DEPARTURE = departureAt(31);
const FULL_DAY = eatDayStr(FULL_DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
describe("bulk b2: PER_ITEM floor vs tonnage wagon math on the CW4 fleet", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
cy.task("db:seedFile", "seed-bulk-items.sql");
(["BA1", "BA2", "BA3"] as const).forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix), freight: "BULK" }),
);
});
it("operations prepares the D+30 break-bulk train with an open window", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
resetCorridorDay(FULL_DEPARTURE);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"],
kind: "bulk",
});
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
});
it("BS17 — 16 autos (40 T): the 4-per-wagon floor binds → 4 wagons, not 1", () => {
bookBulkItems({
suffix: "BA1",
cargoCode: "E2E_IMP_AUTO",
items: 16,
tons: 40,
scheduledDate: BOOKING_DAY,
});
clearToOperationRequestPending("BA1", BOOKING_DAY);
acceptOperation("BA1");
});
it("BS18 — 12 machines @20 T: no floor, tonnage fallback → 3 per wagon → 4 wagons", () => {
bookBulkItems({
suffix: "BA2",
cargoCode: "E2E_IMP_MACHINE",
items: 12,
tons: 240,
scheduledDate: BOOKING_DAY,
});
clearToOperationRequestPending("BA2", BOOKING_DAY);
acceptOperation("BA2");
});
it("batch reserves both; payment allocates exactly 4 + 4 CW4 wagons", () => {
withSchedule(DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
(["BA1", "BA2"] as const).forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
markPaid("BA1");
pollAllocations("BA1", 4);
expectWagonType("BA1", "CW4", 4);
markPaid("BA2");
pollAllocations("BA2", 4);
expectWagonType("BA2", "CW4", 4);
});
it("BS19 — 216 autos (540 T) = exactly 54 wagons: FULL from one break-bulk booking, no split", () => {
createImportSchedule({
departure: FULL_DEPARTURE,
locoPair: ["LOCO-IMP-25", "LOCO-IMP-26"],
kind: "bulk",
});
withSchedule(FULL_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
bookBulkItems({
suffix: "BA3",
cargoCode: "E2E_IMP_AUTO",
items: 216,
tons: 540,
scheduledDate: FULL_DAY,
});
clearToOperationRequestPending("BA3", FULL_DAY);
acceptOperation("BA3");
withSchedule(FULL_DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
pollBookingStatus("BA3", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
markPaid("BA3");
pollAllocations("BA3", 54);
withBooking("BA3", (b) => {
expect(b.is_split, "BA3 whole, not split").to.not.eq(true);
});
withSchedule(FULL_DEPARTURE, (s) => {
endPaymentPhase(s.id);
pollDb<ScheduleRow>(
"full-day schedule FULL + DONE",
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE",
);
});
});
});

View File

@@ -0,0 +1,181 @@
/**
* BULK B3 — PER_ITEM giant split + line quantities (BS20BS23 in
* BULK_SCENARIOS.md).
*
* BG1 240 automobiles (600 T) on a 54-wagon CW4 train (floor 4/wagon
* → needs 60 wagons). Bulk partial offers are WHOLE wagons only →
* offer = 54 wagons / 216 autos. Gateway settle applies the split,
* the train is FULL from one break-bulk booking, and the 24-auto
* outstanding must be rebooked EXACTLY on a later train.
* BQ1 hazardousQuantity 12 on a 10-item line: the engine CLAMPS to 10
* and returns 201 (documented gap — same as container S40; this
* spec pins the CURRENT behaviour so a future fix flips it loudly).
* BQ2 reeferQuantity 3 on an 8-item line is stored on the booking.
*
* ⚠ Authored, not yet run — see BULK_SCENARIOS.md.
*/
import { bookBulkItems } from "./bulk-items-utils";
import {
acceptOperation,
clearToOperationRequestPending,
closeBookingWindow,
completeDocReview,
createImportSchedule,
db,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
forceWindowOpen,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
settleViaGateway,
withBooking,
withSchedule,
type ScheduleRow,
} from "./import-utils";
const GIANT_DEPARTURE = departureAt(32);
const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
const REMAINDER_DEPARTURE = departureAt(33);
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
describe("bulk b3: per-item giant gets a whole-wagon offer; line quantities clamp/store", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
cy.task("db:seedFile", "seed-bulk-items.sql");
(["BG1", "BQ1", "BQ2"] as const).forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix), freight: "BULK" }),
);
});
it("operations prepares the D+32 giant train and the D+33 remainder train", () => {
ensureCorridorRoute();
resetCorridorDay(GIANT_DEPARTURE);
resetCorridorDay(REMAINDER_DEPARTURE);
createImportSchedule({
departure: GIANT_DEPARTURE,
locoPair: ["LOCO-IMP-27", "LOCO-IMP-28"],
kind: "bulk",
});
withSchedule(GIANT_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
});
it("BS20 — 240 autos need 60 wagons: whole-consist offer of 216 autos / 54 wagons, split applied", () => {
bookBulkItems({
suffix: "BG1",
cargoCode: "E2E_IMP_AUTO",
items: 240,
tons: 600,
scheduledDate: GIANT_DAY,
});
clearToOperationRequestPending("BG1", GIANT_DAY);
acceptOperation("BG1");
withSchedule(GIANT_DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
pollBookingStatus("BG1", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
withBooking("BG1", (b) => {
pollDb<{ status: string; offered_wagons: string }>(
"BG1 whole-wagon partial offer",
`SELECT status, offered_wagons 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" && Number(row?.offered_wagons) === 54,
15,
);
});
settleViaGateway("BG1");
pollAllocations("BG1", 54);
withBooking("BG1", (b) => {
expect(b.is_split, "BG1 is split").to.eq(true);
});
withSchedule(GIANT_DEPARTURE, (s) => {
endPaymentPhase(s.id);
pollDb<ScheduleRow>(
"giant train FULL from one break-bulk booking",
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE",
);
});
});
it("BS21 — the 24-auto outstanding must be rebooked EXACTLY on the later train", () => {
createImportSchedule({
departure: REMAINDER_DEPARTURE,
locoPair: ["LOCO-IMP-13", "LOCO-IMP-14"],
kind: "bulk",
});
withSchedule(REMAINDER_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
bookBulkItems({
suffix: "BG1",
cargoCode: "E2E_IMP_AUTO",
items: 10,
tons: 25,
scheduledDate: REMAINDER_DAY,
expectFailure: "must take the whole",
});
bookBulkItems({
suffix: "BG1",
cargoCode: "E2E_IMP_AUTO",
items: 24,
tons: 60,
scheduledDate: REMAINDER_DAY,
});
clearToOperationRequestPending("BG1", REMAINDER_DAY);
});
it("BS22 — hazardousQuantity 12 on a 10-item line is CLAMPED to 10, not rejected (pins the gap)", () => {
bookBulkItems({
suffix: "BQ1",
cargoCode: "E2E_IMP_AUTO",
items: 10,
tons: 25,
scheduledDate: REMAINDER_DAY,
hazardousQuantity: 12,
});
withBooking("BQ1", (b) => {
db<{ bulk_hazardous_quantity: string }>(
`SELECT bulk_hazardous_quantity FROM freight.bookings WHERE id = $1`,
[b.id],
).then(({ rows }) => {
// Engine clamps to 0..quantity (bookings.repository.ts) — a future
// fix that rejects instead will fail HERE first. See BS22.
expect(Number(rows[0].bulk_hazardous_quantity), "clamped hazmat").to.eq(10);
});
});
});
it("BS23 — reeferQuantity 3 on an 8-item line is stored on the booking", () => {
bookBulkItems({
suffix: "BQ2",
cargoCode: "E2E_IMP_AUTO",
items: 8,
tons: 20,
scheduledDate: REMAINDER_DAY,
reeferQuantity: 3,
});
withBooking("BQ2", (b) => {
db<{ bulk_reefer_quantity: string }>(
`SELECT bulk_reefer_quantity FROM freight.bookings WHERE id = $1`,
[b.id],
).then(({ rows }) => {
expect(Number(rows[0].bulk_reefer_quantity), "reefer stored").to.eq(3);
});
});
});
});
export {};

View File

@@ -0,0 +1,37 @@
-- PER_ITEM break-bulk cargo types for the bulk_b2/bulk_b3 specs.
-- Run AFTER seed-import-corridor.sql (needs E2E_IMP_GRAINS + the CW4 fleet).
-- Idempotent — safe on re-runs and cross-origin before() replays.
-- Automobiles: PER_ITEM with a configured physical floor (4 cars per CW4).
INSERT INTO freight.cargo_types (id, code, cargo_type_name, parent_group_id, unit_of_measure, is_active)
SELECT gen_random_uuid(), 'E2E_IMP_AUTO', 'E2E Import Automobiles', g.id, 'PER_ITEM', true
FROM freight.cargo_types g
WHERE g.code = 'E2E_IMP_GRAINS'
AND NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_IMP_AUTO');
-- Machinery: PER_ITEM with NO items_per_wagon_map — exercises the
-- tonnage-only fallback in bulkItemWagonsRequired.
INSERT INTO freight.cargo_types (id, code, cargo_type_name, parent_group_id, unit_of_measure, is_active)
SELECT gen_random_uuid(), 'E2E_IMP_MACHINE', 'E2E Import Machinery', g.id, 'PER_ITEM', true
FROM freight.cargo_types g
WHERE g.code = 'E2E_IMP_GRAINS'
AND NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_IMP_MACHINE');
-- Both ride the corridor's CW4 bulk fleet.
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_AUTO', 'E2E_IMP_MACHINE')
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
);
-- Physical floor: 4 automobiles fit one CW4 regardless of tonnage headroom.
UPDATE freight.cargo_types ct
SET items_per_wagon_map = jsonb_build_object(
(SELECT wt.id::text FROM freight.wagon_types wt WHERE wt.code = 'CW4'), 4)
WHERE ct.code = 'E2E_IMP_AUTO'
AND (ct.items_per_wagon_map IS NULL
OR NOT ct.items_per_wagon_map ? (SELECT wt.id::text FROM freight.wagon_types wt WHERE wt.code = 'CW4'));