mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +00:00
Merge branch 'inspection-load-gate' into dev
This commit is contained in:
@@ -0,0 +1,146 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { WarehouseInspectionService } from './warehouse-inspection.service';
|
||||||
|
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cargo whose inspection failed or is under review must not travel. It becomes
|
||||||
|
* loadable only by being re-inspected and passed, and that reversal has to say
|
||||||
|
* why — the cargo was deliberately held, so its release is deliberate too.
|
||||||
|
*
|
||||||
|
* Only the collaborators each rule touches are stubbed; the instances are built
|
||||||
|
* off the prototype rather than wiring all 20-odd dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('load() — inspection gate', () => {
|
||||||
|
const loadWithInspection = (inspectionStatus: string | null) => {
|
||||||
|
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
|
||||||
|
service.findById = jest.fn().mockResolvedValue({
|
||||||
|
id: 'inv-1',
|
||||||
|
status: 'READY_FOR_LOADING',
|
||||||
|
inspectionStatus,
|
||||||
|
warehouseId: 'w-1',
|
||||||
|
yardId: 'y-1',
|
||||||
|
zoneId: 'z-1',
|
||||||
|
});
|
||||||
|
service.assertTransition = jest.fn();
|
||||||
|
// Reached only if the gate lets the item through — failing loudly here
|
||||||
|
// proves the gate did NOT stop it.
|
||||||
|
service.scheduling = {
|
||||||
|
findWagon: jest.fn().mockRejectedValue(new Error('gate did not block')),
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
service as unknown as { load: (id: string, dto: unknown) => Promise<unknown> }
|
||||||
|
).load.bind(service);
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each(['FAILED', 'NEEDS_REVIEW'])('refuses to load %s cargo', async (status) => {
|
||||||
|
await expect(loadWithInspection(status)('inv-1', { wagonId: 'w' })).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to load cargo that was never inspected', async () => {
|
||||||
|
await expect(loadWithInspection(null)('inv-1', { wagonId: 'w' })).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the outcome so the operator knows what to fix', async () => {
|
||||||
|
await expect(loadWithInspection('FAILED')('inv-1', { wagonId: 'w' })).rejects.toThrow(
|
||||||
|
/FAILED/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets passed cargo through the gate', async () => {
|
||||||
|
// It fails later, at the wagon lookup — which is the proof it got past the
|
||||||
|
// inspection gate rather than being stopped by it.
|
||||||
|
await expect(loadWithInspection('PASSED')('inv-1', { wagonId: 'w' })).rejects.toThrow(
|
||||||
|
'gate did not block',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inspection report — reversing a held inspection', () => {
|
||||||
|
const createReport = (previousStatus: string, itemStatus = 'RECEIVED') => {
|
||||||
|
const update = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const service = Object.create(WarehouseInspectionService.prototype) as Record<string, unknown>;
|
||||||
|
service.dataSource = {
|
||||||
|
getRepository: () => ({
|
||||||
|
findOne: jest.fn().mockResolvedValue({
|
||||||
|
id: 'inv-1',
|
||||||
|
bookingId: 'b-1',
|
||||||
|
inspectionStatus: previousStatus,
|
||||||
|
status: itemStatus,
|
||||||
|
}),
|
||||||
|
update,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
service.inspectionRepository = {
|
||||||
|
findAll: jest.fn().mockResolvedValue([]),
|
||||||
|
create: jest.fn().mockResolvedValue({ id: 'rep-1' }),
|
||||||
|
};
|
||||||
|
service.markImportPickupReadyAndAcceptLastMile = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const create = (
|
||||||
|
service as unknown as {
|
||||||
|
create: (id: string, dto: unknown) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
).create.bind(service);
|
||||||
|
return { create, update };
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each(['FAILED', 'NEEDS_REVIEW'])(
|
||||||
|
'rejects passing %s cargo with no reason given',
|
||||||
|
async (previous) => {
|
||||||
|
const { create } = createReport(previous);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'PASSED' }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects whitespace as a reason', async () => {
|
||||||
|
const { create } = createReport('FAILED');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'PASSED', remarks: ' ' }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the reversal once a reason is recorded', async () => {
|
||||||
|
const { create } = createReport('FAILED');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
create('inv-1', {
|
||||||
|
reportType: 'INSPECTION',
|
||||||
|
inspectionStatus: 'PASSED',
|
||||||
|
remarks: 'Reworked packaging, re-weighed and verified.',
|
||||||
|
}),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('needs no reason for a first-time pass', async () => {
|
||||||
|
const { create } = createReport(null as unknown as string);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'PASSED' }),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pulls failed cargo back out of the ready-to-load queue', async () => {
|
||||||
|
const { create, update } = createReport('PASSED', 'READY_FOR_LOADING');
|
||||||
|
|
||||||
|
await create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'FAILED' });
|
||||||
|
|
||||||
|
expect(update).toHaveBeenCalledWith('inv-1', { status: 'RECEIVED' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves cargo that never reached the ready queue where it is', async () => {
|
||||||
|
const { create, update } = createReport('PASSED', 'STORED');
|
||||||
|
|
||||||
|
await create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'FAILED' });
|
||||||
|
|
||||||
|
expect(update).not.toHaveBeenCalledWith('inv-1', { status: 'RECEIVED' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||||
@@ -37,6 +37,20 @@ export class WarehouseInspectionService {
|
|||||||
throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Overturning a held inspection is a deliberate act: the cargo was kept off
|
||||||
|
// the train, and the record has to say why it may now travel. A bare PASS
|
||||||
|
// with no remarks leaves the release unexplained.
|
||||||
|
const previousStatus = inventory.inspectionStatus;
|
||||||
|
if (
|
||||||
|
dto.inspectionStatus === 'PASSED' &&
|
||||||
|
(previousStatus === 'FAILED' || previousStatus === 'NEEDS_REVIEW') &&
|
||||||
|
!dto.remarks?.trim()
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Give a reason in Remarks for passing cargo whose inspection is ${previousStatus}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const expected = dto.expectedWeight ?? null;
|
const expected = dto.expectedWeight ?? null;
|
||||||
const actual = dto.actualWeight ?? null;
|
const actual = dto.actualWeight ?? null;
|
||||||
const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null;
|
const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null;
|
||||||
@@ -83,6 +97,15 @@ export class WarehouseInspectionService {
|
|||||||
|
|
||||||
if (dto.inspectionStatus === 'PASSED') {
|
if (dto.inspectionStatus === 'PASSED') {
|
||||||
await this.markImportPickupReadyAndAcceptLastMile(inventoryId);
|
await this.markImportPickupReadyAndAcceptLastMile(inventoryId);
|
||||||
|
} else if (
|
||||||
|
inventory.status === 'READY_FOR_LOADING' ||
|
||||||
|
inventory.status === 'READY_FOR_PICKUP'
|
||||||
|
) {
|
||||||
|
// A failed or under-review re-inspection pulls the cargo back out of the
|
||||||
|
// ready queue. load() refuses it either way, but leaving it READY_FOR_*
|
||||||
|
// would keep it sitting on the loading and pickup lists as if nothing
|
||||||
|
// had happened.
|
||||||
|
await inventoryRepo.update(inventoryId, { status: 'RECEIVED' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return report;
|
return report;
|
||||||
|
|||||||
@@ -3096,6 +3096,13 @@ export class WarehouseInventoryService {
|
|||||||
const item = await this.inventoryRepository.findById(inventoryId);
|
const item = await this.inventoryRepository.findById(inventoryId);
|
||||||
if (!item) { skip('Inventory not found'); continue; }
|
if (!item) { skip('Inventory not found'); continue; }
|
||||||
if (item.inspectionStatus === 'PASSED') { skip('Already inspected'); continue; }
|
if (item.inspectionStatus === 'PASSED') { skip('Already inspected'); continue; }
|
||||||
|
// Overturning a failure is a deliberate, reasoned act — never a side
|
||||||
|
// effect of ticking a row in a list. Those items are held back for an
|
||||||
|
// individual re-inspection that records why the cargo may now travel.
|
||||||
|
if (item.inspectionStatus === 'FAILED' || item.inspectionStatus === 'NEEDS_REVIEW') {
|
||||||
|
skip(`Inspection ${item.inspectionStatus} — re-inspect this item individually and give a reason`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!eligible.includes(item.status)) { skip(`Status ${item.status} not eligible for inspection`); continue; }
|
if (!eligible.includes(item.status)) { skip(`Status ${item.status} not eligible for inspection`); continue; }
|
||||||
|
|
||||||
// Reuse the existing inspection service: creates a minimal PASSED report + sets inspectionStatus/inspectedAt.
|
// Reuse the existing inspection service: creates a minimal PASSED report + sets inspectionStatus/inspectedAt.
|
||||||
@@ -5816,6 +5823,18 @@ export class WarehouseInventoryService {
|
|||||||
// 1. inventory status must be READY_FOR_LOADING (and not already LOADED).
|
// 1. inventory status must be READY_FOR_LOADING (and not already LOADED).
|
||||||
this.assertTransition(item.status, 'LOADED');
|
this.assertTransition(item.status, 'LOADED');
|
||||||
|
|
||||||
|
// 1b. Failed or under-review cargo does not travel. Status alone is not
|
||||||
|
// enough: an item that passed, reached READY_FOR_LOADING and was then
|
||||||
|
// re-inspected as FAILED keeps that status, so the inspection outcome is
|
||||||
|
// checked here — the one choke point every loading path runs through.
|
||||||
|
if (item.inspectionStatus !== 'PASSED') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
item.inspectionStatus
|
||||||
|
? `Inspection is ${item.inspectionStatus} — the cargo must be re-inspected and passed, with a reason, before it can be loaded`
|
||||||
|
: 'Inventory must pass inspection before it can be loaded',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 2. inventory is at a valid warehouse/yard/zone location.
|
// 2. inventory is at a valid warehouse/yard/zone location.
|
||||||
if (!item.warehouseId || !item.yardId || !item.zoneId) {
|
if (!item.warehouseId || !item.yardId || !item.zoneId) {
|
||||||
throw new BadRequestException('Inventory must be at a warehouse/yard/zone before loading');
|
throw new BadRequestException('Inventory must be at a warehouse/yard/zone before loading');
|
||||||
|
|||||||
Reference in New Issue
Block a user