mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 16:35:42 +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.
|
-- 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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user