From cb150e46b7156cbb686dfbbdbae825c91452fd51 Mon Sep 17 00:00:00 2001 From: hager Date: Sun, 6 Sep 2026 20:02:10 +0000 Subject: [PATCH] feat(warehouses): pass a whole booking when its cargo is inspected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marking a received item inspected advanced only the rows the operator ticked. A booking's cargo spans one inventory row per container, and a multi-truck arrival files a GRN batch per truck, so a six-container booking stayed half-inspected and never reached Ready To Load. Widen the selection to every still-inspectable row of the same booking before inspecting. Each row is then passed and advanced exactly as before — EXPORT to READY_FOR_LOADING, IMPORT to READY_FOR_PICKUP — so the whole booking moves together. The rows the operator actually ticked lead the response, and are kept even when ineligible so their skip reason still surfaces rather than being dropped silently. Rows already PASSED, rows in a status that cannot be inspected, and rows belonging to another booking are left alone. Ad-hoc inventory with no booking expands to itself. Also show yards by name in the receive and received queues. They selected yards.code, so the route column read KALITY, the internal code for the yard everyone calls GMP / Gelan Multipurpose Port. Verified: freight-api type-check passes; both new SQL statements EXPLAIN-validated against the dev database; 14 warehouse suites pass (78 tests, 5 of them new). Co-Authored-By: Claude Opus 5 --- .../inspection-booking-cascade.spec.ts | 72 +++++++++++++++++++ .../warehouses/warehouse-inventory.service.ts | 57 +++++++++++++-- 2 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/warehouses/inspection-booking-cascade.spec.ts diff --git a/apps/edr-freight-api/src/modules/warehouses/inspection-booking-cascade.spec.ts b/apps/edr-freight-api/src/modules/warehouses/inspection-booking-cascade.spec.ts new file mode 100644 index 000000000..79034312f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/inspection-booking-cascade.spec.ts @@ -0,0 +1,72 @@ +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +/** + * A booking's cargo spans one inventory row per container, and a multi-truck + * arrival files a GRN batch per truck. Inspection is a judgement on the cargo, + * not on the row it happens to sit in, so ticking one row must pass every + * still-inspectable row of the same booking — otherwise a six-container + * booking stays half-inspected and never reaches Ready To Load. + * + * Only the DataSource is touched, so the instance is built off the prototype + * rather than stubbing all 20-odd collaborators. + */ +type Expand = (inventoryIds: string[], eligibleStatuses: string[]) => Promise; + +const ELIGIBLE = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED']; + +function makeExpand(rows: Array<{ id: string }>) { + const query = jest.fn().mockResolvedValue(rows); + const service = Object.create(WarehouseInventoryService.prototype) as Record; + service.dataSource = { query }; + const expand = ( + service as unknown as { expandInspectionToBooking: Expand } + ).expandInspectionToBooking.bind(service); + return { expand, query }; +} + +describe('bulkMarkInspected — booking cascade', () => { + it('pulls in the booking siblings of a selected row', async () => { + const { expand } = makeExpand([{ id: 'inv-1' }, { id: 'inv-2' }, { id: 'inv-3' }]); + + await expect(expand(['inv-1'], ELIGIBLE)).resolves.toEqual([ + 'inv-1', + 'inv-2', + 'inv-3', + ]); + }); + + it('leads with the rows the operator actually ticked', async () => { + // The response the operator reads should open with their own selection, + // whatever order the database returned the siblings in. + const { expand } = makeExpand([{ id: 'inv-3' }, { id: 'inv-2' }, { id: 'inv-1' }]); + + const result = await expand(['inv-1'], ELIGIBLE); + + expect(result[0]).toBe('inv-1'); + expect(result.slice(1).sort()).toEqual(['inv-2', 'inv-3']); + }); + + it('never repeats a row when two siblings are both selected', async () => { + const { expand } = makeExpand([{ id: 'inv-1' }, { id: 'inv-2' }]); + + const result = await expand(['inv-1', 'inv-2'], ELIGIBLE); + + expect(result).toEqual(['inv-1', 'inv-2']); + expect(new Set(result).size).toBe(result.length); + }); + + it('passes the eligible statuses to the query rather than hard-coding them', async () => { + const { expand, query } = makeExpand([{ id: 'inv-1' }]); + + await expand(['inv-1'], ELIGIBLE); + + expect(query).toHaveBeenCalledWith(expect.any(String), [['inv-1'], ELIGIBLE]); + }); + + it('does not query at all for an empty selection', async () => { + const { expand, query } = makeExpand([]); + + await expect(expand([], ELIGIBLE)).resolves.toEqual([]); + expect(query).not.toHaveBeenCalled(); + }); +}); 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 ef68f3673..5e4e7430d 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 @@ -1441,8 +1441,10 @@ export class WarehouseInventoryService { -- 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", + -- Operators know a yard by its name: KALITY is universally called + -- GMP / Gelan Multipurpose Port. Code is only a fallback. + COALESCE(oy.label, oy.code) AS "origin", + COALESCE(dy.label, dy.code) AS "destination", oy.country AS "originCountry", dy.country AS "destinationCountry", b.freight_type AS "freightType", @@ -2032,8 +2034,10 @@ export class WarehouseInventoryService { COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", inv.weight AS "weight", COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", - oy.code AS "origin", - dy.code AS "destination", + -- Operators know a yard by its name: KALITY is universally called + -- GMP / Gelan Multipurpose Port. Code is only a fallback. + COALESCE(oy.label, oy.code) AS "origin", + COALESCE(dy.label, dy.code) AS "destination", oy.country AS "originCountry", dy.country AS "destinationCountry", inv.inspection_status AS "inspectionStatus", @@ -3075,7 +3079,15 @@ export class WarehouseInventoryService { // UNLOADED added for Batch 9 import destination inspection (arrived-train unload landing state). const eligible = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED']; - for (const inventoryId of dto.inventoryIds) { + // Inspection is a judgement on the booking's cargo, not on the row it + // happens to sit in. A booking's cargo spans one inventory row per + // container, and a multi-truck arrival adds a GRN batch per truck — so + // ticking one row passes every eligible row of the same booking and the + // whole booking advances together. Without this a six-container booking + // stayed half-inspected and never reached Ready To Load. + const inventoryIds = await this.expandInspectionToBooking(dto.inventoryIds, eligible); + + for (const inventoryId of inventoryIds) { const skip = (reason: string) => { result.skippedCount += 1; result.results.push({ inventoryId, status: 'SKIPPED', reason }); @@ -3144,6 +3156,41 @@ export class WarehouseInventoryService { return result; } + /** + * Widen a set of selected inventory rows to every still-inspectable row of + * the same booking. + * + * The originally selected ids are always kept, even when ineligible, so the + * caller still reports their skip reason rather than dropping them silently. + * Rows with no booking (ad-hoc inventory) expand to themselves. + */ + private async expandInspectionToBooking( + inventoryIds: string[], + eligibleStatuses: string[], + ): Promise { + if (inventoryIds.length === 0) return []; + const rows: Array<{ id: string }> = await this.dataSource.query( + `SELECT DISTINCT sibling.id AS id + FROM freight.warehouse_inventory selected + JOIN freight.warehouse_inventory sibling + ON sibling.booking_id = selected.booking_id + AND sibling.deleted_at IS NULL + AND sibling.inspection_status IS DISTINCT FROM 'PASSED' + AND sibling.status = ANY($2::text[]) + WHERE selected.id = ANY($1::uuid[]) + AND selected.deleted_at IS NULL + AND selected.booking_id IS NOT NULL + UNION + SELECT id FROM freight.warehouse_inventory + WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`, + [inventoryIds, eligibleStatuses], + ); + // Selected rows first so their results lead the response the operator sees. + const expanded = rows.map((row) => row.id); + const selectedFirst = inventoryIds.filter((id) => expanded.includes(id)); + return [...selectedFirst, ...expanded.filter((id) => !selectedFirst.includes(id))]; + } + // ── Receive ────────────────────────────────────────────────────────────── private async acceptLastMileIfRequested(bookingId?: string | null): Promise {