From 1fe8c2fd8e0da1f0c3f9aaa5979c859d6d79d179 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 21 Jul 2026 08:00:56 +0000 Subject: [PATCH] feat(export): gate receive on payment and loading on received + GRN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/common/export-received-gate.spec.ts | 39 +++++++++++++ .../src/common/export-received-gate.ts | 50 +++++++++++++++++ .../booking-journey.service.ts | 4 ++ .../train-scheduling.service.ts | 37 +++++++++++++ .../warehouses/receive-export-paid.spec.ts | 55 +++++++++++++++++++ .../warehouses/warehouse-inventory.service.ts | 26 +++++++++ 6 files changed, 211 insertions(+) create mode 100644 apps/edr-freight-api/src/common/export-received-gate.spec.ts create mode 100644 apps/edr-freight-api/src/common/export-received-gate.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/receive-export-paid.spec.ts diff --git a/apps/edr-freight-api/src/common/export-received-gate.spec.ts b/apps/edr-freight-api/src/common/export-received-gate.spec.ts new file mode 100644 index 000000000..6aaa24a26 --- /dev/null +++ b/apps/edr-freight-api/src/common/export-received-gate.spec.ts @@ -0,0 +1,39 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; + +import { assertExportReceivedWithGrn } from './export-received-gate'; + +const db = (rows: unknown[]) => + ({ query: jest.fn().mockResolvedValue(rows) }) as unknown as DataSource; + +describe('assertExportReceivedWithGrn', () => { + it('passes when the export booking has a received row with a GRN', async () => { + await expect( + assertExportReceivedWithGrn(db([{ '?column?': 1 }]), { + id: 'b-1', + tradeDirection: 'EXPORT', + }), + ).resolves.toBeUndefined(); + }); + + it('rejects an export booking with nothing received', async () => { + await expect( + assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'EXPORT' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('never blocks import — it loads off a train, not out of the warehouse', async () => { + const source = db([]); + await expect( + assertExportReceivedWithGrn(source, { id: 'b-1', tradeDirection: 'IMPORT' }), + ).resolves.toBeUndefined(); + // Import short-circuits before querying. + expect((source.query as jest.Mock)).not.toHaveBeenCalled(); + }); + + it('does not block intercity cargo', async () => { + await expect( + assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/edr-freight-api/src/common/export-received-gate.ts b/apps/edr-freight-api/src/common/export-received-gate.ts new file mode 100644 index 000000000..0e1728800 --- /dev/null +++ b/apps/edr-freight-api/src/common/export-received-gate.ts @@ -0,0 +1,50 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource, EntityManager } from 'typeorm'; + +/** The booking fields the gate needs. */ +export interface ExportLoadGateBooking { + id: string; + tradeDirection?: string | null; +} + +/** + * Export cargo may not be loaded onto its train until it has physically reached + * the warehouse and been issued a GRN — whether it got there by first-mile or by + * the customer's own truck, and even though a wagon is already allocated. An + * allocation is a plan; the GRN is the proof the goods are actually in hand. + * + * Several loading paths (per-yard load, workspace confirm-loaded) marked cargo + * loaded straight off the allocation, skipping the warehouse, so a booking could + * ride the train with nothing ever received. This closes that for export; import + * loads off a train and is unaffected. + * + * "Received with a GRN" = an inventory row that has reached the warehouse + * (RECEIVED or any later stage) and carries a GRN, in the column or the notes + * fallback older rows use. + */ +export async function assertExportReceivedWithGrn( + db: DataSource | EntityManager, + booking: ExportLoadGateBooking, +): Promise { + if (booking.tradeDirection !== 'EXPORT') return; + + const [row] = await db.query( + `SELECT 1 + FROM freight.warehouse_inventory inv + WHERE inv.booking_id = $1 + AND inv.deleted_at IS NULL + AND inv.status IN ('RECEIVED', 'STORED', 'RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED') + AND COALESCE( + NULLIF(TRIM(inv.grn_number), ''), + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) IS NOT NULL + LIMIT 1`, + [booking.id], + ); + + if (!row) { + throw new BadRequestException( + 'This export booking has not been received at the warehouse yet — receive its cargo and generate a GRN before loading it onto the train.', + ); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index fb140158c..db18d6804 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -21,6 +21,7 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin import { Wagon } from '../wagons/entities/wagon.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; +import { assertExportReceivedWithGrn } from '../../common/export-received-gate'; /** * Per-booking journey along a train's corridor — for EVERY trade direction. @@ -68,6 +69,9 @@ export class BookingJourneyService { } await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin'); + // Export cargo must be in the warehouse with a GRN before it can be loaded, + // however it arrived and whatever it is allocated to. + await assertExportReceivedWithGrn(this.dataSource, booking); const now = new Date(); await this.dataSource.transaction(async (manager) => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 2f7ca0776..93d8bba56 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2632,6 +2632,11 @@ export class TrainSchedulingService { // dispatch pre-check keeps reporting these bookings as unloaded). const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId); if (wagonAssignedIds.size) { + // Export cargo must be received at the warehouse with a GRN before it can + // be confirmed loaded — an allocation is not proof the goods are in hand. + if (this.isExportSchedule(schedule)) { + await this.assertExportBookingsReceived([...wagonAssignedIds]); + } await this.trainScheduleBookingsRepository.updateLoadingStatusMany( scheduleId, [...wagonAssignedIds], @@ -2909,6 +2914,38 @@ export class TrainSchedulingService { return direction === 'EXPORT'; } + /** + * Every export booking being confirmed loaded must already be received at the + * warehouse with a GRN. An allocation puts a booking on a wagon on paper; this + * is the check that the cargo is physically in the yard before we call it loaded. + */ + private async assertExportBookingsReceived(bookingIds: string[]): Promise { + if (!bookingIds.length) return; + const rows: Array<{ reference: string | null }> = await this.dataSource.query( + `SELECT b.reference + FROM freight.bookings b + WHERE b.id = ANY($1) + AND b.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM freight.warehouse_inventory inv + WHERE inv.booking_id = b.id + AND inv.deleted_at IS NULL + AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED','DISPATCHED') + AND COALESCE( + NULLIF(TRIM(inv.grn_number), ''), + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) IS NOT NULL + )`, + [bookingIds], + ); + if (rows.length) { + const refs = rows.map((r) => r.reference ?? '(unknown)').join(', '); + throw new BadRequestException( + `These export bookings are not received at the warehouse yet — receive their cargo and generate a GRN before loading: ${refs}.`, + ); + } + } + private buildImportLoadListHtml(loadList: Awaited>): string { const esc = (value: unknown) => String(value ?? '-') diff --git a/apps/edr-freight-api/src/modules/warehouses/receive-export-paid.spec.ts b/apps/edr-freight-api/src/modules/warehouses/receive-export-paid.spec.ts new file mode 100644 index 000000000..38268af11 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/receive-export-paid.spec.ts @@ -0,0 +1,55 @@ +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; + +function makeGuard(paymentStatus: string | null) { + const query = jest.fn().mockResolvedValue([{ paymentStatus }]); + const service = Object.create(WarehouseInventoryService.prototype) as Record; + 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(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index d05007d8c..d842727ee 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -2550,6 +2550,7 @@ export class WarehouseInventoryService { async receive(dto: ReceiveWarehouseInventoryDto): Promise { const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null; + await this.assertExportBookingPaid(dto.bookingId, bookingDirection); const id = await this.dataSource.transaction(async (manager) => { const { warehouse, yard, zone } = await this.validateLocation(manager, dto); @@ -5648,6 +5649,31 @@ export class WarehouseInventoryService { ); } + /** + * Export cargo is received into the warehouse to wait for its train, and it is + * received only once the booking is paid — receiving an unpaid export booking + * would start storage and mint a GRN against cargo the customer has not settled. + * + * Export only: import cargo arrives OFF a train and its receive is the unload, + * so gating that on payment would strand cargo already at the yard. + */ + private async assertExportBookingPaid( + bookingId: string | null | undefined, + direction: string | null, + ): Promise { + if (!bookingId || direction !== 'EXPORT') return; + const [row]: Array<{ paymentStatus: string | null }> = await this.dataSource.query( + `SELECT payment_status AS "paymentStatus" + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if ((row?.paymentStatus ?? '').toUpperCase() !== 'PAID') { + throw new BadRequestException( + 'This export booking is not paid yet — its cargo cannot be received at the warehouse until payment is settled.', + ); + } + } + private assertCapacity( label: string, node: LocationNode,