feat(bookings): per-row stations + totals footer on carriage acceptance sheet

The sheet printed Departure/Arrival Station as booking-level constants and
squeezed the footer figures into unrelated table columns (gross weight under
Cargo Name, full/empty under Wagon No.).

Stations now resolve per row from the slot's own board/alight yard, falling
back to the schedule's origin/destination and then the booking yards —
mirroring buildExportLoadListHtml's leg-slot handling.

The paper form's footer becomes its own tile block: In Total Wagon No., Tare
Weight, Load Capacity, Gross Weight, Equated Length, Full Wagon, Empty Wagon,
Total Amount. Rendered as .tile so buildTabularFallbackPdf still prints every
figure where Chromium is absent.
This commit is contained in:
Hagernesh
2026-08-31 11:28:36 +00:00
parent f23500c273
commit cc50a6687a
3 changed files with 130 additions and 10 deletions

View File

@@ -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">
${

View File

@@ -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>');
});
});

View File

@@ -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;
}