mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 09:43:40 +00:00
@@ -112,6 +112,9 @@ interface CarriageAcceptanceWagonRow {
|
||||
departureAt: Date | null;
|
||||
marshalledAt: string | null;
|
||||
arrivalAt: string | null;
|
||||
/** Per-row stations: the slot's own board/alight yard, else the schedule's endpoints. */
|
||||
departureStation: string | null;
|
||||
arrivalStation: string | null;
|
||||
containerNumbers: string | null;
|
||||
sealNumbers: string | null;
|
||||
/** Allocation status — LOADED/DEPARTED means EDR has the cargo. */
|
||||
@@ -291,6 +294,8 @@ export class BookingsService {
|
||||
s.scheduled_departure_date AS "departureAt",
|
||||
so.label AS "marshalledAt",
|
||||
sd.label AS "arrivalAt",
|
||||
COALESCE(by_.label, so.label) AS "departureStation",
|
||||
COALESCE(ay.label, sd.label) AS "arrivalStation",
|
||||
a.status AS "status",
|
||||
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
|
||||
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
|
||||
@@ -303,11 +308,14 @@ export class BookingsService {
|
||||
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
|
||||
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
|
||||
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
|
||||
LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id
|
||||
LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id
|
||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
|
||||
GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
|
||||
s.train_number, s.scheduled_departure_date, so.label, sd.label
|
||||
s.train_number, s.scheduled_departure_date, so.label, sd.label,
|
||||
by_.label, ay.label
|
||||
ORDER BY tsw.sequence_no`,
|
||||
[bookingId],
|
||||
);
|
||||
@@ -388,6 +396,8 @@ export class BookingsService {
|
||||
departureAt: null,
|
||||
marshalledAt: null,
|
||||
arrivalAt: null,
|
||||
departureStation: null,
|
||||
arrivalStation: null,
|
||||
containerNumbers: row.containerNumbers,
|
||||
sealNumbers: row.sealNumbers ?? null,
|
||||
// A received line has no allocation; it is cargo EDR already holds.
|
||||
@@ -539,9 +549,9 @@ export class BookingsService {
|
||||
<td class="num">${num(w.tareWeightTons, 2)}</td>
|
||||
<td class="num">${num(w.equatedLength)}</td>
|
||||
<td class="num">${num(w.loadCapacityTons)}</td>
|
||||
<td>${esc(arrivalStation)}</td>
|
||||
<td>${esc(w.arrivalStation ?? arrivalStation)}</td>
|
||||
<td>${esc(cargoName)}</td>
|
||||
<td>${esc(departureStation)}</td>
|
||||
<td>${esc(w.departureStation ?? departureStation)}</td>
|
||||
<td>${esc(w.containerNumbers)}</td>
|
||||
<td>${esc(w.sealNumbers)}</td>
|
||||
<td class="${isLoaded(w) ? 'loaded' : 'pending'}">${
|
||||
@@ -558,16 +568,12 @@ export class BookingsService {
|
||||
const totalsRow = `<tr class="totals">
|
||||
<td>TOT</td>
|
||||
<td>${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'}</td>
|
||||
<td>${
|
||||
pendingWagons
|
||||
? 'pending marshalling'
|
||||
: `full ${fullWagons} / empty ${loadedWagons.length - fullWagons}`
|
||||
}</td>
|
||||
<td></td>
|
||||
<td class="num">${num(totals.tare, 2)}</td>
|
||||
<td class="num">${num(totals.length)}</td>
|
||||
<td class="num">${num(totals.capacity)}</td>
|
||||
<td></td>
|
||||
<td>Gross ${num(totals.tare + totals.load)} T</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
@@ -575,6 +581,23 @@ export class BookingsService {
|
||||
<td class="num">${money(totalAmount)}</td>
|
||||
</tr>`;
|
||||
|
||||
// The signed footer of the paper sheet. Rendered as .tile so the
|
||||
// Chromium-less fallback (buildTabularFallbackPdf parses .tile, not
|
||||
// arbitrary divs) still prints every figure.
|
||||
const footer = `
|
||||
<div class="summary footer-summary">
|
||||
<div class="tile"><span>In Total Wagon No.</span><strong>${loadedWagons.length}</strong></div>
|
||||
<div class="tile"><span>Tare Weight (T)</span><strong>${num(totals.tare, 2)}</strong></div>
|
||||
<div class="tile"><span>Load Capacity (T)</span><strong>${num(totals.capacity)}</strong></div>
|
||||
<div class="tile"><span>Gross Weight (T)</span><strong>${num(totals.tare + totals.load)}</strong></div>
|
||||
<div class="tile"><span>Equated Length</span><strong>${num(totals.length)}</strong></div>
|
||||
<div class="tile"><span>Full Wagon</span><strong>${pendingWagons ? '-' : fullWagons}</strong></div>
|
||||
<div class="tile"><span>Empty Wagon</span><strong>${
|
||||
pendingWagons ? '-' : loadedWagons.length - fullWagons
|
||||
}</strong></div>
|
||||
<div class="tile"><span>Total Amount (${esc(currency)})</span><strong>${money(totalAmount)}</strong></div>
|
||||
</div>`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -591,6 +614,8 @@ export class BookingsService {
|
||||
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
|
||||
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
|
||||
.footer-summary { grid-template-columns: repeat(8, 1fr); margin: 10px 0 0; }
|
||||
.footer-summary .tile { background: #f8fafc; }
|
||||
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
|
||||
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
|
||||
.tile strong { font-size: 11px; }
|
||||
@@ -652,6 +677,7 @@ export class BookingsService {
|
||||
${totalsRow}
|
||||
</tbody>
|
||||
</table>
|
||||
${footer}
|
||||
|
||||
<div class="notice">
|
||||
${
|
||||
|
||||
@@ -24,3 +24,84 @@ describe('carriage acceptance sheet — price split', () => {
|
||||
expect(shares).toEqual([33.33, 33.33, 33.34]);
|
||||
});
|
||||
});
|
||||
|
||||
// The HTML builder only reaches `this` for two prototype helpers (escapeHtml,
|
||||
// splitAmountAcrossWagons), so the prototype itself serves as `this`.
|
||||
const buildSheet = (wagons: unknown[], booking: Record<string, unknown> = {}): string =>
|
||||
(
|
||||
BookingsService.prototype as unknown as {
|
||||
buildCarriageAcceptanceSheetHtml(
|
||||
b: unknown,
|
||||
w: unknown[],
|
||||
o: { pendingWagons: boolean },
|
||||
): string;
|
||||
}
|
||||
).buildCarriageAcceptanceSheetHtml.call(
|
||||
BookingsService.prototype,
|
||||
{
|
||||
reference: 'BK-1',
|
||||
tradeDirection: 'EXPORT',
|
||||
totalAmount: 100,
|
||||
paymentCurrency: 'ETB',
|
||||
originYard: { label: 'Booking Origin' },
|
||||
destinationYard: { label: 'Booking Destination' },
|
||||
...booking,
|
||||
},
|
||||
wagons,
|
||||
{ pendingWagons: false },
|
||||
);
|
||||
|
||||
const wagon = (over: Record<string, unknown> = {}) => ({
|
||||
sequenceNo: 1,
|
||||
wagonType: 'FLAT',
|
||||
wagonNumber: 'W-001',
|
||||
tareWeightTons: '20',
|
||||
equatedLength: '14',
|
||||
loadCapacityTons: '60',
|
||||
allocatedWeightTons: '40',
|
||||
trainNumber: '8302',
|
||||
departureAt: null,
|
||||
marshalledAt: 'DCT/SGTD',
|
||||
arrivalAt: 'GMP',
|
||||
departureStation: null,
|
||||
arrivalStation: null,
|
||||
containerNumbers: 'CN-1',
|
||||
sealNumbers: 'SL-1',
|
||||
status: 'LOADED',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('carriage acceptance sheet — rows and footer', () => {
|
||||
it('prints each row its own Departure/Arrival Station, falling back to the booking yards', () => {
|
||||
const html = buildSheet([
|
||||
wagon({ departureStation: 'Dire Dawa Port', arrivalStation: 'Adama' }),
|
||||
wagon({ sequenceNo: 2, wagonNumber: 'W-002' }),
|
||||
]);
|
||||
expect(html).toContain('<td>Dire Dawa Port</td>');
|
||||
expect(html).toContain('<td>Adama</td>');
|
||||
expect(html).toContain('<td>Booking Origin</td>');
|
||||
expect(html).toContain('<td>Booking Destination</td>');
|
||||
});
|
||||
|
||||
it('totals the footer over loaded wagons only', () => {
|
||||
const html = buildSheet([
|
||||
wagon(),
|
||||
wagon({ sequenceNo: 2, wagonNumber: 'W-002', status: 'ALLOCATED' }),
|
||||
wagon({
|
||||
sequenceNo: 3,
|
||||
wagonNumber: 'W-003',
|
||||
allocatedWeightTons: '0',
|
||||
containerNumbers: null,
|
||||
}),
|
||||
]);
|
||||
// 2 loaded of 3: tare 40, capacity 120, equated length 28, gross 40 + 40 load.
|
||||
expect(html).toContain('<span>In Total Wagon No.</span><strong>2</strong>');
|
||||
expect(html).toContain('<span>Tare Weight (T)</span><strong>40.00</strong>');
|
||||
expect(html).toContain('<span>Load Capacity (T)</span><strong>120.000</strong>');
|
||||
expect(html).toContain('<span>Gross Weight (T)</span><strong>80.000</strong>');
|
||||
expect(html).toContain('<span>Equated Length</span><strong>28.000</strong>');
|
||||
expect(html).toContain('<span>Full Wagon</span><strong>1</strong>');
|
||||
expect(html).toContain('<span>Empty Wagon</span><strong>1</strong>');
|
||||
expect(html).toContain('<span>Total Amount (ETB)</span><strong>100.00</strong>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1062,6 +1062,7 @@ describe('TrainSchedulingService', () => {
|
||||
const makeWagon = (sequenceNo: number, wagonNumber: string, allocations: unknown[]) => ({
|
||||
sequenceNo,
|
||||
wagonNumber,
|
||||
physicalWagonId: `wagon-id-${wagonNumber}`,
|
||||
physicalWagon: { wagonNumber },
|
||||
wagonType: { code: 'NW5', name: 'Flat Wagon', tareWeightTons: 22 },
|
||||
lengthMeters: 14,
|
||||
@@ -1185,6 +1186,57 @@ describe('TrainSchedulingService', () => {
|
||||
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
||||
});
|
||||
|
||||
it('drops a slot with no physical wagon pinned from the import document too', () => {
|
||||
const loadList = {
|
||||
generatedAt: '2026-07-17T08:00:00.000Z',
|
||||
trainScheduleId: 'schedule-1',
|
||||
trainNumber: '7002',
|
||||
route: 'DCT/SGTD → GMP',
|
||||
origin: 'DCT/SGTD',
|
||||
destination: 'GMP',
|
||||
totalBookings: 2,
|
||||
wagons: [
|
||||
{
|
||||
sequenceNo: 1,
|
||||
// No physical wagon pinned (fleet shortfall, or a REAL cut nulled
|
||||
// it out) — nothing physical to marshal, even though the slot
|
||||
// still carries a LOADED allocation.
|
||||
wagonNumber: null,
|
||||
boardYard: null,
|
||||
alightYard: null,
|
||||
allocations: [
|
||||
{
|
||||
...loadedAllocation,
|
||||
containerItems: [{ containerNumber: 'GHOST-001' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
sequenceNo: 2,
|
||||
wagonNumber: 'W-IMP',
|
||||
boardYard: null,
|
||||
alightYard: null,
|
||||
allocations: [
|
||||
{
|
||||
...loadedAllocation,
|
||||
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
operation: { status: {} },
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildImportLoadListHtml: (l: unknown) => string;
|
||||
}).buildImportLoadListHtml(loadList);
|
||||
|
||||
expect(html).not.toContain('GHOST-001');
|
||||
expect(html).toContain('W-IMP');
|
||||
expect(html).toContain('<span>Wagons</span><strong>1</strong>');
|
||||
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
||||
});
|
||||
|
||||
it('drops a leg slot entirely from the export document — not part of the departing consist', () => {
|
||||
const sizedAllocation = {
|
||||
...loadedAllocation,
|
||||
@@ -1211,6 +1263,29 @@ describe('TrainSchedulingService', () => {
|
||||
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
||||
});
|
||||
|
||||
it('drops a whole-route slot with no physical wagon pinned from the export document too', () => {
|
||||
const sizedAllocation = {
|
||||
...loadedAllocation,
|
||||
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
|
||||
};
|
||||
const ghost = { ...makeWagon(2, 'W-GHOST', [sizedAllocation]), id: 'slot-ghost', physicalWagonId: null };
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
direction: 'EXPORT',
|
||||
trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [sizedAllocation]), id: 'slot-1' }, ghost] },
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
|
||||
}).buildExportLoadListHtml(schedule, {});
|
||||
|
||||
expect(html).not.toContain('W-GHOST');
|
||||
expect(html).toContain('<span>Wagons</span><strong>1</strong>');
|
||||
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
||||
});
|
||||
|
||||
it('prints the consist-changes table for this stop, and omits it when there are none', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
@@ -1444,6 +1519,22 @@ describe('TrainSchedulingService', () => {
|
||||
expect(numbers).toEqual(['W-LEG2']);
|
||||
});
|
||||
|
||||
it('drops a whole-route slot with no physical wagon pinned, even though its allocation is LOADED', () => {
|
||||
// A booking can hold a LOADED allocation before a real wagon backs it
|
||||
// (fleet shortfall left the slot unpinned), or a REAL cut nulls
|
||||
// physicalWagonId without ever touching the slot's own status. Either
|
||||
// way there is no physical wagon standing there to marshal.
|
||||
const pinned = makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]);
|
||||
const ghost = { ...makeWagon(2, 'W-002', [allocWith({ status: 'LOADED' })]), physicalWagonId: null };
|
||||
const schedule = { trainSet: { wagons: [pinned, ghost] }, scheduleBookings: [] };
|
||||
|
||||
const { wagons } = onBoardView(schedule);
|
||||
const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map(
|
||||
(w) => w.physicalWagon.wagonNumber,
|
||||
);
|
||||
expect(numbers).toEqual(['W-001']);
|
||||
});
|
||||
|
||||
it('drops a leg slot LOADED by generation time but not yet coupled as of this stop', () => {
|
||||
// Both W-DIRE (coupled+loaded at Dire Dawa) and W-ADAMA (coupled+loaded
|
||||
// at Adama, a LATER stop) read identically to intercityOnBoardView by
|
||||
|
||||
@@ -3602,6 +3602,13 @@ export class TrainSchedulingService {
|
||||
const wagons = (schedule.trainSet?.wagons ?? [])
|
||||
.filter((wagon) => {
|
||||
if (wagon.status === 'DEPARTED') return false;
|
||||
// No physical wagon pinned to the slot — a booking can hold an
|
||||
// allocation before a real wagon backs it (e.g. a fleet shortfall
|
||||
// left it unpinned). There is nothing physical here to marshal, and
|
||||
// a REAL cut also lands here: it nulls physicalWagonId without ever
|
||||
// touching this slot's own status, so a cut wagon would otherwise
|
||||
// linger as a phantom row with its cargo still listed.
|
||||
if (!wagon.physicalWagonId) return false;
|
||||
const hasLoaded = (wagon.allocations ?? []).some((a) => a.status === 'LOADED');
|
||||
return wagon.boardYardId == null || hasLoaded;
|
||||
})
|
||||
@@ -3930,6 +3937,11 @@ export class TrainSchedulingService {
|
||||
// Their own coupling shows up on THAT stop's own marshalling document.
|
||||
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])]
|
||||
.filter((wagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id))
|
||||
// No physical wagon pinned to the slot (fleet shortfall left a booking's
|
||||
// allocation unpinned, or a REAL cut nulled it out): nothing physical
|
||||
// to marshal, so no row. Harmless no-op for the numbered docs, whose
|
||||
// wagons list already went through intercityOnBoardView's own check.
|
||||
.filter((wagon) => Boolean(wagon.physicalWagonId))
|
||||
.sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0));
|
||||
// Empties sit on wagons that carry no booking allocation, keyed by the wagon
|
||||
// slot recorded when they were loaded.
|
||||
@@ -4323,8 +4335,11 @@ export class TrainSchedulingService {
|
||||
// A leg slot (boardYard set) couples mid-corridor — it is not part of the
|
||||
// consist this Djibouti-side document is checked against yet, so it gets
|
||||
// no row and no count here at all. Its own coupling shows up on THAT
|
||||
// stop's own marshalling document once it actually happens.
|
||||
const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard);
|
||||
// stop's own marshalling document once it actually happens. Same for a
|
||||
// slot with no physical wagon pinned at all — a booking can hold an
|
||||
// allocation before a real wagon backs it (fleet shortfall), or a REAL
|
||||
// cut nulled it out; either way there is nothing physical to marshal.
|
||||
const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard && wagon.wagonNumber != null);
|
||||
const totalAllocations = wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
|
||||
const totalWeight = wagons.reduce(
|
||||
(sum, wagon) => sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||
|
||||
@@ -319,6 +319,14 @@ export interface LoadableTrainRow {
|
||||
destination: string | null;
|
||||
status: string;
|
||||
departureTime: string | Date | null;
|
||||
/** freight.yards.id the train departs from — the default boarding yard. */
|
||||
originStationId: string | null;
|
||||
/**
|
||||
* The schedule's per-yard loading/unloading time windows, exactly as the train
|
||||
* schedule page stores them. The warehouse loading queues render the same
|
||||
* Start/End controls off this, so both surfaces show one truth.
|
||||
*/
|
||||
stationWorkLogs: Record<string, StationWorkLogJson> | null;
|
||||
/** Received/ready inventory not yet loaded onto this train. */
|
||||
readyCount: number;
|
||||
/** Inventory already loaded onto this train. */
|
||||
@@ -340,7 +348,12 @@ export interface TrainLoadableItemRow {
|
||||
wagonId: string | null;
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
/** True only when the item is READY_FOR_LOADING and has an allocated wagon. */
|
||||
/** The booking's boarding yard — the yard whose loading window gates this item. */
|
||||
originYardId: string | null;
|
||||
originYardLabel: string | null;
|
||||
/** True once "Start loading" was clicked for this item's boarding yard on this train. */
|
||||
loadingWindowStarted: boolean;
|
||||
/** True only when the item is READY_FOR_LOADING, has an allocated wagon and GRN, and its yard's loading window is open. */
|
||||
loadable: boolean;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user