diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 264ffb811..7324da975 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -370,7 +370,21 @@ export class BookingTransitionService { async startTransit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ["PAID"]); + // Paid is read from the PAYMENT status only; the booking status merely + // guards against re-entering transit from a later stage. + if (booking.paymentStatus !== "PAID") { + throw new ConflictException( + `Booking must be paid before it can start transit (payment status "${booking.paymentStatus ?? "PENDING"}")`, + ); + } + assertBookingStatus(booking, [ + "PAID", + "FULLY_EXECUTED", + "PNR_GENERATED", + "WAGON_ASSIGNED", + "READY_FOR_ASSIGNMENT", + "APPROVED", + ]); const updated = await this.bookingsRepository.update(bookingId, { status: "IN_TRANSIT", @@ -1739,6 +1753,7 @@ export class BookingTransitionService { // (portal and backoffice). Degrades to null like every fragile field here. let trainSchedule: { trainNumber: string | null; + voyageNumber: string | null; reference: string | null; scheduledDepartureDate: Date | null; } | null = null; @@ -1750,6 +1765,8 @@ export class BookingTransitionService { if (s) { trainSchedule = { trainNumber: s.trainNumber ?? null, + // The schedule's own voyage (sailing) number shown to the customer. + voyageNumber: s.voyageNumber ?? null, reference: s.reference ?? null, scheduledDepartureDate: s.scheduledDepartureDate ?? null, }; diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts index 7c41ee43a..23b3f164e 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -270,7 +270,7 @@ export class SchedulingRescheduleService { // M12: only announce a new departure when the date actually moved — // `newDeparture` is null when the date was unchanged, so retained customers // are not falsely told the train was rescheduled. - await this.notifyRescheduleOutcome(dto, newDeparture); + await this.notifyRescheduleOutcome(scheduleId, dto, newDeparture); if (newDeparture) void this.trainSchedulingService.emitWindowState(scheduleId); return { plan, schedule: assignResult }; @@ -283,6 +283,7 @@ export class SchedulingRescheduleService { * company so the notifier has a phone/email to reach. */ private async notifyRescheduleOutcome( + scheduleId: string, dto: ExecuteRescheduleDto, newDeparture: Date | null, ): Promise { @@ -294,9 +295,9 @@ export class SchedulingRescheduleService { const booking = await this.loadBookingForNotify(bookingId); if (!booking) continue; if (isMaintenance) { - this.notifier.maintenanceMoved(booking, newDeparture); + this.notifier.maintenanceMoved(booking, newDeparture, scheduleId, dto.reason); } else { - this.notifier.rescheduled(booking, newDeparture); + this.notifier.rescheduled(booking, newDeparture, scheduleId, dto.reason); } } } @@ -307,7 +308,8 @@ export class SchedulingRescheduleService { for (const bookingId of dto.displacedBookingIds) { const booking = await this.loadBookingForNotify(bookingId); if (!booking) continue; - this.notifier.removedFromTrain(booking); + // Displaced bookings no longer point at the schedule — pass it explicitly. + this.notifier.removedFromTrain(booking, scheduleId); } } } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.spec.ts b/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.spec.ts new file mode 100644 index 000000000..dfafcee43 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.spec.ts @@ -0,0 +1,32 @@ +import { trainRunLabel } from './train-run-label.util'; + +describe('trainRunLabel', () => { + it('names the departure by the schedule train number and voyage number', () => { + expect(trainRunLabel({ trainNumber: '8001', voyageNumber: 'V-117' })).toBe( + 'train 8001 (voyage V-117)', + ); + }); + + it('drops the voyage bracket when the schedule has no voyage number', () => { + expect(trainRunLabel({ trainNumber: '8001', voyageNumber: null })).toBe('train 8001'); + expect(trainRunLabel({ trainNumber: '8001', voyageNumber: ' ' })).toBe('train 8001'); + }); + + it('still quotes the voyage when the pool train number is not assigned yet', () => { + expect(trainRunLabel({ trainNumber: null, voyageNumber: 'V-117' })).toBe( + 'train (voyage V-117)', + ); + }); + + it('returns null when neither number is known so callers can fall back', () => { + expect(trainRunLabel({ trainNumber: null, voyageNumber: null })).toBeNull(); + expect(trainRunLabel(null)).toBeNull(); + expect(trainRunLabel(undefined)).toBeNull(); + }); + + it('capitalizes for sentence starts on request', () => { + expect( + trainRunLabel({ trainNumber: '8001', voyageNumber: 'V-117' }, { capitalize: true }), + ).toBe('Train 8001 (voyage V-117)'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.ts b/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.ts new file mode 100644 index 000000000..3c13506ac --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.ts @@ -0,0 +1,30 @@ +import { TrainSchedule } from './entities/train-schedule.entity'; + +export type TrainRunSource = Pick; + +/** + * How a departure is named in every customer-facing SMS / email: + * + * "train 8001 (voyage V-2026-117)" + * + * Both identifiers are the SCHEDULE's own columns — `train_schedules.train_number` + * and `train_schedules.voyage_number`. The built train (`freight.trains`) carries + * a `train_name` that the build form labels "voyage number"; that is a different + * identifier and must never be quoted to customers. Always pass the schedule. + * + * Returns null when the schedule has neither number (older rows, or an unbuilt + * departure whose pool number is assigned at dispatch) so callers can fall back + * to a generic phrase instead of printing "train (voyage)". + */ +export function trainRunLabel( + schedule: TrainRunSource | null | undefined, + opts: { capitalize?: boolean } = {}, +): string | null { + if (!schedule) return null; + const train = schedule.trainNumber?.trim() || null; + const voyage = schedule.voyageNumber?.trim() || null; + if (!train && !voyage) return null; + const head = train ? `train ${train}` : 'train'; + const label = voyage ? `${head} (voyage ${voyage})` : head; + return opts.capitalize ? label.charAt(0).toUpperCase() + label.slice(1) : label; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index d9bae156a..c933a5e34 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -607,7 +607,6 @@ export class BookingBatchService implements OnModuleInit { const isBatchPaid = booking.status === "SELECTED_FOR_BATCH" || booking.status === "AWAITING_PAYMENT" || - booking.status === "PAID" || booking.paymentStatus === "PAID"; if (!isBatchPaid) return; @@ -786,7 +785,7 @@ export class BookingBatchService implements OnModuleInit { `SELECT id FROM freight.bookings WHERE deleted_at IS NULL AND train_schedule_id IS NULL - AND (payment_status = 'PAID' OR status = 'PAID') + AND payment_status = 'PAID' AND scheduled_date IS NOT NULL AND DATE(scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = $1`, [day], @@ -3476,7 +3475,7 @@ export class BookingBatchService implements OnModuleInit { schedule?.scheduledDepartureDate && eatDay(schedule.scheduledDepartureDate) !== previousDay ) { - this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate); + this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate, schedule); } } @@ -3819,7 +3818,6 @@ export class BookingBatchService implements OnModuleInit { fresh.trainScheduleId === scheduleId && (fresh.status === "SELECTED_FOR_BATCH" || fresh.status === "AWAITING_PAYMENT" || - fresh.status === "PAID" || fresh.paymentStatus === "PAID") ) { this.logger.debug( @@ -4535,7 +4533,7 @@ export class BookingBatchService implements OnModuleInit { manager, ); }); - this.notifier.displaced(victim); + this.notifier.displaced(victim, scheduleId); budget.add(this.needFor(victim, wagonDims), victimLeg); // Displacing frees wagons the same way an expiry does — don't leave the // schedule stuck at FULL. @@ -5489,7 +5487,6 @@ export class BookingBatchService implements OnModuleInit { ).filter( (b) => b.paymentStatus === "PAID" || - b.status === "PAID" || !payWindowLapsed(b.paymentDeadline, deadlineCutoff), ); // Export FCFS: a customer's pending operation request HOLDS its wagons from @@ -5601,7 +5598,6 @@ export class BookingBatchService implements OnModuleInit { return reserved.some( (b) => b.paymentStatus !== "PAID" && - b.status !== "PAID" && b.paymentDeadline != null && !payWindowLapsed(b.paymentDeadline, now), ); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 07de05853..b078dd6dd 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -64,9 +64,14 @@ export class BookingJourneyService { @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} - /** Statuses from which a booking may be loaded (gov bookings don't prepay). */ + /** + * Whether a booking may be loaded. Paid is decided by the booking's + * PAYMENT status only — never by `status === 'PAID'`, which lags or is + * skipped on several flows (batch pay, manual mark-paid, gov expedite). + * Government bookings don't prepay: APPROVED is enough for them. + */ private canLoad(booking: Booking): boolean { - if (booking.status === 'PAID') return true; + if (booking.paymentStatus === 'PAID') return true; return booking.isGovernment && booking.status === 'APPROVED'; } @@ -188,7 +193,8 @@ export class BookingJourneyService { } if (!this.canLoad(booking)) { throw new BadRequestException( - `Booking must be paid before loading (currently ${booking.status})`, + `Booking must be paid before loading (payment status ${booking.paymentStatus ?? 'PENDING'}, ` + + `booking status ${booking.status})`, ); } await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); @@ -464,6 +470,7 @@ export class BookingJourneyService { id: b.id, reference: b.reference, status: b.status, + paymentStatus: b.paymentStatus ?? null, tradeDirection: b.tradeDirection, isGovernment: b.isGovernment, customer: b.company?.name ?? 'Unknown customer', diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.spec.ts new file mode 100644 index 000000000..524eeddc7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.spec.ts @@ -0,0 +1,76 @@ +import { BookingNotifierService } from './booking-notifier.service'; + +/** + * Message wording for the schedule-related customer notices: every one must + * quote the SCHEDULE's train + voyage numbers, and reschedules must carry the + * staff-entered reason instead of a hard-coded "for maintenance". + */ +describe('BookingNotifierService messages', () => { + const schedule = { trainNumber: '8001', voyageNumber: 'V-117' }; + const booking = { id: 'b1', reference: 'BK-2026-000928', companyId: 'c1' } as never; + const departure = new Date('2026-09-01T05:00:00.000Z'); + + let sent: string[]; + let inbox: string[]; + let service: BookingNotifierService; + + beforeEach(() => { + sent = []; + inbox = []; + const notifications = { + directSend: jest.fn(async (_m: string, _to: string, msg: string) => { + sent.push(msg); + }), + }; + const inboxSvc = { + notify: jest.fn(async (input: { body: string }) => { + inbox.push(input.body); + }), + }; + const trainSchedules = { + findByIdWithStations: jest.fn(async () => ({ ...schedule, reference: 'S-2026-00012' })), + }; + // Company contact lookup goes through raw SQL; return one phone + email. + const dataSource = { + query: jest.fn(async () => [{ phone: '+251900000000', email: 'ops@example.com' }]), + }; + service = new BookingNotifierService( + notifications as never, + inboxSvc as never, + trainSchedules as never, + dataSource as never, + ); + }); + + const flush = () => new Promise((r) => setImmediate(r)); + + it('maintenance reschedule quotes train, voyage and the staff reason', async () => { + service.maintenanceMoved(booking, departure, schedule, 'Locomotive maintenance.'); + await flush(); + expect(inbox[0]).toBe( + 'Train 8001 (voyage V-117) for booking BK-2026-000928 was rescheduled — reason: Locomotive maintenance. ' + + 'New departure date: 01/09/2026.', + ); + }); + + it('maintenance reschedule falls back to "for maintenance" without a reason', async () => { + service.maintenanceMoved(booking, departure, schedule, ' '); + await flush(); + expect(inbox[0]).toContain('was rescheduled for maintenance. New departure date'); + }); + + it('plain reschedule carries the reason and the run label', async () => { + service.rescheduled(booking, departure, schedule, 'Crew change'); + await flush(); + expect(inbox[0]).toBe( + 'Booking BK-2026-000928 on train 8001 (voyage V-117) has been rescheduled — reason: Crew change. ' + + 'New departure date: 01/09/2026.', + ); + }); + + it('resolves the run label from a schedule id when only the id is known', async () => { + service.scheduleCancelled(booking, 'sched-1'); + await flush(); + expect(inbox[0]).toMatch(/^Train 8001 \(voyage V-117\) for booking BK-2026-000928 has been cancelled/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 4600f7f38..fd42fdd7b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -14,8 +14,21 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util'; import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { trainRunLabel, type TrainRunSource } from '../train-schedules/train-run-label.util'; import { BATCH_TIMEZONE } from './booking-batch.constants'; +const capitalize = (text: string): string => text.charAt(0).toUpperCase() + text.slice(1); + +/** + * " — reason: Locomotive maintenance" for the staff-entered reschedule reason, + * or '' when none was given. Trailing punctuation is trimmed so the sentence's + * own full stop follows cleanly. + */ +const reasonClause = (reason?: string | null): string => { + const text = reason?.trim().replace(/[.\s]+$/, ''); + return text ? ` — reason: ${text}` : ''; +}; + @Injectable() export class BookingNotifierService { private readonly logger = new Logger(BookingNotifierService.name); @@ -30,8 +43,9 @@ export class BookingNotifierService { /** * Human-readable description of a train schedule for customer messages: - * reference (or train number) + route + departure date. Never leaks a UUID — - * falls back to a generic phrase when the schedule can't be loaded. + * train number + voyage number (both the SCHEDULE's own — see trainRunLabel), + * then reference, route and departure date. Never leaks a UUID — falls back + * to a generic phrase when the schedule can't be loaded. */ private async scheduleLabel(scheduleId?: string | null): Promise { const fallback = 'your selected train'; @@ -39,8 +53,10 @@ export class BookingNotifierService { try { const s = await this.trainSchedules.findByIdWithStations(scheduleId); if (!s) return fallback; - // Customers know the train by its operating number (8001), not the - // schedule reference — lead with it and keep S-… as the secondary id. + // Customers know the departure by its train number (8001) and voyage + // number, not the schedule reference — lead with those and keep S-… as + // the secondary id. + const run = trainRunLabel(s); const parts = [ s.reference, s.originStation?.label && s.destinationStation?.label @@ -59,9 +75,9 @@ export class BookingNotifierService { hour12: false, })} EAT` : ''; - const number = s.trainNumber ?? s.reference ?? null; - return number - ? `train ${number}${number === s.reference ? '' : detail}${departure}` + if (run) return `${run}${detail}${departure}`; + return s.reference + ? `train ${s.reference}${departure}` : `${fallback}${detail}${departure}`; } catch (err) { this.logger.warn( @@ -71,6 +87,51 @@ export class BookingNotifierService { } } + /** + * "train 8001 (voyage V-117)" for the departure a message is about, or null + * when nothing is known. Accepts the schedule row itself (preferred — callers + * that have just cancelled or detached the booking still hold it) or its id, + * falling back to the booking's own train_schedule_id. Never throws: a label + * lookup must not stop a notification going out. + */ + private async trainRun( + b: Booking, + schedule?: TrainRunSource | string | null, + ): Promise { + if (schedule && typeof schedule !== 'string') return trainRunLabel(schedule); + const scheduleId = schedule ?? b.trainScheduleId ?? null; + if (!scheduleId) return null; + try { + const s = await this.trainSchedules.findByIdWithStations(scheduleId); + return trainRunLabel(s); + } catch (err) { + this.logger.warn(`trainRun(${scheduleId}) failed: ${(err as Error).message}`); + return null; + } + } + + /** + * Resolve the run label, then build and send the SMS/email + in-app item. + * Fire-and-forget like every notifier method; `build` receives the label + * (null when unknown) and returns the message text. + */ + private withRun( + b: Booking, + schedule: TrainRunSource | string | null | undefined, + logLabel: string, + title: string, + build: (run: string | null) => string, + opts: { contact?: boolean; inApp?: Partial } = {}, + ): void { + void (async () => { + const msg = build(await this.trainRun(b, schedule)); + if (opts.contact !== false) await this.notifyContact(b, msg, logLabel); + this.inApp(b, title, msg, opts.inApp); + })().catch((err) => + this.logger.warn(`${logLabel} notification failed for ${this.ref(b)}: ${(err as Error).message}`), + ); + } + private ref(b: Booking): string { return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; } @@ -162,21 +223,30 @@ export class BookingNotifierService { } /** Train carrying the booking departed — dispatched origin → destination. */ - dispatched(b: Booking, origin: string | null, destination: string | null): void { - const msg = + dispatched( + b: Booking, + origin: string | null, + destination: string | null, + schedule?: TrainRunSource | string | null, + ): void { + this.withRun(b, schedule, 'DISPATCHED', 'Shipment dispatched', (run) => `Your booking ${b.reference ?? b.id} has been dispatched` + - `${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`; - void this.notifyContact(b, msg, 'DISPATCHED'); - this.inApp(b, 'Shipment dispatched', msg); + `${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}` + + `${run ? ` on ${run}` : ''}.`, + ); } /** Train carrying the booking arrived at destination. */ - arrived(b: Booking, origin: string | null, destination: string | null): void { - const msg = - `Your booking ${b.reference ?? b.id} has arrived` + - `${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`; - void this.notifyContact(b, msg, 'ARRIVED'); - this.inApp(b, 'Shipment arrived', msg); + arrived( + b: Booking, + origin: string | null, + destination: string | null, + schedule?: TrainRunSource | string | null, + ): void { + this.withRun(b, schedule, 'ARRIVED', 'Shipment arrived', (run) => + `Your booking ${b.reference ?? b.id}${run ? ` on ${run}` : ''} has arrived` + + `${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`, + ); } async payNow(b: Booking, deadline: Date): Promise { @@ -318,21 +388,28 @@ export class BookingNotifierService { ); } - displaced(b: Booking): void { - const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`; - void this.notifyContact(b, msg, 'DISPLACED'); - this.inApp(b, 'Booking displaced', msg); + displaced(b: Booking, schedule?: TrainRunSource | string | null): void { + this.withRun(b, schedule, 'DISPLACED', 'Booking displaced', (run) => + `Booking ${b.reference ?? b.id} was displaced${run ? ` from ${run}` : ''} by a government booking. ` + + `Move to another schedule or cancel.`, + ); } /** * Staff rescheduled the train carrying this booking to a new departure date. * The booking stays on the train — only the date moved. */ - rescheduled(b: Booking, newDeparture: Date): void { + rescheduled( + b: Booking, + newDeparture: Date, + schedule?: TrainRunSource | string | null, + reason?: string | null, + ): void { const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); - const msg = `Booking ${b.reference ?? b.id} has been rescheduled. New departure date: ${when}.`; - void this.notifyContact(b, msg, 'RESCHEDULED'); - this.inApp(b, 'Booking rescheduled', msg); + this.withRun(b, schedule, 'RESCHEDULED', 'Booking rescheduled', (run) => + `Booking ${b.reference ?? b.id}${run ? ` on ${run}` : ''} has been rescheduled` + + `${reasonClause(reason)}. New departure date: ${when}.`, + ); } /** @@ -340,49 +417,75 @@ export class BookingNotifierService { * the customer's original choice. In-app only — staff drove the change and * the allocation itself already notifies through the secured path. */ - allocatedOtherDay(b: Booking, newDeparture: Date): void { + allocatedOtherDay( + b: Booking, + newDeparture: Date, + schedule?: TrainRunSource | string | null, + ): void { const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); - const msg = - `Booking ${b.reference ?? b.id} has been allocated to a train on a different date. ` + - `New departure date: ${when}.`; - this.inApp(b, 'Booking allocated to another date', msg); + this.withRun( + b, + schedule, + 'ALLOCATED OTHER DAY', + 'Booking allocated to another date', + (run) => + `Booking ${b.reference ?? b.id} has been allocated to ${run ?? 'a train'} on a different date. ` + + `New departure date: ${when}.`, + { contact: false }, + ); } /** * Booking was removed from its train during a staff reschedule (not a government * pre-empt). It returns to eligible — the customer must rebook or reschedule. */ - removedFromTrain(b: Booking): void { - const msg = - `Booking ${b.reference ?? b.id} has been removed from its train during rescheduling. ` + - `Please rebook or select a new schedule from the portal.`; - void this.notifyContact(b, msg, 'REMOVED FROM TRAIN'); - this.inApp(b, 'Removed from train', msg); + removedFromTrain(b: Booking, schedule?: TrainRunSource | string | null): void { + this.withRun(b, schedule, 'REMOVED FROM TRAIN', 'Removed from train', (run) => + `Booking ${b.reference ?? b.id} has been removed from ${run ?? 'its train'} during rescheduling. ` + + `Please rebook or select a new schedule from the portal.`, + ); } /** * The train carrying this booking was cancelled. The booking is detached and * returns to the eligible pool — the customer must rebook or pick a new schedule. */ - scheduleCancelled(b: Booking): void { - const msg = - `The train for booking ${b.reference ?? b.id} has been cancelled. ` + - `Your booking is not lost — please rebook or select a new schedule from the portal.`; - void this.notifyContact(b, msg, 'TRAIN CANCELLED'); + scheduleCancelled(b: Booking, schedule?: TrainRunSource | string | null): void { // HIGH: a cancelled train invalidates the customer's plans — must reach SMS/email. - this.inApp(b, 'Train cancelled', msg, { priority: NotificationPriority.HIGH }); + this.withRun( + b, + schedule, + 'TRAIN CANCELLED', + 'Train cancelled', + (run) => + `${run ? capitalize(run) : 'The train'} for booking ${b.reference ?? b.id} has been cancelled. ` + + `Your booking is not lost — please rebook or select a new schedule from the portal.`, + { inApp: { priority: NotificationPriority.HIGH } }, + ); } /** - * The train carrying this booking was moved for maintenance to a new departure - * date. The booking stays on the train — only the date moved. + * The train carrying this booking was moved (maintenance reschedule) to a new + * departure date. The booking stays on the train — only the date moved. The + * staff-entered reason is what the customer reads; "for maintenance" is only + * the fallback when none was typed. */ - maintenanceMoved(b: Booking, newDeparture: Date): void { + maintenanceMoved( + b: Booking, + newDeparture: Date, + schedule?: TrainRunSource | string | null, + reason?: string | null, + ): void { const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); - const msg = - `The train for booking ${b.reference ?? b.id} was rescheduled for maintenance. ` + - `New departure date: ${when}.`; - void this.notifyContact(b, msg, 'MAINTENANCE RESCHEDULE'); - this.inApp(b, 'Train maintenance reschedule', msg); + const why = reason?.trim() ? reasonClause(reason) : ' for maintenance'; + this.withRun( + b, + schedule, + 'MAINTENANCE RESCHEDULE', + 'Train rescheduled', + (run) => + `${run ? capitalize(run) : 'The train'} for booking ${b.reference ?? b.id} was rescheduled${why}. ` + + `New departure date: ${when}.`, + ); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 47d0a0d21..49a730e68 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -11,6 +11,7 @@ import { import { Booking } from '../bookings/entities/booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { trainRunLabel } from '../train-schedules/train-run-label.util'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { @@ -671,8 +672,11 @@ export class BookingWindowService implements OnModuleInit { const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE, }); + // Name the departure by the schedule's train + voyage numbers (never the + // built train's name) so customers can match it to yard/customs paperwork. + const run = trainRunLabel(schedule); const msg = - `Booking is now open for the train departing ${depart}. ` + + `Booking is now open for ${run ?? 'the train'} departing ${depart}. ` + `Book your shipment from the portal home page before ${closes} EAT.`; const seenPhone = new Set(); @@ -797,8 +801,9 @@ export class BookingWindowService implements OnModuleInit { const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE, }); + const run = trainRunLabel(schedule, { capitalize: true }); const msgFor = (corridors: string[]) => - `A train is scheduled on your intercity corridor ${corridors.join(', ')}, ` + + `${run ?? 'A train'} is scheduled on your intercity corridor ${corridors.join(', ')}, ` + `departing ${depart}. EDR will confirm once your cargo is placed on a train.`; // One inbox item per booking (its `data` is the once-per-booking marker diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 18904c032..44d0615af 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -6,10 +6,13 @@ import { IsBoolean, IsDateString, IsInt, + IsNotEmpty, IsNumber, IsOptional, + IsString, IsUUID, Max, + MaxLength, Min, ValidateNested, } from 'class-validator'; @@ -119,6 +122,19 @@ export class CreateContainerTrainScheduleDto { @IsDateString() scheduleDate!: string; + @ApiProperty({ + example: 'V-2026-0620', + maxLength: 20, + description: + 'Voyage (sailing) number for this departure — the run identifier yards and ' + + 'customs quote. Required at creation; the UI pre-fills it with the built ' + + "train's direction-matched run number, but staff may override it.", + }) + @IsString() + @IsNotEmpty({ message: 'A voyage number is required' }) + @MaxLength(20) + voyageNumber!: string; + @ApiPropertyOptional({ format: 'uuid', description: diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index 4e2724f49..229b5d8f5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -438,6 +438,7 @@ export class IntercityService { id: booking.id, reference: booking.reference, status: booking.status, + paymentStatus: booking.paymentStatus ?? null, freightType: booking.freightType, isGovernment: booking.isGovernment, customer: booking.company?.name ?? 'Unknown customer', diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index 57bbb2c58..70799a991 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -508,6 +508,7 @@ describe('TrainSchedulingService', () => { const result = await service.createContainerTrainSchedule({ routeId: 'route-1', scheduleDate: futureDeparture, + voyageNumber: 'V-TEST-1', locomotiveIds: ['loc-1', 'loc-2'], }); @@ -610,6 +611,7 @@ describe('TrainSchedulingService', () => { service.createContainerTrainSchedule({ routeId: 'route-1', scheduleDate: '2026-06-20T08:00:00.000Z', + voyageNumber: 'V-TEST-2', locomotiveIds: ['loc-1', 'loc-2'], }), ).rejects.toBeInstanceOf(ConflictException); 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 4d4a01888..ec76cecbe 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 @@ -442,8 +442,11 @@ export class TrainSchedulingService { relations: { company: true }, }); for (const b of bookings) { - if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination); - else this.bookingNotifier.arrived(b, origin, destination); + if (event === 'dispatched') { + this.bookingNotifier.dispatched(b, origin, destination, schedule); + } else { + this.bookingNotifier.arrived(b, origin, destination, schedule); + } } } catch (err) { this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`); @@ -1169,7 +1172,7 @@ export class TrainSchedulingService { }); for (const booking of allocatedBookings) { if (['CANCELLED', 'EXPIRED', 'REJECTED'].includes(booking.status)) continue; - this.bookingNotifier.rescheduled(booking, departure); + this.bookingNotifier.rescheduled(booking, departure, schedule); notifiedCount += 1; } } @@ -1374,7 +1377,7 @@ export class TrainSchedulingService { .getRepository(Booking) .update(aboard.map((b) => b.id), { scheduledDate: departure } as never); for (const booking of aboard) { - this.bookingNotifier.maintenanceMoved(booking, departure); + this.bookingNotifier.maintenanceMoved(booking, departure, schedule, dto.reason); } } @@ -1871,6 +1874,10 @@ export class TrainSchedulingService { status: TrainScheduleStatusEnum.Scheduled, direction, trainNumber: pairTrainNumber ?? undefined, + // Staff-entered at creation; the UI defaults it to the built train's + // own voyage number (Train.trainName). Fall back to the pair train + // number here only for non-UI callers that send none. + voyageNumber: dto.voyageNumber?.trim() || pairTrainNumber || null, maxWagons, plannedWagonYards, reverseWagonOrder: dto.reverseWagonOrder ?? false, @@ -2530,7 +2537,8 @@ export class TrainSchedulingService { .getRepository(Booking) .findOne({ where: { id: bookingId }, relations: { company: true } }); if (removedBooking && opts.notifyCustomer !== false) { - this.bookingNotifier.removedFromTrain(removedBooking); + // The booking's train_schedule_id is already cleared — name the run explicitly. + this.bookingNotifier.removedFromTrain(removedBooking, schedule); } this.logger.log( `Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`, @@ -3101,7 +3109,7 @@ export class TrainSchedulingService { AND b.deleted_at IS NULL AND b.origin_yard_id = $2 AND b.loaded_at IS NULL - AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED')) + AND (b.payment_status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED')) AND ($4::uuid[] IS NULL OR b.id = ANY($4::uuid[]))`, [ scheduleId, @@ -3318,7 +3326,7 @@ export class TrainSchedulingService { AND b.loading_started_at IS NULL AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED' AND b.is_government = false - AND (b.status = 'PAID' + AND (b.payment_status = 'PAID' OR (b.shipping_line_company_id IS NOT NULL AND b.status = 'FULLY_EXECUTED'))`, [scheduleId, originYardId], ); @@ -3432,7 +3440,7 @@ export class TrainSchedulingService { // milestone still counts as paid — the clearance views self-heal the row on // read, and the gate pass must not lag behind that. for (const booking of bookings) { - if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') { + if (booking.paymentStatus === 'PAID') { paidBookingIds.add(booking.id); } } @@ -5667,7 +5675,8 @@ export class TrainSchedulingService { const booking = await this.bookingsRepository .findByIdWithFiles(sb.bookingId) .catch(() => null); - if (booking) this.bookingNotifier.scheduleCancelled(booking); + // Detached above, so pass the cancelled schedule for its train/voyage numbers. + if (booking) this.bookingNotifier.scheduleCancelled(booking, schedule); } // Window retired (DONE) — remove the card from portal/GL lists right away. @@ -10271,6 +10280,8 @@ export class TrainSchedulingService { // without the wagons' tare. The legs tab shows this per booking. cargoWeightTons: sb.booking ? bookingCargoTons(sb.booking) : 0, status: sb.booking?.status ?? null, + // Loadability is decided by the payment status, not `status`. + paymentStatus: sb.booking?.paymentStatus ?? null, schedulingStatus: sb.booking?.schedulingStatus ?? null, freightType: sb.booking?.freightType ?? null, // Which leg of the corridor this booking rides — the workspace can't @@ -11584,7 +11595,11 @@ export class TrainSchedulingService { return assignability.shortage; } - /** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */ + /** + * Paid (or government) bookings that may be loaded onto wagons — excludes + * expired / awaiting payment. "Paid" is read from the PAYMENT status only; + * the booking status is not a reliable payment signal. + */ private isReadyToLoadBooking(booking: { status: string; paymentStatus?: string | null; @@ -11594,7 +11609,7 @@ export class TrainSchedulingService { if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { return false; } - if (booking.status === 'PAID' || booking.paymentStatus === 'PAID') return true; + if (booking.paymentStatus === 'PAID') return true; if (booking.isGovernment) return true; return false; } 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 126998bae..bfbc5dd53 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 @@ -122,6 +122,7 @@ interface BookingSummaryRow { id: string; reference: string | null; status: string | null; + paymentStatus: string | null; customer: string | null; } @@ -1333,14 +1334,19 @@ export class WarehouseInventoryService { return this.findById(saved.id); } - /** Auto-load all READY_FOR_LOADING inventory whose booking is PAID. Unpaid stay pending. */ + /** + * Auto-load all READY_FOR_LOADING inventory whose booking is paid (payment + * status PAID — the booking status is not consulted). Unpaid stay pending. + */ async autoLoadReady(): Promise { const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } }); const result: AutoLoadResult = { loadedCount: 0, skippedCount: 0, results: [] }; for (const item of ready) { - const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null; - if (bookingStatus !== 'PAID') { + const paymentStatus = item.bookingId + ? await this.getBookingPaymentStatus(item.bookingId) + : null; + if (paymentStatus !== 'PAID') { result.skippedCount += 1; result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason: 'Booking not PAID' }); continue; @@ -3150,12 +3156,14 @@ export class WarehouseInventoryService { throw new BadRequestException(`Inventory must be STORED to reserve (current: ${item.status})`); } - const status = await this.getBookingStatus(dto.bookingId); - if (!status) { + const paymentStatus = await this.getBookingPaymentStatus(dto.bookingId); + if (paymentStatus === null) { throw new NotFoundException(`Booking ${dto.bookingId} not found`); } - if (status !== 'PAID') { - throw new BadRequestException(`Booking must be PAID to reserve inventory (current: ${status})`); + if (paymentStatus !== 'PAID') { + throw new BadRequestException( + `Booking must be paid to reserve inventory (payment status: ${paymentStatus})`, + ); } await this.dataSource.transaction(async (manager) => { @@ -6759,12 +6767,19 @@ export class WarehouseInventoryService { }; } - private async getBookingStatus(bookingId: string): Promise { - const [row]: Array<{ status: string | null }> = await this.dataSource.query( - 'SELECT status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1', + /** + * The booking's PAYMENT status — the only signal loading/reservation gates + * use to decide "paid". Returns null when the booking does not exist; + * an existing booking with no payment status yet reads as PENDING. + */ + private async getBookingPaymentStatus(bookingId: string): Promise { + const [row]: Array<{ paymentStatus: string | null }> = await this.dataSource.query( + `SELECT payment_status AS "paymentStatus" + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1`, [bookingId], ); - return row?.status ?? null; + if (!row) return null; + return row.paymentStatus ?? 'PENDING'; } private async attachBookingSummaries(items: WarehouseInventory[]): Promise { @@ -6772,7 +6787,8 @@ export class WarehouseInventoryService { if (bookingIds.length === 0) return; const rows: BookingSummaryRow[] = await this.dataSource.query( - `SELECT b.id, b.reference, b.status, company.name AS customer + `SELECT b.id, b.reference, b.status, b.payment_status AS "paymentStatus", + company.name AS customer FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id WHERE b.id = ANY($1) AND b.deleted_at IS NULL`, @@ -6786,6 +6802,7 @@ export class WarehouseInventoryService { Object.assign(item, { bookingReference: summary.reference, bookingStatus: summary.status, + bookingPaymentStatus: summary.paymentStatus, customerName: summary.customer, }); }); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index eaa399f03..444285d82 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -118,6 +118,7 @@ const blockNegative = (event: KeyboardEvent) => { interface UnitErrors { containerNumber?: string; + sealNumber?: string; vgmTons?: string; } @@ -886,6 +887,11 @@ export default function GlCreateBookingForm() { } else if ((numberCounts.get(key) ?? 0) > 1) { errs.containerNumber = "Duplicate container number in this shipment."; } + // Every container ships sealed and the yard checks the seal against + // the booking — required alongside number and VGM (portal parity). + if (!u.sealNumber.trim()) { + errs.sealNumber = "Seal number is required."; + } const vgm = Number(u.vgmTons); if (u.vgmTons.trim() === "" || Number.isNaN(vgm) || vgm <= 0) { errs.vgmTons = "Enter a valid VGM."; @@ -1047,6 +1053,12 @@ export default function GlCreateBookingForm() { const dateError = !isIntercity && !scheduledDate ? "Select a shipment date." : undefined; + // EXPORT completion locks the booking onto a train. Only raised once a day is + // chosen — the picker is hidden until then and the date error covers it. + const trainError = + isExportPick && scheduledDate && !trainScheduleId + ? "Select a train for the shipment day." + : undefined; const routeError = multiRoute && !contractRouteId ? "Select a route." : undefined; @@ -1065,7 +1077,7 @@ export default function GlCreateBookingForm() { !e.returnQuantity, ) && unitErrors.every((line) => - line.every((e) => !e.containerNumber && !e.vgmTons), + line.every((e) => !e.containerNumber && !e.sealNumber && !e.vgmTons), ) && !cargoDescriptionError : !bulkErrors.quantity && @@ -1112,11 +1124,12 @@ export default function GlCreateBookingForm() { line.units.some( (u) => !ISO_CONTAINER_NUMBER_REGEX.test(u.containerNumber.trim().toUpperCase()) || + !u.sealNumber.trim() || !(Number(u.vgmTons) > 0), ), ); if (badUnit) { - return `Every ${partner.reference} container needs a valid container number and a VGM above 0.`; + return `Every ${partner.reference} container needs a valid container number, a seal number and a VGM above 0.`; } if (!partnerCargoDescription.trim()) { return `Describe the cargo carried in ${partner.reference}'s containers.`; @@ -1142,6 +1155,7 @@ export default function GlCreateBookingForm() { cargoValid && !oddBlocksSubmit && !dateError && + !trainError && !routeError && !partnerError && !currencyError; @@ -1857,7 +1871,7 @@ export default function GlCreateBookingForm() { Container number * - Seal number + Seal number * VGM (tons) * @@ -1901,8 +1915,13 @@ export default function GlCreateBookingForm() { style={{ flex: 1 }} /> patchUnit(lineIdx, unitIdx, { sealNumber: e.currentTarget.value, @@ -2305,12 +2324,19 @@ export default function GlCreateBookingForm() { )} {isExportPick && scheduledDate ? ( - + <> + + {showErrors && trainError && ( + + {trainError} + + )} + ) : null} @@ -2401,7 +2427,11 @@ export default function GlCreateBookingForm() { style={{ flexShrink: 0 }} /> - Fix the highlighted fields to review the price. + {trainError + ? "Select a train for the shipment day to review the price." + : dateError && isExportPick + ? "Select a shipment day and a train to review the price." + : "Fix the highlighted fields to review the price."} )} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx index a3be22ea1..270bce219 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx @@ -196,8 +196,13 @@ export function ConsolidationPartnerPanel({ } /> patchUnit(lineIdx, unitIdx, { sealNumber: e.currentTarget.value, diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx index 69dc7f2b8..b0034f2a9 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx @@ -277,8 +277,15 @@ export function AllocateBookingWizard({ if (!routeId || !scheduleDate || locomotiveIds.length < 2) { throw new Error("Select route, date, and at least two locomotives"); } + // This ad-hoc path has no built train (and so no run number) and no voyage + // input, but voyage number is required at creation — default it to a + // date-stamped placeholder that staff can edit later on the schedule. + const voyageNumber = `V-${new Date(scheduleDate) + .toISOString() + .slice(0, 10) + .replace(/-/g, "")}`; const created = await create.mutateAsync({ - payload: { routeId, scheduleDate, locomotiveIds }, + payload: { routeId, scheduleDate, voyageNumber, locomotiveIds }, }); showScheduleWarnings(created.warnings); setSelectedScheduleId(created.id); diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx index 0342b9ae9..74f376db4 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/IntercityRideAlongPanel.tsx @@ -382,7 +382,12 @@ export function IntercityRideAlongPanel({ - {row.status === "PAID" && ( + {/* Paid = PAYMENT status only; still show Load only + while the cargo has not ridden yet. */} + {row.paymentStatus === "PAID" && + row.status !== "IN_TRANSIT" && + row.status !== "ARRIVED" && + row.status !== "COMPLETED" && ( No locomotive assigned yet. )} - + + setTrainId(v ?? "")} + onChange={(v) => { + setTrainId(v ?? ""); + // Default the voyage number to the picked train's own voyage + // number (the Train Builder stores it as `trainName`). The run + // number is a train number, not a voyage — only fall back to it + // for legacy trains that have no voyage number yet; staff can + // still override. + const picked = (trainsQuery.data ?? []).find((t) => t.id === v); + const runNumber = + selectedRoute?.direction === "IMPORT" + ? picked?.importTrainNumber + : picked?.exportTrainNumber; + setVoyageNumber(picked?.trainName?.trim() || runNumber || ""); + }} searchable disabled={!routeId} nothingFoundMessage={ @@ -698,6 +725,15 @@ export default function TrainScheduleV2ListPage() { : "Select a route first" } /> + setVoyageNumber(e.currentTarget.value)} + />