diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
index 5cdb0d6a4..da7811ecb 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -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 {
${num(w.tareWeightTons, 2)} |
${num(w.equatedLength)} |
${num(w.loadCapacityTons)} |
- ${esc(arrivalStation)} |
+ ${esc(w.arrivalStation ?? arrivalStation)} |
${esc(cargoName)} |
- ${esc(departureStation)} |
+ ${esc(w.departureStation ?? departureStation)} |
${esc(w.containerNumbers)} |
${esc(w.sealNumbers)} |
${
@@ -558,16 +568,12 @@ export class BookingsService {
const totalsRow = ` |
| TOT |
${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'} |
- ${
- pendingWagons
- ? 'pending marshalling'
- : `full ${fullWagons} / empty ${loadedWagons.length - fullWagons}`
- } |
+ |
${num(totals.tare, 2)} |
${num(totals.length)} |
${num(totals.capacity)} |
|
- Gross ${num(totals.tare + totals.load)} T |
+ |
|
|
|
@@ -575,6 +581,23 @@ export class BookingsService {
${money(totalAmount)} |
`;
+ // 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 = `
+ `;
+
return `
@@ -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}
+${footer}
${
diff --git a/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts
index 195e0cab0..45907c09a 100644
--- a/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts
+++ b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts
@@ -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 =>
+ (
+ 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 = {}) => ({
+ 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('| Dire Dawa Port | ');
+ expect(html).toContain('Adama | ');
+ expect(html).toContain('Booking Origin | ');
+ expect(html).toContain('Booking Destination | ');
+ });
+
+ 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('In Total Wagon No.2');
+ expect(html).toContain('Tare Weight (T)40.00');
+ expect(html).toContain('Load Capacity (T)120.000');
+ expect(html).toContain('Gross Weight (T)80.000');
+ expect(html).toContain('Equated Length28.000');
+ expect(html).toContain('Full Wagon1');
+ expect(html).toContain('Empty Wagon1');
+ expect(html).toContain('Total Amount (ETB)100.00');
+ });
+});
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
index 43e7d7ae8..653c2ea5a 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
@@ -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 | 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;
}