Merge branch 'inspection-booking-cascade' into dev

This commit is contained in:
hager
2026-09-06 20:13:50 +00:00
2 changed files with 124 additions and 5 deletions

View File

@@ -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<string[]>;
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<string, unknown>;
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();
});
});

View File

@@ -1441,8 +1441,10 @@ export class WarehouseInventoryService {
-- true regardless of the customer's actual self-haul/EDR-haul choice. -- true regardless of the customer's actual self-haul/EDR-haul choice.
-- The address is the only per-booking record of that 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", (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested",
oy.code AS "origin", -- Operators know a yard by its name: KALITY is universally called
dy.code AS "destination", -- 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", oy.country AS "originCountry",
dy.country AS "destinationCountry", dy.country AS "destinationCountry",
b.freight_type AS "freightType", b.freight_type AS "freightType",
@@ -2032,8 +2034,10 @@ export class WarehouseInventoryService {
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight", inv.weight AS "weight",
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
oy.code AS "origin", -- Operators know a yard by its name: KALITY is universally called
dy.code AS "destination", -- 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", oy.country AS "originCountry",
dy.country AS "destinationCountry", dy.country AS "destinationCountry",
inv.inspection_status AS "inspectionStatus", 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). // UNLOADED added for Batch 9 import destination inspection (arrived-train unload landing state).
const eligible = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED']; 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) => { const skip = (reason: string) => {
result.skippedCount += 1; result.skippedCount += 1;
result.results.push({ inventoryId, status: 'SKIPPED', reason }); result.results.push({ inventoryId, status: 'SKIPPED', reason });
@@ -3144,6 +3156,41 @@ export class WarehouseInventoryService {
return result; 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<string[]> {
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 ────────────────────────────────────────────────────────────── // ── Receive ──────────────────────────────────────────────────────────────
private async acceptLastMileIfRequested(bookingId?: string | null): Promise<void> { private async acceptLastMileIfRequested(bookingId?: string | null): Promise<void> {