mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
Merge pull request #1069 from Tria-plc/freight_feature/usermanagement
Enhance booking and signature functionalities
This commit is contained in:
@@ -3131,6 +3131,14 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
async intercityCapacity(scheduleId: string): Promise<{
|
async intercityCapacity(scheduleId: string): Promise<{
|
||||||
budget: CorridorBudget;
|
budget: CorridorBudget;
|
||||||
needFor: (booking: Booking) => Capacity;
|
needFor: (booking: Booking) => Capacity;
|
||||||
|
/**
|
||||||
|
* Per-wagon-type split of `needFor(booking).wagons`, against THIS
|
||||||
|
* schedule's own wagon stock — so the same booking reads differently on a
|
||||||
|
* different train. Empty when the stock can't be resolved.
|
||||||
|
*/
|
||||||
|
breakdownFor: (
|
||||||
|
booking: Booking,
|
||||||
|
) => Array<{ wagonTypeId: string; code: string; wagons: number }>;
|
||||||
} | null> {
|
} | null> {
|
||||||
const schedule =
|
const schedule =
|
||||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
@@ -3145,7 +3153,24 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
// still accepts ride-alongs on its empty legs — that is the whole point
|
// still accepts ride-alongs on its empty legs — that is the whole point
|
||||||
// of the ride-along flow.
|
// of the ride-along flow.
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
|
// Physical stock of THIS schedule's train (built consist, or the yard fleet
|
||||||
|
// it will draw from) — what makes the breakdown train-specific.
|
||||||
|
const stock = await this.trainSchedulingService.wagonStockForSchedule(
|
||||||
|
schedule.id,
|
||||||
|
schedule.originStationId,
|
||||||
|
budget.stops,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
budget,
|
||||||
|
needFor: (booking) => this.needFor(booking, wagonDims),
|
||||||
|
breakdownFor: (booking) =>
|
||||||
|
this.wagonBreakdownFor(
|
||||||
|
booking,
|
||||||
|
wagonDims,
|
||||||
|
stock.remainingByTypeId,
|
||||||
|
stock.codesByTypeId,
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4094,6 +4119,78 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wagon count of {@link wagonsFor}, split across the wagon TYPES this
|
||||||
|
* particular train stocks — "3 × N35 + 1 × PW2" rather than a bare 4.
|
||||||
|
*
|
||||||
|
* `wagonsFor` sizes the booking on ONE representative type (the first the
|
||||||
|
* cargo type allows), which is all the abstract budget needs. Staff placing a
|
||||||
|
* ride-along need the physical picture: how many of each type this schedule
|
||||||
|
* must actually give up. So each allowed type is sized on its OWN capacity and
|
||||||
|
* items-fit, then filled greedily from the type with the largest per-wagon
|
||||||
|
* take, bounded by what the schedule has left of it.
|
||||||
|
*
|
||||||
|
* Because the stock is per-schedule, the same booking breaks down differently
|
||||||
|
* on a train stocking 60T N35s than on one stocking 40T PW2s. Returns [] when
|
||||||
|
* the booking's types are unconfigured or the train stocks none of them — the
|
||||||
|
* caller then shows the plain total.
|
||||||
|
*/
|
||||||
|
private wagonBreakdownFor(
|
||||||
|
booking: Booking,
|
||||||
|
wagonDims: WagonDims,
|
||||||
|
stockByTypeId: Map<string, number>,
|
||||||
|
codesByTypeId: Map<string, string>,
|
||||||
|
): Array<{ wagonTypeId: string; code: string; wagons: number }> {
|
||||||
|
const total = this.wagonsFor(booking, wagonDims);
|
||||||
|
if (total <= 0) return [];
|
||||||
|
|
||||||
|
// Per-wagon take of each allowed type ON THIS TRAIN, largest first: a type
|
||||||
|
// that swallows more of the booking per wagon needs fewer wagons.
|
||||||
|
const options = this.allowedDimsWithTypes(booking, wagonDims)
|
||||||
|
.filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0)
|
||||||
|
.map((o) => {
|
||||||
|
const wagonTypeId = o.wagonTypeId as string;
|
||||||
|
const wagonsIfAlone = Math.max(
|
||||||
|
1,
|
||||||
|
bulkItemWagonsRequired(
|
||||||
|
booking,
|
||||||
|
o.dims.capacityTons,
|
||||||
|
bulkItemsFitFor(booking.cargoType, wagonTypeId),
|
||||||
|
) ||
|
||||||
|
(o.dims.capacityTons > 0
|
||||||
|
? Math.ceil(bookingCargoTons(booking) / o.dims.capacityTons)
|
||||||
|
: total),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
wagonTypeId,
|
||||||
|
code: codesByTypeId.get(wagonTypeId) ?? '—',
|
||||||
|
available: stockByTypeId.get(wagonTypeId) ?? 0,
|
||||||
|
// Share of the whole booking one wagon of this type carries.
|
||||||
|
takePerWagon: 1 / wagonsIfAlone,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.takePerWagon - a.takePerWagon);
|
||||||
|
if (!options.length) return [];
|
||||||
|
|
||||||
|
// Fill greedily by take, capped by stock; `remaining` is the fraction of the
|
||||||
|
// booking still unplaced, so a wagon of any type covers `takePerWagon` of it.
|
||||||
|
const out: Array<{ wagonTypeId: string; code: string; wagons: number }> = [];
|
||||||
|
let remaining = 1;
|
||||||
|
for (const option of options) {
|
||||||
|
if (remaining <= 1e-9) break;
|
||||||
|
const wagons = Math.min(
|
||||||
|
option.available,
|
||||||
|
Math.ceil(remaining / option.takePerWagon),
|
||||||
|
);
|
||||||
|
if (wagons <= 0) continue;
|
||||||
|
out.push({ wagonTypeId: option.wagonTypeId, code: option.code, wagons });
|
||||||
|
remaining -= wagons * option.takePerWagon;
|
||||||
|
}
|
||||||
|
// The train cannot hold the whole booking in the types it stocks — the
|
||||||
|
// `fits` check already fails it; report only what it CAN take.
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
private fits(need: Capacity, budget: Capacity): boolean {
|
private fits(need: Capacity, budget: Capacity): boolean {
|
||||||
return (
|
return (
|
||||||
need.wagons <= budget.wagons &&
|
need.wagons <= budget.wagons &&
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The intercity ride-along board shows WHICH wagon types a booking takes from
|
||||||
|
* the train it is being placed on ("3 × N35 + 1 × PW2"), not just how many
|
||||||
|
* wagons. Because the split is drawn against that schedule's own stock, the
|
||||||
|
* same booking must read differently on a different train.
|
||||||
|
*/
|
||||||
|
describe('BookingBatchService — intercity wagon breakdown', () => {
|
||||||
|
const N35 = 'wagon-type-n35';
|
||||||
|
const PW2 = 'wagon-type-pw2';
|
||||||
|
|
||||||
|
const dims = (capacityTons: number) => ({
|
||||||
|
capacityTons,
|
||||||
|
lengthMeters: 14,
|
||||||
|
tareWeightTons: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wagonDims = {
|
||||||
|
bulk: dims(60),
|
||||||
|
container: dims(60),
|
||||||
|
byWagonTypeId: new Map([
|
||||||
|
[N35, dims(60)],
|
||||||
|
[PW2, dims(20)],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const codes = new Map([
|
||||||
|
[N35, 'N35'],
|
||||||
|
[PW2, 'PW2'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 400 break-bulk items weighing 800t — 2t per item. On a 60t N35 that is 30
|
||||||
|
* items per wagon (14 wagons); on a 20t PW2, 10 items (40 wagons).
|
||||||
|
*/
|
||||||
|
const perItemBooking = {
|
||||||
|
id: 'booking-1',
|
||||||
|
freightType: 'BULK',
|
||||||
|
cargoTotalWeightVgm: 400,
|
||||||
|
bulkTotalWeightTons: 800,
|
||||||
|
bookingContainers: [],
|
||||||
|
cargoType: {
|
||||||
|
wagonTypes: [{ id: N35 }, { id: PW2 }],
|
||||||
|
itemsPerWagonMap: {},
|
||||||
|
},
|
||||||
|
} as unknown as Booking;
|
||||||
|
|
||||||
|
const service = Object.create(
|
||||||
|
BookingBatchService.prototype,
|
||||||
|
) as BookingBatchService;
|
||||||
|
|
||||||
|
const breakdown = (
|
||||||
|
booking: Booking,
|
||||||
|
stock: Map<string, number>,
|
||||||
|
): Array<{ code: string; wagons: number }> =>
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
wagonBreakdownFor: (
|
||||||
|
b: Booking,
|
||||||
|
d: typeof wagonDims,
|
||||||
|
s: Map<string, number>,
|
||||||
|
c: Map<string, string>,
|
||||||
|
) => Array<{ code: string; wagons: number }>;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.wagonBreakdownFor(booking, wagonDims, stock, codes)
|
||||||
|
.map(({ code, wagons }) => ({ code, wagons }));
|
||||||
|
|
||||||
|
it('takes the highest-capacity type first when the train stocks plenty', () => {
|
||||||
|
const rows = breakdown(perItemBooking, new Map([[N35, 50], [PW2, 50]]));
|
||||||
|
expect(rows).toEqual([{ code: 'N35', wagons: 14 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the smaller type for the remainder when the big one runs short', () => {
|
||||||
|
// Only 10 of the 14 N35s the booking wants — the rest rides PW2s. Ten N35s
|
||||||
|
// carry 10/14 of the booking, leaving 4/14, which needs ceil(40 × 4/14) PW2s.
|
||||||
|
const rows = breakdown(perItemBooking, new Map([[N35, 10], [PW2, 50]]));
|
||||||
|
expect(rows[0]).toEqual({ code: 'N35', wagons: 10 });
|
||||||
|
expect(rows[1].code).toBe('PW2');
|
||||||
|
expect(rows[1].wagons).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads differently on a train that stocks only the small type', () => {
|
||||||
|
const rows = breakdown(perItemBooking, new Map([[PW2, 60]]));
|
||||||
|
expect(rows).toEqual([{ code: 'PW2', wagons: 40 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours the configured items-per-wagon fit over raw tonnage', () => {
|
||||||
|
// Floor space binds before weight: an N35 physically holds 20 of these
|
||||||
|
// items even though 30 would fit by weight → 20 wagons, not 14.
|
||||||
|
const floorBound = {
|
||||||
|
...perItemBooking,
|
||||||
|
cargoType: {
|
||||||
|
wagonTypes: [{ id: N35 }],
|
||||||
|
itemsPerWagonMap: { [N35]: 20 },
|
||||||
|
},
|
||||||
|
} as unknown as Booking;
|
||||||
|
expect(breakdown(floorBound, new Map([[N35, 50]]))).toEqual([
|
||||||
|
{ code: 'N35', wagons: 20 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing when the train stocks none of the allowed types', () => {
|
||||||
|
expect(breakdown(perItemBooking, new Map([['other-type', 30]]))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -155,12 +155,17 @@ export class IntercityService {
|
|||||||
return {
|
return {
|
||||||
...this.mapBooking(booking, need),
|
...this.mapBooking(booking, need),
|
||||||
need,
|
need,
|
||||||
|
wagonBreakdown: capacity?.breakdownFor(booking) ?? [],
|
||||||
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
|
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
accepted: accepted.map((booking) => {
|
accepted: accepted.map((booking) => {
|
||||||
const need = capacity?.needFor(booking) ?? null;
|
const need = capacity?.needFor(booking) ?? null;
|
||||||
return { ...this.mapBooking(booking, need), need };
|
return {
|
||||||
|
...this.mapBooking(booking, need),
|
||||||
|
need,
|
||||||
|
wagonBreakdown: capacity?.breakdownFor(booking) ?? [],
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -200,7 +205,9 @@ export class IntercityService {
|
|||||||
where: { id: bookingId },
|
where: { id: bookingId },
|
||||||
relations: {
|
relations: {
|
||||||
bookingContainers: { containerType: true },
|
bookingContainers: { containerType: true },
|
||||||
cargoType: true,
|
// wagonTypes drives the break-bulk items-per-wagon fit — the accept
|
||||||
|
// check must size the booking exactly as the candidate list did.
|
||||||
|
cargoType: { wagonTypes: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
@@ -326,6 +333,10 @@ export class IntercityService {
|
|||||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||||
|
// The allowed wagon-type list is what sizes a break-bulk (PER_ITEM)
|
||||||
|
// booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and
|
||||||
|
// the wagon count silently degrades to tonnage-only.
|
||||||
|
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType')
|
||||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||||
.where(`booking.trade_direction = 'DOMESTIC'`)
|
.where(`booking.trade_direction = 'DOMESTIC'`)
|
||||||
@@ -355,6 +366,10 @@ export class IntercityService {
|
|||||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||||
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||||
|
// The allowed wagon-type list is what sizes a break-bulk (PER_ITEM)
|
||||||
|
// booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and
|
||||||
|
// the wagon count silently degrades to tonnage-only.
|
||||||
|
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType')
|
||||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||||
.where(`booking.trade_direction = 'DOMESTIC'`)
|
.where(`booking.trade_direction = 'DOMESTIC'`)
|
||||||
|
|||||||
@@ -1435,4 +1435,44 @@ describe('TrainSchedulingService', () => {
|
|||||||
).rejects.toThrow(/free only 5/);
|
).rejects.toThrow(/free only 5/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('effectiveWagonsRequired', () => {
|
||||||
|
const effective = (booking: unknown): number =>
|
||||||
|
(service as never as { effectiveWagonsRequired(b: unknown): number })
|
||||||
|
.effectiveWagonsRequired(booking);
|
||||||
|
|
||||||
|
// 20-item / 100T break-bulk on 70T wagons with a 4-items-per-wagon fit:
|
||||||
|
// ceil(20/4) = 5 wagons.
|
||||||
|
const perItemBooking = (wagonsRequired: number | null) => ({
|
||||||
|
freightType: 'BULK',
|
||||||
|
cargoTotalWeightVgm: 20,
|
||||||
|
bulkTotalWeightTons: 100,
|
||||||
|
wagonsRequired,
|
||||||
|
cargoType: {
|
||||||
|
wagonTypes: [{ id: 'wt-nw5', capacityTons: 70 }],
|
||||||
|
itemsPerWagonMap: { 'wt-nw5': 4 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overrides a stale too-small stamp with the item-aware recompute', () => {
|
||||||
|
// Stamped 1 by old code that read the PER_ITEM count (20) as tons.
|
||||||
|
expect(effective(perItemBooking(1))).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a stored stamp that is at least the recompute', () => {
|
||||||
|
expect(effective(perItemBooking(7))).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trusts the stamp when BULK cargo relations are not loaded', () => {
|
||||||
|
expect(
|
||||||
|
effective({
|
||||||
|
freightType: 'BULK',
|
||||||
|
cargoTotalWeightVgm: 20,
|
||||||
|
bulkTotalWeightTons: 100,
|
||||||
|
wagonsRequired: 5,
|
||||||
|
cargoType: null,
|
||||||
|
}),
|
||||||
|
).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7819,7 +7819,7 @@ export class TrainSchedulingService {
|
|||||||
*/
|
*/
|
||||||
private effectiveWagonsRequired(booking: Booking): number {
|
private effectiveWagonsRequired(booking: Booking): number {
|
||||||
const stored = Number(booking.wagonsRequired);
|
const stored = Number(booking.wagonsRequired);
|
||||||
if (stored > 0) return Math.ceil(stored);
|
const storedCeil = stored > 0 ? Math.ceil(stored) : 0;
|
||||||
const bulkCapacities = (booking.cargoType?.wagonTypes ?? [])
|
const bulkCapacities = (booking.cargoType?.wagonTypes ?? [])
|
||||||
.map((wt) => Number(wt.capacityTons))
|
.map((wt) => Number(wt.capacityTons))
|
||||||
.filter((c) => c > 0);
|
.filter((c) => c > 0);
|
||||||
@@ -7827,7 +7827,15 @@ export class TrainSchedulingService {
|
|||||||
booking.freightType === 'BULK' && bulkCapacities.length
|
booking.freightType === 'BULK' && bulkCapacities.length
|
||||||
? Math.max(...bulkCapacities)
|
? Math.max(...bulkCapacities)
|
||||||
: undefined;
|
: undefined;
|
||||||
return wagonsRequiredForBooking(booking, bulkCapacity);
|
// BULK with no cargo relations loaded: recomputing would size against a
|
||||||
|
// 1T capacity and read a PER_ITEM item count as tons — trust the stamp.
|
||||||
|
if (booking.freightType === 'BULK' && bulkCapacity === undefined && storedCeil > 0) {
|
||||||
|
return storedCeil;
|
||||||
|
}
|
||||||
|
// Stored is a candidate, never an early return (batch parity): rows
|
||||||
|
// stamped while BULK sizing read the PER_ITEM item count as tons carry a
|
||||||
|
// too-small footprint — a 20-item/100T booking was stamped 1 wagon.
|
||||||
|
return Math.max(storedCeil, wagonsRequiredForBooking(booking, bulkCapacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Package } from "lucide-react";
|
|||||||
import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core";
|
import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core";
|
||||||
|
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||||
|
|
||||||
import { SectionCard } from "./SectionCard";
|
import { SectionCard } from "./SectionCard";
|
||||||
import { MetricTile } from "./MetricTile";
|
import { MetricTile } from "./MetricTile";
|
||||||
@@ -13,6 +14,7 @@ export interface BookingCargoCardProps {
|
|||||||
/** Cargo specs + container manifest table. */
|
/** Cargo specs + container manifest table. */
|
||||||
export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||||
const containers = booking.bookingContainers ?? [];
|
const containers = booking.bookingContainers ?? [];
|
||||||
|
const { tons, items } = cargoTonsAndItems(booking);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
|
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
|
||||||
@@ -21,7 +23,8 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
|||||||
label="Cargo type"
|
label="Cargo type"
|
||||||
value={booking.cargoType?.label ?? booking.freightType}
|
value={booking.cargoType?.label ?? booking.freightType}
|
||||||
/>
|
/>
|
||||||
<MetricTile label="Total VGM" value={`${booking.cargoTotalWeightVgm} tons`} />
|
<MetricTile label="Total VGM" value={`${tons} tons`} />
|
||||||
|
{items != null && <MetricTile label="Items" value={`${items}`} />}
|
||||||
<MetricTile
|
<MetricTile
|
||||||
label="Hazardous"
|
label="Hazardous"
|
||||||
value={booking.isHazardous ? "Yes" : "No"}
|
value={booking.isHazardous ? "Yes" : "No"}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { ReactNode } from "react";
|
|||||||
import { Hash, Package, Ship, Weight, Clock } from "lucide-react";
|
import { Hash, Package, Ship, Weight, Clock } from "lucide-react";
|
||||||
import { Group, Stack, Text, Divider } from "@mantine/core";
|
import { Group, Stack, Text, Divider } from "@mantine/core";
|
||||||
|
|
||||||
|
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||||
|
|
||||||
import { SectionCard } from "./SectionCard";
|
import { SectionCard } from "./SectionCard";
|
||||||
import { formatDate, type BookingDetailView } from "./booking-detail.styles";
|
import { formatDate, type BookingDetailView } from "./booking-detail.styles";
|
||||||
|
|
||||||
@@ -38,7 +40,14 @@ export function BookingFactsCard({ booking }: BookingFactsCardProps) {
|
|||||||
{ icon: Hash, label: "PNR Code", value: booking.pnrCode || "—" },
|
{ icon: Hash, label: "PNR Code", value: booking.pnrCode || "—" },
|
||||||
{ icon: Package, label: "Cargo Type", value: booking.cargoType?.label ?? "—" },
|
{ icon: Package, label: "Cargo Type", value: booking.cargoType?.label ?? "—" },
|
||||||
{ icon: Ship, label: "Shipping Line", value: booking.shippingLine?.label ?? "—" },
|
{ icon: Ship, label: "Shipping Line", value: booking.shippingLine?.label ?? "—" },
|
||||||
{ icon: Weight, label: "VGM Weight", value: `${booking.cargoTotalWeightVgm} tons` },
|
{
|
||||||
|
icon: Weight,
|
||||||
|
label: "VGM Weight",
|
||||||
|
value: (() => {
|
||||||
|
const { tons, items } = cargoTonsAndItems(booking);
|
||||||
|
return items != null ? `${tons} tons (${items} items)` : `${tons} tons`;
|
||||||
|
})(),
|
||||||
|
},
|
||||||
{ icon: Clock, label: "Last Updated", value: formatDate(booking.updatedAt) },
|
{ icon: Clock, label: "Last Updated", value: formatDate(booking.updatedAt) },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||||
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
||||||
@@ -52,7 +53,7 @@ export function BookingRequestHero({
|
|||||||
(sum, c) => sum + Number(c.quantity ?? 0),
|
(sum, c) => sum + Number(c.quantity ?? 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
@@ -162,7 +163,7 @@ export function BookingRequestHero({
|
|||||||
icon={Weight}
|
icon={Weight}
|
||||||
label="Cargo weight"
|
label="Cargo weight"
|
||||||
value={`${weight} T`}
|
value={`${weight} T`}
|
||||||
hint="VGM total"
|
hint={itemCount != null ? `${itemCount} items` : "VGM total"}
|
||||||
accent="blue"
|
accent="blue"
|
||||||
/>
|
/>
|
||||||
<HeroTile
|
<HeroTile
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function MySignatureCard() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<FileSignature className="size-4" />
|
<FileSignature className="size-4" />
|
||||||
My signature
|
Signature & Stamp
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
This signature can be reused to sign booking contracts.
|
This signature can be reused to sign booking contracts.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { formatRouteLabel } from "@/services/routes.service";
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
import { cargoTonsAndItems } from "@/utils/cargoWeight";
|
||||||
import type {
|
import type {
|
||||||
ContainerPlacement,
|
ContainerPlacement,
|
||||||
FreightType,
|
FreightType,
|
||||||
@@ -416,7 +417,7 @@ export function AllocateBookingWizard({
|
|||||||
const amount = Number(booking.totalAmount);
|
const amount = Number(booking.totalAmount);
|
||||||
const containers = booking.bookingContainers ?? [];
|
const containers = booking.bookingContainers ?? [];
|
||||||
const containerCount = containers.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
const containerCount = containers.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
||||||
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
|
const { tons: weight, items: itemCount } = cargoTonsAndItems(booking);
|
||||||
const holdCountdown = formatCountdown(booking.holdExpiresAt);
|
const holdCountdown = formatCountdown(booking.holdExpiresAt);
|
||||||
|
|
||||||
const containerComplete =
|
const containerComplete =
|
||||||
@@ -945,7 +946,13 @@ export function AllocateBookingWizard({
|
|||||||
})}`}
|
})}`}
|
||||||
hint={booking.paymentStatus}
|
hint={booking.paymentStatus}
|
||||||
/>
|
/>
|
||||||
<StatTile onDark icon={Weight} label="Cargo weight" value={`${weight} T`} hint="VGM total" />
|
<StatTile
|
||||||
|
onDark
|
||||||
|
icon={Weight}
|
||||||
|
label="Cargo weight"
|
||||||
|
value={`${weight} T`}
|
||||||
|
hint={itemCount != null ? `${itemCount} items` : "VGM total"}
|
||||||
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
onDark
|
onDark
|
||||||
icon={ContainerIcon}
|
icon={ContainerIcon}
|
||||||
|
|||||||
@@ -68,11 +68,27 @@ function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NeedCells({ need }: { need: IntercityCapacity | null }) {
|
function NeedCells({ row }: { row: IntercityBookingRow }) {
|
||||||
|
const { need, wagonBreakdown } = row;
|
||||||
if (!need) return <Table.Td colSpan={3}>—</Table.Td>;
|
if (!need) return <Table.Td colSpan={3}>—</Table.Td>;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Table.Td>{fmt(need.wagons)}</Table.Td>
|
<Table.Td>
|
||||||
|
{wagonBreakdown?.length ? (
|
||||||
|
// Which wagon TYPES this train gives up, not just how many wagons —
|
||||||
|
// a break-bulk booking's count depends on each type's capacity and
|
||||||
|
// its configured items-per-wagon fit, so it differs per train.
|
||||||
|
<Stack gap={2}>
|
||||||
|
{wagonBreakdown.map((entry) => (
|
||||||
|
<Text key={entry.wagonTypeId} size="sm">
|
||||||
|
{entry.wagons} × {entry.code}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
fmt(need.wagons)
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
<Table.Td>{fmt(need.weightTons)} t</Table.Td>
|
<Table.Td>{fmt(need.weightTons)} t</Table.Td>
|
||||||
<Table.Td>{fmt(need.lengthMeters)} m</Table.Td>
|
<Table.Td>{fmt(need.lengthMeters)} m</Table.Td>
|
||||||
</>
|
</>
|
||||||
@@ -285,7 +301,7 @@ export function IntercityRideAlongPanel({
|
|||||||
<Table.Td>
|
<Table.Td>
|
||||||
<CorridorCell row={row} />
|
<CorridorCell row={row} />
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<NeedCells need={row.need} />
|
<NeedCells row={row} />
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{row.fits ? (
|
{row.fits ? (
|
||||||
<Badge size="sm" variant="light" color="teal">
|
<Badge size="sm" variant="light" color="teal">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import toast from "react-hot-toast";
|
|||||||
|
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||||
|
import { StampUpload } from "@/components/contracts/StampUpload";
|
||||||
import { bookingSurface } from "@/components/bookings/booking-ui.styles";
|
import { bookingSurface } from "@/components/bookings/booking-ui.styles";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
|
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
|
||||||
@@ -40,6 +41,9 @@ export default function BookingContractPage() {
|
|||||||
const [signOpen, setSignOpen] = useState(false);
|
const [signOpen, setSignOpen] = useState(false);
|
||||||
const [signerName, setSignerName] = useState("");
|
const [signerName, setSignerName] = useState("");
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
|
// Company stamp: prefilled from the profile, or uploaded here when none is
|
||||||
|
// saved yet.
|
||||||
|
const [stampData, setStampData] = useState<string | null>(null);
|
||||||
// When the user has a saved signature we offer it for approval first; they
|
// When the user has a saved signature we offer it for approval first; they
|
||||||
// can switch to drawing a fresh one.
|
// can switch to drawing a fresh one.
|
||||||
const [drawNew, setDrawNew] = useState(false);
|
const [drawNew, setDrawNew] = useState(false);
|
||||||
@@ -55,6 +59,7 @@ export default function BookingContractPage() {
|
|||||||
|
|
||||||
const savedSignature = data?.savedSignature ?? null;
|
const savedSignature = data?.savedSignature ?? null;
|
||||||
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
||||||
|
const savedStampImage = savedSignature?.stampImageUrl ?? null;
|
||||||
// Show the approval view only while a saved signature exists and the user
|
// Show the approval view only while a saved signature exists and the user
|
||||||
// hasn't opted to draw a new one.
|
// hasn't opted to draw a new one.
|
||||||
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
||||||
@@ -98,6 +103,8 @@ export default function BookingContractPage() {
|
|||||||
// approve it; otherwise start with an empty pad.
|
// approve it; otherwise start with an empty pad.
|
||||||
setSignerName(savedSignature?.signerDisplayName ?? "");
|
setSignerName(savedSignature?.signerDisplayName ?? "");
|
||||||
setSignatureData(null);
|
setSignatureData(null);
|
||||||
|
// Prefill with the reusable stamp saved on the profile; still replaceable.
|
||||||
|
setStampData(savedStampImage);
|
||||||
setDrawNew(false);
|
setDrawNew(false);
|
||||||
setSignOpen(true);
|
setSignOpen(true);
|
||||||
};
|
};
|
||||||
@@ -106,10 +113,12 @@ export default function BookingContractPage() {
|
|||||||
if (!canSign || !signerName.trim()) return;
|
if (!canSign || !signerName.trim()) return;
|
||||||
// Approve the saved signature, or submit the freshly drawn one.
|
// Approve the saved signature, or submit the freshly drawn one.
|
||||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||||
if (!image) return;
|
// The API rejects a STAFF signature without a stamp.
|
||||||
|
if (!image || !stampData) return;
|
||||||
signMutation.mutate({
|
signMutation.mutate({
|
||||||
role: "STAFF",
|
role: "STAFF",
|
||||||
signatureImageBase64: image,
|
signatureImageBase64: image,
|
||||||
|
stampImageBase64: stampData,
|
||||||
signerDisplayName: signerName.trim(),
|
signerDisplayName: signerName.trim(),
|
||||||
consentText: "I agree to the terms of this contract.",
|
consentText: "I agree to the terms of this contract.",
|
||||||
});
|
});
|
||||||
@@ -234,6 +243,15 @@ export default function BookingContractPage() {
|
|||||||
) : (
|
) : (
|
||||||
<ContractSignaturePad onChange={setSignatureData} />
|
<ContractSignaturePad onChange={setSignatureData} />
|
||||||
)}
|
)}
|
||||||
|
<StampUpload
|
||||||
|
value={stampData}
|
||||||
|
onChange={setStampData}
|
||||||
|
description={
|
||||||
|
savedStampImage
|
||||||
|
? "Your saved company stamp — replace it for this contract if needed."
|
||||||
|
: "Required. Attach your official company stamp or seal."
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
||||||
@@ -243,6 +261,7 @@ export default function BookingContractPage() {
|
|||||||
disabled={
|
disabled={
|
||||||
signMutation.isPending ||
|
signMutation.isPending ||
|
||||||
(!usingSaved && !signatureData) ||
|
(!usingSaved && !signatureData) ||
|
||||||
|
!stampData ||
|
||||||
!signerName.trim()
|
!signerName.trim()
|
||||||
}
|
}
|
||||||
onClick={confirmSign}
|
onClick={confirmSign}
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export interface ContractView {
|
|||||||
savedSignature?: {
|
savedSignature?: {
|
||||||
signerDisplayName: string;
|
signerDisplayName: string;
|
||||||
signatureImageUrl?: string | null;
|
signatureImageUrl?: string | null;
|
||||||
|
stampImageUrl?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +114,8 @@ export interface ConsolidationDetails {
|
|||||||
export interface SignContractPayload {
|
export interface SignContractPayload {
|
||||||
role: "CUSTOMER" | "STAFF";
|
role: "CUSTOMER" | "STAFF";
|
||||||
signatureImageBase64: string;
|
signatureImageBase64: string;
|
||||||
|
/** Company stamp/seal image; the API requires one for CUSTOMER and STAFF. */
|
||||||
|
stampImageBase64?: string;
|
||||||
signerDisplayName: string;
|
signerDisplayName: string;
|
||||||
consentText?: string;
|
consentText?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ export interface BookingDetail {
|
|||||||
/** What the containers carry / bulk commodity label — entered at booking time. */
|
/** What the containers carry / bulk commodity label — entered at booking time. */
|
||||||
cargoFreeText?: string | null;
|
cargoFreeText?: string | null;
|
||||||
cargoTotalWeightVgm: number;
|
cargoTotalWeightVgm: number;
|
||||||
|
/** Break-bulk (PER_ITEM) only: real total tons — cargoTotalWeightVgm then holds the item count. */
|
||||||
|
bulkTotalWeightTons?: number | null;
|
||||||
isHazardous: boolean;
|
isHazardous: boolean;
|
||||||
consolidationPartnerId?: string | null;
|
consolidationPartnerId?: string | null;
|
||||||
consolidationPartner?: BookingNamedRef & { reference?: string } | null;
|
consolidationPartner?: BookingNamedRef & { reference?: string } | null;
|
||||||
|
|||||||
@@ -959,6 +959,14 @@ export interface IntercityCapacity {
|
|||||||
lengthMeters: number | null;
|
lengthMeters: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One wagon type this train must give up, and how many of it. */
|
||||||
|
export interface IntercityWagonBreakdownEntry {
|
||||||
|
wagonTypeId: string;
|
||||||
|
/** Wagon-type code as marshalled, e.g. "N35" / "PW2". */
|
||||||
|
code: string;
|
||||||
|
wagons: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IntercityBookingRow {
|
export interface IntercityBookingRow {
|
||||||
id: string;
|
id: string;
|
||||||
reference: string | null;
|
reference: string | null;
|
||||||
@@ -973,6 +981,12 @@ export interface IntercityBookingRow {
|
|||||||
weightTons: number;
|
weightTons: number;
|
||||||
paymentDeadline: string | null;
|
paymentDeadline: string | null;
|
||||||
need: IntercityCapacity | null;
|
need: IntercityCapacity | null;
|
||||||
|
/**
|
||||||
|
* `need.wagons` split across the wagon types THIS schedule stocks — the same
|
||||||
|
* booking reads differently on a train of 60T wagons than on one of 40T.
|
||||||
|
* Empty when the stock or the cargo type's wagon list is unresolved.
|
||||||
|
*/
|
||||||
|
wagonBreakdown?: IntercityWagonBreakdownEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntercityCandidateRow extends IntercityBookingRow {
|
export interface IntercityCandidateRow extends IntercityBookingRow {
|
||||||
|
|||||||
18
apps/edr-freight-web/backoffice/src/utils/cargoWeight.ts
Normal file
18
apps/edr-freight-web/backoffice/src/utils/cargoWeight.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Break-bulk (PER_ITEM) bulk bookings overload `cargoTotalWeightVgm` with the
|
||||||
|
* ITEM COUNT; their real tonnage lives in `bulkTotalWeightTons`. Every other
|
||||||
|
* booking stores tons in `cargoTotalWeightVgm` directly. Rendering the raw
|
||||||
|
* VGM column showed a 20-item / 100T booking as "20 tons".
|
||||||
|
*/
|
||||||
|
export function cargoTonsAndItems(booking: {
|
||||||
|
freightType?: string | null;
|
||||||
|
cargoTotalWeightVgm?: number | string | null;
|
||||||
|
bulkTotalWeightTons?: number | string | null;
|
||||||
|
}): { tons: number; items: number | null } {
|
||||||
|
const bulkTons = Number(booking.bulkTotalWeightTons ?? 0);
|
||||||
|
const vgm = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||||
|
if (booking.freightType === "BULK" && bulkTons > 0) {
|
||||||
|
return { tons: bulkTons, items: vgm > 0 ? vgm : null };
|
||||||
|
}
|
||||||
|
return { tons: vgm, items: null };
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ export function MySignatureCard() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<FileSignature className="size-5 text-primary" />
|
<FileSignature className="size-5 text-primary" />
|
||||||
My signature
|
Signature & Stamp
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Reused to approve and sign booking contracts.
|
Reused to approve and sign booking contracts.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function MySignaturePage() {
|
|||||||
<div className="mx-auto flex max-w-md flex-col gap-6">
|
<div className="mx-auto flex max-w-md flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-black tracking-tight text-foreground">
|
<h1 className="text-2xl font-black tracking-tight text-foreground">
|
||||||
My signature
|
Signature & Stamp
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Saved and reused to approve and sign booking contracts.
|
Saved and reused to approve and sign booking contracts.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||||
|
import { StampUpload } from "@/components/contracts/StampUpload";
|
||||||
import {
|
import {
|
||||||
bookingsService,
|
bookingsService,
|
||||||
type SignContractPayload,
|
type SignContractPayload,
|
||||||
@@ -25,6 +26,9 @@ export default function BookingContractPage() {
|
|||||||
const [signOpen, setSignOpen] = useState(false);
|
const [signOpen, setSignOpen] = useState(false);
|
||||||
const [signerName, setSignerName] = useState("");
|
const [signerName, setSignerName] = useState("");
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
|
// Company stamp: prefilled from the profile, or uploaded here when the
|
||||||
|
// customer has never saved one.
|
||||||
|
const [stampData, setStampData] = useState<string | null>(null);
|
||||||
// When a saved signature exists we offer it for approval first; the customer
|
// When a saved signature exists we offer it for approval first; the customer
|
||||||
// can switch to drawing a fresh one.
|
// can switch to drawing a fresh one.
|
||||||
const [drawNew, setDrawNew] = useState(false);
|
const [drawNew, setDrawNew] = useState(false);
|
||||||
@@ -37,12 +41,14 @@ export default function BookingContractPage() {
|
|||||||
|
|
||||||
const savedSignature = data?.savedSignature ?? null;
|
const savedSignature = data?.savedSignature ?? null;
|
||||||
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
||||||
|
const savedStampImage = savedSignature?.stampImageUrl ?? null;
|
||||||
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
||||||
|
|
||||||
const openSign = () => {
|
const openSign = () => {
|
||||||
// Prefill from the saved signature so the customer only has to approve it.
|
// Prefill from the saved signature so the customer only has to approve it.
|
||||||
setSignerName(savedSignature?.signerDisplayName ?? "");
|
setSignerName(savedSignature?.signerDisplayName ?? "");
|
||||||
setSignatureData(null);
|
setSignatureData(null);
|
||||||
|
setStampData(savedStampImage);
|
||||||
setDrawNew(false);
|
setDrawNew(false);
|
||||||
setSignOpen(true);
|
setSignOpen(true);
|
||||||
};
|
};
|
||||||
@@ -51,10 +57,12 @@ export default function BookingContractPage() {
|
|||||||
if (!signerName.trim()) return;
|
if (!signerName.trim()) return;
|
||||||
// Approve the saved signature, or submit the freshly drawn one.
|
// Approve the saved signature, or submit the freshly drawn one.
|
||||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||||
if (!image) return;
|
// The API rejects a CUSTOMER signature without a stamp.
|
||||||
|
if (!image || !stampData) return;
|
||||||
signMutation.mutate({
|
signMutation.mutate({
|
||||||
role: "CUSTOMER",
|
role: "CUSTOMER",
|
||||||
signatureImageBase64: image,
|
signatureImageBase64: image,
|
||||||
|
stampImageBase64: stampData,
|
||||||
signerDisplayName: signerName.trim(),
|
signerDisplayName: signerName.trim(),
|
||||||
consentText: "I agree to the terms of this contract.",
|
consentText: "I agree to the terms of this contract.",
|
||||||
});
|
});
|
||||||
@@ -191,6 +199,15 @@ export default function BookingContractPage() {
|
|||||||
) : (
|
) : (
|
||||||
<ContractSignaturePad onChange={setSignatureData} />
|
<ContractSignaturePad onChange={setSignatureData} />
|
||||||
)}
|
)}
|
||||||
|
<StampUpload
|
||||||
|
value={stampData}
|
||||||
|
onChange={setStampData}
|
||||||
|
description={
|
||||||
|
savedStampImage
|
||||||
|
? "Your saved company stamp — replace it for this contract if needed."
|
||||||
|
: "Required. Attach your official company stamp or seal."
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 flex justify-end gap-2">
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
||||||
@@ -200,6 +217,7 @@ export default function BookingContractPage() {
|
|||||||
disabled={
|
disabled={
|
||||||
signMutation.isPending ||
|
signMutation.isPending ||
|
||||||
(!usingSaved && !signatureData) ||
|
(!usingSaved && !signatureData) ||
|
||||||
|
!stampData ||
|
||||||
!signerName.trim()
|
!signerName.trim()
|
||||||
}
|
}
|
||||||
onClick={confirmSign}
|
onClick={confirmSign}
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ export function totalVgmTons(b: BookingDetail): number {
|
|||||||
);
|
);
|
||||||
if (sum > 0) return sum;
|
if (sum > 0) return sum;
|
||||||
}
|
}
|
||||||
|
// Break-bulk (PER_ITEM): cargoTotalWeightVgm holds the ITEM COUNT — the
|
||||||
|
// real tonnage lives in bulkTotalWeightTons.
|
||||||
|
const bulkTons = Number(b.bulkTotalWeightTons || 0);
|
||||||
|
if (bulkTons > 0) return bulkTons;
|
||||||
return Number(b.cargoTotalWeightVgm || 0);
|
return Number(b.cargoTotalWeightVgm || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
101
e2e/freight/BULK_SCENARIOS.md
Normal file
101
e2e/freight/BULK_SCENARIOS.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Bulk scenario catalog — BS1–BS40
|
||||||
|
|
||||||
|
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, S1–S40).
|
||||||
|
|
||||||
|
**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 |
|
||||||
61
e2e/freight/cypress/e2e/flows/bulk-items-utils.ts
Normal file
61
e2e/freight/cypress/e2e/flows/bulk-items-utils.ts
Normal 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]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 {};
|
||||||
147
e2e/freight/cypress/e2e/flows/bulk_b2_per_item_floor.cy.ts
Normal file
147
e2e/freight/cypress/e2e/flows/bulk_b2_per_item_floor.cy.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* BULK B2 — break-bulk PER_ITEM wagon math (BS17–BS19 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* BULK B3 — PER_ITEM giant split + line quantities (BS20–BS23 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 {};
|
||||||
37
e2e/freight/cypress/fixtures/seed-bulk-items.sql
Normal file
37
e2e/freight/cypress/fixtures/seed-bulk-items.sql
Normal 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'));
|
||||||
Reference in New Issue
Block a user