diff --git a/apps/edr-freight-api/src/modules/warehouses/unload-booking-grn.spec.ts b/apps/edr-freight-api/src/modules/warehouses/unload-booking-grn.spec.ts new file mode 100644 index 000000000..0835ac862 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/unload-booking-grn.spec.ts @@ -0,0 +1,86 @@ +import { WarehouseInventoryService } from './warehouse-inventory.service'; +import type { UnloadBookingDto } from './dto/unload-booking.dto'; + +/** + * A GRN is the receipt for cargo entering the warehouse, so unloadBooking must + * issue one for every direction — import as well as export. It used to mint only + * for export, leaving import cargo received with no GRN. + */ +function makeService(opts: { + tradeDirection: string | null; + existing?: { id: string; grnNumber: string | null }; +}) { + const created: Record[] = []; + const updated: Array<{ id: string; patch: Record }> = []; + + const inventoryRepository = { + findAll: jest.fn().mockResolvedValue(opts.existing ? [opts.existing] : []), + update: jest.fn((id: string, patch: Record) => { + updated.push({ id, patch }); + return Promise.resolve(); + }), + create: jest.fn((row: Record) => { + created.push(row); + return Promise.resolve({ id: 'new-inv', ...row }); + }), + }; + + const service = Object.create(WarehouseInventoryService.prototype) as Record; + service.inventoryRepository = inventoryRepository; + service.dataSource = { + query: jest.fn().mockResolvedValue([{ tradeDirection: opts.tradeDirection }]), + }; + // Location comes straight from the dto in these cases, so pickDefaultLocation + // is never reached; findById just echoes what was written. + service.findById = jest.fn((id: string) => + Promise.resolve(updated.find((u) => u.id === id)?.patch ?? created[0] ?? { id }), + ); + + const dto: UnloadBookingDto = { + warehouseId: 'w1', + yardId: 'y1', + zoneId: 'z1', + } as UnloadBookingDto; + + return { service: service as unknown as WarehouseInventoryService, dto, created, updated }; +} + +describe('unloadBooking — GRN issuance', () => { + it('issues an IMPORT GRN when unloading a fresh import booking', async () => { + const { service, dto, created } = makeService({ tradeDirection: 'IMPORT' }); + + await service.unloadBooking('b-import', dto); + + expect(created[0].grnNumber).toMatch(/^GRN-IMPORT-/); + }); + + it('still issues an EXPORT GRN', async () => { + const { service, dto, created } = makeService({ tradeDirection: 'EXPORT' }); + + await service.unloadBooking('b-export', dto); + + expect(created[0].grnNumber).toMatch(/^GRN-EXPORT-/); + }); + + it('mints a GRN for an existing import row that has none', async () => { + const { service, dto, updated } = makeService({ + tradeDirection: 'IMPORT', + existing: { id: 'inv-1', grnNumber: null }, + }); + + await service.unloadBooking('b-import', dto); + + expect(updated[0].patch.grnNumber).toMatch(/^GRN-IMPORT-/); + }); + + it('does not reissue when the row already has a GRN', async () => { + const { service, dto, updated } = makeService({ + tradeDirection: 'IMPORT', + existing: { id: 'inv-1', grnNumber: 'GRN-IMPORT-EXISTING' }, + }); + + await service.unloadBooking('b-import', dto); + + expect(updated[0].patch).not.toHaveProperty('grnNumber'); + }); +}); 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 5fe25416f..d05007d8c 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 @@ -1138,14 +1138,15 @@ export class WarehouseInventoryService { /** Unload a single arrived booking into a chosen (or default) location. */ async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise { const existing = await this.inventoryRepository.findAll({ where: { bookingId } }); - // EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto - // a train without one. Import GRN handling is left untouched. + // A GRN is the receipt for cargo entering the warehouse, so every booking + // gets one on unload — import as well as export. The direction only decides + // the GRN prefix, not whether one is issued. const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query( `SELECT trade_direction AS "tradeDirection" FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, [bookingId], ); - const isExport = bookingRow?.tradeDirection === 'EXPORT'; + const grnDirection = bookingRow?.tradeDirection ?? 'WH'; let location: DefaultLocation | null = dto.warehouseId && dto.yardId && dto.zoneId @@ -1166,10 +1167,10 @@ export class WarehouseInventoryService { zoneId: location.zoneId, status: 'RECEIVED', arrivedAt, - // Export only, and keep an already-issued GRN rather than reissuing. - ...(isExport && !existing[0].grnNumber - ? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) } - : {}), + // Keep an already-issued GRN rather than reissuing; mint one otherwise. + ...(existing[0].grnNumber + ? {} + : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }), notes: dto.notes ?? existing[0].notes ?? 'Unloaded', }); return this.findById(existing[0].id); @@ -1184,9 +1185,7 @@ export class WarehouseInventoryService { weight: 0, status: 'RECEIVED', arrivedAt, - ...(isExport - ? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) } - : {}), + grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt), notes: dto.notes ?? 'Unloaded', }); return this.findById(saved.id); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx index 6bda9e88d..02f85a3d7 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInfoCard.tsx @@ -13,6 +13,19 @@ import { ReceiveInventoryModal } from './ReceiveInventoryModal'; interface WarehouseInfoCardProps { bookingId: string; bookingReference?: string; + /** + * Booking payment status. Export cargo is received into the warehouse only + * after the booking is paid — receiving an unpaid booking starts storage and + * GRN against cargo the customer has not settled. Optional so existing callers + * that do not have the booking to hand keep their current behaviour. + */ + paymentStatus?: string | null; + /** + * IMPORT | EXPORT | DOMESTIC. The payment gate is export-only: import cargo + * arrives OFF a train, so blocking its receive would strand cargo already at + * the yard. + */ + tradeDirection?: string | null; } function Row({ label, value }: { label: string; value: React.ReactNode }) { @@ -28,7 +41,12 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) { ); } -export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfoCardProps) { +export function WarehouseInfoCard({ + bookingId, + bookingReference, + paymentStatus, + tradeDirection, +}: WarehouseInfoCardProps) { const [modalOpen, setModalOpen] = useState(false); const { data, isLoading } = useQuery( api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }), @@ -46,6 +64,13 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo const wagon = scheduleView?.wagon; const isLoadedOrDispatched = latest?.status === 'LOADED' || latest?.status === 'DISPATCHED'; + // Export only, and only when we were actually told the status — an absent prop + // means the caller cannot answer, and guessing "unpaid" would disable a valid + // action. Mirrors the server guard on receive(). + const awaitingPayment = + tradeDirection?.toUpperCase() === 'EXPORT' && + paymentStatus != null && + paymentStatus.toUpperCase() !== 'PAID'; return ( @@ -127,8 +152,12 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo )} {/* span wrapper so the tooltip still fires on the disabled button */} @@ -138,7 +167,7 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo leftSection={} onClick={() => setModalOpen(true)} fullWidth - disabled={Boolean(latest)} + disabled={Boolean(latest) || awaitingPayment} > {latest ? 'Received At Warehouse' : 'Receive At Warehouse'} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index e8bd568ce..01e2368bd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -260,6 +260,8 @@ export default function BookingRequestDetailPage() {