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,