diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts index 37612d318..8d56bd0d7 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -378,6 +378,13 @@ export class LastMileRequestsService { contractGeneratedAt: new Date(), } as Partial); + // Only now, with the request APPROVED, is an advance actually owed on the + // leg. The warehouse auto-accept (IMPORT inspection PASSED) may have already + // opened that leg at READY_TO_TRANSIT, so pull it back to PAYMENT_PENDING — + // otherwise this booking would be dispatchable before the customer has + // signed the contract or paid a birr. No-op for a leg this call just created. + await this.lastMileService.holdForAdvance(lastMile.id); + if (booking.companyId) { void this.notifications.notify({ recipients: { companyId: booking.companyId }, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.advance-gate.spec.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.advance-gate.spec.ts new file mode 100644 index 000000000..8e0b8ba55 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.advance-gate.spec.ts @@ -0,0 +1,214 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; + +import { ADVANCE_UNPAID_MESSAGE, LastMileService } from './last-mile.service'; +import type { LastMileStatus } from './entities/last-mile.entity'; +import type { UpdateLastMileDto } from './dto/update-last-mile.dto'; + +/** + * The advance gate: a delivery becomes dispatchable (READY_TO_TRANSIT) or moves + * (IN_TRANSIT) only once the customer has paid the advance the Truck & Machinery + * chief approved. + * + * It used to leak both ways. The warehouse auto-accept (IMPORT inspection + * PASSED) opens the leg at READY_TO_TRANSIT and runs independently of the + * review, so whichever side acted second found the other already done: accept + * first and the leg was dispatchable before an advance was ever asked for; + * approve first and create() handed the existing dispatchable leg straight back + * untouched. + */ +function makeService( + opts: { + /** APPROVED requests on the booking carrying a positive advance. */ + advancesDue?: number; + /** PAID LAST_MILE_ADVANCE invoices on the leg. */ + advancesPaid?: number; + status?: LastMileStatus; + } = {}, +) { + const leg = { + id: 'lm-1', + bookingId: 'b-1', + status: opts.status ?? 'READY_TO_TRANSIT', + vehicleId: 'v-1', + booking: { reference: 'BK-001' }, + }; + + const query = jest.fn((sql: string) => { + if (sql.includes('customer_truck_assignments')) return Promise.resolve([]); + // The batched list enrichment, not the gate's own lookup. + if (sql.includes('FROM freight.last_mile lm')) return Promise.resolve([]); + if (sql.includes('freight.last_mile_requests')) { + return Promise.resolve([{ count: opts.advancesDue ?? 0 }]); + } + // Discriminated on the charge type, not the table: attachMileFinancials + // also queries freight.invoices (for the booking-invoice advance line). + if (sql.includes('LAST_MILE_ADVANCE')) { + return Promise.resolve([{ count: opts.advancesPaid ?? 0 }]); + } + if (sql.includes('FROM freight.bookings')) { + return Promise.resolve([ + { tradeDirection: 'IMPORT', firstMile: null, lastMile: 'Bole, Addis Ababa' }, + ]); + } + return Promise.resolve([]); + }); + + const lastMileRepository = { + findAll: jest.fn().mockResolvedValue([]), + findById: jest.fn().mockResolvedValue(leg), + create: jest.fn((row: unknown) => Promise.resolve({ id: 'lm-1', ...(row as object) })), + update: jest.fn((_id: string, patch: object) => Promise.resolve({ ...leg, ...patch })), + }; + + const service = new LastMileService( + lastMileRepository as never, + {} as never, // bookingsRepository + { + findById: jest.fn().mockResolvedValue({ + id: 'v-1', + plateNumber: 'AA-123', + assignedDriverId: 'd-1', + assignedDriverName: 'Driver', + }), + setAvailability: jest.fn(), + releaseIfUnused: jest.fn(), + } as never, // vehiclesService + {} as never, // driversService + {} as never, // smsClient + { + query, + // DELIVERED frees the trucks this leg was holding. + manager: { + find: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }, + } as unknown as DataSource, + { record: jest.fn() } as never, // history + { findBySourceIds: jest.fn().mockResolvedValue([]) } as never, // billing + { findLiveRatesDetailed: jest.fn().mockResolvedValue([]) } as never, // ratesService + {} as never, // filesService + ); + + return { service, lastMileRepository, leg }; +} + +const createdStatus = (repo: { create: jest.Mock }) => + (repo.create.mock.calls[0]?.[0] as { status?: string } | undefined)?.status; + +describe('LastMileService - advance gate on creation', () => { + it('opens an auto-accepted leg at PAYMENT_PENDING when an advance is owed', async () => { + const { service, lastMileRepository } = makeService({ advancesDue: 1 }); + + // The warehouse path asks for no status at all - it used to get + // READY_TO_TRANSIT and hand the customer a dispatchable unpaid delivery. + await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never); + + expect(createdStatus(lastMileRepository)).toBe('PAYMENT_PENDING'); + }); + + it('still opens at READY_TO_TRANSIT when no approved request owes an advance', async () => { + const { service, lastMileRepository } = makeService({ advancesDue: 0 }); + + await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never); + + expect(createdStatus(lastMileRepository)).toBe('READY_TO_TRANSIT'); + }); +}); + +describe('LastMileService - advance gate on transitions', () => { + it('refuses IN_TRANSIT while the advance is unpaid', async () => { + const { service } = makeService({ advancesDue: 1, advancesPaid: 0 }); + + await expect( + service.update('lm-1', { status: 'IN_TRANSIT' } as UpdateLastMileDto), + ).rejects.toThrow(ADVANCE_UNPAID_MESSAGE); + }); + + it('refuses a leg being made dispatchable while the advance is unpaid', async () => { + const { service } = makeService({ + advancesDue: 1, + advancesPaid: 0, + status: 'PAYMENT_PENDING', + }); + + await expect( + service.update('lm-1', { status: 'READY_TO_TRANSIT' } as UpdateLastMileDto), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('allows IN_TRANSIT once the advance invoice is paid', async () => { + const { service } = makeService({ advancesDue: 1, advancesPaid: 1 }); + + const updated = await service.update('lm-1', { + status: 'IN_TRANSIT', + } as UpdateLastMileDto); + + expect(updated.status).toBe('IN_TRANSIT'); + }); + + it('requires one paid advance per approved departure', async () => { + // Containers arriving across two departures get a request - and an advance + // - each. One paid advance does not release the second. + const { service } = makeService({ advancesDue: 2, advancesPaid: 1 }); + + await expect( + service.update('lm-1', { status: 'IN_TRANSIT' } as UpdateLastMileDto), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('lets the paid listener through before the invoice row is visible', async () => { + // Billing emits inline, pre-commit, when the transition joins a caller's + // transaction - so the invoice still reads unpaid here. The event is the + // proof of payment; re-reading the row would refuse the transition the + // payment just earned. + const { service } = makeService({ + advancesDue: 1, + advancesPaid: 0, + status: 'PAYMENT_PENDING', + }); + + const updated = await service.update( + 'lm-1', + { status: 'READY_TO_TRANSIT' } as UpdateLastMileDto, + { advanceSettled: true }, + ); + + expect(updated.status).toBe('READY_TO_TRANSIT'); + }); + + it('leaves states that are not transit alone', async () => { + const { service } = makeService({ advancesDue: 1, advancesPaid: 0 }); + + await expect( + service.update('lm-1', { status: 'DELIVERED' } as UpdateLastMileDto), + ).resolves.toBeDefined(); + }); +}); + +describe('LastMileService.holdForAdvance', () => { + it('pulls an already-dispatchable leg back when approval imposes an advance', async () => { + const { service, lastMileRepository } = makeService({ + advancesDue: 1, + status: 'READY_TO_TRANSIT', + }); + + await service.holdForAdvance('lm-1'); + + expect(lastMileRepository.update).toHaveBeenCalledWith( + 'lm-1', + expect.objectContaining({ status: 'PAYMENT_PENDING' }), + ); + }); + + it('never rewrites a leg that is already on the road', async () => { + const { service, lastMileRepository } = makeService({ + advancesDue: 1, + status: 'IN_TRANSIT', + }); + + await service.holdForAdvance('lm-1'); + + expect(lastMileRepository.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 8ed5ae8aa..4f8acd03a 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -59,6 +59,13 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [ 'createdAt', ]; +/** The states that mean the delivery is dispatchable or already on the road. */ +const TRANSIT_STATUSES: LastMileStatus[] = ['READY_TO_TRANSIT', 'IN_TRANSIT']; + +export const ADVANCE_UNPAID_MESSAGE = + 'The last-mile advance has not been paid yet — this delivery cannot become ' + + 'dispatchable or move until the advance invoice is settled.'; + @Injectable() export class LastMileService { private readonly logger = new Logger(LastMileService.name); @@ -93,9 +100,47 @@ export class LastMileService { for (const r of records) { (r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; } + await this.attachAdvanceState(records); await attachMileFinancials(this.dataSource, records, 'LAST_MILE'); } + /** + * Flag the legs whose advance is still owed, so the UI can disable the actions + * the API would refuse instead of firing them into a 400. Same rule as + * {@link advanceOutstanding}, batched over the whole page. + */ + private async attachAdvanceState(records: LastMile[]): Promise { + const ids = records.map((r) => r.id).filter(Boolean); + if (!ids.length) return; + const rows: Array<{ lastMileId: string; due: number; paid: number }> = + await this.dataSource.query( + `SELECT lm.id AS "lastMileId", + (SELECT COUNT(*) + FROM freight.last_mile_requests lmr + WHERE lmr.booking_id = lm.booking_id + AND lmr.deleted_at IS NULL + AND lmr.status = 'APPROVED' + AND COALESCE(lmr.approved_advance_amount, 0) > 0)::int AS "due", + (SELECT COUNT(*) + FROM freight.invoices i + WHERE i.source = 'last_mile' + AND i.source_id = lm.id::text + AND i.type = 'LAST_MILE_ADVANCE' + AND i.status = 'PAID' + AND i.deleted_at IS NULL)::int AS "paid" + FROM freight.last_mile lm + WHERE lm.id = ANY($1::uuid[]) AND lm.deleted_at IS NULL`, + [ids], + ); + const outstanding = new Map( + rows.map((r) => [r.lastMileId, Number(r.due) > Number(r.paid)]), + ); + for (const r of records) { + (r as LastMile & { advanceOutstanding?: boolean }).advanceOutstanding = + outstanding.get(r.id) ?? false; + } + } + /** Resolve a vehicle's driver + human labels, for stamping mile events onto * the driver's timeline and naming the vehicle. Best-effort — never throws. */ private async vehicleInfo( @@ -185,6 +230,67 @@ export class LastMileService { } } + /** + * Whether this booking still owes an advance on its delivery. + * + * An advance is owed for every APPROVED last-mile request carrying a positive + * approved amount — a booking whose containers arrive across several + * departures gets a request, and therefore an advance, per departure. Each is + * settled by a PAID `LAST_MILE_ADVANCE` invoice raised on the leg when the + * customer signs that request's contract, so the leg is clear only once it has + * as many paid advance invoices as the booking has approved requests. + * + * A booking with no approved request owes nothing and is unaffected: legs that + * never went through the confirmation flow keep behaving exactly as before. + * `lastMileId` is null while the leg is still being created — no invoice can + * point at a row that does not exist yet, so nothing can have been settled. + */ + private async advanceOutstanding( + bookingId: string, + lastMileId: string | null, + ): Promise { + const [due] = await this.dataSource.query( + `SELECT COUNT(*)::int AS "count" + FROM freight.last_mile_requests lmr + WHERE lmr.booking_id = $1 + AND lmr.deleted_at IS NULL + AND lmr.status = 'APPROVED' + AND COALESCE(lmr.approved_advance_amount, 0) > 0`, + [bookingId], + ); + const owed = Number(due?.count ?? 0); + if (!owed) return false; + if (!lastMileId) return true; + + const [paid] = await this.dataSource.query( + `SELECT COUNT(*)::int AS "count" + FROM freight.invoices i + WHERE i.source = 'last_mile' + AND i.source_id = $1 + AND i.type = 'LAST_MILE_ADVANCE' + AND i.status = 'PAID' + AND i.deleted_at IS NULL`, + [lastMileId], + ); + return Number(paid?.count ?? 0) < owed; + } + + /** + * Hold a leg at PAYMENT_PENDING because an advance has just been imposed on it. + * + * The warehouse auto-accept (IMPORT inspection PASSED) opens the leg + * independently of the chief's review, and opens it at READY_TO_TRANSIT. When + * that happens first, approval has to pull the leg back — otherwise the advance + * gate never holds on that ordering and the delivery is dispatchable unpaid. + * A leg already IN_TRANSIT or DELIVERED is left alone: that is a record of what + * happened, not a plan that can still be changed. + */ + async holdForAdvance(id: string): Promise { + const record = await this.findById(id); + if (record.status !== 'READY_TO_TRANSIT') return; + await this.update(id, { status: 'PAYMENT_PENDING' } as UpdateLastMileDto); + } + async acceptBooking(bookingReference: string): Promise { const booking = await this.bookingsRepository.findByReference(bookingReference); @@ -425,9 +531,16 @@ export class LastMileService { await this.assertEdrHaulsThisBooking(dto.bookingId); + // A leg that owes an advance is not dispatchable, whatever the caller asked + // for. The warehouse auto-accept path asks for no status at all and used to + // land straight in READY_TO_TRANSIT, which let an unpaid delivery go. + const status: LastMileStatus = (await this.advanceOutstanding(dto.bookingId, null)) + ? 'PAYMENT_PENDING' + : (dto.status ?? 'READY_TO_TRANSIT'); + const record = await this.lastMileRepository.create({ bookingId: dto.bookingId, - status: dto.status ?? 'READY_TO_TRANSIT', + status, advancedPayment: dto.advancedPayment ?? 0, remainingPayment: dto.remainingPayment ?? 0, estimatedKm: dto.estimatedKm ?? (await estimateMileKm(this.dataSource, dto.bookingId, 'LAST')), @@ -461,11 +574,16 @@ export class LastMileService { async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { try { if (payload.type === 'LAST_MILE_ADVANCE') { - // Advance paid → the leg becomes dispatchable, not delivered. - await this.update(payload.sourceId, { - status: 'READY_TO_TRANSIT', - advancedPayment: payload.totalAmount, - } as unknown as UpdateLastMileDto); + // Advance paid → the leg becomes dispatchable, not delivered. This event + // IS the settlement, so it carries its own way past the advance gate. + await this.update( + payload.sourceId, + { + status: 'READY_TO_TRANSIT', + advancedPayment: payload.totalAmount, + } as unknown as UpdateLastMileDto, + { advanceSettled: true }, + ); this.logger.log( `Last-mile ${payload.sourceId} READY_TO_TRANSIT on advance invoice ${payload.invoiceId} payment`, ); @@ -484,9 +602,32 @@ export class LastMileService { } } - async update(id: string, dto: UpdateLastMileDto): Promise { + /** + * `opts.advanceSettled` is the paid listener's own bypass, and nothing else + * should pass it: the invoice event is itself the proof of payment, and it can + * reach us inline before the invoice row commits (billing emits before commit + * when the transition is enlisted in a caller-supplied manager), so re-reading + * the invoice here would still see it unpaid and refuse the very transition the + * payment just earned. + */ + async update( + id: string, + dto: UpdateLastMileDto, + opts: { advanceSettled?: boolean } = {}, + ): Promise { const existing = await this.findById(id); + // Nothing becomes dispatchable, and nothing moves, until the advance is paid. + if ( + !opts.advanceSettled && + dto.status !== undefined && + dto.status !== existing.status && + TRANSIT_STATUSES.includes(dto.status) && + (await this.advanceOutstanding(existing.bookingId, id)) + ) { + throw new BadRequestException(ADVANCE_UNPAID_MESSAGE); + } + // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle // assigned in this same request). if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 15a3f6c4a..77755fa2f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -199,9 +199,14 @@ export class WarehouseInventoryController { @Get('loadable-trains') @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) - @ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' }) - loadableTrains() { - return this.inventoryService.loadableTrains(); + @ApiOperation({ + summary: + 'EXPORT trains with inventory waiting to be loaded — pre-dispatch by default; `includeDispatched=true` adds rolling trains still picking cargo up along the corridor', + }) + loadableTrains(@Query('includeDispatched') includeDispatched?: string) { + return this.inventoryService.loadableTrains({ + includeDispatched: includeDispatched === 'true' || includeDispatched === '1', + }); } @Get('train/:scheduleId/loadable-items') 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 82231bacc..ef68f3673 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 @@ -2091,7 +2091,17 @@ export class WarehouseInventoryService { */ private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE; - async loadableTrains(): Promise { + /** + * @param includeDispatched also list DISPATCHED trains. Loading follows the + * train after it rolls — a mid-corridor warehouse boards its cargo when the + * train stands at its yard — so the warehouse's train-centric loading view + * needs the same set the schedule workspace offers Load on. The default + * (pre-dispatch only) keeps the existing auto-load picker unchanged. + */ + async loadableTrains(opts: { includeDispatched?: boolean } = {}): Promise { + const statuses = opts.includeDispatched + ? ['DRAFT', 'SCHEDULED', 'DISPATCHED'] + : ['DRAFT', 'SCHEDULED']; const rows: Array< LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null } > = await this.dataSource.query( @@ -2129,7 +2139,7 @@ export class WarehouseInventoryService { AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED') ) ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, - [['DRAFT', 'SCHEDULED']], + [statuses], ); return rows diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index b10e937c3..1f3a1cf81 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -11,8 +11,10 @@ import { Loader, Menu, Modal, + MultiSelect, NumberInput, ScrollArea, + SegmentedControl, Select, SimpleGrid, Stack, @@ -91,6 +93,7 @@ import { MoveInventoryModal } from './MoveInventoryModal'; import { ReleaseOrderModal } from './ReleaseOrderModal'; import { StoreInventoryModal } from './StoreInventoryModal'; import { WarehouseInquiryTable } from './WarehouseInquiryTable'; +import { TrainLoadingWorkspace } from './TrainLoadingWorkspace'; import { YardLoadingWindows } from './YardLoadingWindows'; import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options'; import { openPdfBlob } from './pdf'; @@ -1861,6 +1864,9 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: api.warehouses.readyToLoadExport.queryOptions({ enabled }), ); const qc = useQueryClient(); + // "Items" is the inventory list with the auto-load picker; "By train" mirrors + // the train schedule's per-booking Load / Wagons / Unload workspace here. + const [view, setView] = useState<'items' | 'train'>('items'); const [trainPickerOpen, setTrainPickerOpen] = useState(false); const [expandedRow, setExpandedRow] = useState(null); const [targetScheduleId, setTargetScheduleId] = useState(null); @@ -1952,16 +1958,50 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: } }; + if (view === 'train') { + return ( + + + setView(v as 'items' | 'train')} + data={[ + { value: 'items', label: 'Items' }, + { value: 'train', label: 'By train' }, + ]} + /> + + Per-booking Load, wagon-by-wagon loading and unloading — the train schedule's own + actions, run from the warehouse. + + + + + ); + } + return ( - - - {selected.size > 0 ? ( - <>{selected.size} of {controls.filteredRows.length} selected - ) : ( - <>{controls.filteredRows.length} item{controls.filteredRows.length !== 1 ? 's' : ''} ready to load - )} - + + + setView(v as 'items' | 'train')} + data={[ + { value: 'items', label: 'Items' }, + { value: 'train', label: 'By train' }, + ]} + /> + + {selected.size > 0 ? ( + <>{selected.size} of {controls.filteredRows.length} selected + ) : ( + <>{controls.filteredRows.length} item{controls.filteredRows.length !== 1 ? 's' : ''} ready to load + )} + +