fix(freight): correct direct CAS lines and sync inventory on schedule load

End-to-end testing of the direct truck-to-train flow surfaced two defects
in the carriage acceptance sheet's no-wagon fallback. Container numbers
were read from freight.containers, which only gains rows at allocation,
so a direct booking's declared containers never appeared; they are read
from booking_container_units now, seal numbers included. The weight used
bulkTotalWeightTons alone, which is null for CONTAINER and PER_TON bulk
bookings, printing 0.00 — cargoTotalWeightVgm is the real weight there
and only holds an item count for PER_ITEM break-bulk.

Separately, warehouse cargo can be loaded from the Load-to-Train queue or
from the schedule, but loading from the schedule never advanced
warehouse_inventory. The booking went IN_TRANSIT while its cargo still
read as sitting in the warehouse — the same inconsistency that produced
the spurious dispatch block. loadBooking now moves the inventory to
LOADED in the same transaction, and no-ops for direct bookings.
This commit is contained in:
Hagernesh
2026-08-09 16:03:52 +00:00
parent 1797238d26
commit cde67e4c6a
5 changed files with 31 additions and 424 deletions

View File

@@ -92,6 +92,7 @@ interface CarriageAcceptanceWagonRow {
interface CarriageAcceptanceReceivedRow {
allocatedWeightTons: string | null;
containerNumbers: string | null;
sealNumbers?: string | null;
}
const URGENT_PRIORITY_THRESHOLD = 1000;
@@ -291,15 +292,19 @@ export class BookingsService {
if (pendingWagons) {
// Direct truck-to-train cargo never enters the warehouse, so there is no
// GRN'd inventory to build the sheet from. Choosing direct handover is
// itself the acceptance, so the sheet issues off the booking's own
// containers (or its VGM weight when the cargo is bulk).
// itself the acceptance, so the sheet issues off the containers the
// customer declared on the booking — freight.containers only gains rows at
// allocation, by which point the wagon query above already serves.
const receivedLines: CarriageAcceptanceReceivedRow[] = isDirectExport
? await this.dataSource.query(
`SELECT NULL::numeric AS "allocatedWeightTons",
c.container_number AS "containerNumbers"
FROM freight.containers c
WHERE c.booking_id = $1 AND c.deleted_at IS NULL
ORDER BY c.container_number`,
unit.container_number AS "containerNumbers",
unit.seal_number AS "sealNumbers"
FROM freight.booking_container_units unit
JOIN freight.booking_container line
ON line.id = unit.booking_container_id AND line.deleted_at IS NULL
WHERE line.booking_id = $1 AND unit.deleted_at IS NULL
ORDER BY unit.container_number`,
[bookingId],
)
: booking.tradeDirection === 'EXPORT'
@@ -319,11 +324,13 @@ export class BookingsService {
)
: [];
// Bulk direct cargo has no containers — one line carrying the booking's
// declared weight still makes a valid sheet.
// declared weight still makes a valid sheet. bulkTotalWeightTons only
// holds the real tonnage for PER_ITEM break-bulk; everywhere else (PER_TON
// bulk and every container booking) the VGM column is the weight.
if (isDirectExport && receivedLines.length === 0) {
const totalWeight = booking.bulkTotalWeightTons ?? booking.cargoTotalWeightVgm;
receivedLines.push({
allocatedWeightTons:
booking.bulkTotalWeightTons == null ? null : String(booking.bulkTotalWeightTons),
allocatedWeightTons: totalWeight == null ? null : String(totalWeight),
containerNumbers: null,
});
}
@@ -347,7 +354,7 @@ export class BookingsService {
marshalledAt: null,
arrivalAt: null,
containerNumbers: row.containerNumbers,
sealNumbers: null,
sealNumbers: row.sealNumbers ?? null,
}));
}

View File

@@ -95,6 +95,20 @@ export class BookingJourneyService {
await manager
.getRepository(TrainScheduleBooking)
.update({ trainScheduleId: scheduleId, bookingId }, { loadingStatus: 'LOADED' });
// Warehouse cargo may be loaded either from the warehouse Load-to-Train
// queue or from the schedule itself. Loading here must move its inventory
// too, otherwise the goods read as still sitting in the shed while the
// train leaves with them. No-ops for direct truck-to-train (no inventory).
// ponytail: no WarehouseLoading record on this path — those are only read
// back as per-inventory loading history, never billed. Create them here if
// that history ever has to be complete.
await manager.query(
`UPDATE freight.warehouse_inventory
SET status = 'LOADED', loaded_at = COALESCE(loaded_at, $2), updated_at = NOW()
WHERE booking_id = $1 AND deleted_at IS NULL
AND status NOT IN ('LOADED', 'DISPATCHED')`,
[bookingId, now],
);
// The facility handed the cargo over — raise its GRN. No-ops for yards
// without a facility (import/export terminals), which keep their own flow.
await this.facilityHandling.recordHandling(manager, {