From 035369af4333d6a38c654f59d673cbe2e1a7b604 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 1 Aug 2026 09:10:04 +0000 Subject: [PATCH 01/70] fix intercity unloading at facility --- .../booking-journey.service.ts | 37 +++++++++++++++++-- .../backfill-missing-unload-inventory.ts | 8 ++++ 2 files changed, 41 insertions(+), 4 deletions(-) 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 87e756ba6..e5933d35f 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 @@ -327,19 +327,48 @@ export class BookingJourneyService { RETURNING b.id, b.trade_direction`, [schedule.id, schedule.destinationStationId, now], ); + if (rows.length === 0) return []; + + // The facility took the cargo off the train at the final yard — raise its + // GRN, same as the per-booking unloadBooking() path does. Only when that + // yard also has a warehouse (or has no facility at all, e.g. Kality) does + // WarehouseInventoryService additionally get to allocate a warehouse/yard/ + // zone row: a pure facility yard (Dire Dawa, Modjo, Sebeta, Adama) is + // fully represented by the facility event alone — there is nothing there + // for warehouse_inventory's NOT NULL warehouse/yard/zone to point at. + const facility = await this.yardFacilities.facilityForYard(schedule.destinationStationId); + const bookings = await manager + .getRepository(Booking) + .find({ where: { id: In(rows.map((r) => r.id)) }, relations: ['company'] }); + const bookingById = new Map(bookings.map((b) => [b.id, b])); + for (const row of rows) { // Intercity rows just completed — let a ONE_TIME contract close on delivery. if (row.trade_direction === 'DOMESTIC') { this.events.emit('booking.completed', { bookingId: row.id }); } + + const booking = bookingById.get(row.id); + if (booking) { + await this.facilityHandling.recordHandling(manager, { + booking, + yardId: schedule.destinationStationId, + trainScheduleId: schedule.id, + eventType: 'UNLOAD', + occurredAt: now, + }); + } + // Same event the per-booking unloadBooking() path emits — WarehouseInventoryService // listens for this to auto-create the warehouse_inventory row (import/intercity only, // it filters EXPORT itself). The bulk SQL update above skipped this entirely, so // bookings caught by this fallback never left "awaiting unload". - this.events.emit('booking.unloadedAtYard', { - bookingId: row.id, - tradeDirection: row.trade_direction, - }); + if (row.trade_direction !== 'EXPORT' && (!facility?.hasFacility || facility.hasWarehouse)) { + this.events.emit('booking.unloadedAtYard', { + bookingId: row.id, + tradeDirection: row.trade_direction, + }); + } } return rows.map((r) => r.id); } diff --git a/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts b/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts index 9d51107fe..608fba613 100644 --- a/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts +++ b/apps/edr-freight-api/src/scripts/backfill-missing-unload-inventory.ts @@ -27,12 +27,20 @@ async function main() { const dataSource = app.get(DataSource); const inventory = app.get(WarehouseInventoryService); + // Skip bookings destined for a pure facility yard (has a facility but no + // warehouse, e.g. Dire Dawa) — those are fully represented by their + // facility_handling_events UNLOAD record, not a warehouse_inventory row. + // Same gate as BookingJourneyService.autoArriveAtFinalYard. const bookings: { id: string; tradeDirection: string }[] = await dataSource.query( `SELECT b.id, b.trade_direction AS "tradeDirection" FROM freight.bookings b + JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.yard_facilities yf + ON yf.yard_id = dy.id AND yf.deleted_at IS NULL AND yf.is_active = true WHERE b.deleted_at IS NULL AND b.trade_direction IN ('IMPORT', 'DOMESTIC') AND b.status IN ('ARRIVED', 'COMPLETED') + AND NOT (dy.has_facility = true AND COALESCE(yf.has_warehouse, false) = false) AND NOT EXISTS ( SELECT 1 FROM freight.warehouse_inventory wi WHERE wi.booking_id = b.id AND wi.deleted_at IS NULL From 6a62ece8142cca4e0dbf4e4ad64d40d1f2634309 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sun, 2 Aug 2026 23:07:32 +0000 Subject: [PATCH 02/70] feat(train-scheduling): auto-place intercity cargo on wagons freed mid-corridor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intercity bookings ride the wagons freed by earlier unloads (e.g. import containers uncoupled at Dire Dawa). loadBooking now auto-allocates a DOMESTIC booking onto on-train slots whose cargo has all departed — greedy in consist order by capacity, container numbers copied for the marshalling tally. Falls back to unallocated load when nothing is free. --- .../booking-journey.service.spec.ts | 145 ++++++++++++++++++ .../booking-journey.service.ts | 101 ++++++++++++ .../train-scheduling.controller.ts | 14 ++ .../train-scheduling.service.spec.ts | 126 +++++++++++++++ .../train-scheduling.service.ts | 113 +++++++++++++- .../backoffice/src/constants/URLS.ts | 2 + .../TrainScheduleTrackPage.tsx | 65 ++++++-- .../TrainScheduleV2DetailPage.tsx | 37 ++++- .../src/services/trainScheduling.service.ts | 10 ++ 9 files changed, 593 insertions(+), 20 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts new file mode 100644 index 000000000..0a8cc99fe --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts @@ -0,0 +1,145 @@ +import { BookingJourneyService } from './booking-journey.service'; + +/** + * autoPlaceOnFreedWagons: intercity cargo boards the wagons freed by earlier + * unloads. Exercised directly with a stubbed EntityManager — the surrounding + * loadBooking flow is integration-tested through the running app. + */ +describe('BookingJourneyService.autoPlaceOnFreedWagons', () => { + const service = new BookingJourneyService( + {} as never, // dataSource + {} as never, // yardFacilities + {} as never, // facilityHandling + { emit: jest.fn() } as never, // events + ); + + const schedule = { id: 'sched-1', trainSetId: 'ts-1' }; + const booking = { + id: 'booking-1', + reference: 'BK-1', + cargoTotalWeightVgm: 50, + freightType: 'CONTAINER', + }; + + const makeManager = (slots: unknown[], existingAllocs: unknown[] = []) => { + const savedAllocs: Array> = []; + const savedItems: Array> = []; + const allocQb = { + innerJoinAndSelect: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(existingAllocs), + }; + const slotQb = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(slots), + }; + let allocId = 0; + const manager = { + getRepository: jest.fn((entity: { name?: string }) => { + const name = entity?.name; + if (name === 'WagonBookingAllocation') { + return { + createQueryBuilder: jest.fn(() => allocQb), + create: jest.fn((v: Record) => v), + save: jest.fn(async (v: Record) => { + const row = { ...v, id: `alloc-${++allocId}` }; + savedAllocs.push(row); + return row; + }), + update: jest.fn(), + }; + } + if (name === 'TrainSetWagon') { + return { createQueryBuilder: jest.fn(() => slotQb) }; + } + if (name === 'BookingContainer') { + return { + find: jest.fn().mockResolvedValue([ + { + id: 'line-1', + containerNumber: 'LINE-001', + containerTypeId: 'ct-20', + units: [{ containerNumber: 'UNIT-001' }, { containerNumber: 'UNIT-002' }], + }, + ]), + }; + } + if (name === 'WagonAllocationContainerItem') { + return { + create: jest.fn((v: Record) => v), + save: jest.fn(async (v: Record) => { + savedItems.push(v); + return v; + }), + }; + } + throw new Error(`Unexpected repository ${name}`); + }), + }; + return { manager, savedAllocs, savedItems }; + }; + + const call = (manager: unknown) => + (service as never as { + autoPlaceOnFreedWagons: (m: unknown, s: unknown, b: unknown) => Promise; + }).autoPlaceOnFreedWagons(manager, schedule, booking); + + it('places the booking on freed slots in consist order, with container items', async () => { + const slots = [ + // Active cargo still riding — NOT freed. + { id: 'slot-1', sequenceNo: 1, capacityTons: 60, allocations: [{ status: 'LOADED' }] }, + // Freed by an earlier unload. + { id: 'slot-2', sequenceNo: 2, capacityTons: 60, allocations: [{ status: 'DEPARTED' }] }, + { id: 'slot-3', sequenceNo: 3, capacityTons: 60, allocations: [] }, + ]; + const { manager, savedAllocs, savedItems } = makeManager(slots); + + await call(manager); + + // 50 t fits on the first freed slot alone. + expect(savedAllocs).toHaveLength(1); + expect(savedAllocs[0]).toMatchObject({ + trainSetWagonId: 'slot-2', + bookingId: 'booking-1', + allocatedWeightTons: 50, + status: 'LOADED', + }); + // One item per physical unit, on the first allocation. + expect(savedItems.map((i) => i.containerNumber)).toEqual(['UNIT-001', 'UNIT-002']); + expect(savedItems.every((i) => i.wagonBookingAllocationId === 'alloc-1')).toBe(true); + }); + + it('spills over onto the next freed slot when one is not enough', async () => { + const slots = [ + { id: 'slot-2', sequenceNo: 2, capacityTons: 30, allocations: [{ status: 'DEPARTED' }] }, + { id: 'slot-3', sequenceNo: 3, capacityTons: 30, allocations: [] }, + ]; + const { manager, savedAllocs } = makeManager(slots); + + await call(manager); + + expect(savedAllocs.map((a) => [a.trainSetWagonId, a.allocatedWeightTons])).toEqual([ + ['slot-2', 30], + ['slot-3', 20], + ]); + }); + + it('does nothing when the booking already has allocations', async () => { + const { manager, savedAllocs } = makeManager([], [{ id: 'existing' }]); + await call(manager); + expect(savedAllocs).toHaveLength(0); + }); + + it('loads without allocation when no wagon is free', async () => { + const slots = [ + { id: 'slot-1', sequenceNo: 1, capacityTons: 60, allocations: [{ status: 'LOADED' }] }, + ]; + const { manager, savedAllocs } = makeManager(slots); + await expect(call(manager)).resolves.toBeUndefined(); + expect(savedAllocs).toHaveLength(0); + }); +}); 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 e5933d35f..8668b4f75 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 @@ -13,6 +13,8 @@ import { Freight } from '@edr/types'; import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service'; import { FacilityHandlingService } from './facility-handling.service'; import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { Yard } from '../rule-engine/entities/yard.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; @@ -82,6 +84,11 @@ export class BookingJourneyService { loadedAt: now, loadedByUserId: userId ?? null, } as never); + // Intercity cargo rides the wagons freed by earlier unloads along the + // corridor — place it before the status flip so it boards with a wagon. + if (booking.tradeDirection === 'DOMESTIC') { + await this.autoPlaceOnFreedWagons(manager, schedule, booking); + } await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED'); // Keep the schedule↔booking link's tracking flag in sync — the dispatch // readiness warnings and workspace badges read loading_status, not loadedAt. @@ -464,6 +471,100 @@ export class BookingJourneyService { } } + /** + * INTERCITY ONLY. Intercity cargo does not get its own wagons — it rides the + * slots freed by cargo already unloaded along the corridor (e.g. import + * containers uncoupled at Dire Dawa). Staff pinning is a pre-dispatch tool, + * so a DOMESTIC booking loaded mid-corridor is auto-placed here: greedy over + * on-train slots (not DEPARTED) with no active cargo (every allocation + * DEPARTED, or none), in consist order, by capacity. Container numbers are + * copied onto the first allocation so the marshalling document and its + * 40ft/20ft tally stay truthful. When nothing is free the load proceeds + * unallocated — the marshalling document then lists the booking as on board + * without a recorded wagon. + * ponytail: remainder over free capacity is dumped on the last used slot + * (paper overload beats missing cargo); upgrade path is a capacity guard in + * the intercity accept step. + */ + private async autoPlaceOnFreedWagons( + manager: EntityManager, + schedule: TrainSchedule, + booking: Booking, + ): Promise { + const existing = await this.allocationsForBooking(manager, schedule.id, booking.id); + if (existing.length) return; + + const slots = await manager + .getRepository(TrainSetWagon) + .createQueryBuilder('slot') + .leftJoinAndSelect('slot.allocations', 'alloc') + .innerJoin( + TrainSchedule, + 'schedule', + 'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId', + { scheduleId: schedule.id }, + ) + .where(`slot.status != 'DEPARTED'`) + .orderBy('slot.sequence_no', 'ASC') + .getMany(); + const freed = slots.filter((slot) => + (slot.allocations ?? []).every((a) => a.status === 'DEPARTED'), + ); + if (!freed.length) { + this.logger.warn( + `No freed wagon for intercity booking ${booking.reference} on schedule ${schedule.id} — loading without wagon allocation`, + ); + return; + } + + let remaining = Number(booking.cargoTotalWeightVgm) || 0; + const allocRepo = manager.getRepository(WagonBookingAllocation); + const created: WagonBookingAllocation[] = []; + for (const slot of freed) { + const capacity = Number(slot.capacityTons) || remaining || 1; + const take = Math.min(remaining || capacity, capacity); + created.push( + await allocRepo.save( + allocRepo.create({ + trainSetWagonId: slot.id, + bookingId: booking.id, + allocatedWeightTons: take, + loadType: booking.freightType ?? null, + status: 'LOADED', + }), + ), + ); + remaining = Math.max(0, remaining - take); + if (remaining <= 0) break; + } + if (remaining > 0 && created.length) { + await allocRepo.update(created[created.length - 1].id, { + allocatedWeightTons: () => `allocated_weight_tons + ${remaining}`, + } as never); + } + + // Container numbers onto the first allocation, from the booking's container + // lines (per physical unit when recorded, else per line). + const lines = await manager + .getRepository(BookingContainer) + .find({ where: { bookingId: booking.id }, relations: { units: true } }); + const itemRepo = manager.getRepository(WagonAllocationContainerItem); + const first = created[0]; + for (const line of lines) { + const units = line.units?.length ? line.units : [null]; + for (const unit of units) { + await itemRepo.save( + itemRepo.create({ + wagonBookingAllocationId: first.id, + bookingContainerId: line.id, + containerNumber: unit?.containerNumber ?? line.containerNumber ?? null, + containerTypeId: line.containerTypeId ?? null, + }), + ); + } + } + } + private async setAllocationStatuses( manager: EntityManager, scheduleId: string, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 485a3ceea..ed1b52318 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -711,6 +711,20 @@ export class TrainSchedulingController { return res.send(buffer); } + @Get("schedules/:id/intercity/marshalling/document") + @TrainSchedulingView() + @ApiOperation({ summary: "Download current on-board intercity marshalling (Marshalling 2) PDF" }) + async intercityMarshallingDocument( + @Param("id", ParseUUIDPipe) id: string, + @Res() res: Response, + ) { + const { filename, buffer } = await this.trainSchedulingService.intercityMarshallingDocument(id); + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `inline; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + return res.send(buffer); + } + // ---- batch / booking-window staff actions ---- @Post("schedules/:id/run-batch") diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 406f51988..d2e1268ea 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -1121,6 +1121,132 @@ describe('TrainSchedulingService', () => { expect(html).not.toContain('empty)'); expect(html).not.toContain('EMPTY'); }); + + // ---- intercity marshalling (Marshalling 2): the current on-board view ---- + + const onBoardView = (schedule: unknown) => + (service as never as { + intercityOnBoardView: (s: unknown) => { wagons: unknown[]; unassignedBookings: unknown[] }; + }).intercityOnBoardView(schedule); + + const buildWithOpts = (schedule: unknown, opts: unknown) => + (service as never as { + buildExportLoadListHtml: (s: unknown, o?: unknown) => string; + }).buildExportLoadListHtml(schedule, opts); + + const allocWith = (over: Record) => ({ ...loadedAllocation, ...over }); + + it('drops DEPARTED wagon slots and DEPARTED allocations from the on-board view', () => { + const schedule = { + trainSet: { + wagons: [ + { ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' }, + { ...makeWagon(2, 'W-002', [allocWith({ status: 'LOADED' })]), status: 'DEPARTED' }, + { + ...makeWagon(3, 'W-003', [ + allocWith({ status: 'LOADED', bookingId: 'booking-3' }), + allocWith({ status: 'DEPARTED', bookingId: 'booking-4' }), + ]), + status: 'RESERVED', + }, + ], + }, + scheduleBookings: [], + }; + + const { wagons } = onBoardView(schedule); + const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map( + (w) => w.physicalWagon.wagonNumber, + ); + expect(numbers).toEqual(['W-001', 'W-003']); + const w3 = (wagons as Array<{ physicalWagon: { wagonNumber: string }; allocations: Array<{ bookingId: string }> }>).find( + (w) => w.physicalWagon.wagonNumber === 'W-003', + ); + expect(w3?.allocations.map((a) => a.bookingId)).toEqual(['booking-3']); + }); + + it('keeps an attached wagon whose cargo all departed, as an EMPTY row', () => { + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { + wagons: [ + { ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' }, + { ...makeWagon(2, 'W-002', [allocWith({ status: 'DEPARTED' })]), status: 'RESERVED' }, + ], + }, + scheduleBookings: [], + }; + + const { wagons, unassignedBookings } = onBoardView(schedule); + const html = buildWithOpts(schedule, { wagons, unassignedBookings }); + expect(html).toContain('W-002'); + expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1); + expect(html).toContain('2 (1 empty)'); + }); + + it('hides a leg slot (boardYardId set) until it has confirmed LOADED cargo', () => { + const legWagonEmpty = { ...makeWagon(2, 'W-LEG', [allocWith({ status: 'RESERVED' })]), status: 'RESERVED', boardYardId: 'yard-mid' }; + const legWagonLoaded = { ...makeWagon(3, 'W-LEG2', [allocWith({ status: 'LOADED' })]), status: 'RESERVED', boardYardId: 'yard-mid' }; + const schedule = { + trainSet: { wagons: [legWagonEmpty, legWagonLoaded] }, + scheduleBookings: [], + }; + + const { wagons } = onBoardView(schedule); + const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map( + (w) => w.physicalWagon.wagonNumber, + ); + expect(numbers).toEqual(['W-LEG2']); + }); + + it('lists an IN_TRANSIT booking with no wagon allocation in the unassigned section', () => { + const rider = { + id: 'booking-9', + reference: 'BK-2026-000009', + status: 'IN_TRANSIT', + company: { name: 'Rider Co' }, + cargoType: { cargoTypeName: 'Cement', code: 'CEM' }, + originYard: { label: 'Adama' }, + destinationYard: { label: 'Dire Dawa' }, + bookingContainers: [{ containerNumber: 'RIDE-001' }], + }; + const done = { id: 'booking-8', reference: 'BK-2026-000008', status: 'COMPLETED' }; + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' }] }, + scheduleBookings: [{ bookingId: rider.id, booking: rider }, { bookingId: done.id, booking: done }], + }; + + const { wagons, unassignedBookings } = onBoardView(schedule); + expect((unassignedBookings as Array<{ id: string }>).map((b) => b.id)).toEqual(['booking-9']); + + const html = buildWithOpts(schedule, { + title: 'Intercity Marshalling Document / Load List (Marshalling 2)', + positionLabel: 'After Dire Dawa', + wagons, + unassignedBookings, + }); + expect(html).toContain('ON BOARD — WAGON NOT RECORDED'); + expect(html).toContain('BK-2026-000009'); + expect(html).toContain('RIDE-001'); + expect(html).not.toContain('BK-2026-000008'); + expect(html).toContain('Intercity Marshalling Document / Load List (Marshalling 2)'); + expect(html).toContain('After Dire Dawa'); + }); + + it('rejects the intercity marshalling document for a train that has not been dispatched', async () => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ + id: 'schedule-1', + status: 'SCHEDULED', + }); + await expect( + service.intercityMarshallingDocument('schedule-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); }); describe('moveWagonLoad — staff rearrange', () => { 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 495feedf0..ce95b208e 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 @@ -2928,6 +2928,78 @@ export class TrainSchedulingService { }; } + /** + * The train's composition as it stands right now — the source for the + * intercity marshalling ("Marshalling 2") document printed after mid-corridor + * station work. A wagon slot is on the train iff it has not DEPARTED and + * either rides the whole corridor (no boardYardId) or has confirmed LOADED + * cargo. Kept wagons carry only their LOADED allocations (DEPARTED = + * unloaded, PLANNED/RESERVED = not on board yet). + * ponytail: boardYardId presence is the "boarded yet?" heuristic; upgrade + * path is comparing the board yard against the latest checkpoint sequence. + */ + private intercityOnBoardView(schedule: TrainSchedule): { + wagons: TrainSetWagon[]; + unassignedBookings: Booking[]; + } { + const wagons = (schedule.trainSet?.wagons ?? []) + .filter((wagon) => { + if (wagon.status === 'DEPARTED') return false; + const hasLoaded = (wagon.allocations ?? []).some((a) => a.status === 'LOADED'); + return wagon.boardYardId == null || hasLoaded; + }) + .map((wagon) => ({ + ...wagon, + allocations: (wagon.allocations ?? []).filter((a) => a.status === 'LOADED'), + })) as TrainSetWagon[]; + + const onBoardBookingIds = new Set( + wagons.flatMap((wagon) => (wagon.allocations ?? []).map((a) => a.bookingId)), + ); + // IN_TRANSIT bookings with no kept allocation: intercity riders accepted + // after dispatch (never wagon-pinned) and loads whose allocation was never + // confirmed LOADED. They are physically on the train, so they get a row. + const unassignedBookings = (schedule.scheduleBookings ?? []) + .map((link) => link.booking) + .filter((booking): booking is Booking => Boolean(booking)) + .filter((booking) => booking.status === 'IN_TRANSIT' && !onBoardBookingIds.has(booking.id)); + + return { wagons, unassignedBookings }; + } + + async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== 'DISPATCHED' && schedule.status !== 'ARRIVED') { + throw new BadRequestException( + 'Intercity marshalling document applies only to dispatched or arrived trains', + ); + } + + const checkpoints = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId); + const last = checkpoints[checkpoints.length - 1]; + const positionLabel = last + ? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}` + : `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`; + + const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule); + const html = this.buildExportLoadListHtml(schedule, { + title: 'Intercity Marshalling Document / Load List (Marshalling 2)', + positionLabel, + wagons, + unassignedBookings, + }); + // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. + const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list'); + const reference = schedule.trainNumber ?? schedule.id; + return { + filename: `intercity-marshalling-${this.safeDocumentName(reference)}.pdf`, + buffer, + }; + } + /** * A container item's size in feet, for the marshalling document's 40ft/20ft * tally. Two independent sources, since only one is populated depending on @@ -2954,7 +3026,15 @@ export class TrainSchedulingService { return null; } - private buildExportLoadListHtml(schedule: TrainSchedule): string { + private buildExportLoadListHtml( + schedule: TrainSchedule, + opts?: { + title?: string; + positionLabel?: string; + wagons?: TrainSetWagon[]; + unassignedBookings?: Booking[]; + }, + ): string { const esc = (value: unknown) => String(value ?? '-') .replace(/&/g, '&') @@ -2967,7 +3047,7 @@ export class TrainSchedulingService { const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking])); // The document is checked against the physical train, so it has to run in // consist order — the relation comes back unordered. - const wagons = [...(schedule.trainSet?.wagons ?? [])].sort( + const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort( (a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0), ); const rows = wagons @@ -3011,6 +3091,29 @@ export class TrainSchedulingService { }); }) .join(''); + // Intercity riders accepted after dispatch have no wagon slot recorded — + // they are still physically on the train, so they get rows of their own. + const unassigned = opts?.unassignedBookings ?? []; + const unassignedRows = unassigned.length + ? `ON BOARD — WAGON NOT RECORDED` + + unassigned + .map((booking) => { + const containerNumbers = (booking.bookingContainers ?? []) + .map((container) => container.containerNumber) + .filter(Boolean) + .join(', '); + const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'} → ${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`; + return ` + ${esc(booking.reference)} — ${esc(leg)} + ${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)} + ${esc(booking.company?.name)} + ${esc(containerNumbers)} + - + - + `; + }) + .join('') + : ''; const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length; const totalWeight = wagons.reduce( (sum, wagon) => @@ -3034,7 +3137,7 @@ export class TrainSchedulingService { - Export Marshalling Document + ${esc(opts?.title ?? 'Export Marshalling Document')} + + +
+
+
EDR
+
+

Ethio-Djibouti Standard Gauge Railway Share Company

+

Last-Mile Delivery Contract

+
+
+ +

Last-Mile Delivery Contract

+

Booking {{bookingReference}} — {{companyName}}

+ +

Shipment Details

+ + + + {{#if containerCount}} + + + {{/if}} + {{#if cargoDescription}} + + {{/if}} + {{#if deliveryAddress}} + + {{/if}} + {{#if trainDepartureDate}} + + {{/if}} + + + +
Client{{companyName}}
Booking Reference{{bookingReference}}
Number of Containers{{containerCount}}
Containers{{containerList}}
Cargo Description{{cargoDescription}}
Delivery Address{{deliveryAddress}}
Train Departure from Djibouti{{trainDepartureDate}}
Last-Mile Delivery Date{{deliveryDate}}
Request Date{{requestDate}}
Approval Date{{approvalDate}}
+ +

Rates

+ + + + + + {{#each rateLines}} + + {{/each}} + {{#if estimatedKm}} + + {{/if}} + + + + +
DescriptionAmount{{#if currency}} ({{currency}}){{/if}}
{{description}}{{amount}}
Estimated distance{{estimatedKm}} km
Advance payable on signing{{advanceAmount}}{{#if currency}} {{currency}}{{/if}}
+ +

Terms

+
+

1. The Service Provider shall deliver the goods identified above from the arrival yard to the Client's delivery address on or about the last-mile delivery date stated above.

+

2. The Client shall pay the advance stated above upon signing this contract. The final delivery fee is computed on completion per the Service Provider's published last-mile rates and actual distance.

+

3. The Client shall ensure access and receipt of the goods at the delivery address. Waiting time and truck detention beyond free time may incur additional charges per the applicable tariff.

+

4. This contract is governed by the laws applicable to the Ethio-Djibouti Standard Gauge Railway Share Company's freight services.

+
+ +

Signatures

+
+
+

Client

+ {{#if signature}} + Customer signature +
{{signature.signerDisplayName}} — signed {{signature.signedAt}}
+ {{#if signature.consentText}}{{/if}} + {{else}} +

Awaiting customer signature.

+
Name, signature & date
+ {{/if}} +
+
+

Service Provider

+

Ethio-Djibouti Standard Gauge Railway Share Company

+
Authorized representative
+
+
+
+ + diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/dto/sign-last-mile-contract.dto.ts b/apps/edr-freight-api/src/modules/last-mile-requests/dto/sign-last-mile-contract.dto.ts new file mode 100644 index 000000000..9ebd25d38 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/dto/sign-last-mile-contract.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class SignLastMileContractDto { + @ApiPropertyOptional({ + description: + 'Signature PNG as base64 (data URI or raw). Omitted = reuse the saved profile signature.', + }) + @IsOptional() + @IsString() + signatureImageBase64?: string; + + @ApiProperty({ description: 'Name shown under the signature.' }) + @IsString() + @IsNotEmpty() + @MaxLength(160) + signerDisplayName!: string; + + @ApiPropertyOptional({ description: 'The consent statement the customer agreed to.' }) + @IsOptional() + @IsString() + @MaxLength(500) + consentText?: string; +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-contract.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-contract.service.ts new file mode 100644 index 000000000..c30112617 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-contract.service.ts @@ -0,0 +1,318 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; +import Handlebars from 'handlebars'; +import { Readable } from 'stream'; +import { DataSource } from 'typeorm'; +import { LastMileRequestStatus } from '@edr/types'; + +import { ContractPdfService } from '../../contracts/contract-pdf.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingsService } from '../bookings/bookings.service'; +import { FilesService } from '../files/files.service'; +import { FileRecord } from '../files/entities/file.entity'; +import { MinioService } from '../minio/minio.service'; +import { SignaturesService } from '../signatures/signatures.service'; +import { SignLastMileContractDto } from './dto/sign-last-mile-contract.dto'; +import { LastMileRequest } from './entities/last-mile-request.entity'; +import { LastMileRequestsRepository } from './last-mile-requests.repository'; +import { LastMileRequestsService } from './last-mile-requests.service'; + +const FILE_RESOURCE = 'last_mile_requests'; + +/** + * The LM contract in front of the advance payment: generated when the chief + * approves the request, viewed and signed by the customer in the portal, and + * only then invoiced (LastMileRequestsService.generateAdvanceInvoice). Single + * signer (customer), so the signature lives on the request row itself — no + * signature-rows table like bookings/CRSP contracts need for multi-role. + */ +@Injectable() +export class LastMileContractService { + private readonly logger = new Logger(LastMileContractService.name); + private compiledTemplate: Handlebars.TemplateDelegate | null = null; + + constructor( + private readonly requestsRepository: LastMileRequestsRepository, + private readonly requestsService: LastMileRequestsService, + private readonly bookingsService: BookingsService, + private readonly filesService: FilesService, + private readonly minioService: MinioService, + private readonly pdfService: ContractPdfService, + private readonly signaturesService: SignaturesService, + private readonly dataSource: DataSource, + ) {} + + async getContractView(id: string, viewerUserId?: string | null) { + const request = await this.requireApprovedRequest(id); + const booking = await this.requireBooking(request); + const view = await this.buildViewModel(request, booking); + const html = this.render(view); + const savedSignature = viewerUserId + ? ((await this.signaturesService.getForUser(viewerUserId)) ?? undefined) + : undefined; + return { + requestId: request.id, + bookingId: request.bookingId, + bookingReference: booking.reference, + status: request.status, + html, + customerSignedAt: request.customerSignedAt ?? null, + signerDisplayName: request.signerDisplayName ?? null, + canSign: !request.customerSignedAt, + savedSignature, + }; + } + + async streamContract(id: string) { + const request = await this.requireApprovedRequest(id); + const booking = await this.requireBooking(request); + const record = await this.upsertContractPdf(request, booking); + return this.filesService.streamById(record.id); + } + + async sign( + id: string, + dto: SignLastMileContractDto, + signerUserId: string | null, + ): Promise { + const request = await this.requireApprovedRequest(id); + if (request.customerSignedAt) { + throw new BadRequestException('This last-mile contract is already signed'); + } + const booking = await this.requireBooking(request); + + if (signerUserId) { + const companyId = await this.bookingsService.resolveCustomerCompanyId(signerUserId); + if (companyId && booking.companyId && companyId !== booking.companyId) { + throw new BadRequestException('This request does not belong to your company'); + } + } + + // Drawn signature wins; otherwise fall back to the saved profile signature + // (same contract-signing convention as modules/contracts). + let imageBase64 = dto.signatureImageBase64; + if (!imageBase64 && signerUserId) { + const saved = await this.signaturesService.getForUser(signerUserId); + if (saved?.signatureImageUrl?.startsWith('data:')) { + imageBase64 = saved.signatureImageUrl; + } + } + if (!imageBase64) { + throw new BadRequestException( + 'No signature image provided and no saved signature on your profile', + ); + } + + const buffer = this.decodeSignatureImage(imageBase64); + const sigFile = this.toUploadFile( + `signature-customer-${booking.reference ?? request.id}.png`, + 'image/png', + buffer, + ); + const fileRecord = await this.filesService.upsertByCode({ + resourceId: request.id, + resource: FILE_RESOURCE, + code: 'signature_customer', + file: sigFile, + }); + + await this.requestsRepository.update(id, { + customerSignedAt: new Date(), + signerDisplayName: dto.signerDisplayName, + consentText: dto.consentText ?? null, + } as Partial); + + // Best-effort: keep the reusable profile signature fresh for next time. + if (signerUserId && dto.signatureImageBase64) { + try { + await this.signaturesService.upsertForUser({ + userId: signerUserId, + signerDisplayName: dto.signerDisplayName, + signatureImageBase64: dto.signatureImageBase64, + }); + } catch (err) { + this.logger.warn(`Could not save reusable signature for user ${signerUserId}: ${err}`); + } + } + + const signed = (await this.requestsRepository.findById(id, { + relations: { booking: { company: true } }, + }))!; + + // Render + store the signed PDF, then invoice the advance. PDF failure must + // not block the invoice — the document re-renders on view/download. + try { + await this.upsertContractPdf(signed, booking, fileRecord); + } catch (err) { + this.logger.warn( + `Signed LM contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`, + ); + } + await this.requestsService.generateAdvanceInvoice(signed); + + return signed; + } + + private async upsertContractPdf( + request: LastMileRequest, + booking: Booking, + signatureRecord?: FileRecord, + ): Promise { + const view = await this.buildViewModel(request, booking, signatureRecord); + const html = this.render(view); + const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html); + const companyName = booking.company?.name ?? 'Customer'; + const fileName = `LM_${companyName.replace(/[^A-Za-z0-9._-]+/g, '_')}.pdf`; + const file = this.toUploadFile(fileName, 'application/pdf', pdfBuffer); + return this.filesService.upsertByCode({ + resourceId: request.id, + resource: FILE_RESOURCE, + code: 'contract', + file, + }); + } + + private async buildViewModel( + request: LastMileRequest, + booking: Booking, + signatureRecord?: FileRecord, + ) { + const summary = request.contractSummary; + const containers = request.requestedContainerNumbers ?? []; + const cargoDescription = + booking.cargoFreeText || booking.cargoType?.cargoTypeName || null; + + const departedRows: Array<{ departedAt: Date | null }> = await this.dataSource.query( + `SELECT departed_from_djibouti_at AS "departedAt" + FROM freight.import_djibouti_operations + WHERE train_schedule_id = $1 AND deleted_at IS NULL + LIMIT 1`, + [request.trainScheduleId], + ); + + return { + companyName: booking.company?.name ?? 'Customer', + bookingReference: booking.reference ?? request.bookingId, + containerCount: containers.length || null, + containerList: containers.join(', '), + cargoDescription, + deliveryAddress: booking.lastMileDeliveryAddress ?? null, + trainDepartureDate: this.formatDate(departedRows[0]?.departedAt), + deliveryDate: this.formatDate(request.requestedDeliveryDate) ?? '—', + requestDate: this.formatDate(request.submittedAt ?? request.reminderSentAt ?? request.createdAt) ?? '—', + approvalDate: this.formatDate(request.reviewedAt) ?? '—', + currency: summary?.currency ?? booking.paymentCurrency ?? 'ETB', + rateLines: (summary?.lines ?? []).map((l) => ({ + description: l.description, + amount: this.formatAmount(l.amount), + })), + estimatedKm: summary?.estimatedKm ?? null, + advanceAmount: this.formatAmount( + summary?.advanceAmount ?? request.approvedAdvanceAmount ?? 0, + ), + signature: request.customerSignedAt + ? { + signerDisplayName: request.signerDisplayName ?? '', + signedAt: this.formatDate(request.customerSignedAt) ?? '', + consentText: request.consentText ?? null, + imageUrl: await this.signatureImageDataUri(request, signatureRecord), + } + : null, + }; + } + + /** Signature PNG as a data URI so the PDF renderer needs no MinIO access. */ + private async signatureImageDataUri( + request: LastMileRequest, + signatureRecord?: FileRecord, + ): Promise { + try { + const record = + signatureRecord ?? + (await this.filesService.findByCode(request.id, FILE_RESOURCE, 'signature_customer')); + if (!record.url) return null; + const objectName = this.minioService.getObjectNameFromUrl(record.url); + const stream = await this.minioService.getFileStream(objectName); + const buffer = await this.streamToBuffer(stream); + return `data:image/png;base64,${buffer.toString('base64')}`; + } catch { + return null; + } + } + + private render(view: Record): string { + if (!this.compiledTemplate) { + const source = fs.readFileSync( + path.join(__dirname, '..', '..', 'contracts', 'templates', 'last-mile.hbs'), + 'utf-8', + ); + this.compiledTemplate = Handlebars.compile(source); + } + return this.compiledTemplate(view); + } + + private async requireApprovedRequest(id: string): Promise { + const request = await this.requestsService.findById(id); + if (request.status !== LastMileRequestStatus.Approved) { + throw new BadRequestException( + `The last-mile contract is available once the request is approved (current status: ${request.status})`, + ); + } + return request; + } + + private async requireBooking(request: LastMileRequest): Promise { + const booking = await this.dataSource.manager.findOne(Booking, { + where: { id: request.bookingId }, + relations: { company: true, cargoType: true }, + }); + if (!booking) throw new BadRequestException(`Booking ${request.bookingId} not found`); + return booking; + } + + private formatDate(value?: Date | string | null): string | null { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date.toISOString().slice(0, 10); + } + + private formatAmount(value: number): string { + return Number(value).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + } + + private toUploadFile(name: string, mimetype: string, buffer: Buffer): Express.Multer.File { + return { + fieldname: 'file', + originalname: name, + encoding: '7bit', + mimetype, + size: buffer.length, + buffer, + stream: Readable.from(buffer), + destination: '', + filename: '', + path: '', + }; + } + + private decodeSignatureImage(base64: string): Buffer { + const raw = base64.includes(',') ? base64.split(',')[1]! : base64; + return Buffer.from(raw, 'base64'); + } + + private streamToBuffer(stream: Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks))); + }); + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts index d02d00adb..4c4ac88e5 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts @@ -1,5 +1,6 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query, Res } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { Response } from 'express'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; @@ -9,14 +10,19 @@ import { BookingStaff } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { ApproveLastMileRequestDto } from './dto/approve-last-mile-request.dto'; import { RejectLastMileRequestDto } from './dto/reject-last-mile-request.dto'; +import { SignLastMileContractDto } from './dto/sign-last-mile-contract.dto'; import { SubmitLastMileRequestDto } from './dto/submit-last-mile-request.dto'; +import { LastMileContractService } from './last-mile-contract.service'; import { LastMileRequestsService } from './last-mile-requests.service'; @ApiTags('last-mile-requests') @ApiBearerAuth() @Controller('last-mile-requests') export class LastMileRequestsController { - constructor(private readonly requestsService: LastMileRequestsService) {} + constructor( + private readonly requestsService: LastMileRequestsService, + private readonly contractService: LastMileContractService, + ) {} @Get() @BookingStaff(FREIGHT_PERMS.lastMile.requestView) @@ -52,6 +58,36 @@ export class LastMileRequestsController { return this.requestsService.priceEstimate(id); } + // Customer-facing like :id/submit — the service ownership-checks against the + // resolved company; staff may also open it (read-only view). + @Get(':id/contract/view') + @ApiOperation({ summary: 'LM contract view model + rendered HTML + saved signature' }) + contractView(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.contractService.getContractView(id, user?.id ?? null); + } + + @Get(':id/contract/document') + @ApiOperation({ summary: 'Download the LM contract PDF (LM_.pdf)' }) + async contractDocument( + @Param('id', ParseUUIDPipe) id: string, + @Res() res: Response, + ): Promise { + const { stream, record } = await this.contractService.streamContract(id); + res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${record.name}"`); + stream.pipe(res); + } + + @Post(':id/contract/sign') + @ApiOperation({ summary: 'Customer agrees and signs the LM contract — then the advance invoice is issued' }) + signContract( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SignLastMileContractDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.contractService.sign(id, dto, user?.id ?? null); + } + @Get(':id') @BookingStaff(FREIGHT_PERMS.lastMile.requestView) @ApiOperation({ summary: 'Get a last-mile confirmation request by ID' }) @@ -69,12 +105,12 @@ export class LastMileRequestsController { @Body() dto: SubmitLastMileRequestDto, @CurrentUser() user: TCurrentUser, ) { - return this.requestsService.submit(id, user?.id ?? null, dto.containerNumbers); + return this.requestsService.submit(id, user?.id ?? null, dto.containerNumbers, dto.deliveryDate); } @Post(':id/approve') @BookingStaff(FREIGHT_PERMS.lastMile.requestApprove) - @ApiOperation({ summary: 'Truck & Machinery chief approves the request — generates the advance invoice' }) + @ApiOperation({ summary: 'Truck & Machinery chief approves the request — LM contract becomes signable; the advance invoice follows the customer signature' }) approve( @Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveLastMileRequestDto, diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts index 944954e65..c58e3d1c8 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.module.ts @@ -1,11 +1,16 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; +import { FilesModule } from '../files/files.module'; import { LastMileModule } from '../last-mile/last-mile.module'; +import { MinioModule } from '../minio/minio.module'; import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { SignaturesModule } from '../signatures/signatures.module'; import { LastMileRequest } from './entities/last-mile-request.entity'; +import { LastMileContractService } from './last-mile-contract.service'; import { LastMileRequestsController } from './last-mile-requests.controller'; import { LastMileRequestsRepository } from './last-mile-requests.repository'; import { LastMileRequestsService } from './last-mile-requests.service'; @@ -17,9 +22,17 @@ import { LastMileRequestsService } from './last-mile-requests.service'; forwardRef(() => BookingsModule), LastMileModule, NotificationInboxModule, + FilesModule, + MinioModule, + SignaturesModule, ], controllers: [LastMileRequestsController], - providers: [LastMileRequestsRepository, LastMileRequestsService], + providers: [ + LastMileRequestsRepository, + LastMileRequestsService, + LastMileContractService, + ContractPdfService, + ], exports: [LastMileRequestsService], }) export class LastMileRequestsModule {} 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 98b07c822..901e7b9b5 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 @@ -314,12 +314,53 @@ export class LastMileRequestsService { advancedPayment: 0, }); + // No invoice yet: the advance is invoiced by LastMileContractService.sign() + // once the customer has signed the LM contract — doc first, then payment. + // Snapshot the rate estimate now so the contract shows the numbers the + // chief actually approved against, immune to later rate edits. + const estimate = await this.priceEstimate(id); + + await this.requestsRepository.update(id, { + status: LastMileRequestStatus.Approved, + reviewedByStaffId: staffId, + reviewedAt: new Date(), + resultingLastMileId: lastMile.id, + approvedAdvanceAmount: advanceAmount, + contractSummary: { ...estimate, advanceAmount }, + contractGeneratedAt: new Date(), + } as Partial); + + if (booking.companyId) { + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.CONTRACT_STATUS, + title: 'Last-mile contract ready — view and sign', + body: `Your last-mile request for booking ${booking.reference ?? booking.id} was approved. Review and sign the last-mile contract to receive your advance invoice.`, + link: `/bookings/${booking.id}/last-mile-contract?requestId=${id}`, + data: { bookingId: booking.id, requestId: id, lastMileId: lastMile.id }, + priority: NotificationPriority.HIGH, + }); + } + + return this.findById(id); + } + + /** The advance invoice, deferred from approve() until the LM contract is signed. */ + async generateAdvanceInvoice(request: LastMileRequest): Promise { + const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId)); + if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`); + const advanceAmount = request.approvedAdvanceAmount; + if (!advanceAmount || !request.resultingLastMileId) { + throw new BadRequestException('Request has no approved advance to invoice'); + } + await this.billing.generateInvoice({ // 'last_mile' (not the InvoiceSource.LastMile enum value "lastmile") to // match the existing source string LastMileInvoiceService/LastMileService // already query by (findBySourceIds/findPayable/attachInvoices). source: 'last_mile' as Freight.InvoiceSource, - sourceId: lastMile.id, + sourceId: request.resultingLastMileId, type: 'LAST_MILE_ADVANCE', companyId: booking.companyId, companyProfileId: booking.companyProfileId || '', @@ -334,27 +375,18 @@ export class LastMileRequestsService { totalAmount: advanceAmount, }); - await this.requestsRepository.update(id, { - status: LastMileRequestStatus.Approved, - reviewedByStaffId: staffId, - reviewedAt: new Date(), - resultingLastMileId: lastMile.id, - } as Partial); - if (booking.companyId) { void this.notifications.notify({ recipients: { companyId: booking.companyId }, audience: NotificationAudience.PORTAL, type: NotificationType.INVOICE_ISSUED, - title: 'Last-mile request approved — payment due', - body: `Your last-mile request for booking ${booking.reference ?? booking.id} was approved. Pay the advance invoice to proceed.`, + title: 'Last-mile contract signed — payment due', + body: `Thank you for signing the last-mile contract for booking ${booking.reference ?? booking.id}. Pay the advance invoice to proceed.`, link: '/billing/invoices', - data: { bookingId: booking.id, requestId: id, lastMileId: lastMile.id }, + data: { bookingId: booking.id, requestId: request.id, lastMileId: request.resultingLastMileId }, priority: NotificationPriority.HIGH, }); } - - return this.findById(id); } async reject(id: string, staffId: string | null, reason: string): Promise { diff --git a/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx index 3c4781fbe..eabdcbc20 100644 --- a/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/operations/LastMileRequestsPanel.tsx @@ -94,6 +94,20 @@ export function LastMileRequestsPanel() { const invalidate = () => qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.ROOT }); + const downloadContract = async (r: LastMileRequest) => { + try { + const { data: blob } = await lastMileRequestsService.contractDocument(r.id); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `LM_${r.booking?.company?.name?.replace(/[^A-Za-z0-9._-]+/g, "_") ?? r.id}.pdf`; + a.click(); + URL.revokeObjectURL(url); + } catch { + toast({ title: "Contract PDF not available", variant: "destructive" }); + } + }; + const approve = useMutation({ mutationFn: () => lastMileRequestsService.approve(approveTarget!.id, Number(advanceAmount)), @@ -141,8 +155,16 @@ export function LastMileRequestsPanel() { id: "containers", header: () => Requested Containers, cell: ({ row }) => { - const nums = row.original.requestedContainerNumbers; - return {nums?.length ? nums.join(", ") : "—"}; + const r = row.original; + const nums = r.requestedContainerNumbers; + return ( + + {nums?.length ? nums.join(", ") : "—"} + {r.requestedDeliveryDate && ( + Delivery: {r.requestedDeliveryDate} + )} + + ); }, }, { @@ -162,6 +184,24 @@ export function LastMileRequestsPanel() { ); }, }, + { + id: "contract", + header: () => LM Contract, + cell: ({ row }) => { + const r = row.original; + if (r.status !== "APPROVED") return ; + return ( + + + {r.customerSignedAt ? "Signed" : "Awaiting signature"} + + + + ); + }, + }, ...(canApprove ? [ { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 6b055e82e..cb45a34ae 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -714,6 +714,7 @@ export const URL_CONSTANTS = { PRICE_ESTIMATE: (id: string) => `/last-mile-requests/${id}/price-estimate`, APPROVE: (id: string) => `/last-mile-requests/${id}/approve`, REJECT: (id: string) => `/last-mile-requests/${id}/reject`, + CONTRACT_DOCUMENT: (id: string) => `/last-mile-requests/${id}/contract/document`, }, DRIVERS: { diff --git a/apps/edr-freight-web/backoffice/src/services/last-mile-requests.service.ts b/apps/edr-freight-web/backoffice/src/services/last-mile-requests.service.ts index 28ada14eb..bdc33100e 100644 --- a/apps/edr-freight-web/backoffice/src/services/last-mile-requests.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/last-mile-requests.service.ts @@ -28,6 +28,9 @@ export interface LastMileRequest { reviewedAt?: string | null; rejectionReason?: string | null; resultingLastMileId?: string | null; + requestedDeliveryDate?: string | null; + customerSignedAt?: string | null; + signerDisplayName?: string | null; createdAt: string; updatedAt: string; } @@ -58,4 +61,6 @@ export const lastMileRequestsService = { api.post(LMR.APPROVE(id), { advanceAmount }), reject: (id: string, reason: string) => api.post(LMR.REJECT(id), { reason }), + contractDocument: (id: string) => + api.get(LMR.CONTRACT_DOCUMENT(id), { responseType: 'blob' }), }; diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 9cda9e9cb..aefb94594 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -47,6 +47,7 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage"; import BookingsListPage from "./pages/bookings/BookingsListPage"; import EditBookingPage from "./pages/bookings/EditBookingPage"; import LastMileConfirmPage from "./pages/bookings/last-mile-confirm/LastMileConfirmPage"; +import LastMileContractPage from "./pages/bookings/last-mile-contract/LastMileContractPage"; import ContractDetailPage from "./pages/contracts/ContractDetailPage"; import ContractViewPage from "./pages/contracts/ContractViewPage"; import ContractsList from "./pages/contracts/ContractsList"; @@ -323,6 +324,10 @@ const App = () => { path="/bookings/:id/last-mile-confirm" element={} /> + } + /> } diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index dcc4d23c7..88b310e84 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -221,5 +221,8 @@ export const URL_CONSTANTS = { LAST_MILE_REQUESTS: { BY_ID: (id: string) => `/last-mile-requests/${id}`, SUBMIT: (id: string) => `/last-mile-requests/${id}/submit`, + CONTRACT_VIEW: (id: string) => `/last-mile-requests/${id}/contract/view`, + CONTRACT_DOCUMENT: (id: string) => `/last-mile-requests/${id}/contract/document`, + CONTRACT_SIGN: (id: string) => `/last-mile-requests/${id}/contract/sign`, }, }; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/last-mile-confirm/LastMileConfirmPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/last-mile-confirm/LastMileConfirmPage.tsx index 0a0e02d02..cfd93ab99 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/last-mile-confirm/LastMileConfirmPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/last-mile-confirm/LastMileConfirmPage.tsx @@ -1,4 +1,5 @@ import { Button, Center, Checkbox, Loader, Stack, Text } from "@mantine/core"; +import { DatePickerInput } from "@mantine/dates"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; @@ -31,6 +32,7 @@ export default function LastMileConfirmPage() { const queryClient = useQueryClient(); const [selected, setSelected] = useState([]); + const [deliveryDate, setDeliveryDate] = useState(null); const { data: request, @@ -51,7 +53,7 @@ export default function LastMileConfirmPage() { const containerNumbers = booking?.containerNumbers ?? []; const submitMutation = useMutation({ - mutationFn: () => lastMileRequestsService.submit(requestId!, selected), + mutationFn: () => lastMileRequestsService.submit(requestId!, selected, deliveryDate!), onSuccess: () => { toast.success("Last-mile confirmation submitted"); queryClient.invalidateQueries({ queryKey: ["last-mile-request", requestId] }); @@ -101,6 +103,23 @@ export default function LastMileConfirmPage() { Reason: {request.rejectionReason} )} + {request.status === "APPROVED" && ( + <> + + {request.customerSignedAt + ? "You have signed the last-mile contract." + : "Review and sign the last-mile contract to receive your advance invoice."} + + + + )} ); @@ -146,9 +165,24 @@ export default function LastMileConfirmPage() { ))} + + setDeliveryDate( + date ? new Date(date).toISOString().slice(0, 10) : null, + ) + } + minDate={new Date()} + required + /> + + + ); + } + + return ( +
+
+
+ +
+ + + {data.canSign && ( + + )} +
+
+ + {data.customerSignedAt && ( +

+ Signed by {data.signerDisplayName} on{" "} + {new Date(data.customerSignedAt).toLocaleDateString()}. +

+ )} + +