Files
edr-platform/apps/edr-freight-api/src/modules/warehouses/receive-export-paid.spec.ts
Hagernesh 1fe8c2fd8e feat(export): gate receive on payment and loading on received + GRN
Three export rules that the flow left open.

An unpaid export booking could be received at the warehouse. Receiving is
what starts storage and mints a GRN, so it must not happen against cargo the
customer has not settled. receive() now rejects an unpaid EXPORT booking.
Import is untouched — it arrives OFF a train and its receive is the unload,
so gating that on payment would strand cargo already at the yard.

An allocated export booking could be marked loaded onto its train without
ever reaching the warehouse. An allocation is a plan; the GRN is the proof
the goods are in hand. Two loading paths skipped that check — the per-yard
loadBooking and the workspace confirmScheduleLoading — and both now require
every export booking to be received with a GRN first, however it arrived
(first-mile or the customer's own truck) and whatever it is allocated to.
The rule lives in one shared guard (assertExportReceivedWithGrn) so the two
paths cannot drift.

Export self-haul without a first-mile leg already worked and is unchanged:
assertSelfHaulPaid allows a customer truck when there is no EDR mile leg and
the booking is paid, and addTruck applies the same one-40ft-or-two-20ft rule
to containers and the tonnage drawdown to bulk, exactly as import does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:01:32 +00:00

56 lines
1.9 KiB
TypeScript

import { BadRequestException } from '@nestjs/common';
import { WarehouseInventoryService } from './warehouse-inventory.service';
/**
* Export cargo is received into the warehouse to wait for its train, and only a
* paid booking may be received — otherwise storage and a GRN would start against
* cargo the customer has not settled. Import is never blocked: it arrives OFF a
* train and its receive is the unload.
*
* The guard touches only the DataSource, so the instance is built off the
* prototype rather than stubbing all 20-odd collaborators.
*/
type Guard = (
bookingId: string | null | undefined,
direction: string | null,
) => Promise<void>;
function makeGuard(paymentStatus: string | null) {
const query = jest.fn().mockResolvedValue([{ paymentStatus }]);
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
service.dataSource = { query };
const guard = (
service as unknown as { assertExportBookingPaid: Guard }
).assertExportBookingPaid.bind(service);
return { guard, query };
}
describe('receive() — export paid gate', () => {
it('rejects an unpaid export booking', async () => {
const { guard } = makeGuard('PENDING');
await expect(guard('b-1', 'EXPORT')).rejects.toBeInstanceOf(BadRequestException);
});
it('allows a paid export booking', async () => {
const { guard } = makeGuard('PAID');
await expect(guard('b-1', 'EXPORT')).resolves.toBeUndefined();
});
it('never blocks import, paid or not', async () => {
const { guard, query } = makeGuard('PENDING');
await expect(guard('b-1', 'IMPORT')).resolves.toBeUndefined();
expect(query).not.toHaveBeenCalled();
});
it('ignores a receive with no booking attached', async () => {
const { guard, query } = makeGuard('PENDING');
await expect(guard(null, 'EXPORT')).resolves.toBeUndefined();
expect(query).not.toHaveBeenCalled();
});
});