diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts index 0354dbc84..b08b7e8c5 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts @@ -124,6 +124,8 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () = }; it('refuses an odd-20ft rebook without a GL-picked partner', async () => { + // An odd credit always leaves a half-empty wagon, so GL must name who fills + // it — the rebook is refused rather than shipping a half-empty wagon. await expect( makeSvc().rebook('wc1', { scheduledDate: '2026-09-01' }), ).rejects.toThrow(/pick a consolidation partner/i); @@ -143,6 +145,74 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () = }), ).rejects.toThrow(/already shares a wagon/i); }); + + /** + * An EXPIRED partner has no pay window left, so pairing the PAID rebook + * straight onto it strands the shared wagon: neither half can board and + * nothing ever breaks the pair (BK-2026-001114). Its cargo must move to a + * fresh booking that carries its own invoice. + */ + it('clones an EXPIRED partner into a new booking instead of pairing the dead one', async () => { + const dead = { + id: 'p1', + reference: 'BK-2026-001114', + status: 'EXPIRED', + contractId: 'c1', + consolidationPartnerId: null, + paymentCurrency: 'USD', + originYardId: 'y1', + destinationYardId: 'y2', + tradeDirection: 'IMPORT', + scheduledDate: '2026-09-01', + bookingContainers: [ + { + containerSize: '20ft', + quantity: 1, + hazardousQuantity: 0, + reeferQuantity: 0, + containerType: { sizeFt: 20 }, + units: [ + { + containerNumber: 'PCONT0', + sealNumber: null, + vgmTons: 9, + isHazardous: false, + isReefer: false, + }, + ], + }, + ], + }; + const clone = { ...dead, id: 'p1-clone', reference: 'BK-2026-001116', status: 'SUBMITTED' }; + + const svc = makeSvc(dead) as Record; + let createdUnderContract: string | null = null; + let pairedWith: string | null = null; + (svc as { bookingsRepository: Record }).bookingsRepository = { + findById: async () => source, + findByIdWithFiles: async (id: string) => (id === 'p1-clone' ? clone : dead), + hasSpentCancellationCredit: async () => false, + }; + (svc as { contractBooking: unknown }).contractBooking = { + createUnderContract: async (contractId: string) => { + createdUnderContract = contractId; + return { booking: { id: 'p1-clone' } }; + }, + }; + (svc as { notifyCustomer: unknown }).notifyCustomer = () => undefined; + + const cloned = await ( + svc as unknown as { + cloneDeadPartner(p: unknown, d: string): Promise<{ id: string; reference: string }>; + } + ).cloneDeadPartner(dead, '2026-09-01'); + + // The dead booking is left dead; the clone is what gets paired and paid. + expect(cloned.id).toBe('p1-clone'); + expect(cloned.reference).toBe('BK-2026-001116'); + expect(createdUnderContract).toBe('c1'); + expect(pairedWith).toBeNull(); + }); }); /** diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index 4507e6479..983564a51 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -1035,6 +1035,9 @@ export class BookingWagonCancellationService { let partner: Booking | null = null; if (oddFt20) { createDto.skipAutoConsolidation = true; + // An odd credit always leaves a half-empty wagon, so GL names who fills + // it. The candidate list is wide enough (any unpaired, unspent booking on + // the day) that a partner is expected to exist. if (!dto.partnerBookingId) { throw new BadRequestException( 'This credit carries an odd 20ft container — pick a consolidation partner booking to share its wagon (see the rebook-partners list).', @@ -1045,6 +1048,15 @@ export class BookingWagonCancellationService { dto.partnerBookingId, dto.scheduledDate, ); + // A dead partner cannot be paid where it stands — its cargo moves to a + // fresh booking that can carry its own invoice and pay window. + if (['EXPIRED', 'CANCELLED'].includes(partner.status)) { + partner = await this.cloneDeadPartner( + partner, + dto.scheduledDate, + userId, + ); + } } const created = await this.contractBooking.createUnderContract( source.contractId, @@ -1079,6 +1091,15 @@ export class BookingWagonCancellationService { ); } if (partner) { + // Corrections GL made to the partner's own containers while pairing — + // scoped to that booking by the repository, so a stray id cannot touch + // another booking's cargo. + if (dto.partnerUnits?.length) { + await this.bookingsRepository.patchContainerUnitsForBooking( + partner.id, + dto.partnerUnits, + ); + } // Consolidated rebook: never allocate the half-wagon booking alone. It // rides PAID and the batch engine settles the pair atomically once the // partner's own invoice is paid. @@ -1130,6 +1151,13 @@ export class BookingWagonCancellationService { status: string; scheduledDate: string | null; ft20Quantity: number; + units: Array<{ + id: string; + containerSize: string; + containerNumber: string; + sealNumber: string | null; + vgmTons: number; + }>; }> > { const row = await this.mustFind(cancellationId); @@ -1150,6 +1178,18 @@ export class BookingWagonCancellationService { ft20Quantity: (b.bookingContainers ?? []) .filter((line) => Number(line.containerType?.sizeFt) === 20) .reduce((sum, line) => sum + Number(line.quantity || 0), 0), + // Editable while pairing — GL corrects these on the rebook form. + units: (b.bookingContainers ?? []).flatMap((line) => + (line.units ?? []).map((u) => ({ + id: u.id, + containerSize: line.containerType?.sizeFt + ? `${line.containerType.sizeFt}ft` + : '', + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? null, + vgmTons: Number(u.vgmTons ?? 0), + })), + ), })); } @@ -1168,11 +1208,29 @@ export class BookingWagonCancellationService { `Booking ${partner.reference} already shares a wagon with another booking.`, ); } - if (!['SUBMITTED', 'PENDING_CONSOLIDATION'].includes(partner.status)) { + // Mirrors findRebookConsolidationCandidates: a partner need not be a live + // committed shipment. One that lost its slot or was called off still has + // cargo to move, and the rebooked wagon is how it moves. + if ( + ![ + 'SUBMITTED', + 'PENDING_CONSOLIDATION', + 'CLEARANCE_READY', + 'OPERATION_CHANGES_REQUESTED', + 'EXPIRED', + 'CANCELLED', + ].includes(partner.status) + ) { throw new BadRequestException( `Booking ${partner.reference} cannot be consolidated (status ${partner.status}).`, ); } + // A booking whose own credit was already rebooked elsewhere is spent. + if (await this.bookingsRepository.hasSpentCancellationCredit(partner.id)) { + throw new BadRequestException( + `Booking ${partner.reference} has already been rebooked from its cancellation credit.`, + ); + } if ( partner.originYardId !== source.originYardId || partner.destinationYardId !== source.destinationYardId || @@ -1200,6 +1258,74 @@ export class BookingWagonCancellationService { return partner; } + /** + * A dead (EXPIRED/CANCELLED) partner still has cargo to move, but it can no + * longer be paid: its pay window is gone and finalizing it issues nothing a + * customer can settle, so pairing the PAID rebook with it strands the shared + * wagon forever (BK-2026-001114: EXPIRED/PENDING, paired to a PAID rebook, + * no payment_deadline — neither half could ever board). So the cargo is + * cloned into a fresh booking under the same contract, which finalizes + * normally into its own invoice and pay window; the dead booking stays dead. + */ + private async cloneDeadPartner( + partner: Booking, + scheduledDate: string, + userId?: string, + ): Promise { + if (!partner.contractId) { + throw new BadRequestException( + `Booking ${partner.reference} has no contract to rebook its cargo under — pick a live partner instead.`, + ); + } + const dto: CreateBookingUnderContractDto = { + scheduledDate, + paymentCurrency: partner.paymentCurrency ?? undefined, + // GL already chose this pairing — the auto-matcher must not re-home the + // clone behind their back (same reasoning as the rebooked side). + skipAutoConsolidation: true, + containers: (partner.bookingContainers ?? []).map((line) => { + const units = line.units ?? []; + return { + containerSize: line.containerSize ?? undefined, + quantity: Number(line.quantity), + units: units.map((u) => ({ + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? '', + vgmTons: u.vgmTons, + isHazardous: u.isHazardous, + isReefer: u.isReefer, + })), + hazardousQuantity: Number(line.hazardousQuantity ?? 0), + reeferQuantity: Number(line.reeferQuantity ?? 0), + }; + }) as CreateBookingUnderContractDto['containers'], + }; + const created = await this.contractBooking.createUnderContract( + partner.contractId, + dto, + { id: userId ?? partner.createdByUserId ?? undefined }, + { permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] }, + // The dead partner's own contract may have lapsed while it sat expired; + // its cargo is still the cargo GL picked to fill the shared wagon. + { allowExpiredContract: true }, + ); + const clone = await this.bookingsRepository.findByIdWithFiles( + created.booking.id, + ); + if (!clone) { + throw new NotFoundException( + `Replacement booking for ${partner.reference} could not be loaded.`, + ); + } + this.notifyCustomer( + partner, + 'Replacement booking created', + `${partner.reference} had expired, so its cargo moved to ${clone.reference} to share a wagon with a rebooked shipment. Pay ${clone.reference} to board.`, + clone.id, + ); + return clone; + } + /** * Link the rebooked (already PAID) booking with the GL-picked partner. A * parked partner is resumed the way pairConsolidation would resume it — diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 381510e7e..66958bc38 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -599,6 +599,34 @@ export class BookingsController { return this.bookingsService.wagonAllocations(id); } + @Get(":id/wagons/export") + @MixedAudience(FREIGHT_PERMS.bookings.view) + @ApiOperation({ + summary: + "Download the booking's allocated wagons as an Excel workbook (customer name + one row per wagon)", + }) + async wagonAllocationsExport( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } + const { filename, buffer } = + await this.bookingsService.wagonAllocationsWorkbook(id); + res.setHeader( + "Content-Type", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.send(buffer); + } + // ── Partial wagon cancellation (paid bookings) ──────────────────────────── // Customer endpoints are ownership-scoped (no portal permission keys); the // staff history/void/rebook variants are permission-gated below. diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index e119af52e..6146da711 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -6,6 +6,7 @@ import { registerExchangeModule } from "../exchange-settings/exchange-module-opt // import { CustomersModule } from '../customers/customers.module'; import { CompaniesModule } from '../companies/companies.module'; +import { ExportsModule } from '../exports/exports.module'; import { FilesModule } from '../files/files.module'; import { MinioModule } from '../minio/minio.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; @@ -105,6 +106,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; // CustomersModule, RuleEngineModule, FileUploadSettingsModule, + ExportsModule, SignaturesModule, registerExchangeModule(), ], diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 863b61fee..21428c77d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -34,10 +34,12 @@ import { DocumentReviewStatus, } from './entities/booking-document-review.entity'; import { BookingContainer } from './entities/booking-container.entity'; +import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity'; import { BookingContainerUnit } from './entities/booking-container-unit.entity'; import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { Booking } from './entities/booking.entity'; import { BookingContractSignature, @@ -332,21 +334,22 @@ export class BookingsRepository extends BaseRepository { * Bookings a GL operator may manually link to `booking` as its odd-20ft * consolidation partner (Path B customs flow). Unlike * {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact - * quantity complement — this lists CANDIDATES for a human to choose from, so - * the filter is deliberately looser: any other customs booking on the same - * route/direction that is itself carrying an odd 20ft count. Two odd counts - * always sum to even, so any pick fills the shared wagon. + * quantity complement — this lists CANDIDATES for a human to choose from, but + * every row must still be a legal pick: another customs booking on the same + * route/direction, riding the same booking day, that is itself carrying an odd + * 20ft count. Two odd counts always sum to even, so any pick fills the shared + * wagon. * - * Bare instances awaiting completion have no persisted containers yet, so the - * odd-count test runs on the requested container lines when they exist and the - * booking is offered as a candidate when they do not (GL enters its cargo on - * the split form). + * A booking whose cargo is not entered yet is NOT a candidate: with no + * container lines its 20ft count is unknown, so pairing with it cannot be + * shown to fill the wagon. Same rule as + * {@link findRebookConsolidationCandidates}. */ async findManualConsolidationCandidates( booking: Booking, limit = 50, ): Promise { - const rows = await this.repository + const qb = this.repository .createQueryBuilder('b') .leftJoinAndSelect('b.bookingContainers', 'bc') .leftJoinAndSelect('bc.containerType', 'ct') @@ -376,17 +379,27 @@ export class BookingsRepository extends BaseRepository { 'OPERATION_CHANGES_REQUESTED', 'PENDING_CONSOLIDATION', ], - }) - .orderBy('b.createdAt', 'ASC') - .take(limit) - .getMany(); + }); - // Odd-20ft test in memory: a bare instance has no containers yet (GL fills - // them on the split form) and stays a candidate; one that already carries - // cargo qualifies only when its 20ft total is odd. + // Same EAT booking day — the pair shares one physical wagon, so it must + // board one train. Applied only when this booking has a date of its own; + // without one there is no day to match against and route/direction stand + // alone, mirroring findComplementaryConsolidationPartner. + if (booking.scheduledDate) { + qb.andWhere( + `DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`, + { bookingDate: booking.scheduledDate }, + ); + } + + const rows = await qb.orderBy('b.createdAt', 'ASC').take(limit).getMany(); + + // Odd-20ft test in memory. A booking with no container lines has an unknown + // 20ft count, so it cannot be shown to complete the wagon and is not + // offered. return rows.filter((row) => { const lines = row.bookingContainers ?? []; - if (lines.length === 0) return true; + if (lines.length === 0) return false; const ft20 = lines .filter((line) => Number(line.containerType?.sizeFt) === 20) .reduce((sum, line) => sum + Number(line.quantity || 0), 0); @@ -396,10 +409,16 @@ export class BookingsRepository extends BaseRepository { /** * Candidate partners for rebooking an odd-20ft cancellation credit: unpaired - * odd-20ft bookings on the same route/direction riding the requested day — - * SUBMITTED (committed direct booking) or parked PENDING_CONSOLIDATION. + * odd-20ft bookings on the same route/direction riding the requested day. * Unlike {@link findManualConsolidationCandidates} this is not customs-only: * GL picks who shares the rebooked wagon whatever the contract kind. + * + * The status set is deliberately wide. A partner here is not required to be a + * live, committed shipment — a booking that lost its slot (EXPIRED) or was + * cancelled still has cargo that GL can put back on a train, and pairing it + * with the rebooked credit is how both halves get moving again. What it must + * not be is already spoken for: a booking whose own cancellation credit has + * been rebooked elsewhere is excluded, as is one already paired. */ async findRebookConsolidationCandidates( booking: Booking, @@ -410,6 +429,9 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('b') .leftJoinAndSelect('b.bookingContainers', 'bc') .leftJoinAndSelect('bc.containerType', 'ct') + // Units come back so GL can correct the partner's container numbers, + // seals and VGMs while pairing. + .leftJoinAndSelect('bc.units', 'unit') .leftJoinAndSelect('b.company', 'company') .where('b.id != :bookingId', { bookingId: booking.id }) .andWhere('b.consolidationPartnerId IS NULL') @@ -423,8 +445,27 @@ export class BookingsRepository extends BaseRepository { tradeDirection: booking.tradeDirection, }) .andWhere('b.status IN (:...statuses)', { - statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'], + statuses: [ + 'SUBMITTED', + 'PENDING_CONSOLIDATION', + 'CLEARANCE_READY', + 'OPERATION_CHANGES_REQUESTED', + // Lost its slot or was called off — its cargo is still real and can + // ride the rebooked wagon. + 'EXPIRED', + 'CANCELLED', + ], }) + // A cancelled booking whose own credit was already spent on a rebook is + // gone — pairing with it would hand the same cargo out twice. + .andWhere( + `NOT EXISTS ( + SELECT 1 FROM freight.booking_wagon_cancellations c + WHERE c.booking_id = b.id + AND c.rebooked_booking_id IS NOT NULL + AND c.deleted_at IS NULL + )`, + ) // Same EAT booking day as the rebook — the pair shares one physical // wagon, so it must board one train. .andWhere( @@ -729,6 +770,89 @@ export class BookingsRepository extends BaseRepository { return new Set(rows.map((r) => r.bookingId)); } + /** + * Bookings among `bookingIds` that hold a redeemable wagon-cancellation + * credit — the cut is settled (CREDIT_AVAILABLE), the credit is worth + * something, and it has not been spent on a rebook yet. Surfaced on the GL + * clearance queue so a paid-for credit is visibly rebookable from the list + * rather than only from the booking's own page. + */ + async findBookingsWithRedeemableCredit( + bookingIds: string[], + ): Promise> { + if (bookingIds.length === 0) return new Map(); + const rows = (await this.dataSource + .getRepository(BookingWagonCancellation) + .createQueryBuilder('c') + .select('c.booking_id', 'bookingId') + .addSelect('c.id', 'cancellationId') + .where('c.booking_id IN (:...bookingIds)', { bookingIds }) + .andWhere('c.status = :status', { status: 'CREDIT_AVAILABLE' }) + .andWhere('c.credit_amount > 0') + .andWhere('c.rebooked_booking_id IS NULL') + .andWhere('c.deleted_at IS NULL') + .getRawMany()) as Array<{ bookingId: string; cancellationId: string }>; + return new Map(rows.map((r) => [r.bookingId, r.cancellationId])); + } + + /** + * Apply container-unit corrections (number / seal / VGM) to units that belong + * to `bookingId`. The ownership join is the point: a unit id from another + * booking silently matches nothing rather than editing a stranger's cargo. + * Sizes and quantities are never touched — only the identifying details. + * Returns how many units were actually updated. + */ + async patchContainerUnitsForBooking( + bookingId: string, + patches: Array<{ + id: string; + containerNumber?: string; + sealNumber?: string; + vgmTons?: number; + }>, + ): Promise { + if (patches.length === 0) return 0; + const unitRepo = this.dataSource.getRepository(BookingContainerUnit); + const owned = await unitRepo + .createQueryBuilder('u') + .innerJoin('u.bookingContainer', 'bc') + .where('bc.booking_id = :bookingId', { bookingId }) + .andWhere('u.id IN (:...ids)', { ids: patches.map((p) => p.id) }) + .select('u.id', 'id') + .getRawMany<{ id: string }>(); + const ownedIds = new Set(owned.map((r) => r.id)); + + let updated = 0; + for (const patch of patches) { + if (!ownedIds.has(patch.id)) continue; + const set: Record = {}; + if (patch.containerNumber !== undefined) + set.containerNumber = patch.containerNumber; + if (patch.sealNumber !== undefined) set.sealNumber = patch.sealNumber; + if (patch.vgmTons !== undefined) set.vgmTons = patch.vgmTons; + if (Object.keys(set).length === 0) continue; + await unitRepo.update(patch.id, set as never); + updated += 1; + } + return updated; + } + + /** + * Has this booking's own wagon-cancellation credit already been spent on a + * rebook? Such a booking must not be offered or accepted as a consolidation + * partner — its cargo has already moved to the rebooked booking. + */ + async hasSpentCancellationCredit(bookingId: string): Promise { + const count = await this.dataSource + .getRepository(BookingWagonCancellation) + .createQueryBuilder('c') + .where('c.booking_id = :bookingId', { bookingId }) + .andWhere('c.rebooked_booking_id IS NOT NULL') + .andWhere('c.deleted_at IS NULL') + .getCount(); + return count > 0; + } + findDocumentReview( bookingId: string, settingCode: string, @@ -1043,9 +1167,38 @@ export class BookingsRepository extends BaseRepository { select: { bookingId: true, trainScheduleId: true }, }); const scheduleByBooking = new Map(links.map((link) => [link.bookingId, link.trainScheduleId])); + + // The allocated train's own departure date — distinct from the customer's + // requested `booking.scheduledDate`. The list column shows this once a + // booking is on a train, so fetch it alongside the link ids. + const scheduleIds = [...new Set([...scheduleByBooking.values()].filter(Boolean))] as string[]; + const schedules = scheduleIds.length + ? await this.dataSource.getRepository(TrainSchedule).find({ + where: { id: In(scheduleIds) }, + select: { + id: true, + reference: true, + trainNumber: true, + status: true, + scheduledDepartureDate: true, + }, + }) + : []; + const scheduleById = new Map(schedules.map((schedule) => [schedule.id, schedule])); + for (const item of items) { - (item as Booking & { trainScheduleId?: string | null }).trainScheduleId = - scheduleByBooking.get(item.id) ?? null; + const scheduleId = scheduleByBooking.get(item.id) ?? null; + const enriched = item as Booking & { + trainScheduleId?: string | null; + trainScheduleReference?: string | null; + trainScheduleDepartureDate?: string | null; + }; + enriched.trainScheduleId = scheduleId; + const schedule = scheduleId ? scheduleById.get(scheduleId) : undefined; + enriched.trainScheduleReference = schedule?.reference ?? schedule?.trainNumber ?? null; + enriched.trainScheduleDepartureDate = schedule?.scheduledDepartureDate + ? new Date(schedule.scheduledDepartureDate).toISOString() + : null; } } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index c7294a004..ea901bb16 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -12,6 +12,7 @@ import { Freight, SchedulingStatus } from '@edr/types'; import { insertWithGeneratedReference, logCtx } from '@edr/api-common'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; +import { TabularExportService } from '../exports/tabular-export.service'; import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity'; import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; @@ -104,6 +105,38 @@ export interface PaginatedBookings { }; } +/** One container on an allocated wagon (raw SQL json_agg projection). */ +export interface WagonAllocationContainer { + containerNumber: string | null; + sealNumber: string | null; + positionOnWagon: number | null; + grossWeightTons: number | null; + sizeFt: number | null; +} + +/** One allocated wagon as returned by `wagonAllocations` (raw SQL projection). */ +export interface WagonAllocationRow { + allocationId: string; + sequenceNo: number | null; + wagonNumber: string | null; + wagonType: string | null; + wagonTypeCode: string | null; + /** numeric columns arrive as strings from pg. */ + tareWeightTons: string | null; + capacityTons: string | null; + lengthMeters: string | null; + allocatedWeightTons: string | null; + loadType: string | null; + status: string | null; + trainNumber: string | null; + departureAt: string | Date | null; + originStation: string | null; + destinationStation: string | null; + bulkCargoDescription: string | null; + bulkQuantity: string | null; + containers: WagonAllocationContainer[]; +} + /** One wagon line on the carriage acceptance sheet (raw SQL projection). */ interface CarriageAcceptanceWagonRow { sequenceNo: number; @@ -172,6 +205,7 @@ export class BookingsService { private readonly bookingContractService: BookingContractService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, + private readonly tabularExport: TabularExportService, ) {} async assignCustomerTruck( @@ -447,7 +481,7 @@ export class BookingsService { * an array per wagon, bulk load description when the wagon carries bulk). * Empty array until the booking has been allocated onto a train. */ - async wagonAllocations(bookingId: string): Promise { + async wagonAllocations(bookingId: string): Promise { return this.dataSource.query( `SELECT a.id AS "allocationId", tsw.sequence_no AS "sequenceNo", @@ -501,6 +535,103 @@ export class BookingsService { ); } + /** + * The Wagons tab's Excel export: the booking's customer identity in the KPI + * header, then one row per allocated wagon. + * + * Container numbers are flattened into a single cell rather than exploded + * into one row per container — the sheet is a wagon manifest, and a reader + * counting rows must get the wagon count. + */ + async wagonAllocationsWorkbook( + bookingId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + const booking = await this.findById(bookingId); + const wagons = await this.wagonAllocations(bookingId); + + // Same precedence the booking list uses: a shipping line owns its bookings + // directly, a government booking names its institution, everyone else is + // the customer company. + // `shippingLineCompany` is attached by `findById` (attachShippingLineCompanies), + // not a declared relation on the entity — hence the cast, matching that helper. + const shippingLine = (booking as Booking & { shippingLineCompany?: { name?: string } }) + .shippingLineCompany; + const customerName = + shippingLine?.name ?? + (booking.isGovernment ? booking.governmentInstitution : null) ?? + booking.company?.name ?? + '—'; + + const rows = wagons.map((w) => ({ + sequenceNo: w.sequenceNo, + wagonNumber: w.wagonNumber ?? '—', + wagonType: w.wagonType ?? '—', + loadType: w.loadType ?? '—', + status: w.status ?? '—', + tareWeightTons: w.tareWeightTons === null ? null : Number(w.tareWeightTons), + capacityTons: w.capacityTons === null ? null : Number(w.capacityTons), + allocatedWeightTons: + w.allocatedWeightTons === null ? null : Number(w.allocatedWeightTons), + lengthMeters: w.lengthMeters === null ? null : Number(w.lengthMeters), + containerCount: w.containers?.length ?? 0, + containerNumbers: + (w.containers ?? []).map((c) => c.containerNumber).filter(Boolean).join(', ') || '—', + sealNumbers: + (w.containers ?? []).map((c) => c.sealNumber).filter(Boolean).join(', ') || '—', + bulkCargo: w.bulkCargoDescription ?? '—', + bulkQuantity: w.bulkQuantity === null ? null : Number(w.bulkQuantity), + trainNumber: w.trainNumber ?? '—', + departureAt: w.departureAt ? new Date(w.departureAt).toISOString().slice(0, 10) : '—', + originStation: w.originStation ?? '—', + destinationStation: w.destinationStation ?? '—', + // Repeated on every row so the sheet survives being filtered, sorted or + // pasted into a combined workbook, where the header block is lost. + customerName, + bookingReference: booking.reference, + })); + + const totalAllocated = rows.reduce( + (sum, r) => sum + (r.allocatedWeightTons ?? 0), + 0, + ); + + const buffer = await this.tabularExport.toXlsx({ + title: `Wagons ${booking.reference}`.slice(0, 31), + description: `Wagons allocated to booking ${booking.reference} — ${customerName}`, + label: 'booking:wagon-allocations', + kpis: [ + { label: 'Wagons', value: rows.length }, + { label: 'Containers', value: rows.reduce((sum, r) => sum + r.containerCount, 0) }, + { label: 'Allocated weight', value: Number(totalAllocated.toFixed(3)), unit: 't' }, + ], + columns: [ + { key: 'bookingReference', label: 'Booking', type: 'string' }, + { key: 'customerName', label: 'Customer', type: 'string' }, + { key: 'sequenceNo', label: 'Seq', type: 'number' }, + { key: 'wagonNumber', label: 'Wagon number', type: 'string' }, + { key: 'wagonType', label: 'Wagon type', type: 'string' }, + { key: 'loadType', label: 'Load type', type: 'string' }, + { key: 'status', label: 'Status', type: 'string' }, + { key: 'tareWeightTons', label: 'Tare', type: 'tons' }, + { key: 'capacityTons', label: 'Capacity', type: 'tons' }, + { key: 'allocatedWeightTons', label: 'Allocated', type: 'tons' }, + { key: 'lengthMeters', label: 'Length (m)', type: 'number' }, + { key: 'containerCount', label: 'Containers', type: 'number' }, + { key: 'containerNumbers', label: 'Container numbers', type: 'string' }, + { key: 'sealNumbers', label: 'Seal numbers', type: 'string' }, + { key: 'bulkCargo', label: 'Bulk cargo', type: 'string' }, + { key: 'bulkQuantity', label: 'Bulk quantity', type: 'number' }, + { key: 'trainNumber', label: 'Train', type: 'string' }, + { key: 'departureAt', label: 'Departure', type: 'date' }, + { key: 'originStation', label: 'Origin', type: 'string' }, + { key: 'destinationStation', label: 'Destination', type: 'string' }, + ], + rows, + }); + + return { filename: `wagons-${booking.reference}.xlsx`, buffer }; + } + /** * Split the booking amount across its wagons, proportional to allocated weight * (equal shares when no weights are recorded). The last row absorbs the rounding diff --git a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts index 7b4d3ca19..eda4b6386 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts @@ -105,6 +105,31 @@ export class RebookContainerLineDto { units!: RebookUnitDto[]; } +/** One edited container unit on the consolidation partner booking. */ +export class PartnerUnitPatchDto { + @ApiProperty({ description: 'Id of the partner booking container unit being edited' }) + @IsUUID() + id!: string; + + @ApiPropertyOptional({ description: 'Container number' }) + @IsOptional() + @IsString() + @MaxLength(64) + containerNumber?: string; + + @ApiPropertyOptional({ description: 'Seal number' }) + @IsOptional() + @IsString() + @MaxLength(64) + sealNumber?: string; + + @ApiPropertyOptional({ description: 'VGM (tons) of the unit' }) + @IsOptional() + @IsNumber() + @Min(0) + vgmTons?: number; +} + export class RebookCancelledWagonsDto { @ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' }) @IsDateString() @@ -132,6 +157,19 @@ export class RebookCancelledWagonsDto { @IsOptional() @IsUUID() partnerBookingId?: string; + + @ApiPropertyOptional({ + description: + 'Corrections to the partner booking\'s own container units (number / seal ' + + '/ VGM). Only the units listed are touched; sizes and quantities are never ' + + 'changed. Ignored unless partnerBookingId is set.', + type: [PartnerUnitPatchDto], + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PartnerUnitPatchDto) + partnerUnits?: PartnerUnitPatchDto[]; } export class FilterWagonCancellationsDto { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index 129789898..faa59e802 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -43,6 +43,9 @@ function makeService(overrides?: { findBookingsWithUnreviewedDocuments: jest .fn() .mockResolvedValue(new Set()), + findBookingsWithRedeemableCredit: jest + .fn() + .mockResolvedValue(new Map()), }; const bookingsService = { findById: jest.fn().mockResolvedValue(booking), diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index b776a547c..5d0943fe6 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -1347,6 +1347,17 @@ export class BookingClearanceService { .hasDocumentsAwaitingReview = pending.has(b.id); } + // A cancelled booking may still hold a paid-for wagon-cancellation credit. + // GL redeems it from this queue, so the row carries the cancellation id the + // rebook action needs. + const credits = await this.bookingsRepository.findBookingsWithRedeemableCredit( + filtered.map((b) => b.id), + ); + for (const b of filtered) { + (b as Booking & { rebookableCancellationId?: string | null }) + .rebookableCancellationId = credits.get(b.id) ?? null; + } + const rows = await this.attachContractSummary(filtered); return this.narrowToYardScope(rows, user); } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts index 49e77627f..b601e1c3e 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts @@ -183,8 +183,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => { { quantity: 4, containerType: { sizeFt: 20 } }, ], }, - // A bare instance has no cargo yet — GL enters it on the split form, so it - // stays a candidate. + // Cargo not entered yet — its 20ft count is unknown, so it cannot be + // shown to fill the wagon and is not offered. { id: 'bare', reference: 'BK-BARE', bookingContainers: [] }, ]; @@ -198,7 +198,7 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => { rows.filter((row) => { void booking; const lines = row.bookingContainers ?? []; - if (lines.length === 0) return true; + if (lines.length === 0) return false; const ft20 = lines .filter((l) => Number(l.containerType?.sizeFt) === 20) .reduce((sum, l) => sum + Number(l.quantity || 0), 0); @@ -209,8 +209,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => { }); const candidates = await service.listConsolidationCandidates('c-1', 'b-1'); - expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD', 'BK-BARE']); + expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD']); expect(candidates[0].ft20Quantity).toBe(3); - expect(candidates[1].hasCargo).toBe(false); + expect(candidates[0].hasCargo).toBe(true); }); }); 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/rule-engine/services/rate-change-requests.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts index f8617cd65..60121c2fd 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts @@ -33,6 +33,9 @@ describe('RateChangeRequestsService', () => { rate?: Rate; pending?: RateChangeRequest | null; applyThrows?: Error; + /** Columns buildUpdate would derive beyond the literal patch (e.g. rateType). */ + derived?: Partial; + previewThrows?: Error; } = {}) => { const rate = opts.rate ?? liveRate(); const saved: RateChangeRequest[] = []; @@ -53,6 +56,12 @@ describe('RateChangeRequestsService', () => { const rates = { findById: jest.fn(async () => rate), assertUpdateValid: jest.fn(async () => undefined), + // Stands in for buildUpdate: it resolves a patch into the full column + // set, including columns the form never posts (rateType and friends). + previewUpdate: jest.fn(async (_id: string, dto: Record) => { + if (opts.previewThrows) throw opts.previewThrows; + return { ...dto, ...(opts.derived ?? {}) } as Partial; + }), applyApprovedUpdate: jest.fn(async () => { if (opts.applyThrows) throw opts.applyThrows; return rate; @@ -155,14 +164,48 @@ describe('RateChangeRequestsService', () => { }); it('validates up front so the requester hears about a bad patch, not the approver', async () => { - const { service, rates } = build(); - rates.assertUpdateValid.mockRejectedValueOnce( - new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'), - ); + // Resolving the patch IS the validation — buildUpdate throws on a bad + // unit, so previewUpdate surfaces it at submit time. + const { service } = build({ + previewThrows: new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'), + }); await expect( service.submit({ rateId: 'rate-1', update: { rateUnit: 'PER_TON' } }), ).rejects.toThrow(/not valid for this rate/); }); + + it('shows the approver a bulk switch, which only exists as a derived column', async () => { + // The form posts intercityKind: BULK — never stored. The real edit lands + // on rateType (+ the cargo/container swap), so that is what the approver + // must see. Diffing the raw patch showed an empty change list. + const { service } = build({ + rate: liveRate({ + rateType: 'INTERCITY_CONTAINER', + appliesTo: 'INTERCITY', + containerTypeId: 'ct-1', + }), + derived: { + rateType: 'INTERCITY_BULK', + containerTypeId: null, + cargoTypeId: 'cargo-9', + } as Partial, + }); + + const request = await service.submit({ + rateId: 'rate-1', + update: { intercityKind: 'BULK', cargoTypeId: 'cargo-9' } as never, + }); + + expect(request.payload).toMatchObject({ + rateType: 'INTERCITY_BULK', + containerTypeId: null, + cargoTypeId: 'cargo-9', + }); + expect(request.previousValues).toMatchObject({ + rateType: 'INTERCITY_CONTAINER', + containerTypeId: 'ct-1', + }); + }); }); describe('approve', () => { diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts index 8913c9ef9..68d2ccb2e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -24,7 +24,14 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; /** Backoffice page where both the queue and the rates live. */ const RATES_LINK = '/dashboard/rules/rates'; -/** Fields a change request may carry — anything else in the patch is ignored. */ +/** + * Persisted columns an approver is shown a before→after for. + * + * These are RESOLVED entity columns, not raw form fields: the diff runs + * against `RatesService.previewUpdate`, so a change the form expresses through + * a non-stored selector still shows up here as the column it actually moves + * (a flip to bulk lands on `rateType` + the container/cargo swap). + */ const DIFFABLE_FIELDS = [ 'rateValue', 'currency', @@ -34,6 +41,13 @@ const DIFFABLE_FIELDS = [ 'tradeDirection', 'containerTypeId', 'cargoTypeId', + // The container-vs-bulk shape of the rate. Missing here, switching a LIVE + // rate to bulk showed the approver an empty change list — the only column + // that records the kind is rateType, and the form never posts it directly. + 'rateType', + // Line-scoped pricing. Missing here, moving a rate onto (or off) a shipping + // line diffed to nothing. + 'shippingLineCompanyId', // The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate // diffed to nothing and the submit was refused as "nothing changed". 'originYardId', @@ -82,7 +96,11 @@ export class RateChangeRequestsService { ); } - const payload = this.changedFieldsOnly(rate, dto.update); + // Diff the RESOLVED columns, not the raw patch: the form's cargoKind / + // intercityKind selectors are never stored, so a bulk switch only shows up + // once the patch is resolved into the columns it moves. + const resolved = await this.rates.previewUpdate(dto.rateId, dto.update as UpdateRateDto); + const payload = this.changedFieldsOnly(rate, resolved); if (Object.keys(payload).length === 0) { throw new BadRequestException('Nothing changed — the proposed values match the live rate.'); } @@ -98,7 +116,9 @@ export class RateChangeRequestsService { ); } - await this.rates.assertUpdateValid(dto.rateId, payload as UpdateRateDto); + // previewUpdate above already ran the full validation (it IS buildUpdate), + // so re-validating here would only repeat it — and the trimmed payload is + // resolved columns, not a form patch, so it is not the right input for it. const request = await this.repo.save( this.repo.create({ @@ -186,13 +206,14 @@ export class RateChangeRequestsService { } /** - * Keep only fields the requester actually changed. A form posts every field - * back, so without this the diff would list untouched values as changes. + * Keep only columns the edit actually moves. `buildUpdate` returns a full + * resolved column set (it re-derives scope on every patch), so without this + * the diff would list every untouched column as a change. */ - private changedFieldsOnly(rate: Rate, update: UpdateRateDto): Record { + private changedFieldsOnly(rate: Rate, resolved: Partial): Record { const patch: Record = {}; for (const field of DIFFABLE_FIELDS) { - const proposed = (update as Record)[field]; + const proposed = (resolved as Record)[field]; if (proposed === undefined) continue; if (this.sameValue(proposed, (rate as unknown as Record)[field])) continue; patch[field] = proposed; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 6677fcce5..a3361e526 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -753,6 +753,19 @@ export class RatesService { await this.buildUpdate(await this.findById(id), dto); } + /** + * The exact column changes applying this patch would make, without writing. + * + * A change request diffs against THIS rather than the raw patch: the form + * posts selectors that are never stored (`cargoKind`, `intercityKind`), and + * the real edit they encode lands on derived columns — flipping a rate to + * bulk moves `rateType` and swaps `containerTypeId`/`cargoTypeId`. Diffing + * the raw patch missed all of it, so the approver saw an empty change list. + */ + async previewUpdate(id: string, dto: UpdateRateDto): Promise> { + return this.buildUpdate(await this.findById(id), dto); + } + private async applyUpdate(existing: Rate, dto: UpdateRateDto): Promise { const updates = await this.buildUpdate(existing, dto); const updated = await this.repository.update(existing.id, updates); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index 4fc921ccb..1c7cd11ca 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -867,6 +867,27 @@ export class TrainSchedulingController { return res.send(buffer); } + @Get("schedules/:id/wagons/export") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Download the schedule's wagon list as an Excel workbook (one row per container: wagon, container, VGM, route, customer)", + }) + async scheduleWagonListExport( + @Param("id", ParseUUIDPipe) id: string, + @Res() res: Response, + ) { + const { filename, buffer } = + await this.trainSchedulingService.scheduleWagonListWorkbook(id); + res.setHeader( + "Content-Type", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + return res.send(buffer); + } + @Get("schedules/:id/export/load-list/document") @TrainSchedulingView() @ApiOperation({ summary: "Download printable export marshalling / load list PDF" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 4f416cdcf..03d75da18 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -74,6 +74,25 @@ import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository'; import { Wagon } from '../../wagons/entities/wagon.entity'; import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service'; +import { TabularExportService } from '../../exports/tabular-export.service'; + +/** One line of the schedule wagon-list export (raw SQL projection). */ +interface ScheduleWagonListRow { + sequenceNo: number | null; + wagonNumber: string | null; + wagonType: string | null; + containerNumber: string | null; + containerSizeFt: number | null; + loadType: string | null; + status: string | null; + bulkCargoDescription: string | null; + /** numeric columns arrive as strings from pg. */ + vgmTons: string | null; + originLabel: string | null; + destinationLabel: string | null; + bookingReference: string | null; + customerName: string | null; +} import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto'; import { AssignBookingsDto } from '../dto/assign-bookings.dto'; import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto'; @@ -427,6 +446,9 @@ export class TrainSchedulingService { // Per-wagon history ledger (global module). @Optional keeps the positional // spec constructors working; production always has it. @Optional() private readonly wagonHistory?: WagonHistoryService, + // Trailing + @Optional so the positional constructors in the existing specs + // keep working; production always resolves it from ExportsModule. + @Optional() private readonly tabularExport?: TabularExportService, ) {} /** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */ @@ -3747,6 +3769,132 @@ export class TrainSchedulingService { }; } + /** + * The schedule detail page's wagon-list Excel export. + * + * One row per container (a wagon carrying two boxes yields two rows, repeating + * the wagon number) so each container's own VGM is present and totals footable. + * Bulk wagons, having no containers, yield a single row carrying the bulk + * description and the allocated tonnage as the VGM figure. + * + * Only wagon slots that actually carry an allocation are listed — empty slots + * on the consist are omitted. + */ + async scheduleWagonListWorkbook( + scheduleId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!this.tabularExport) { + throw new BadRequestException('Tabular export service is unavailable'); + } + + // Row grain is the container item; the LEFT JOIN keeps bulk (and any + // container-less) allocation as one row. `booking_container_units` is joined + // on BOTH container number and its booking_container line — container + // numbers repeat across bookings, so number alone would multiply rows. + const rows: ScheduleWagonListRow[] = await this.dataSource.query( + `SELECT tsw.sequence_no AS "sequenceNo", + w.wagon_number AS "wagonNumber", + COALESCE(wt.name, wt.code) AS "wagonType", + ci.container_number AS "containerNumber", + cit.size_ft AS "containerSizeFt", + a.load_type AS "loadType", + a.status AS "status", + bl.cargo_description AS "bulkCargoDescription", + COALESCE( + ci.gross_weight_tons, + bcu.vgm_tons, + bc.vgm_per_unit_tons, + a.allocated_weight_tons + ) AS "vgmTons", + COALESCE(by_.label, so.label) AS "originLabel", + COALESCE(ay.label, sd.label) AS "destinationLabel", + b.reference AS "bookingReference", + COALESCE( + slc.name, + CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END, + c.name + ) AS "customerName" + FROM freight.train_schedules s + JOIN freight.train_set_wagons tsw + ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL + JOIN freight.wagon_booking_allocations a + ON a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL + LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id + LEFT JOIN freight.bookings b ON b.id = a.booking_id + LEFT JOIN freight.companies c ON c.id = b.company_id + LEFT JOIN freight.shipping_line_companies slc ON slc.id = b.shipping_line_company_id + LEFT JOIN freight.wagon_allocation_container_items ci + ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL + LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id + LEFT JOIN freight.booking_container bc + ON bc.id = ci.booking_container_id AND bc.deleted_at IS NULL + LEFT JOIN freight.booking_container_units bcu + ON bcu.container_number = ci.container_number + AND bcu.booking_container_id = bc.id + AND bcu.deleted_at IS NULL + LEFT JOIN freight.wagon_allocation_bulk_loads bl + ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL + LEFT JOIN freight.yards so ON so.id = s.origin_station_id + LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id + LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id + LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id + WHERE s.id = $1 AND s.deleted_at IS NULL + ORDER BY tsw.sequence_no, ci.position_on_wagon, ci.container_number`, + [scheduleId], + ); + + // "number" is the printed line number of the sheet, not the wagon sequence — + // a two-container wagon occupies two lines, and the reader counts lines. + const sheetRows = rows.map((row, index) => ({ + number: index + 1, + wagonNumber: row.wagonNumber ?? '—', + containerNumber: + row.containerNumber ?? + (row.loadType === 'BULK' ? (row.bulkCargoDescription ?? 'Bulk') : '—'), + vgmTons: row.vgmTons === null ? null : Number(row.vgmTons), + originLabel: row.originLabel ?? '—', + destinationLabel: row.destinationLabel ?? '—', + customerName: row.customerName ?? '—', + })); + + const totalVgm = sheetRows.reduce((sum, r) => sum + (r.vgmTons ?? 0), 0); + const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id; + + const buffer = await this.tabularExport.toXlsx({ + title: `Wagons ${reference}`.slice(0, 31), + description: `Wagon list for train ${reference}`, + label: 'train-schedule:wagon-list', + kpis: [ + { label: 'Lines', value: sheetRows.length }, + { + label: 'Wagons', + value: new Set(rows.map((r) => r.sequenceNo)).size, + }, + { label: 'Total VGM', value: Number(totalVgm.toFixed(3)), unit: 't' }, + ], + columns: [ + { key: 'number', label: 'No.', type: 'number' }, + { key: 'wagonNumber', label: 'Wagon', type: 'string' }, + { key: 'containerNumber', label: 'Container number', type: 'string' }, + { key: 'vgmTons', label: 'VGM', type: 'tons' }, + { key: 'originLabel', label: 'Origin', type: 'string' }, + { key: 'destinationLabel', label: 'Destination', type: 'string' }, + { key: 'customerName', label: 'Customer', type: 'string' }, + ], + rows: sheetRows, + }); + + return { + filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`, + buffer, + }; + } + async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index dd5cc74bc..fdbc504b5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -6,6 +6,7 @@ import { BillingModule } from '../billing/billing.module'; import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; import { BookingsModule } from '../bookings/bookings.module'; import { Container } from '../container-management/entities/container.entity'; +import { ExportsModule } from '../exports/exports.module'; import { LocomotivesModule } from '../locomotives/locomotives.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FacilityHandlingService } from './facility-handling.service'; @@ -67,6 +68,7 @@ import { ContractsModule } from '../contracts/contracts.module'; UserTradeAccessModule, NotificationsModule, NotificationInboxModule, + ExportsModule, LocomotivesModule, WagonTypesModule, TrainSetsModule, diff --git a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts index 1e2db1631..4631430e0 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/list-wagons-query.dto.ts @@ -105,4 +105,18 @@ export class ListWagonsQueryDto { @IsOptional() @IsDateString() maintenanceTo?: string; + + @ApiPropertyOptional({ + description: + 'Window (days) the per-row load/move counts are counted over. Does not filter rows.', + default: 90, + minimum: 1, + maximum: 3650, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(3650) + statsWindowDays?: number; } diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index f15722b67..1a0de17d7 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -177,6 +177,7 @@ export class WagonsService { async findAll(query: ListWagonsQueryDto = {}): Promise> { const page = await paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 }); await this.attachStatusDates(page.items); + await this.attachMovementStats(page.items, query.statsWindowDays ?? 90); return page; } @@ -216,6 +217,56 @@ export class WagonsService { } } + /** + * Per-wagon movement rollups for the wagon performance report: when the + * wagon last arrived anywhere (the idle clock), and how many loaded / total + * moves it made inside `windowDays`. One grouped query per page, in the same + * shape as `attachStatusDates` above — never one request per row. + */ + private async attachMovementStats(wagons: Wagon[], windowDays: number): Promise { + if (!wagons.length) return; + const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000); + const rows: Array<{ + wagonId: string; + lastMovedAt: Date | null; + loadsInWindow: string; + movesInWindow: string; + emptyMovesInWindow: string; + }> = await this.dataSource + .getRepository(WagonMovement) + .createQueryBuilder('m') + .select('m.wagon_id', 'wagonId') + .addSelect('MAX(m.occurred_at)', 'lastMovedAt') + .addSelect( + 'COUNT(*) FILTER (WHERE m.occurred_at >= :since AND m.kind = :loaded)', + 'loadsInWindow', + ) + .addSelect( + 'COUNT(*) FILTER (WHERE m.occurred_at >= :since AND m.kind = :empty)', + 'emptyMovesInWindow', + ) + .addSelect('COUNT(*) FILTER (WHERE m.occurred_at >= :since)', 'movesInWindow') + .where('m.wagon_id IN (:...ids)', { ids: wagons.map((w) => w.id) }) + .setParameters({ + since, + loaded: WagonMovementKind.Loaded, + empty: WagonMovementKind.EmptyReposition, + }) + .groupBy('m.wagon_id') + .getRawMany(); + + const byId = new Map(rows.map((r) => [r.wagonId, r])); + for (const w of wagons) { + const r = byId.get(w.id); + Object.assign(w, { + lastMovedAt: r?.lastMovedAt ?? null, + loadsInWindow: Number(r?.loadsInWindow ?? 0), + movesInWindow: Number(r?.movesInWindow ?? 0), + emptyMovesInWindow: Number(r?.emptyMovesInWindow ?? 0), + }); + } + } + async findById(id: string): Promise { const wagon = await this.wagonRepo.findOne({ where: { id }, @@ -328,11 +379,35 @@ export class WagonsService { /** Movement ledger for one wagon, newest first (loaded legs, repositions, manual moves). */ async listMovements(wagonId: string): Promise { await this.findById(wagonId); // 404 on unknown wagon - return this.dataSource.getRepository(WagonMovement).find({ + const movements = await this.dataSource.getRepository(WagonMovement).find({ where: { wagonId }, relations: { fromYard: true, toYard: true }, order: { occurredAt: 'DESC', createdAt: 'DESC' }, }); + await this.attachBookingReferences(movements); + return movements; + } + + /** + * Resolve each loaded move's booking to its human reference, so the UI can + * show (and link to) "BKG-11284" rather than a raw uuid. One query for the + * whole ledger; `wagon_movements` deliberately has no FK to bookings, so + * this is a read-time join on primary keys, exactly like the labels in + * `wagon-history.service`. + */ + private async attachBookingReferences(movements: WagonMovement[]): Promise { + const ids = [...new Set(movements.map((m) => m.bookingId).filter((v): v is string => !!v))]; + if (!ids.length) return; + const rows: Array<{ id: string; reference: string }> = await this.dataSource.query( + `SELECT id, reference FROM freight.bookings WHERE id = ANY($1::uuid[])`, + [ids], + ); + const byId = new Map(rows.map((r) => [r.id, r.reference])); + for (const m of movements) { + Object.assign(m, { + bookingReference: m.bookingId ? (byId.get(m.bookingId) ?? null) : null, + }); + } } async remove(id: string, userId?: string | null): Promise { 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/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index f95fe7cca..652a331c7 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -62,6 +62,8 @@ import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesP import PortalContentPage from "./pages/portal_content/PortalContentPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; +import WagonPerformancePage from "./pages/wagon-performance/WagonPerformancePage"; +import WagonPerformanceDetailPage from "./pages/wagon-performance/WagonPerformanceDetailPage"; import WagonTransfersPage from "./pages/wagons/WagonTransfersPage"; import VehicleDetailPage from "./pages/fleet/VehicleDetailPage"; import DriverDetailPage from "./pages/fleet/DriverDetailPage"; @@ -232,6 +234,24 @@ const App = () => { } /> + {/* Wagon performance — a read-only executive report beside Overview. + Separate from the Fleet Management wagons desk, which owns CRUD. */} + + + + } + /> + + + + } + /> {/* One drill-down route per overview domain — the old per-tab charts, now each on its own page. Single source of truth for the permission gate is OVERVIEW_DOMAINS, shared with the summary diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingWagonsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingWagonsPanel.tsx new file mode 100644 index 000000000..456c9f3d6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingWagonsPanel.tsx @@ -0,0 +1,255 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Badge, + Button, + Center, + Group, + Loader, + SimpleGrid, + Stack, + Table, + Text, +} from "@mantine/core"; +import { Container, FileSpreadsheet, Train } from "lucide-react"; +import toast from "react-hot-toast"; + +import { api } from "@/services/api"; +import { bookingsService } from "@/services/bookings.service"; +import { extractDownloadErrorMessage } from "@/components/warehouses/options"; +import { formatDate } from "@/lib/format"; +import type { BookingWagonRow } from "@/types/trainScheduling"; + +import { SectionCard } from "./SectionCard"; +import { MetricTile } from "./MetricTile"; + +/** pg returns numerics as strings; everything here is arithmetic on tons/metres. */ +const num = (value: number | string | null | undefined): number => { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const tons = (value: number | string | null | undefined): string => + `${num(value).toLocaleString(undefined, { maximumFractionDigits: 3 })} t`; + +/** Allocation status → badge colour. PLANNED is the pre-loading default. */ +const STATUS_COLORS: Record = { + PLANNED: "blue", + LOADED: "edr-green", + UNLOADED: "gray", + CANCELLED: "red", +}; + +/** + * The booking detail page's "Wagons" tab: every wagon allocated to this booking, + * with its containers or bulk load, plus an Excel export of the same list. + * + * A booking has no wagons until it is paid and placed on a train, so the empty + * state is the normal case for most of a booking's life — it explains the + * precondition rather than reading as an error. + */ +export function BookingWagonsPanel({ + bookingId, + bookingReference, +}: { + bookingId: string; + bookingReference: string; +}) { + const [exporting, setExporting] = useState(false); + + const { data, isLoading, isError } = useQuery( + api.trainScheduling.bookingWagons.queryOptions({ input: { bookingId } }), + ); + + const wagons = useMemo(() => data ?? [], [data]); + + const totals = useMemo(() => { + const containerCount = wagons.reduce( + (sum, w) => sum + (w.containers?.length ?? 0), + 0, + ); + const allocated = wagons.reduce( + (sum, w) => sum + num(w.allocatedWeightTons), + 0, + ); + const capacity = wagons.reduce((sum, w) => sum + num(w.capacityTons), 0); + return { containerCount, allocated, capacity }; + }, [wagons]); + + // The train is a property of the allocation, so every wagon on this booking + // carries the same one — read it off the first row rather than per row. + const train = wagons[0]; + + const handleExport = async () => { + setExporting(true); + try { + const blob = await bookingsService.downloadWagonsWorkbook(bookingId); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `wagons-${bookingReference}.xlsx`; + a.click(); + URL.revokeObjectURL(url); + } catch (error) { + // Blob response: the JSON reason is inside the Blob, so the sync path + // would surface only "Request failed with status code 400". + toast.error(await extractDownloadErrorMessage(error)); + } finally { + setExporting(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + } + loading={exporting} + // The sheet would be headers with no rows — nothing to hand over. + disabled={wagons.length === 0} + onClick={() => void handleExport()} + > + Export Excel + + } + > + {isError ? ( + + Could not load the wagon allocations for this booking. + + ) : wagons.length === 0 ? ( + + Wagons appear here once the booking is paid and allocated onto a train. + + ) : ( + + + + + + + + + {train?.departureAt ? ( + + + Departs {formatDate(train.departureAt)} + + {train.originStation && train.destinationStation ? ( + + · {train.originStation} → {train.destinationStation} + + ) : null} + + ) : null} + + + + + + Seq + Wagon + Type + Status + Allocated + Capacity + Load + + + + {wagons.map((w) => ( + + + + {w.sequenceNo ?? "—"} + + + + + {w.wagonNumber ?? "—"} + + + + {w.wagonType ?? "—"} + + + + {w.status} + + + + {tons(w.allocatedWeightTons)} + + + + {tons(w.capacityTons)} + + + + {w.containers?.length ? ( + + {w.containers.map((c, i) => ( + + + + {c.containerNumber ?? "—"} + {c.sizeFt ? ` · ${c.sizeFt}ft` : ""} + + + ))} + + ) : w.bulkCargoDescription || w.loadType === "BULK" ? ( + + {w.bulkCargoDescription ?? "Bulk"} + {w.bulkQuantity ? ` · ${num(w.bulkQuantity)}` : ""} + + ) : ( + + — + + )} + + + ))} + +
+
+
+ )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index 23f9b2bd8..d23b75f43 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -3,6 +3,7 @@ export * from "./SectionCard"; export * from "./ClearanceReviewSection"; export * from "./BookingDocumentsPanel"; export * from "./BookingTrucksPanel"; +export * from "./BookingWagonsPanel"; export * from "./ContractOrdersPanel"; export * from "./MetricTile"; export * from "./BookingDetailToolbar"; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx index 2d37be840..318ce3f9b 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx @@ -1,11 +1,11 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core"; -import { DatePickerInput } from "@mantine/dates"; import { useMutation, useQuery } from "@tanstack/react-query"; import toast from "react-hot-toast"; +import { OperationDatePicker } from "@edr/ui-common"; import { api } from "@/auth/http"; -import { toDayString } from "@/hooks/useListControls"; +import { api as rpc } from "@/services/api"; import { formatMoney } from "@/lib/format"; import { hasOddFt20, @@ -13,6 +13,7 @@ import { type WagonCancellation, } from "./types"; + /** Editable rebook unit — prefilled from the cancelled snapshot. */ interface RebookUnitDraft { containerSize: string; @@ -21,6 +22,46 @@ interface RebookUnitDraft { vgmTons: number | ""; } +/** Editable unit on the consolidation partner — prefilled from its own cargo. */ +interface PartnerUnitDraft { + id: string; + containerSize: string; + containerNumber: string; + sealNumber: string; + vgmTons: number | ""; +} + +const partnerDraftsFrom = (c: RebookPartnerCandidate | undefined) => + (c?.units ?? []).map((u) => ({ + id: u.id, + containerSize: u.containerSize, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? "", + vgmTons: Number(u.vgmTons) || ("" as const), + })); + +/** Only the units GL actually changed are sent. */ +const partnerUnitsPayload = ( + drafts: PartnerUnitDraft[], + original: PartnerUnitDraft[], +) => + drafts + .filter((d, i) => { + const o = original[i]; + return ( + !o || + d.containerNumber !== o.containerNumber || + d.sealNumber !== o.sealNumber || + d.vgmTons !== o.vgmTons + ); + }) + .map((d) => ({ + id: d.id, + containerNumber: d.containerNumber.trim(), + sealNumber: d.sealNumber.trim(), + ...(d.vgmTons !== "" ? { vgmTons: Number(d.vgmTons) } : {}), + })); + const draftsFrom = (r: WagonCancellation): RebookUnitDraft[] => (r.cancelledQuantities?.units ?? []).map((u) => ({ containerSize: u.containerSize, @@ -62,30 +103,75 @@ export function RebookWagonCancellationModal({ /** Called after a successful rebook with the new booking id (when the API returns it). */ onRebooked?: (result: { bookingId?: string }) => void; }) { - const [date, setDate] = useState(null); + // Held as the picker's own `yyyy-MM-dd` string, never a Date: converting a + // local-midnight Date back with toISOString() shifts it into the previous day + // in any timezone east of UTC (EAT is +03), which both mis-rendered the + // selection and submitted the wrong shipment day. + const [date, setDate] = useState(null); const [partnerId, setPartnerId] = useState(null); + const [partnerDrafts, setPartnerDrafts] = useState([]); const [drafts, setDrafts] = useState([]); // Fresh form per row: the modal instance is long-lived on the host page. useEffect(() => { setDate(null); setPartnerId(null); + setPartnerDrafts([]); setDrafts(cancellation ? draftsFrom(cancellation) : []); }, [cancellation]); const needsPartner = cancellation ? hasOddFt20(cancellation) : false; + + // The rebook rides the same lane with the same cargo as the cancelled + // shipment, so the shipment day must come from the days that lane actually + // runs — an arbitrary calendar day has no train and no wagon capacity. + const daysQuery = useMemo(() => { + const b = cancellation?.booking; + if (!b?.originYardId || !b?.destinationYardId) return null; + const containers = Object.entries( + cancellation?.cancelledQuantities?.bySize ?? {}, + ) + .map(([containerSize, quantity]) => ({ + containerSize, + quantity: Number(quantity || 0), + })) + .filter((c) => c.quantity >= 1); + if (containers.length > 0) { + return { + originYardId: b.originYardId, + destinationYardId: b.destinationYardId, + freightType: "CONTAINER" as const, + containers, + }; + } + const tons = Number(cancellation?.weightTons || 0); + if (tons <= 0) return null; + return { + originYardId: b.originYardId, + destinationYardId: b.destinationYardId, + freightType: "BULK" as const, + totalWeightTons: tons, + }; + }, [cancellation]); + + const { data: availableDays, isLoading: daysLoading } = useQuery({ + ...rpc.trainScheduling.availableDaysForCargo.queryOptions({ + input: daysQuery ?? { freightType: "BULK" as const }, + }), + enabled: Boolean(cancellation) && daysQuery !== null, + }); const partners = useQuery({ queryKey: [ "wagon-cancellations", cancellation?.id, "rebook-partners", - date ? toDayString(date) : null, + date, ], enabled: Boolean(cancellation && needsPartner && date), queryFn: async () => { const res = await api.get( `/bookings/wagon-cancellations/${cancellation!.id}/rebook-partners`, - { params: { scheduledDate: toDayString(date!) } }, + { params: { scheduledDate: date } }, ); return res.data; }, @@ -96,15 +182,28 @@ export function RebookWagonCancellationModal({ const res = await api.post<{ bookingId?: string }>( `/bookings/wagon-cancellations/${cancellation!.id}/rebook`, { - scheduledDate: toDayString(date!), + scheduledDate: date, ...(drafts.length ? { containers: containersPayload(drafts) } : {}), ...(partnerId ? { partnerBookingId: partnerId } : {}), + ...(() => { + if (!partnerId) return {}; + const original = partnerDraftsFrom( + (partners.data ?? []).find((c) => c.id === partnerId), + ); + const changed = partnerUnitsPayload(partnerDrafts, original); + return changed.length ? { partnerUnits: changed } : {}; + })(), }, ); return res.data ?? {}; }, }); + const patchPartnerDraft = (i: number, patch: Partial) => + setPartnerDrafts((prev) => + prev.map((d, idx) => (idx === i ? { ...d, ...patch } : d)), + ); + const patchDraft = (i: number, patch: Partial) => setDrafts((prev) => prev.map((x, idx) => (idx === i ? { ...x, ...patch } : x))); @@ -115,6 +214,9 @@ export function RebookWagonCancellationModal({ title="Rebook cancelled wagons" centered radius="md" + // Wide enough for the calendar plus two container-unit editors side by + // side without the number / seal / VGM fields cramping. + size="xl" > {cancellation && ( @@ -123,21 +225,30 @@ export function RebookWagonCancellationModal({ {cancellation.wagonsCancelled} wagon(s) · credit{" "} {formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)} - { - setDate(v ? new Date(v) : null); + + Shipment day + + { + setDate(d || null); setPartnerId(null); + setPartnerDrafts([]); }} - minDate={new Date()} - radius="md" /> {needsPartner && ( v && setScheduleId(v)} + searchable + allowDeselect={false} + miw={420} + maw={640} + style={{ flex: 1 }} + /> + + {schedule ? ( + + {String(schedule.status).replace(/_/g, ' ')} + + ) : null} + } + > + {trainAtLabel ? `Train at ${trainAtLabel}` : 'Position unknown'} + + {scheduleId ? ( + + ) : null} + + + + {/* Port-ops flow the actions below follow */} + + {FLOW_STEPS.map((step, i) => ( + + + {step} + + {i < FLOW_STEPS.length - 1 ? ( + + ) : null} + + ))} + + + + {forbidden ? ( + }> + The train-scheduling API refused the journey read for this train. The + train scheduling: view permission is required to load from here. + + ) : detailQuery.isLoading || yardWorkQuery.isLoading ? ( + + + + ) : ( + + + + Load is only offered where the train actually stands, inside a started loading window + — the same rule the train schedule enforces. Removing a booking from the train, + cancelling wagons and direct truck-to-train stay on the schedule workspace. + + setOnlyInWarehouse(e.currentTarget.checked)} + /> + + + {corridorGroups.length === 0 ? ( + + {onlyInWarehouse + ? 'None of this train’s bookings have cargo in the warehouse yet.' + : 'No bookings allocated to this train yet.'} + + ) : null} + + {corridorGroups.map((group) => { + const groupIdx = stationIdx.get(group.yardId) ?? 0; + const trainHere = trainAtYardId === group.yardId; + const passed = trainIdx != null && groupIdx < trainIdx; + const loadLog = workLogs[group.yardId]?.loading; + return ( + + + + + + {group.label} + + {trainHere ? ( + } + > + Train here + + ) : passed ? ( + + Passed + + ) : ( + + Ahead + + )} + + {group.rows.length} + + + + {/* The yard's loading window — same store the schedule writes. */} + {scheduleId && (trainHere || loadLog?.startedAt) ? ( + + + + ) : null} + + {group.rows.map((b) => { + const ref = b.reference ?? b.id.slice(0, 8); + const journey = journeyById.get(b.id); + const wh = warehouseByBooking.get(b.id) ?? null; + const loadWindowStarted = Boolean(loadLog?.startedAt); + const riding = b.status === 'IN_TRANSIT'; + const done = ['ARRIVED', 'COMPLETED', 'DELIVERED'].includes(b.status ?? ''); + const boardHere = trainHere; + const showLoad = canWork && !riding && !done && (journey?.canLoad ?? false); + const paid = PAID_OR_LATER.has(b.status ?? ''); + const wagonPinned = Boolean(b.wagonAssigned) || Boolean(wh?.wagons.length); + const allLoaded = + riding || + Boolean(b.loadedAt) || + (wh != null && wh.loaded === wh.items.length && wh.items.length > 0); + return ( + + + + + + {b.status ? : null} + {b.tradeDirection === 'DOMESTIC' ? ( + + Intercity + + ) : null} + {riding || Boolean(b.loadedAt) || wagonPinned ? ( + + {allLoaded + ? 'Loaded' + : wh && wh.loaded > 0 + ? `Partly loaded ${wh.loaded}/${wh.items.length}` + : 'Unloaded'} + + ) : null} + + + + {b.customer ?? '—'} + + {b.weightTons != null ? ( + + + + {Number(b.weightTons).toFixed(1)}T + + + ) : null} + {b.origin && + b.destination && + (b.originYardId !== schedule?.originStation?.id || + b.destinationYardId !== schedule?.destinationStation?.id) ? ( + + {b.origin} → {b.destination} + + ) : null} + {wh ? ( + + {wh.items.length} item + {wh.items.length === 1 ? '' : 's'} in warehouse + {wh.wagons.length ? ` · wagon ${wh.wagons.join(', ')}` : ''} + {wh.items[0]?.grnNumber ? ` · ${wh.items[0].grnNumber}` : ''} + + ) : ( + + Not received at the warehouse + + )} + + {/* Pre-load checklist — every server gate, visible before Load. */} + {!riding && !done ? ( + + + + + + + + + ) : null} + + + + {showLoad ? ( + + + + ) : null} + {showLoad && boardHere ? ( + + + + ) : null} + + + + ); + })} + + + ); + })} + + {/* Unload side — bookings alighting where the train stands (port arrival). */} + {trainAtYardId && alightingHere.length > 0 ? ( + + + + + + Unload at {trainAtLabel ?? 'this yard'} + + + {alightingHere.length} + + + {scheduleId ? ( + + + + ) : null} + {alightingHere.map((b) => { + const ref = b.reference ?? b.id.slice(0, 8); + const journey = journeyById.get(b.id); + const unloadWindowStarted = Boolean( + workLogs[trainAtYardId]?.unloading?.startedAt, + ); + const showUnload = canWork && (journey?.canUnload ?? false); + return ( + + + + + + {b.status ? : null} + + + + {b.customer ?? '—'} + + {b.weightTons != null ? ( + + {Number(b.weightTons).toFixed(1)}T + + ) : null} + + + + {showUnload ? ( + <> + + + + + + + + ) : null} + + + + ); + })} + + + ) : null} + + )} + + {wagonModal && scheduleId ? ( + setWagonModal(null)} + onChanged={afterChange} + /> + ) : null} + + {/* Confirm load / unload — same wording as the schedule workspace */} + setConfirmAction(null)} + centered + radius="lg" + size="md" + withCloseButton={false} + title={ + confirmAction ? ( + + + {confirmAction.kind === 'unload' ? ( + + ) : ( + + )} + +
+ + {confirmAction.kind === 'unload' + ? 'Unload cargo at this yard?' + : 'Load cargo onto the train?'} + + + {confirmAction.ref} + +
+
+ ) : null + } + > + {confirmAction ? ( + + + {confirmAction.kind === 'unload' + ? "Stamps the booking's arrival at this yard and frees its wagons for reuse." + : 'Stamps the booking as loaded at this yard and moves its warehouse inventory to LOADED. The server checks the train is actually standing here.'} + + + + + + + ) : null} +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/TrainWagonLoadModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/TrainWagonLoadModal.tsx new file mode 100644 index 000000000..92d397a59 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/TrainWagonLoadModal.tsx @@ -0,0 +1,320 @@ +import { useState } from 'react'; +import { + Alert, + Badge, + Button, + Checkbox, + Group, + Modal, + Paper, + Stack, + Text, + ThemeIcon, + Tooltip, +} from '@mantine/core'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { CheckCircle2, Info, PackageCheck, PackageOpen, Train } from 'lucide-react'; + +import { useToast } from '@/hooks/use-toast'; +import { api } from '@/services/api'; +import type { BookingWagonRow } from '@/types/trainScheduling'; +import { extractErrorMessage } from './options'; + +/** + * Wagon-by-wagon load / unload of one booking on one train — the warehouse + * mirror of the train schedule's "Wagons" button. + * + * It calls the SAME per-wagon journey endpoints the schedule workspace calls + * (`schedules/:id/bookings/:bookingId/wagons/:allocationId/load|unload`), so + * every server gate — train at the yard, loading window started, PAID, GRN — + * is the schedule's own, and the two surfaces can never disagree on what got + * loaded. Wagons go one at a time in order: the server flips the booking to + * IN_TRANSIT / ARRIVED on whichever call clears the last wagon, so sequential + * is required, not just convenient. A failure stops the run; the wagons + * already sent stay done and the toast says how many, so a retry only resends + * the rest. + * + * Deliberately NOT mirrored here: cancelling wagons that will not ride and the + * direct truck-to-train handover. Both are commercial/allocation decisions + * (fees, credits, GRN waiver) that belong to the train schedule workspace, not + * the warehouse floor. + */ +export function TrainWagonLoadModal({ + scheduleId, + bookingId, + reference, + phase, + onClose, + onChanged, +}: { + scheduleId: string; + bookingId: string; + reference: string; + phase: 'load' | 'unload'; + onClose: () => void; + onChanged?: () => void; +}) { + const { toast } = useToast(); + const qc = useQueryClient(); + const [picked, setPicked] = useState>(new Set()); + const [submitting, setSubmitting] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + + const wagonsQuery = useQuery( + api.trainScheduling.bookingWagons.queryOptions({ input: { bookingId } }), + ); + const wagons: BookingWagonRow[] = wagonsQuery.data ?? []; + const isDone = (w: BookingWagonRow) => + phase === 'load' ? w.status === 'LOADED' || w.status === 'DEPARTED' : w.status === 'DEPARTED'; + const doneCount = wagons.filter(isDone).length; + const pending = wagons.filter((w) => !isDone(w)); + const pickedPending = pending.filter((w) => picked.has(w.allocationId)); + + const loadWagon = useMutation(api.trainScheduling.loadScheduleBookingWagon.mutationOptions()); + const unloadWagon = useMutation(api.trainScheduling.unloadScheduleBookingWagon.mutationOptions()); + const act = phase === 'load' ? loadWagon : unloadWagon; + + const toggle = (allocationId: string) => + setPicked((prev) => { + const next = new Set(prev); + if (next.has(allocationId)) next.delete(allocationId); + else next.add(allocationId); + return next; + }); + + const afterChange = () => { + void wagonsQuery.refetch(); + // The warehouse queues read inventory status, which the journey load moves. + void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + void qc.invalidateQueries({ queryKey: ['train-loadable-items'] }); + void qc.invalidateQueries({ queryKey: ['loadable-trains'] }); + onChanged?.(); + }; + + const submit = async () => { + const targets = pickedPending; + if (!targets.length) return; + setConfirmOpen(false); + setSubmitting(true); + let done = 0; + let completed = false; + try { + for (const w of targets) { + const r = await act.mutateAsync({ + scheduleId, + bookingId, + allocationId: w.allocationId, + }); + done += 1; + if (r.completed) completed = true; + } + afterChange(); + setPicked(new Set()); + if (completed) { + toast({ + title: phase === 'load' ? 'Booking fully loaded' : 'Booking fully unloaded', + description: + phase === 'load' + ? `${reference}: every wagon is loaded — the booking is in transit.` + : `${reference}: every wagon is unloaded — the booking arrived.`, + }); + onClose(); + } else { + toast({ + title: phase === 'load' ? 'Wagons loaded' : 'Wagons unloaded', + description: `${reference}: ${done} wagon${done === 1 ? '' : 's'} ${phase === 'load' ? 'loaded' : 'unloaded'}.`, + }); + } + } catch (error) { + if (done > 0) afterChange(); + toast({ + variant: 'destructive', + title: phase === 'load' ? 'Wagon load failed' : 'Wagon unload failed', + description: done + ? `${done} wagon(s) went through before this: ${extractErrorMessage(error)}` + : extractErrorMessage(error), + }); + } finally { + setSubmitting(false); + } + }; + + const color = phase === 'load' ? 'edr-green' : 'orange'; + const Icon = phase === 'load' ? PackageCheck : PackageOpen; + + return ( + + + + {phase === 'load' ? 'Load' : 'Unload'} {reference} wagon by wagon + + + } + centered + radius="lg" + size="lg" + > + + + + {doneCount}/{wagons.length} {phase === 'load' ? 'loaded' : 'unloaded'} + + {phase === 'load' && doneCount > 0 && pending.length > 0 ? ( + + The train cannot dispatch until the rest are loaded or cancelled. + + ) : null} + + + {wagonsQuery.isLoading ? ( + + Loading wagons… + + ) : wagons.length === 0 ? ( + + No wagon allocations yet — use the whole-booking button instead. + + ) : ( + wagons.map((w) => ( + + + + {!isDone(w) ? ( + toggle(w.allocationId)} + disabled={submitting} + color={color} + aria-label={`Select wagon ${w.sequenceNo ?? ''} to ${phase}`} + /> + ) : null} + + {w.sequenceNo != null ? `#${w.sequenceNo}` : '—'} + + + {w.wagonNumber ?? w.wagonType ?? 'Wagon'} + + + {w.wagonTypeCode ?? ''} + {w.allocatedWeightTons ? ` · ${Number(w.allocatedWeightTons).toFixed(1)}T` : ''} + {w.containers?.length ? ` · ${w.containers.length} ctr` : ''} + + + {isDone(w) ? ( + } + > + {phase === 'load' ? 'Loaded' : 'Unloaded'} + + ) : null} + + + )) + )} + + {pending.length > 0 && !confirmOpen ? ( + + + + + {pickedPending.length} of {pending.length} selected + + + + + + + ) : null} + + {confirmOpen ? ( + + + + + + +
+ + {phase === 'load' ? 'Load' : 'Unload'} {pickedPending.length} wagon + {pickedPending.length === 1 ? '' : 's'}? + + + {reference} + +
+
+ + {phase === 'load' + ? 'Stamps the selected wagons as loaded at this yard. Export cargo must already be received at the warehouse with a GRN.' + : 'Stamps the selected wagons as unloaded and frees them for reuse.'} + + {pickedPending.length < pending.length ? ( + + {pending.length - pickedPending.length} wagon + {pending.length - pickedPending.length === 1 ? '' : 's'} left un + {phase === 'load' ? 'loaded' : 'unloaded'} — the train cannot dispatch until they + are {phase === 'load' ? 'loaded' : 'unloaded'} or cancelled. + + ) : null} + + + + +
+
+ ) : null} + + {phase === 'load' && pending.length > 0 ? ( + } p="xs"> + + A wagon that will not ride (cancel with fee / EDR fault) and direct truck-to-train + loading are decided on the train schedule workspace, not here. + + + ) : null} +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts index 4e0ebd7f8..4302cc979 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/index.ts @@ -38,3 +38,5 @@ export { AccrualDashboard } from './AccrualDashboard'; export { DwellAgingCard } from './DwellAgingCard'; export { CycleTimeCard } from './CycleTimeCard'; export { GateThroughputCard } from './GateThroughputCard'; +export { TrainLoadingWorkspace } from './TrainLoadingWorkspace'; +export { TrainWagonLoadModal } from './TrainWagonLoadModal'; diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 8de3bd320..ae972022e 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -226,6 +226,7 @@ export const URL_CONSTANTS = { CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`, CARRIAGE_ACCEPTANCE_SHEET: (id: string) => `/bookings/${id}/carriage-acceptance-sheet`, + WAGONS_EXPORT: (id: string) => `/bookings/${id}/wagons/export`, EXPORT_HANDOVER_MODE: (id: string) => `/bookings/${id}/export-handover-mode`, SUMMARY: (id: string) => `/bookings/${id}/summary`, @@ -536,6 +537,8 @@ export const URL_CONSTANTS = { `/train-scheduling/schedules/${id}/import-djibouti/load-list/document`, EXPORT_LOAD_LIST_DOCUMENT: (id: string) => `/train-scheduling/schedules/${id}/export/load-list/document`, + SCHEDULE_WAGONS_EXPORT: (id: string) => + `/train-scheduling/schedules/${id}/wagons/export`, INTERCITY_MARSHALLING_DOCUMENT: (id: string) => `/train-scheduling/schedules/${id}/intercity/marshalling/document`, MARSHALLING_STOPS: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts index 66f991989..fd86498a2 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts @@ -44,6 +44,18 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow { booking.serviceType?.name ?? booking.serviceType?.code, trainScheduleId: booking.trainScheduleId ?? null, + // List rows carry the flat departure date; detail responses carry the fuller + // summary object instead — fall back to it so a row mapped from either shape + // shows the same date. + trainScheduleDepartureDate: + booking.trainScheduleDepartureDate ?? + booking.trainScheduleSummary?.scheduledDepartureDate ?? + null, + trainScheduleReference: + booking.trainScheduleReference ?? + booking.trainScheduleSummary?.reference ?? + booking.trainScheduleSummary?.trainNumber ?? + null, isGovernment: booking.isGovernment ?? false, governmentInstitution: booking.governmentInstitution ?? null, consolidationPartnerId: booking.consolidationPartnerId ?? null, 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 8e64cd990..a75e242e1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -16,6 +16,7 @@ import { Receipt, RefreshCw, Ship, + Train, Truck, Wallet, Weight, @@ -63,6 +64,7 @@ import { BookingSchedulingWindowCard, BookingDocumentsPanel, BookingTrucksPanel, + BookingWagonsPanel, ContractOrdersPanel, } from "@/components/bookings/detail"; import { WarehouseInfoCard } from "@/components/warehouses"; @@ -219,9 +221,11 @@ export default function BookingRequestDetailPage() { ? "documents" : requestedTab === "trucks" ? "trucks" - : requestedTab === "additional-charges" - ? "additional-charges" - : "overview"; + : requestedTab === "wagons" + ? "wagons" + : requestedTab === "additional-charges" + ? "additional-charges" + : "overview"; const setActiveTab = (tab: string | null) => { const next = new URLSearchParams(searchParams); if (tab && tab !== "overview") next.set("tab", tab); @@ -522,6 +526,9 @@ export default function BookingRequestDetailPage() { }> Trucks + }> + Wagons + {canSeeAdditionalCharges && ( + + + {canSeeAdditionalCharges && ( diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index a277d0570..1d86d9a7e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -13,6 +13,7 @@ import { Plus, RefreshCw, Ship, + Train, User, } from "lucide-react"; import { useCallback, useMemo, useRef, useState } from "react"; @@ -609,7 +610,7 @@ export default function BookingRequestsPage() { }, { id: "scheduled", - header: () => Scheduled, + header: () => Requested, cell: ({ row }) => ( @@ -617,6 +618,50 @@ export default function BookingRequestsPage() { ), }, + { + // The date of the train the booking is actually allocated to. Empty until + // allocation, which is why it is separate from the requested date above — + // the two differ whenever staff move a booking to another day. + id: "scheduledDate", + header: () => ( + Scheduled date + ), + cell: ({ row }) => { + const b = row.original; + if (!b.trainScheduleDepartureDate) { + return ( + + Not scheduled + + ); + } + const movedFromRequest = + b.scheduledDate && + new Date(b.trainScheduleDepartureDate).toDateString() !== + new Date(b.scheduledDate).toDateString(); + return ( +
+ + + {formatDate(b.trainScheduleDepartureDate)} + + {b.trainScheduleReference ? ( +

+ {b.trainScheduleReference} +

+ ) : null} + {movedFromRequest ? ( + + Date changed + + ) : null} +
+ ); + }, + }, { id: "priority", header: () => Priority, diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx index e664f1bd3..c5bf55c79 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx @@ -58,6 +58,12 @@ import { summarizeRequestedCargo, } from "@/features/clearance/requestedCargo"; import { contractsService } from "@/services/contracts.service"; +import { api } from "@/auth/http"; +import { + RebookWagonCancellationModal, + canRebookWagonCancellations, + type WagonCancellation, +} from "@/components/bookings/wagon-cancellation"; import "./contract-clearance-table.css"; /** Yards carry `label` (API) — older shapes used `name`/`code`. */ @@ -214,6 +220,7 @@ export default function ContractClearanceListPage() { const canCreateBooking = hasPermission(user, FREIGHT_PERMS.contracts.createBooking) && !isDjiboutiGl(user); + const canRebookCredit = canRebookWagonCancellations(user); const [query, setQuery] = useState(""); const [tab, setTab] = useState("all"); @@ -234,6 +241,21 @@ export default function ContractClearanceListPage() { refetch, } = useBookingEtClearanceQueue(true); + // Credit rebook opens the shared modal, which needs the full cancellation + // row — the queue only carries its id, so fetch it on demand. + const [creditRebook, setCreditRebook] = useState( + null, + ); + const openCreditRebook = useCallback(async (row: ShipmentBookingRow) => { + const res = await api.get< + { items?: WagonCancellation[] } | WagonCancellation[] + >(`/bookings/${row.id}/wagon-cancellations`); + const body = res.data; + const list = Array.isArray(body) ? body : (body?.items ?? []); + const match = list.find((c) => c.id === row.rebookableCancellationId); + if (match) setCreditRebook(match); + }, []); + // Shipment requests carry the requested quantities (per container type, or // bulk weight/items). Map them onto the booking rows by createdBookingId so // the queue shows what each shipment was requested for. @@ -272,6 +294,7 @@ export default function ContractClearanceListPage() { // A bare initiated instance has no cargo/price yet — GL still has to // create (complete) the booking. bookingCreated: Number(b.totalAmount ?? 0) > 0, + rebookableCancellationId: b.rebookableCancellationId ?? null, })) as ShipmentBookingRow[]; }, [bookingQueue, requestedByBooking]); @@ -602,12 +625,14 @@ export default function ContractClearanceListPage() { hasFilters={hasFilters} onClearFilters={clearFilters} canCreateBooking={canCreateBooking} + canRebookCredit={canRebookCredit} onOpen={openBooking} onCreateBooking={(row) => navigate( `/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`, ) } + onRebookCredit={openCreditRebook} onRebook={(row) => // Re-complete the SAME expired booking (new day, same finished // per-booking clearance) — a fresh instance would force the @@ -623,6 +648,15 @@ export default function ContractClearanceListPage() { + setCreditRebook(null)} + onRebooked={() => { + setCreditRebook(null); + // The credit is spent and a new booking exists — both change the queue. + void refetch(); + }} + /> ); } @@ -650,6 +684,8 @@ interface ShipmentBookingRow { createdAt: string | null; /** true once GL has actually created (completed) the booking. */ bookingCreated: boolean; + /** Unspent wagon-cancellation credit on this booking, if any. */ + rebookableCancellationId: string | null; } type PaginationState = ReturnType["pagination"]; @@ -666,9 +702,11 @@ function ShipmentBookingsTable({ hasFilters, onClearFilters, canCreateBooking, + canRebookCredit, onOpen, onCreateBooking, onRebook, + onRebookCredit, onViewContract, }: { rows: ShipmentBookingRow[]; @@ -681,9 +719,11 @@ function ShipmentBookingsTable({ hasFilters: boolean; onClearFilters: () => void; canCreateBooking: boolean; + canRebookCredit: boolean; onOpen: (id: string) => void; onCreateBooking: (row: ShipmentBookingRow) => void; onRebook: (row: ShipmentBookingRow) => void; + onRebookCredit: (row: ShipmentBookingRow) => void; onViewContract: (contractId: string) => void; }) { // A bare initiated instance that has cleared but not yet been created by GL. @@ -701,6 +741,14 @@ function ShipmentBookingsTable({ r.customs && r.status === "EXPIRED"; + // A cancelled booking whose wagon-cancellation credit is paid for and unspent. + // Redeeming it is a different action from re-completing an expired booking — + // it opens the credit rebook modal rather than the completion form. Gated on + // the rebook permission (not booking-creation) so the button matches exactly + // who the API lets through. + const hasRebookableCredit = (r: ShipmentBookingRow) => + canRebookCredit && Boolean(r.rebookableCancellationId); + const columns = useMemo[]>( () => [ { @@ -840,6 +888,7 @@ function ShipmentBookingsTable({ const r = row.original; const bookable = isBookable(r); const rebookable = isRebookable(r); + const creditRebookable = hasRebookableCredit(r); return ( ) : null} + {creditRebookable ? ( + + ) : null} ) : null} + {creditRebookable ? ( + } + onClick={() => onRebookCredit(r)} + > + Rebook cancellation credit + + ) : null} {r.contractId ? ( } diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 9f7198609..87f098dc6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1226,7 +1226,9 @@ const LastMilePage = () => { // (same as "Mark In Transit") alongside the warehouse exit-weighing flow. const handleTruckLeaving = (record: LastMileRecord) => { openTruckArrival(record); - if (record.status === "READY_TO_TRANSIT") { + // Legs recorded before the advance gate can still sit at READY_TO_TRANSIT + // with the advance unpaid; the API would refuse the hop, so don't fire it. + if (record.status === "READY_TO_TRANSIT" && !record.advanceOutstanding) { updateMutation.mutate({ id: record.id, data: { status: "IN_TRANSIT" } }); } }; @@ -1414,10 +1416,17 @@ const LastMilePage = () => { const hasDistance = row.original.exactKm != null; // Advance: PAYMENT_PENDING→Ready, READY_TO_TRANSIT→In-transit (needs a // vehicle), IN_TRANSIT→Delivered (needs distance/invoice). + // Mirrors the server's advance gate: nothing becomes dispatchable and + // nothing moves until the approved advance is paid. Delivery (the + // IN_TRANSIT step) is not gated, so only the two transit hops are. + const advanceBlocked = + Boolean(row.original.advanceOutstanding) && + (nextStatus === "READY_TO_TRANSIT" || nextStatus === "IN_TRANSIT"); const canAdvance = - status === "PAYMENT_PENDING" || - (status === "READY_TO_TRANSIT" && assigned) || - (status === "IN_TRANSIT" && hasDistance); + !advanceBlocked && + (status === "PAYMENT_PENDING" || + (status === "READY_TO_TRANSIT" && assigned) || + (status === "IN_TRANSIT" && hasDistance)); // Assign stays active until the whole load has trucks: container // bookings until every container is on a truck; bulk until the // tonnage is drawn down (trucks depart one by one). Already-departed @@ -1470,9 +1479,11 @@ const LastMilePage = () => { disabled={!nextStatus || !canAdvance} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus - ? `Mark ${STATUS_META[nextStatus].label}` - : STATUS_META[row.original.status].label} + {advanceBlocked + ? "Awaiting advance payment" + : nextStatus + ? `Mark ${STATUS_META[nextStatus].label}` + : STATUS_META[row.original.status].label} = { cargoTypeId: "Cargo type", originYardId: "Origin yard", destinationYardId: "Destination yard", + minKm: "From km", + maxKm: "To km", + baseLiters: "Base liters", + rateType: "Rate type", }; +/** + * A key the backend diffed but the UI has no label for still names a real + * change, so turn "baseLiters" into "Base liters" rather than hiding it. + */ +const labelFor = (field: string): string => + FIELD_LABELS[field] ?? + field + .replace(/([A-Z])/g, " $1") + .replace(/^./, (c) => c.toUpperCase()) + .replace(/\bId\b/, "") + .trim(); + const fmtDateTime = (iso: string) => new Date(iso).toLocaleString("en-GB", { day: "numeric", @@ -43,13 +56,16 @@ const fmtValue = ( value: unknown, labels?: Record, ): string => { - if (value === null || value === undefined || value === "") return "—"; + // "Not set" reads as a real before-state; a bare em dash on both sides of the + // arrow made a newly-set field look like no change at all. + if (value === null || value === undefined || value === "") return "Not set"; if (field === "rateValue") { const num = Number(value); return Number.isNaN(num) ? String(value) : num.toLocaleString(); } - // Yard ids are unreadable — an approver decides on the route, not a UUID. - if (field === "originYardId" || field === "destinationYardId") { + // Any id is unreadable — an approver decides on "Perishable → Truck", not on + // a pair of uuids. Covers yards, cargo types, container types and lines. + if (field.endsWith("Id")) { return labels?.[String(value)] ?? String(value); } return String(value).replace(/_/g, " "); @@ -66,13 +82,33 @@ const rateSummary = (r: RateChangeRequest): string => { return parts.join(" · ") || "Rate"; }; -/** The headline change, so the queue is scannable without expanding: "100 → 200 USD". */ -const headline = (r: RateChangeRequest): string | null => { - if (!("rateValue" in r.payload)) return null; - const currency = String(r.payload.currency ?? r.previousValues.currency ?? (r.rate as Record | undefined)?.currency ?? ""); - const before = fmtValue("rateValue", r.previousValues.rateValue); - const after = fmtValue("rateValue", r.payload.rateValue); - return `${before} → ${after}${currency ? ` ${currency}` : ""}`; +/** + * Every change in the request, as readable before→after pairs. The queue must + * be scannable without expanding: a cargo or direction change is just as much + * the point as a repricing, so it gets the same one-line treatment as the rate. + */ +const summaryRows = ( + r: RateChangeRequest, + labels?: Record, +): Array<{ field: string; label: string; before: string; after: string; suffix: string }> => { + const currency = String( + r.payload.currency ?? + r.previousValues.currency ?? + (r.rate as Record | undefined)?.currency ?? + "", + ); + // Rate first — it is what most changes are about — then the rest in a stable + // order so the same edit always reads the same way. + const fields = Object.keys(r.payload).sort((a, b) => + a === "rateValue" ? -1 : b === "rateValue" ? 1 : a.localeCompare(b), + ); + return fields.map((field) => ({ + field, + label: labelFor(field), + before: fmtValue(field, r.previousValues[field], labels), + after: fmtValue(field, r.payload[field], labels), + suffix: field === "rateValue" && currency ? ` ${currency}` : "", + })); }; type Decide = UseMutationResult< @@ -87,8 +123,9 @@ interface RateApprovalsSectionProps { canDecide: boolean; approve: Decide; reject: Decide; - /** yardId → label, so a re-routed rate reads as yards, not UUIDs. */ - yardLabels?: Record; + /** id → label for every reference a diff can name (yards, cargo/container + * types, shipping lines), so a change reads as names, not UUIDs. */ + refLabels?: Record; } /** @@ -101,11 +138,8 @@ const RateApprovalsSection = ({ canDecide, approve, reject, - yardLabels, + refLabels, }: RateApprovalsSectionProps) => { - const [openId, setOpenId] = useState(null); - const [notes, setNotes] = useState>({}); - if (requests.length === 0) return null; const decidingId = approve.variables?.id ?? reject.variables?.id ?? null; @@ -125,9 +159,8 @@ const RateApprovalsSection = ({ {requests.map((r) => { - const isOpen = openId === r.id; const fields = Object.keys(r.payload); - const summaryLine = headline(r); + const rows = summaryRows(r, refLabels); // Only the row being decided shows a spinner — the mutation's // isPending is shared across every row. const busy = decidingId === r.id; @@ -145,39 +178,26 @@ const RateApprovalsSection = ({ - {summaryLine ? ( - + {rows.map((row) => ( + + + {row.label} + - {fmtValue("rateValue", r.previousValues.rateValue)} + {row.before} - + - {fmtValue("rateValue", r.payload.rateValue)} - - - {String( - r.payload.currency ?? - r.previousValues.currency ?? - (r.rate as Record | undefined)?.currency ?? - "", - )} + {row.after} + {row.suffix} - ) : null} + ))} - - - Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "} - {fields.length === 1 ? "field" : "fields"} changed - - - + + Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "} + {fields.length === 1 ? "field" : "fields"} changed + {canDecide ? ( @@ -190,7 +210,7 @@ const RateApprovalsSection = ({ loading={busy && reject.isPending} disabled={busy && approve.isPending} onClick={() => - reject.mutate({ id: r.id, decisionNote: notes[r.id] || undefined }) + reject.mutate({ id: r.id }) } > Reject @@ -202,7 +222,7 @@ const RateApprovalsSection = ({ loading={busy && approve.isPending} disabled={busy && reject.isPending} onClick={() => - approve.mutate({ id: r.id, decisionNote: notes[r.id] || undefined }) + approve.mutate({ id: r.id }) } > Approve & apply @@ -217,38 +237,6 @@ const RateApprovalsSection = ({ )} - - - {fields.map((field) => ( - - - {FIELD_LABELS[field] ?? field} - - - {fmtValue(field, r.previousValues[field], yardLabels)} - - - - {fmtValue(field, r.payload[field], yardLabels)} - - - ))} - {canDecide ? ( -