From d229808fd8674bea4a4e68bb35d4329e351507f5 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 31 Jul 2026 13:06:32 +0000 Subject: [PATCH] =?UTF-8?q?Fix=202=20=E2=80=94=20lastmile=20should=20only?= =?UTF-8?q?=20be=20yes=20if=20EDR=20last=20mile.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../booking-journey.service.ts | 8 + .../booking-unloaded-at-yard.spec.ts | 79 ++++++++++ .../warehouse-inspection.service.ts | 11 +- .../warehouses/warehouse-inventory.service.ts | 146 +++++++++++++++--- .../pages/warehouses/WarehouseRulesPage.tsx | 12 +- 5 files changed, 225 insertions(+), 31 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/warehouses/booking-unloaded-at-yard.spec.ts diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 3b9fddf1f..50835d5b0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -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' diff --git a/apps/edr-freight-api/src/modules/warehouses/booking-unloaded-at-yard.spec.ts b/apps/edr-freight-api/src/modules/warehouses/booking-unloaded-at-yard.spec.ts new file mode 100644 index 000000000..836dd38b7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/booking-unloaded-at-yard.spec.ts @@ -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 | null; +}) { + const created: Record[] = []; + + const inventoryRepository = { + findAll: jest.fn().mockResolvedValue(opts.existingInventory ? [opts.existingInventory] : []), + create: jest.fn((row: Record) => { + 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; + 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 }) + .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 }) + .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 }) + .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 }) + .handleBookingUnloadedAtYard({ bookingId: 'b4', tradeDirection: 'IMPORT' }); + + expect(created).toHaveLength(0); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts index b5894c21b..be1c68bbd 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts @@ -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); 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 1355b7218..1159c1151 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 @@ -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 { + 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 { 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); diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index 032bd22db..a6339965a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -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 }) => {row.original.targetYardCode}, + cell: ({ row }) => ( + + {yardNameByCode[row.original.targetYardCode] ?? row.original.targetYardCode} + + ), }, { 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, send it to {form.targetYardCode || 'a selected target yard'}. + is received, send it to{' '} + {(form.targetYardCode && yardNameByCode[form.targetYardCode]) || form.targetYardCode || 'a selected target yard'}.