mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 04:20:55 +00:00
174 lines
6.4 KiB
TypeScript
174 lines
6.4 KiB
TypeScript
/**
|
|
* BULK B3 — PER_ITEM giant split and line quantities.
|
|
*
|
|
* The scenario: 240 automobiles (600 T) on a 54-wagon CW4 train. The
|
|
* 4-per-wagon floor needs 60 wagons, so the batch should offer the whole
|
|
* consist (216 autos / 54 wagons), the settlement should apply the split, and
|
|
* the 24-auto remainder should have to be rebooked exactly. Plus two field
|
|
* checks: hazardousQuantity above the line count clamps, reeferQuantity is
|
|
* stored.
|
|
*
|
|
* WHAT ACTUALLY HAPPENS — two defects this file pins:
|
|
*
|
|
* 1. PER_ITEM bookings never get a partial offer. `sizeOffer`
|
|
* (booking-split.service.ts) sizes a bulk offer by WEIGHT off
|
|
* `cargoTotalWeightVgm` — but for PER_ITEM cargo that column holds the
|
|
* ITEM COUNT (240), not tonnage (600, kept in `bulk_total_weight_tons`).
|
|
* 240 "tons" fits 54 wagons, so no offer is made; the booking is reserved
|
|
* without a wagon count, allocates nothing, and silently expires with the
|
|
* day. The 24-auto remainder step therefore cannot happen at all.
|
|
*
|
|
* 2. The contract booking path DROPS per-line `hazardousQuantity` /
|
|
* `reeferQuantity` for bulk. Only the direct booking path
|
|
* (`POST /api/bookings`, bookings.service.ts) maps them onto
|
|
* `bulk_hazardous_quantity` / `bulk_reefer_quantity` — and only that path
|
|
* clamps them to the cargo amount.
|
|
*
|
|
* Both are asserted as they behave today, so a fix fails here loudly.
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { closeDb, db, gateway } from "./client";
|
|
import {
|
|
bookBulkItems,
|
|
bookBulkItemsReady,
|
|
bookingFor,
|
|
bookingRow,
|
|
closeBookingWindow,
|
|
completeDocReview,
|
|
createSchedule,
|
|
departureAt,
|
|
eatDayStr,
|
|
endPaymentPhase,
|
|
ensureCorridorRoute,
|
|
forceWindowOpen,
|
|
payViaGateway,
|
|
pollAllocations,
|
|
pollBookingStatus,
|
|
pollPartialOffer,
|
|
pollWindow,
|
|
releaseUnpaidHolds,
|
|
resetCorridorDay,
|
|
seedTenantContracts,
|
|
} from "./flows";
|
|
|
|
const GIANT_DEPARTURE = departureAt(43);
|
|
const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
|
|
const REMAINDER_DEPARTURE = departureAt(44);
|
|
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
|
|
const STAMP = String(Date.now());
|
|
|
|
describe("bulk b3: per-item giant offer and line quantities", () => {
|
|
let contracts: Map<string, string>;
|
|
let giantScheduleId: string;
|
|
let bg1: string;
|
|
|
|
beforeAll(async () => {
|
|
await gateway.reset();
|
|
await releaseUnpaidHolds();
|
|
await ensureCorridorRoute();
|
|
await resetCorridorDay(GIANT_DEPARTURE);
|
|
await resetCorridorDay(REMAINDER_DEPARTURE);
|
|
|
|
contracts = await seedTenantContracts(
|
|
STAMP,
|
|
["BG1", "BQ1", "BQ2"].map((suffix) => ({ suffix, freight: "BULK" as const })),
|
|
);
|
|
giantScheduleId = (
|
|
await createSchedule({
|
|
departure: GIANT_DEPARTURE,
|
|
kind: "bulk",
|
|
locoPair: ["LOCO-IMP-27", "LOCO-IMP-28"],
|
|
})
|
|
).id;
|
|
await forceWindowOpen(giantScheduleId, 45);
|
|
|
|
bg1 = await bookBulkItemsReady({
|
|
contractId: contracts.get("BG1")!,
|
|
cargoCode: "E2E_IMP_AUTO",
|
|
items: 240,
|
|
tons: 600,
|
|
scheduledDate: GIANT_DAY,
|
|
});
|
|
}, 1_800_000);
|
|
|
|
afterAll(closeDb);
|
|
|
|
it("a 240-auto booking (60 wagons' worth) gets NO partial offer — it is sized as 240 tons", async () => {
|
|
await closeBookingWindow(giantScheduleId);
|
|
await completeDocReview(giantScheduleId);
|
|
await pollBookingStatus(bg1, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
|
|
|
// DEFECT 1 (see header): the split sizer reads the item count as tonnage,
|
|
// so a booking needing 60 wagons looks like it needs 4 and no offer opens.
|
|
const offers = await db<{ n: string }>(
|
|
`SELECT count(*)::text AS n FROM freight.booking_batch_offers
|
|
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
|
[bg1],
|
|
);
|
|
expect(Number(offers[0].n), "no partial offer for the per-item giant").toBe(0);
|
|
|
|
const row = await bookingRow(bg1);
|
|
expect(Number(row.cargo_total_weight_vgm), "cargoTotalWeightVgm holds ITEMS").toBe(240);
|
|
expect(row.wagons_required, "reserved without a wagon count").toBeNull();
|
|
|
|
// Nothing is allocated: the reservation cannot be honoured on a 54-wagon
|
|
// train, and no offer exists to shrink it.
|
|
const [alloc] = await db<{ n: string }>(
|
|
`SELECT count(*)::text AS n FROM freight.wagon_booking_allocations
|
|
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
|
[bg1],
|
|
);
|
|
expect(Number(alloc.n), "no wagons allocated").toBe(0);
|
|
}, 900_000);
|
|
|
|
it.skip("the 24-auto outstanding must be rebooked EXACTLY on the later train", async () => {
|
|
// Unreachable while defect 1 stands: no split ever applies, so there is no
|
|
// outstanding remainder and the contract still carries a live booking
|
|
// (rebooking answers 409 "already has an active booking"). Un-skip with the
|
|
// fix to sizeOffer.
|
|
});
|
|
|
|
it("the contract path DROPS a per-line hazardousQuantity for bulk", async () => {
|
|
const res = await bookBulkItems({
|
|
contractId: contracts.get("BQ1")!,
|
|
cargoCode: "E2E_IMP_AUTO",
|
|
items: 10,
|
|
tons: 25,
|
|
scheduledDate: REMAINDER_DAY,
|
|
hazardousQuantity: 12,
|
|
});
|
|
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
|
|
|
|
const booking = await bookingFor(contracts.get("BQ1")!);
|
|
const [row] = await db<{ bulk_hazardous_quantity: string }>(
|
|
`SELECT bulk_hazardous_quantity FROM freight.bookings WHERE id = $1`,
|
|
[booking.id],
|
|
);
|
|
// DEFECT 2 (see header). The scenario expects the 12 to be CLAMPED to the
|
|
// 10-item line and stored; the contract path stores nothing at all, so the
|
|
// hazmat surcharge never fires for a contract booking. The direct booking
|
|
// path does clamp (clampToCargo, bookings.service.ts) — it is the mapping
|
|
// in contract-booking.service.ts that is missing.
|
|
expect(Number(row.bulk_hazardous_quantity), "hazmat dropped, not clamped").toBe(0);
|
|
});
|
|
|
|
it("the contract path DROPS a per-line reeferQuantity for bulk", async () => {
|
|
const res = await bookBulkItems({
|
|
contractId: contracts.get("BQ2")!,
|
|
cargoCode: "E2E_IMP_AUTO",
|
|
items: 8,
|
|
tons: 20,
|
|
scheduledDate: REMAINDER_DAY,
|
|
reeferQuantity: 3,
|
|
});
|
|
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
|
|
|
|
const booking = await bookingFor(contracts.get("BQ2")!);
|
|
const [row] = await db<{ bulk_reefer_quantity: string }>(
|
|
`SELECT bulk_reefer_quantity FROM freight.bookings WHERE id = $1`,
|
|
[booking.id],
|
|
);
|
|
expect(Number(row.bulk_reefer_quantity), "reefer dropped").toBe(0);
|
|
});
|
|
});
|