Merge pull request #1049 from Tria-plc/edrmiles

Fix 2 — lastmile should only be yes if EDR last mile.
This commit is contained in:
Hagernesh Tadesse
2026-07-31 16:10:49 +03:00
committed by GitHub
5 changed files with 225 additions and 31 deletions

View File

@@ -158,6 +158,14 @@ export class BookingJourneyService {
this.events.emit('booking.completed', { bookingId });
}
// The cargo is physically off the train at its own yard — mid-corridor or
// final. WarehouseInventoryService picks this up to create the warehouse
// record (import/intercity only; export already has one from receive).
this.events.emit('booking.unloadedAtYard', {
bookingId,
tradeDirection: booking.tradeDirection,
});
// Customer tracking: THIS booking arrived (train may still be rolling).
void this.completeMilestones(booking, [
...(booking.tradeDirection === 'IMPORT'

View File

@@ -0,0 +1,79 @@
import { WarehouseInventoryService } from './warehouse-inventory.service';
/**
* A mid-corridor booking (import destined at an intermediate yard, or any
* DOMESTIC/intercity ride-along) used to have its booking.status flipped by
* the checkpoint-driven unload but never got a warehouse_inventory row — the
* Arrival Queue's unload count never moved and the booking was effectively
* stranded. handleBookingUnloadedAtYard reacts to the 'booking.unloadedAtYard'
* event BookingJourneyService.unloadBooking() emits and creates that row.
*/
function makeService(opts: {
existingInventory?: unknown;
booking?: Record<string, unknown> | null;
}) {
const created: Record<string, unknown>[] = [];
const inventoryRepository = {
findAll: jest.fn().mockResolvedValue(opts.existingInventory ? [opts.existingInventory] : []),
create: jest.fn((row: Record<string, unknown>) => {
created.push(row);
return Promise.resolve({ id: 'new-inv', ...row });
}),
};
const bookingRow =
opts.booking === undefined
? [{ weight: '10', freightType: 'CONTAINER', cargoTypeCode: 'GEN', customer: 'Acme' }]
: opts.booking
? [opts.booking]
: [];
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
service.inventoryRepository = inventoryRepository;
service.dataSource = { query: jest.fn().mockResolvedValue(bookingRow), manager: {} };
service.allocation = { resolveLocation: jest.fn().mockResolvedValue(null) };
service.pickDefaultLocation = jest.fn().mockResolvedValue({ warehouseId: 'w1', yardId: 'y1', zoneId: 'z1' });
service.applyCapacityDelta = jest.fn().mockResolvedValue(undefined);
service.activityLog = { record: jest.fn().mockResolvedValue(undefined) };
service.logger = { warn: jest.fn() };
return { service: service as unknown as WarehouseInventoryService, created };
}
describe('handleBookingUnloadedAtYard', () => {
it('creates an UNLOADED row with an IMPORT GRN for a fresh import booking', async () => {
const { service, created } = makeService({});
await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise<void> })
.handleBookingUnloadedAtYard({ bookingId: 'b1', tradeDirection: 'IMPORT' });
expect(created).toHaveLength(1);
expect(created[0]).toMatchObject({ bookingId: 'b1', status: 'UNLOADED' });
expect(created[0].grnNumber).toMatch(/^GRN-IMPORT-/);
});
it('creates one for a DOMESTIC/intercity ride-along too', async () => {
const { service, created } = makeService({});
await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise<void> })
.handleBookingUnloadedAtYard({ bookingId: 'b2', tradeDirection: 'DOMESTIC' });
expect(created).toHaveLength(1);
expect(created[0].grnNumber).toMatch(/^GRN-DOMESTIC-/);
});
it('skips EXPORT — its warehouse record already exists from the origin receive', async () => {
const { service, created } = makeService({});
await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise<void> })
.handleBookingUnloadedAtYard({ bookingId: 'b3', tradeDirection: 'EXPORT' });
expect(created).toHaveLength(0);
});
it('is idempotent — a booking that already has an inventory row is left alone', async () => {
const { service, created } = makeService({ existingInventory: { id: 'existing' } });
await (service as unknown as { handleBookingUnloadedAtYard: (p: unknown) => Promise<void> })
.handleBookingUnloadedAtYard({ bookingId: 'b4', tradeDirection: 'IMPORT' });
expect(created).toHaveLength(0);
});
});

View File

@@ -95,11 +95,9 @@ export class WarehouseInspectionService {
b.company_id AS "companyId",
b.trade_direction AS "tradeDirection",
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[inventoryId],
@@ -111,8 +109,11 @@ export class WarehouseInspectionService {
readyForPickupAt: new Date(),
});
const hasLastMile =
Boolean(row.lastMileDeliveryAddress?.trim?.()) || Boolean(row.serviceIncludesLastMile);
// service_types.includes_last_mile is NOT read here — every service type
// ships with it true, which made this always true regardless of the
// customer's actual self-haul/EDR-haul choice and permanently dead-coded
// the self-haul nudge below. The delivery address is the real signal.
const hasLastMile = Boolean(row.lastMileDeliveryAddress?.trim?.());
if (row.bookingReference && hasLastMile) {
await this.lastMileService.acceptBooking(row.bookingReference);

View File

@@ -1,5 +1,5 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { Cron, CronExpression } from '@nestjs/schedule';
import {
Between,
@@ -1339,8 +1339,11 @@ export class WarehouseInventoryService {
bcu.seal_numbers AS "sealNumbers",
bc.container_quantity AS "containerQuantity",
bc.container_packaging_type AS "containerPackagingType",
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
-- service_types.includes_last_mile/first_mile are NOT read here: every
-- service type ships with both true, so OR-ing them in made this always
-- true regardless of the customer's actual self-haul/EDR-haul choice.
-- The address is the only per-booking record of that choice.
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested",
oy.code AS "origin",
dy.code AS "destination",
oy.country AS "originCountry",
@@ -1351,8 +1354,7 @@ export class WarehouseInventoryService {
b.cargo_total_weight_vgm AS "weight",
b.payment_status AS "paymentStatus",
b.status AS "status",
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile",
fm.id AS "firstMileRequestId",
fm.status AS "firstMileStatus",
fm.vehicle_id AS "firstMileVehicleId",
@@ -1386,7 +1388,6 @@ export class WarehouseInventoryService {
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
LEFT JOIN LATERAL (
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
@@ -1494,8 +1495,8 @@ export class WarehouseInventoryService {
bc.container_packaging_type AS "containerPackagingType",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
oy.country AS "originCountry", dy.country AS "destinationCountry",
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
-- No service_types OR here either — see eligibleBookings above.
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile",
fm.id AS "firstMileRequestId",
fm.status AS "firstMileStatus",
v.plate_number AS "firstMileTruckPlateNumber",
@@ -1519,14 +1520,12 @@ export class WarehouseInventoryService {
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
b.company_id AS "companyId",
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile"
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
${primaryContactUserJoin('company')}
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
LEFT JOIN LATERAL (
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
@@ -1980,11 +1979,10 @@ export class WarehouseInventoryService {
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
ts.train_number AS "trainSchedule",
inv.inspection_status AS "inspectionStatus",
-- No service_types OR here either — see eligibleBookings above.
CASE WHEN NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_last_mile, false)
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested",
-- Multi-truck self-haul writes plates/drivers to
-- customer_truck_assignments and leaves the booking columns null,
-- so read the assignments first and keep the legacy column as the
@@ -2019,7 +2017,6 @@ export class WarehouseInventoryService {
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
@@ -2113,6 +2110,108 @@ export class WarehouseInventoryService {
);
}
/**
* A booking alighted from a train at ITS OWN destination yard — emitted by
* BookingJourneyService.unloadBooking() for every direction, whether that
* yard is a mid-corridor stop (checkpoint auto-unload) or the train's final
* yard (manual per-booking unload). That per-booking flow only ever flips
* booking.status; it never creates a warehouse_inventory row, which used to
* strand mid-corridor IMPORT and DOMESTIC/intercity bookings — their status
* read ARRIVED/COMPLETED but the Arrival Queue's unload count never moved
* (nothing else was watching for a mid-corridor arrival). This creates that
* row the moment the cargo is physically off the train.
*
* EXPORT is deliberately skipped: its warehouse_inventory row (and GRN) is
* created at the ORIGIN warehouse receive, before the cargo ever boards —
* see BookingsService.carriageAcceptanceSheet and receive()/bulkReceive()
* above. Creating a second row here would duplicate that receipt.
*
* Idempotent — a booking already unloaded via this listener, a retried
* checkpoint, or the final-yard "Auto Unload" bulk action is left alone.
*/
@OnEvent('booking.unloadedAtYard')
async handleBookingUnloadedAtYard(payload: {
bookingId: string;
tradeDirection: string | null;
}): Promise<void> {
if (payload.tradeDirection !== 'IMPORT' && payload.tradeDirection !== 'DOMESTIC') return;
try {
const existing = (
await this.inventoryRepository.findAll({ where: { bookingId: payload.bookingId } })
)[0];
if (existing) return;
const [booking]: Array<{
weight: string | null;
freightType: string | null;
cargoTypeCode: string | null;
customer: string | null;
}> = await this.dataSource.query(
`SELECT COALESCE(
NULLIF(b.cargo_total_weight_vgm, 0),
(SELECT SUM(bcu.vgm_tons)
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc2
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL)
) AS weight,
b.freight_type AS "freightType",
cgt.code AS "cargoTypeCode",
company.name AS customer
FROM freight.bookings b
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE b.id = $1 AND b.deleted_at IS NULL`,
[payload.bookingId],
);
if (!booking) return;
const allocated = await this.allocation.resolveLocation({
freightType: booking.freightType,
tradeDirection: payload.tradeDirection,
cargoTypeCode: booking.cargoTypeCode,
});
const location = allocated ?? (await this.pickDefaultLocation());
if (!location) {
this.logger.warn(
`Checkpoint auto-unload for booking ${payload.bookingId}: no warehouse/yard/zone configured`,
);
return;
}
const now = new Date();
const saved = await this.inventoryRepository.create({
warehouseId: location.warehouseId,
yardId: location.yardId,
zoneId: location.zoneId,
bookingId: payload.bookingId,
quantity: 1,
weight: Number(booking.weight) || 0,
status: 'UNLOADED',
grnNumber: this.generateGrnNumber(payload.tradeDirection, payload.bookingId, now, booking.customer),
arrivedAt: now,
unloadedAt: now,
notes:
(allocated as { rule?: { name: string } | null } | null)?.rule
? `Unloaded → ${(allocated as { path?: string | null }).path}`
: 'Unloaded from arrived train (checkpoint auto-unload)',
});
await this.activityLog.record({
activityType: 'INVENTORY_UNLOADED',
inventoryId: saved.id,
warehouseId: saved.warehouseId,
description: 'Unloaded from arrived train (checkpoint auto-unload)',
});
if (Number(saved.weight) > 0) {
await this.applyCapacityDelta(this.dataSource.manager, location, Number(saved.weight), 0, 0);
}
} catch (err) {
this.logger.warn(
`Checkpoint auto-unload inventory create failed for ${payload.bookingId}: ${(err as Error).message}`,
);
}
}
/**
* Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state.
* Reuses the allocation + inventory + activity-log plumbing. Does NOT store and does NOT inspect —
@@ -2690,16 +2789,16 @@ export class WarehouseInventoryService {
if (!bookingId) return;
const [booking] = await this.dataSource.query(
`SELECT reference,
last_mile_delivery_address AS "lastMileDeliveryAddress",
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
last_mile_delivery_address AS "lastMileDeliveryAddress"
FROM freight.bookings b
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
WHERE b.id = $1 AND b.deleted_at IS NULL
LIMIT 1`,
[bookingId],
);
const hasLastMile =
Boolean(booking?.lastMileDeliveryAddress?.trim?.()) || Boolean(booking?.serviceIncludesLastMile);
// service_types.includes_last_mile is NOT read here — every service type
// ships with it true, so it can't distinguish EDR last-mile from self-haul.
// The delivery address is the only per-booking record of that choice.
const hasLastMile = Boolean(booking?.lastMileDeliveryAddress?.trim?.());
if (!booking?.reference || !hasLastMile) return;
await this.lastMileService.acceptBooking(booking.reference);
}
@@ -3062,14 +3161,15 @@ export class WarehouseInventoryService {
*/
private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise<boolean> {
const runner = manager ?? this.dataSource;
// service_types.includes_last_mile is NOT read here — every service type
// ships with it true, which made the second disjunct below unreachable and
// this method effectively return true only from an assigned truck.
const [row]: Array<{ ok: number }> = await runner.query(
`SELECT 1 AS ok
FROM freight.bookings b
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
WHERE b.id = $1 AND b.deleted_at IS NULL
AND (b.customer_truck_assigned_at IS NOT NULL
OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL
AND COALESCE(st.includes_last_mile, false) = false))`,
OR NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL)`,
[bookingId],
);
return Boolean(row);

View File

@@ -150,8 +150,9 @@ function AllocationRules() {
.filter((yard) => yard.code)
.map((yard) => ({
value: yard.code,
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
label: `${yard.name} (${yard.code})${yard.warehouse?.code ? ` ${yard.warehouse.code}` : ''}`,
}));
const yardNameByCode = Object.fromEntries(yards.map((yard) => [yard.code, yard.name]));
const resetForm = () => {
setEditingId(null);
@@ -223,7 +224,11 @@ function AllocationRules() {
{
id: 'targetYard',
header: 'Target yard',
cell: ({ row }) => <Badge variant="light">{row.original.targetYardCode}</Badge>,
cell: ({ row }) => (
<Badge variant="light">
{yardNameByCode[row.original.targetYardCode] ?? row.original.targetYardCode}
</Badge>
),
},
{
id: 'active',
@@ -307,7 +312,8 @@ function AllocationRules() {
{anyLabel(form.freightType, 'freight type').toLowerCase()} booking
{form.cargoTypeCode.trim() ? ` with cargo code ${form.cargoTypeCode.trim()}` : ''}
{form.containerStatus.trim() ? ` and container status ${form.containerStatus.trim()}` : ''}{' '}
is received, <b>send it to</b> {form.targetYardCode || 'a selected target yard'}.
is received, <b>send it to</b>{' '}
{(form.targetYardCode && yardNameByCode[form.targetYardCode]) || form.targetYardCode || 'a selected target yard'}.
</Text>
</Stack>
</Card>