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 {