mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +00:00
Merge branch 'inspection-booking-cascade' into dev
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async acceptLastMileIfRequested(bookingId?: string | null): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user