fix(freight): gate loading on booking paymentStatus only; default schedule voyage no. to train's voyage number

This commit is contained in:
marshal
2026-09-02 21:15:48 +00:00
parent d51bed5630
commit 3e274cc2a2
30 changed files with 629 additions and 117 deletions

View File

@@ -370,7 +370,21 @@ export class BookingTransitionService {
async startTransit(bookingId: string): Promise<Booking> { async startTransit(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); 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, { const updated = await this.bookingsRepository.update(bookingId, {
status: "IN_TRANSIT", status: "IN_TRANSIT",
@@ -1739,6 +1753,7 @@ export class BookingTransitionService {
// (portal and backoffice). Degrades to null like every fragile field here. // (portal and backoffice). Degrades to null like every fragile field here.
let trainSchedule: { let trainSchedule: {
trainNumber: string | null; trainNumber: string | null;
voyageNumber: string | null;
reference: string | null; reference: string | null;
scheduledDepartureDate: Date | null; scheduledDepartureDate: Date | null;
} | null = null; } | null = null;
@@ -1750,6 +1765,8 @@ export class BookingTransitionService {
if (s) { if (s) {
trainSchedule = { trainSchedule = {
trainNumber: s.trainNumber ?? null, trainNumber: s.trainNumber ?? null,
// The schedule's own voyage (sailing) number shown to the customer.
voyageNumber: s.voyageNumber ?? null,
reference: s.reference ?? null, reference: s.reference ?? null,
scheduledDepartureDate: s.scheduledDepartureDate ?? null, scheduledDepartureDate: s.scheduledDepartureDate ?? null,
}; };

View File

@@ -270,7 +270,7 @@ export class SchedulingRescheduleService {
// M12: only announce a new departure when the date actually moved — // M12: only announce a new departure when the date actually moved —
// `newDeparture` is null when the date was unchanged, so retained customers // `newDeparture` is null when the date was unchanged, so retained customers
// are not falsely told the train was rescheduled. // 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); if (newDeparture) void this.trainSchedulingService.emitWindowState(scheduleId);
return { plan, schedule: assignResult }; return { plan, schedule: assignResult };
@@ -283,6 +283,7 @@ export class SchedulingRescheduleService {
* company so the notifier has a phone/email to reach. * company so the notifier has a phone/email to reach.
*/ */
private async notifyRescheduleOutcome( private async notifyRescheduleOutcome(
scheduleId: string,
dto: ExecuteRescheduleDto, dto: ExecuteRescheduleDto,
newDeparture: Date | null, newDeparture: Date | null,
): Promise<void> { ): Promise<void> {
@@ -294,9 +295,9 @@ export class SchedulingRescheduleService {
const booking = await this.loadBookingForNotify(bookingId); const booking = await this.loadBookingForNotify(bookingId);
if (!booking) continue; if (!booking) continue;
if (isMaintenance) { if (isMaintenance) {
this.notifier.maintenanceMoved(booking, newDeparture); this.notifier.maintenanceMoved(booking, newDeparture, scheduleId, dto.reason);
} else { } 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) { for (const bookingId of dto.displacedBookingIds) {
const booking = await this.loadBookingForNotify(bookingId); const booking = await this.loadBookingForNotify(bookingId);
if (!booking) continue; if (!booking) continue;
this.notifier.removedFromTrain(booking); // Displaced bookings no longer point at the schedule — pass it explicitly.
this.notifier.removedFromTrain(booking, scheduleId);
} }
} }
} }

View File

@@ -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)');
});
});

View File

@@ -0,0 +1,30 @@
import { TrainSchedule } from './entities/train-schedule.entity';
export type TrainRunSource = Pick<TrainSchedule, 'trainNumber' | 'voyageNumber'>;
/**
* 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;
}

View File

@@ -607,7 +607,6 @@ export class BookingBatchService implements OnModuleInit {
const isBatchPaid = const isBatchPaid =
booking.status === "SELECTED_FOR_BATCH" || booking.status === "SELECTED_FOR_BATCH" ||
booking.status === "AWAITING_PAYMENT" || booking.status === "AWAITING_PAYMENT" ||
booking.status === "PAID" ||
booking.paymentStatus === "PAID"; booking.paymentStatus === "PAID";
if (!isBatchPaid) return; if (!isBatchPaid) return;
@@ -786,7 +785,7 @@ export class BookingBatchService implements OnModuleInit {
`SELECT id FROM freight.bookings `SELECT id FROM freight.bookings
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND train_schedule_id 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 scheduled_date IS NOT NULL
AND DATE(scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = $1`, AND DATE(scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = $1`,
[day], [day],
@@ -3476,7 +3475,7 @@ export class BookingBatchService implements OnModuleInit {
schedule?.scheduledDepartureDate && schedule?.scheduledDepartureDate &&
eatDay(schedule.scheduledDepartureDate) !== previousDay 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.trainScheduleId === scheduleId &&
(fresh.status === "SELECTED_FOR_BATCH" || (fresh.status === "SELECTED_FOR_BATCH" ||
fresh.status === "AWAITING_PAYMENT" || fresh.status === "AWAITING_PAYMENT" ||
fresh.status === "PAID" ||
fresh.paymentStatus === "PAID") fresh.paymentStatus === "PAID")
) { ) {
this.logger.debug( this.logger.debug(
@@ -4535,7 +4533,7 @@ export class BookingBatchService implements OnModuleInit {
manager, manager,
); );
}); });
this.notifier.displaced(victim); this.notifier.displaced(victim, scheduleId);
budget.add(this.needFor(victim, wagonDims), victimLeg); budget.add(this.needFor(victim, wagonDims), victimLeg);
// Displacing frees wagons the same way an expiry does — don't leave the // Displacing frees wagons the same way an expiry does — don't leave the
// schedule stuck at FULL. // schedule stuck at FULL.
@@ -5489,7 +5487,6 @@ export class BookingBatchService implements OnModuleInit {
).filter( ).filter(
(b) => (b) =>
b.paymentStatus === "PAID" || b.paymentStatus === "PAID" ||
b.status === "PAID" ||
!payWindowLapsed(b.paymentDeadline, deadlineCutoff), !payWindowLapsed(b.paymentDeadline, deadlineCutoff),
); );
// Export FCFS: a customer's pending operation request HOLDS its wagons from // Export FCFS: a customer's pending operation request HOLDS its wagons from
@@ -5601,7 +5598,6 @@ export class BookingBatchService implements OnModuleInit {
return reserved.some( return reserved.some(
(b) => (b) =>
b.paymentStatus !== "PAID" && b.paymentStatus !== "PAID" &&
b.status !== "PAID" &&
b.paymentDeadline != null && b.paymentDeadline != null &&
!payWindowLapsed(b.paymentDeadline, now), !payWindowLapsed(b.paymentDeadline, now),
); );

View File

@@ -64,9 +64,14 @@ export class BookingJourneyService {
@Optional() private readonly milestoneService?: ClearanceMilestoneService, @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 { private canLoad(booking: Booking): boolean {
if (booking.status === 'PAID') return true; if (booking.paymentStatus === 'PAID') return true;
return booking.isGovernment && booking.status === 'APPROVED'; return booking.isGovernment && booking.status === 'APPROVED';
} }
@@ -188,7 +193,8 @@ export class BookingJourneyService {
} }
if (!this.canLoad(booking)) { if (!this.canLoad(booking)) {
throw new BadRequestException( 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'); await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
@@ -464,6 +470,7 @@ export class BookingJourneyService {
id: b.id, id: b.id,
reference: b.reference, reference: b.reference,
status: b.status, status: b.status,
paymentStatus: b.paymentStatus ?? null,
tradeDirection: b.tradeDirection, tradeDirection: b.tradeDirection,
isGovernment: b.isGovernment, isGovernment: b.isGovernment,
customer: b.company?.name ?? 'Unknown customer', customer: b.company?.name ?? 'Unknown customer',

View File

@@ -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/);
});
});

View File

@@ -14,8 +14,21 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util'; import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util'; import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; 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'; 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() @Injectable()
export class BookingNotifierService { export class BookingNotifierService {
private readonly logger = new Logger(BookingNotifierService.name); private readonly logger = new Logger(BookingNotifierService.name);
@@ -30,8 +43,9 @@ export class BookingNotifierService {
/** /**
* Human-readable description of a train schedule for customer messages: * Human-readable description of a train schedule for customer messages:
* reference (or train number) + route + departure date. Never leaks a UUID — * train number + voyage number (both the SCHEDULE's own — see trainRunLabel),
* falls back to a generic phrase when the schedule can't be loaded. * 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<string> { private async scheduleLabel(scheduleId?: string | null): Promise<string> {
const fallback = 'your selected train'; const fallback = 'your selected train';
@@ -39,8 +53,10 @@ export class BookingNotifierService {
try { try {
const s = await this.trainSchedules.findByIdWithStations(scheduleId); const s = await this.trainSchedules.findByIdWithStations(scheduleId);
if (!s) return fallback; if (!s) return fallback;
// Customers know the train by its operating number (8001), not the // Customers know the departure by its train number (8001) and voyage
// schedule reference — lead with it and keep S-… as the secondary id. // number, not the schedule reference — lead with those and keep S-… as
// the secondary id.
const run = trainRunLabel(s);
const parts = [ const parts = [
s.reference, s.reference,
s.originStation?.label && s.destinationStation?.label s.originStation?.label && s.destinationStation?.label
@@ -59,9 +75,9 @@ export class BookingNotifierService {
hour12: false, hour12: false,
})} EAT` })} EAT`
: ''; : '';
const number = s.trainNumber ?? s.reference ?? null; if (run) return `${run}${detail}${departure}`;
return number return s.reference
? `train ${number}${number === s.reference ? '' : detail}${departure}` ? `train ${s.reference}${departure}`
: `${fallback}${detail}${departure}`; : `${fallback}${detail}${departure}`;
} catch (err) { } catch (err) {
this.logger.warn( 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<string | null> {
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<NotifyInput> } = {},
): 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 { private ref(b: Booking): string {
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
} }
@@ -162,21 +223,30 @@ export class BookingNotifierService {
} }
/** Train carrying the booking departed — dispatched origin → destination. */ /** Train carrying the booking departed — dispatched origin → destination. */
dispatched(b: Booking, origin: string | null, destination: string | null): void { dispatched(
const msg = 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` + `Your booking ${b.reference ?? b.id} has been dispatched` +
`${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`; `${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}` +
void this.notifyContact(b, msg, 'DISPATCHED'); `${run ? ` on ${run}` : ''}.`,
this.inApp(b, 'Shipment dispatched', msg); );
} }
/** Train carrying the booking arrived at destination. */ /** Train carrying the booking arrived at destination. */
arrived(b: Booking, origin: string | null, destination: string | null): void { arrived(
const msg = b: Booking,
`Your booking ${b.reference ?? b.id} has arrived` + origin: string | null,
`${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`; destination: string | null,
void this.notifyContact(b, msg, 'ARRIVED'); schedule?: TrainRunSource | string | null,
this.inApp(b, 'Shipment arrived', msg); ): 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<void> { async payNow(b: Booking, deadline: Date): Promise<void> {
@@ -318,21 +388,28 @@ export class BookingNotifierService {
); );
} }
displaced(b: Booking): void { displaced(b: Booking, schedule?: TrainRunSource | string | null): void {
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`; this.withRun(b, schedule, 'DISPLACED', 'Booking displaced', (run) =>
void this.notifyContact(b, msg, 'DISPLACED'); `Booking ${b.reference ?? b.id} was displaced${run ? ` from ${run}` : ''} by a government booking. ` +
this.inApp(b, 'Booking displaced', msg); `Move to another schedule or cancel.`,
);
} }
/** /**
* Staff rescheduled the train carrying this booking to a new departure date. * Staff rescheduled the train carrying this booking to a new departure date.
* The booking stays on the train — only the date moved. * 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 when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
const msg = `Booking ${b.reference ?? b.id} has been rescheduled. New departure date: ${when}.`; this.withRun(b, schedule, 'RESCHEDULED', 'Booking rescheduled', (run) =>
void this.notifyContact(b, msg, 'RESCHEDULED'); `Booking ${b.reference ?? b.id}${run ? ` on ${run}` : ''} has been rescheduled` +
this.inApp(b, 'Booking rescheduled', msg); `${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 customer's original choice. In-app only — staff drove the change and
* the allocation itself already notifies through the secured path. * 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 when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
const msg = this.withRun(
`Booking ${b.reference ?? b.id} has been allocated to a train on a different date. ` + b,
`New departure date: ${when}.`; schedule,
this.inApp(b, 'Booking allocated to another date', msg); '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 * 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. * pre-empt). It returns to eligible — the customer must rebook or reschedule.
*/ */
removedFromTrain(b: Booking): void { removedFromTrain(b: Booking, schedule?: TrainRunSource | string | null): void {
const msg = this.withRun(b, schedule, 'REMOVED FROM TRAIN', 'Removed from train', (run) =>
`Booking ${b.reference ?? b.id} has been removed from its train during rescheduling. ` + `Booking ${b.reference ?? b.id} has been removed from ${run ?? 'its train'} during rescheduling. ` +
`Please rebook or select a new schedule from the portal.`; `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);
} }
/** /**
* The train carrying this booking was cancelled. The booking is detached and * 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. * returns to the eligible pool — the customer must rebook or pick a new schedule.
*/ */
scheduleCancelled(b: Booking): void { scheduleCancelled(b: Booking, schedule?: TrainRunSource | string | null): 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');
// HIGH: a cancelled train invalidates the customer's plans — must reach SMS/email. // 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 * The train carrying this booking was moved (maintenance reschedule) to a new
* date. The booking stays on the train — only the date moved. * 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 when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
const msg = const why = reason?.trim() ? reasonClause(reason) : ' for maintenance';
`The train for booking ${b.reference ?? b.id} was rescheduled for maintenance. ` + this.withRun(
`New departure date: ${when}.`; b,
void this.notifyContact(b, msg, 'MAINTENANCE RESCHEDULE'); schedule,
this.inApp(b, 'Train maintenance reschedule', msg); 'MAINTENANCE RESCHEDULE',
'Train rescheduled',
(run) =>
`${run ? capitalize(run) : 'The train'} for booking ${b.reference ?? b.id} was rescheduled${why}. ` +
`New departure date: ${when}.`,
);
} }
} }

View File

@@ -11,6 +11,7 @@ import {
import { Booking } from '../bookings/entities/booking.entity'; import { Booking } from '../bookings/entities/booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { trainRunLabel } from '../train-schedules/train-run-label.util';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { import {
@@ -671,8 +672,11 @@ export class BookingWindowService implements OnModuleInit {
const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', { const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', {
timeZone: BATCH_TIMEZONE, 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 = 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.`; `Book your shipment from the portal home page before ${closes} EAT.`;
const seenPhone = new Set<string>(); const seenPhone = new Set<string>();
@@ -797,8 +801,9 @@ export class BookingWindowService implements OnModuleInit {
const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', { const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', {
timeZone: BATCH_TIMEZONE, timeZone: BATCH_TIMEZONE,
}); });
const run = trainRunLabel(schedule, { capitalize: true });
const msgFor = (corridors: string[]) => 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.`; `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 // One inbox item per booking (its `data` is the once-per-booking marker

View File

@@ -6,10 +6,13 @@ import {
IsBoolean, IsBoolean,
IsDateString, IsDateString,
IsInt, IsInt,
IsNotEmpty,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString,
IsUUID, IsUUID,
Max, Max,
MaxLength,
Min, Min,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
@@ -119,6 +122,19 @@ export class CreateContainerTrainScheduleDto {
@IsDateString() @IsDateString()
scheduleDate!: string; 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({ @ApiPropertyOptional({
format: 'uuid', format: 'uuid',
description: description:

View File

@@ -438,6 +438,7 @@ export class IntercityService {
id: booking.id, id: booking.id,
reference: booking.reference, reference: booking.reference,
status: booking.status, status: booking.status,
paymentStatus: booking.paymentStatus ?? null,
freightType: booking.freightType, freightType: booking.freightType,
isGovernment: booking.isGovernment, isGovernment: booking.isGovernment,
customer: booking.company?.name ?? 'Unknown customer', customer: booking.company?.name ?? 'Unknown customer',

View File

@@ -508,6 +508,7 @@ describe('TrainSchedulingService', () => {
const result = await service.createContainerTrainSchedule({ const result = await service.createContainerTrainSchedule({
routeId: 'route-1', routeId: 'route-1',
scheduleDate: futureDeparture, scheduleDate: futureDeparture,
voyageNumber: 'V-TEST-1',
locomotiveIds: ['loc-1', 'loc-2'], locomotiveIds: ['loc-1', 'loc-2'],
}); });
@@ -610,6 +611,7 @@ describe('TrainSchedulingService', () => {
service.createContainerTrainSchedule({ service.createContainerTrainSchedule({
routeId: 'route-1', routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z', scheduleDate: '2026-06-20T08:00:00.000Z',
voyageNumber: 'V-TEST-2',
locomotiveIds: ['loc-1', 'loc-2'], locomotiveIds: ['loc-1', 'loc-2'],
}), }),
).rejects.toBeInstanceOf(ConflictException); ).rejects.toBeInstanceOf(ConflictException);

View File

@@ -442,8 +442,11 @@ export class TrainSchedulingService {
relations: { company: true }, relations: { company: true },
}); });
for (const b of bookings) { for (const b of bookings) {
if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination); if (event === 'dispatched') {
else this.bookingNotifier.arrived(b, origin, destination); this.bookingNotifier.dispatched(b, origin, destination, schedule);
} else {
this.bookingNotifier.arrived(b, origin, destination, schedule);
}
} }
} catch (err) { } catch (err) {
this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`); 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) { for (const booking of allocatedBookings) {
if (['CANCELLED', 'EXPIRED', 'REJECTED'].includes(booking.status)) continue; if (['CANCELLED', 'EXPIRED', 'REJECTED'].includes(booking.status)) continue;
this.bookingNotifier.rescheduled(booking, departure); this.bookingNotifier.rescheduled(booking, departure, schedule);
notifiedCount += 1; notifiedCount += 1;
} }
} }
@@ -1374,7 +1377,7 @@ export class TrainSchedulingService {
.getRepository(Booking) .getRepository(Booking)
.update(aboard.map((b) => b.id), { scheduledDate: departure } as never); .update(aboard.map((b) => b.id), { scheduledDate: departure } as never);
for (const booking of aboard) { 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, status: TrainScheduleStatusEnum.Scheduled,
direction, direction,
trainNumber: pairTrainNumber ?? undefined, 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, maxWagons,
plannedWagonYards, plannedWagonYards,
reverseWagonOrder: dto.reverseWagonOrder ?? false, reverseWagonOrder: dto.reverseWagonOrder ?? false,
@@ -2530,7 +2537,8 @@ export class TrainSchedulingService {
.getRepository(Booking) .getRepository(Booking)
.findOne({ where: { id: bookingId }, relations: { company: true } }); .findOne({ where: { id: bookingId }, relations: { company: true } });
if (removedBooking && opts.notifyCustomer !== false) { 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( this.logger.log(
`Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`, `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.deleted_at IS NULL
AND b.origin_yard_id = $2 AND b.origin_yard_id = $2
AND b.loaded_at IS NULL 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[]))`, AND ($4::uuid[] IS NULL OR b.id = ANY($4::uuid[]))`,
[ [
scheduleId, scheduleId,
@@ -3318,7 +3326,7 @@ export class TrainSchedulingService {
AND b.loading_started_at IS NULL AND b.loading_started_at IS NULL
AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED' AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED'
AND b.is_government = false 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'))`, OR (b.shipping_line_company_id IS NOT NULL AND b.status = 'FULLY_EXECUTED'))`,
[scheduleId, originYardId], [scheduleId, originYardId],
); );
@@ -3432,7 +3440,7 @@ export class TrainSchedulingService {
// milestone still counts as paid — the clearance views self-heal the row on // milestone still counts as paid — the clearance views self-heal the row on
// read, and the gate pass must not lag behind that. // read, and the gate pass must not lag behind that.
for (const booking of bookings) { for (const booking of bookings) {
if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') { if (booking.paymentStatus === 'PAID') {
paidBookingIds.add(booking.id); paidBookingIds.add(booking.id);
} }
} }
@@ -5667,7 +5675,8 @@ export class TrainSchedulingService {
const booking = await this.bookingsRepository const booking = await this.bookingsRepository
.findByIdWithFiles(sb.bookingId) .findByIdWithFiles(sb.bookingId)
.catch(() => null); .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. // 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. // without the wagons' tare. The legs tab shows this per booking.
cargoWeightTons: sb.booking ? bookingCargoTons(sb.booking) : 0, cargoWeightTons: sb.booking ? bookingCargoTons(sb.booking) : 0,
status: sb.booking?.status ?? null, status: sb.booking?.status ?? null,
// Loadability is decided by the payment status, not `status`.
paymentStatus: sb.booking?.paymentStatus ?? null,
schedulingStatus: sb.booking?.schedulingStatus ?? null, schedulingStatus: sb.booking?.schedulingStatus ?? null,
freightType: sb.booking?.freightType ?? null, freightType: sb.booking?.freightType ?? null,
// Which leg of the corridor this booking rides — the workspace can't // Which leg of the corridor this booking rides — the workspace can't
@@ -11584,7 +11595,11 @@ export class TrainSchedulingService {
return assignability.shortage; 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: { private isReadyToLoadBooking(booking: {
status: string; status: string;
paymentStatus?: string | null; paymentStatus?: string | null;
@@ -11594,7 +11609,7 @@ export class TrainSchedulingService {
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
return false; return false;
} }
if (booking.status === 'PAID' || booking.paymentStatus === 'PAID') return true; if (booking.paymentStatus === 'PAID') return true;
if (booking.isGovernment) return true; if (booking.isGovernment) return true;
return false; return false;
} }

View File

@@ -122,6 +122,7 @@ interface BookingSummaryRow {
id: string; id: string;
reference: string | null; reference: string | null;
status: string | null; status: string | null;
paymentStatus: string | null;
customer: string | null; customer: string | null;
} }
@@ -1333,14 +1334,19 @@ export class WarehouseInventoryService {
return this.findById(saved.id); 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<AutoLoadResult> { async autoLoadReady(): Promise<AutoLoadResult> {
const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } }); const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } });
const result: AutoLoadResult = { loadedCount: 0, skippedCount: 0, results: [] }; const result: AutoLoadResult = { loadedCount: 0, skippedCount: 0, results: [] };
for (const item of ready) { for (const item of ready) {
const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null; const paymentStatus = item.bookingId
if (bookingStatus !== 'PAID') { ? await this.getBookingPaymentStatus(item.bookingId)
: null;
if (paymentStatus !== 'PAID') {
result.skippedCount += 1; result.skippedCount += 1;
result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason: 'Booking not PAID' }); result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason: 'Booking not PAID' });
continue; continue;
@@ -3150,12 +3156,14 @@ export class WarehouseInventoryService {
throw new BadRequestException(`Inventory must be STORED to reserve (current: ${item.status})`); throw new BadRequestException(`Inventory must be STORED to reserve (current: ${item.status})`);
} }
const status = await this.getBookingStatus(dto.bookingId); const paymentStatus = await this.getBookingPaymentStatus(dto.bookingId);
if (!status) { if (paymentStatus === null) {
throw new NotFoundException(`Booking ${dto.bookingId} not found`); throw new NotFoundException(`Booking ${dto.bookingId} not found`);
} }
if (status !== 'PAID') { if (paymentStatus !== 'PAID') {
throw new BadRequestException(`Booking must be PAID to reserve inventory (current: ${status})`); throw new BadRequestException(
`Booking must be paid to reserve inventory (payment status: ${paymentStatus})`,
);
} }
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
@@ -6759,12 +6767,19 @@ export class WarehouseInventoryService {
}; };
} }
private async getBookingStatus(bookingId: string): Promise<string | null> { /**
const [row]: Array<{ status: string | null }> = await this.dataSource.query( * The booking's PAYMENT status — the only signal loading/reservation gates
'SELECT status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1', * 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<string | null> {
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], [bookingId],
); );
return row?.status ?? null; if (!row) return null;
return row.paymentStatus ?? 'PENDING';
} }
private async attachBookingSummaries(items: WarehouseInventory[]): Promise<void> { private async attachBookingSummaries(items: WarehouseInventory[]): Promise<void> {
@@ -6772,7 +6787,8 @@ export class WarehouseInventoryService {
if (bookingIds.length === 0) return; if (bookingIds.length === 0) return;
const rows: BookingSummaryRow[] = await this.dataSource.query( 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 FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE b.id = ANY($1) AND b.deleted_at IS NULL`, WHERE b.id = ANY($1) AND b.deleted_at IS NULL`,
@@ -6786,6 +6802,7 @@ export class WarehouseInventoryService {
Object.assign(item, { Object.assign(item, {
bookingReference: summary.reference, bookingReference: summary.reference,
bookingStatus: summary.status, bookingStatus: summary.status,
bookingPaymentStatus: summary.paymentStatus,
customerName: summary.customer, customerName: summary.customer,
}); });
}); });

View File

@@ -118,6 +118,7 @@ const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
interface UnitErrors { interface UnitErrors {
containerNumber?: string; containerNumber?: string;
sealNumber?: string;
vgmTons?: string; vgmTons?: string;
} }
@@ -886,6 +887,11 @@ export default function GlCreateBookingForm() {
} else if ((numberCounts.get(key) ?? 0) > 1) { } else if ((numberCounts.get(key) ?? 0) > 1) {
errs.containerNumber = "Duplicate container number in this shipment."; 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); const vgm = Number(u.vgmTons);
if (u.vgmTons.trim() === "" || Number.isNaN(vgm) || vgm <= 0) { if (u.vgmTons.trim() === "" || Number.isNaN(vgm) || vgm <= 0) {
errs.vgmTons = "Enter a valid VGM."; errs.vgmTons = "Enter a valid VGM.";
@@ -1047,6 +1053,12 @@ export default function GlCreateBookingForm() {
const dateError = const dateError =
!isIntercity && !scheduledDate ? "Select a shipment date." : undefined; !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 = const routeError =
multiRoute && !contractRouteId ? "Select a route." : undefined; multiRoute && !contractRouteId ? "Select a route." : undefined;
@@ -1065,7 +1077,7 @@ export default function GlCreateBookingForm() {
!e.returnQuantity, !e.returnQuantity,
) && ) &&
unitErrors.every((line) => unitErrors.every((line) =>
line.every((e) => !e.containerNumber && !e.vgmTons), line.every((e) => !e.containerNumber && !e.sealNumber && !e.vgmTons),
) && ) &&
!cargoDescriptionError !cargoDescriptionError
: !bulkErrors.quantity && : !bulkErrors.quantity &&
@@ -1112,11 +1124,12 @@ export default function GlCreateBookingForm() {
line.units.some( line.units.some(
(u) => (u) =>
!ISO_CONTAINER_NUMBER_REGEX.test(u.containerNumber.trim().toUpperCase()) || !ISO_CONTAINER_NUMBER_REGEX.test(u.containerNumber.trim().toUpperCase()) ||
!u.sealNumber.trim() ||
!(Number(u.vgmTons) > 0), !(Number(u.vgmTons) > 0),
), ),
); );
if (badUnit) { 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()) { if (!partnerCargoDescription.trim()) {
return `Describe the cargo carried in ${partner.reference}'s containers.`; return `Describe the cargo carried in ${partner.reference}'s containers.`;
@@ -1142,6 +1155,7 @@ export default function GlCreateBookingForm() {
cargoValid && cargoValid &&
!oddBlocksSubmit && !oddBlocksSubmit &&
!dateError && !dateError &&
!trainError &&
!routeError && !routeError &&
!partnerError && !partnerError &&
!currencyError; !currencyError;
@@ -1857,7 +1871,7 @@ export default function GlCreateBookingForm() {
Container number * Container number *
</Text> </Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}> <Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Seal number Seal number *
</Text> </Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}> <Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
VGM (tons) * VGM (tons) *
@@ -1901,8 +1915,13 @@ export default function GlCreateBookingForm() {
style={{ flex: 1 }} style={{ flex: 1 }}
/> />
<TextInput <TextInput
placeholder="Optional" placeholder="e.g. SL0123456"
value={unit.sealNumber} value={unit.sealNumber}
error={
showErrors
? unitErrors[lineIdx]?.[unitIdx]?.sealNumber
: undefined
}
onChange={(e) => onChange={(e) =>
patchUnit(lineIdx, unitIdx, { patchUnit(lineIdx, unitIdx, {
sealNumber: e.currentTarget.value, sealNumber: e.currentTarget.value,
@@ -2305,12 +2324,19 @@ export default function GlCreateBookingForm() {
</Text> </Text>
)} )}
{isExportPick && scheduledDate ? ( {isExportPick && scheduledDate ? (
<ExportTrainPicker <>
options={exportTrainsQuery.data ?? []} <ExportTrainPicker
loading={exportTrainsQuery.isLoading} options={exportTrainsQuery.data ?? []}
value={trainScheduleId} loading={exportTrainsQuery.isLoading}
onChange={setTrainScheduleId} value={trainScheduleId}
/> onChange={setTrainScheduleId}
/>
{showErrors && trainError && (
<Text fz="xs" c="red" mt={6}>
{trainError}
</Text>
)}
</>
) : null} ) : null}
</Box> </Box>
</StepCard> </StepCard>
@@ -2401,7 +2427,11 @@ export default function GlCreateBookingForm() {
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
/> />
<Text fz={13} fw={500} c="#C0392B"> <Text fz={13} fw={500} c="#C0392B">
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."}
</Text> </Text>
</> </>
)} )}

View File

@@ -196,8 +196,13 @@ export function ConsolidationPartnerPanel({
} }
/> />
<TextInput <TextInput
label="Seal number" label="Seal number *"
value={unit.sealNumber} value={unit.sealNumber}
error={
showErrors && !unit.sealNumber.trim()
? "Seal number is required."
: undefined
}
onChange={(e) => onChange={(e) =>
patchUnit(lineIdx, unitIdx, { patchUnit(lineIdx, unitIdx, {
sealNumber: e.currentTarget.value, sealNumber: e.currentTarget.value,

View File

@@ -277,8 +277,15 @@ export function AllocateBookingWizard({
if (!routeId || !scheduleDate || locomotiveIds.length < 2) { if (!routeId || !scheduleDate || locomotiveIds.length < 2) {
throw new Error("Select route, date, and at least two locomotives"); 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({ const created = await create.mutateAsync({
payload: { routeId, scheduleDate, locomotiveIds }, payload: { routeId, scheduleDate, voyageNumber, locomotiveIds },
}); });
showScheduleWarnings(created.warnings); showScheduleWarnings(created.warnings);
setSelectedScheduleId(created.id); setSelectedScheduleId(created.id);

View File

@@ -382,7 +382,12 @@ export function IntercityRideAlongPanel({
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap="xs" justify="flex-end"> <Group gap="xs" justify="flex-end">
{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" && (
<Tooltip <Tooltip
label={ label={
canLoad canLoad

View File

@@ -534,7 +534,8 @@ function LocoDetailPanel({
<Text size="sm" c="gray.5">No locomotive assigned yet.</Text> <Text size="sm" c="gray.5">No locomotive assigned yet.</Text>
)} )}
<Divider color="rgba(255,255,255,0.1)" label="Train" labelPosition="left" /> <Divider color="rgba(255,255,255,0.1)" label="Train" labelPosition="left" />
<InfoRow label="Voyage / reference" value={schedule.reference} /> <InfoRow label="Voyage number" value={schedule.voyageNumber} />
<InfoRow label="Reference" value={schedule.reference} />
<InfoRow label="Train number" value={schedule.trainNumber} /> <InfoRow label="Train number" value={schedule.trainNumber} />
<InfoRow <InfoRow
label="Train" label="Train"

View File

@@ -504,9 +504,10 @@ export default function TrainScheduleV2DetailPage() {
b.originYardId === originYardId && b.originYardId === originYardId &&
!b.loadedAt && !b.loadedAt &&
(b.loadingStatus ?? "UNLOADED") !== "LOADED" && (b.loadingStatus ?? "UNLOADED") !== "LOADED" &&
// Paid is read from the PAYMENT status only, never booking.status.
(b.isGovernment (b.isGovernment
? b.status === "APPROVED" || b.status === "PAID" ? b.status === "APPROVED" || b.paymentStatus === "PAID"
: b.status === "PAID" || : b.paymentStatus === "PAID" ||
// Shipping-line bookings ride from accept on the credit ledger. // Shipping-line bookings ride from accept on the credit ledger.
(Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")), (Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")),
); );

View File

@@ -158,6 +158,11 @@ export default function TrainScheduleV2ListPage() {
const [routeId, setRouteId] = useState(""); const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState(""); const [scheduleDate, setScheduleDate] = useState("");
const [trainId, setTrainId] = useState(""); const [trainId, setTrainId] = useState("");
// Voyage number for this departure — required. Auto-filled from the selected
// train's own voyage number (typed in the Train Builder) when a train is
// picked; legacy trains without one fall back to the direction-matched run
// number. Staff may edit.
const [voyageNumber, setVoyageNumber] = useState("");
const [reverseWagonOrder, setReverseWagonOrder] = useState(false); const [reverseWagonOrder, setReverseWagonOrder] = useState(false);
// "" = a normal customer train; an id dedicates the departure to that // "" = a normal customer train; an id dedicates the departure to that
// shipping line and hides it from every customer-facing view. // shipping line and hides it from every customer-facing view.
@@ -461,6 +466,13 @@ export default function TrainScheduleV2ListPage() {
}); });
return; return;
} }
if (!voyageNumber.trim()) {
toast({
title: "Voyage number is required",
variant: "destructive",
});
return;
}
// Only build the window override when the toggle is on — off means "inherit // Only build the window override when the toggle is on — off means "inherit
// the global rules", which the API expresses as an absent windowRule. // the global rules", which the API expresses as an absent windowRule.
let windowRule: CreateScheduleWindowRulePayload | undefined; let windowRule: CreateScheduleWindowRulePayload | undefined;
@@ -483,6 +495,7 @@ export default function TrainScheduleV2ListPage() {
routeId, routeId,
scheduleDate: new Date(scheduleDate).toISOString(), scheduleDate: new Date(scheduleDate).toISOString(),
trainId, trainId,
voyageNumber: voyageNumber.trim(),
reverseWagonOrder, reverseWagonOrder,
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}), ...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
...(windowRule ? { windowRule } : {}), ...(windowRule ? { windowRule } : {}),
@@ -490,6 +503,7 @@ export default function TrainScheduleV2ListPage() {
}); });
toast({ title: "Train schedule created" }); toast({ title: "Train schedule created" });
showScheduleWarnings(created.warnings); showScheduleWarnings(created.warnings);
setVoyageNumber("");
setReverseWagonOrder(false); setReverseWagonOrder(false);
setShippingLineCompanyId(""); setShippingLineCompanyId("");
setConfigureWindow(false); setConfigureWindow(false);
@@ -689,7 +703,20 @@ export default function TrainScheduleV2ListPage() {
}; };
})} })}
value={trainId || null} value={trainId || null}
onChange={(v) => 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 searchable
disabled={!routeId} disabled={!routeId}
nothingFoundMessage={ nothingFoundMessage={
@@ -698,6 +725,15 @@ export default function TrainScheduleV2ListPage() {
: "Select a route first" : "Select a route first"
} }
/> />
<TextInput
label="Voyage number"
description="Sailing/run number for this departure that yards and customs quote. Defaults to the selected train's voyage number — edit if needed."
placeholder={trainId ? "e.g. V-2026-0620" : "Select a train first"}
required
maxLength={20}
value={voyageNumber}
onChange={(e) => setVoyageNumber(e.currentTarget.value)}
/>
<Select <Select
label="Shipping line (optional)" label="Shipping line (optional)"
description="Dedicate this departure to one shipping line. The train is then hidden from customers and shown only in that line's portal." description="Dedicate this departure to one shipping line. The train is then hidden from customers and shown only in that line's portal."
@@ -930,7 +966,12 @@ function TrainIdentityCell({ schedule }: { schedule: TrainScheduleListItem }) {
let subtitle = ""; let subtitle = "";
if (schedule.train) { if (schedule.train) {
title = schedule.trainNumber ?? schedule.train.code; title = schedule.trainNumber ?? schedule.train.code;
subtitle = [schedule.trainNumber ? schedule.train.code : null, schedule.train.trainName] // Show THIS departure's voyage number (the schedule's own), not the train's
// voyage/name — one train serves many departures, each with its own voyage.
subtitle = [
schedule.trainNumber ? schedule.train.code : null,
schedule.voyageNumber ? `Voyage ${schedule.voyageNumber}` : null,
]
.filter(Boolean) .filter(Boolean)
.join(" · "); .join(" · ");
} else if (locos.length) { } else if (locos.length) {

View File

@@ -205,7 +205,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
<Table.Td> <Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap"> <Group gap="xs" justify="flex-end" wrap="nowrap">
{/* Work the cargo right here while the train is at the yard. */} {/* Work the cargo right here while the train is at the yard. */}
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && ( {r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.paymentStatus === "PAID" && (
<Tooltip label={canLoad ? undefined : "You don't have permission to load cargo"} disabled={canLoad}> <Tooltip label={canLoad ? undefined : "You don't have permission to load cargo"} disabled={canLoad}>
<Button <Button
size="compact-xs" size="compact-xs"

View File

@@ -18,7 +18,9 @@ import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import type { WarehouseInventoryItem } from '@/types/warehouse'; import type { WarehouseInventoryItem } from '@/types/warehouse';
const isPaid = (item: WarehouseInventoryItem) => item.booking?.status === 'PAID'; // "Paid" is the booking's PAYMENT status only — never booking.status === 'PAID'.
const isPaid = (item: WarehouseInventoryItem) =>
(item.booking?.paymentStatus ?? item.bookingPaymentStatus) === 'PAID';
/** /**
* Loading Queue — manage inventory through the loading workflow. * Loading Queue — manage inventory through the loading workflow.

View File

@@ -192,6 +192,8 @@ export interface TrainScheduleListItem {
createdAt?: string | null; createdAt?: string | null;
scheduleDate: string; scheduleDate: string;
trainNumber?: string | null; trainNumber?: string | null;
/** Voyage (sailing) number for THIS departure — the schedule's own, not the train's. */
voyageNumber?: string | null;
/** Trade direction of this departure (IMPORT / EXPORT), when known. */ /** Trade direction of this departure (IMPORT / EXPORT), when known. */
direction?: string | null; direction?: string | null;
routeName?: string | null; routeName?: string | null;
@@ -766,6 +768,8 @@ export interface TrainScheduleDetail {
/** Cargo only (VGM/bulk tons) — the booked weight without wagon tare. */ /** Cargo only (VGM/bulk tons) — the booked weight without wagon tare. */
cargoWeightTons?: number; cargoWeightTons?: number;
status: string | null; status: string | null;
/** Payment status — the only signal that decides whether cargo may load. */
paymentStatus?: string | null;
schedulingStatus?: SchedulingStatus | null; schedulingStatus?: SchedulingStatus | null;
freightType?: FreightType | string | null; freightType?: FreightType | string | null;
/** DOMESTIC = intercity ride-along; rides only its own leg below. */ /** DOMESTIC = intercity ride-along; rides only its own leg below. */
@@ -1021,6 +1025,12 @@ export interface ReschedulePlan {
export interface CreateTrainSchedulePayload { export interface CreateTrainSchedulePayload {
routeId: string; routeId: string;
scheduleDate: string; scheduleDate: string;
/**
* Voyage (sailing) number for this departure — required. The create dialog
* pre-fills it with the selected train's direction-matched run number; staff
* may override before submitting.
*/
voyageNumber: string;
/** Built train (Train Builder) to run this departure — its locomotives are used. */ /** Built train (Train Builder) to run this departure — its locomotives are used. */
trainId?: string; trainId?: string;
/** Hand-picked locomotives (minimum 2 — front and back). Ignored when trainId is set. */ /** Hand-picked locomotives (minimum 2 — front and back). Ignored when trainId is set. */
@@ -1132,6 +1142,8 @@ export interface IntercityBookingRow {
id: string; id: string;
reference: string | null; reference: string | null;
status: string; status: string;
/** Payment status — the only signal that decides whether cargo may load. */
paymentStatus?: string | null;
freightType: FreightType | null; freightType: FreightType | null;
isGovernment: boolean; isGovernment: boolean;
customer: string; customer: string;
@@ -1252,6 +1264,8 @@ export interface IntercityRideAlongRow {
bookingId: string; bookingId: string;
reference: string | null; reference: string | null;
status: string; status: string;
/** Payment status — the only signal that decides whether cargo may load. */
paymentStatus?: string | null;
freightType: string | null; freightType: string | null;
weightTons: number | null; weightTons: number | null;
loadedAt: string | null; loadedAt: string | null;

View File

@@ -228,6 +228,8 @@ export interface WarehouseInventoryItem {
/** Flat booking summary fields attached by the inventory list (attachBookingSummaries). */ /** Flat booking summary fields attached by the inventory list (attachBookingSummaries). */
bookingReference?: string | null; bookingReference?: string | null;
bookingStatus?: string | null; bookingStatus?: string | null;
/** Payment status of the booking — the only signal that decides "paid". */
bookingPaymentStatus?: string | null;
customerName?: string | null; customerName?: string | null;
} }

View File

@@ -80,6 +80,8 @@ export type BookingDetail = Freight.IBooking & {
/** The allocated train, present once the booking is placed on a schedule. */ /** The allocated train, present once the booking is placed on a schedule. */
trainSchedule?: { trainSchedule?: {
trainNumber: string | null; trainNumber: string | null;
/** The schedule's own voyage (sailing) number for this departure. */
voyageNumber: string | null;
reference: string | null; reference: string | null;
scheduledDepartureDate: string | null; scheduledDepartureDate: string | null;
} | null; } | null;

View File

@@ -106,12 +106,19 @@ export function ScheduleCard({
value: <StatusPill status={booking.status as string} />, value: <StatusPill status={booking.status as string} />,
}; };
// The schedule's own voyage (sailing) number — shown once the booking has an
// assigned train that carries one.
const voyageRows: Row[] = schedule?.voyageNumber
? [{ label: "Voyage number", value: schedule.voyageNumber }]
: [];
const rows: Row[] = consignment const rows: Row[] = consignment
? [ ? [
{ label: "Consignment ID", value: booking.reference }, { label: "Consignment ID", value: booking.reference },
{ label: "Service", value: service }, { label: "Service", value: service },
{ label: "Equipment return", value: equipmentReturn }, { label: "Equipment return", value: equipmentReturn },
assignedTrain, assignedTrain,
...voyageRows,
{ label: "Scheduled", value: fmtDate(booking.scheduledDate) }, { label: "Scheduled", value: fmtDate(booking.scheduledDate) },
] ]
: [ : [
@@ -120,6 +127,7 @@ export function ScheduleCard({
{ label: "Equipment return", value: equipmentReturn }, { label: "Equipment return", value: equipmentReturn },
{ label: "Proposed date", value: fmtDate(booking.scheduledDate) }, { label: "Proposed date", value: fmtDate(booking.scheduledDate) },
assignedTrain, assignedTrain,
...voyageRows,
]; ];
return ( return (

View File

@@ -660,6 +660,21 @@ function NewShipmentBookingForm({
// summary alert next to the submit button so the click never looks inert. // summary alert next to the submit button so the click never looks inert.
const showValidationSummary = const showValidationSummary =
form.formState.isSubmitted && !form.formState.isValid; form.formState.isSubmitted && !form.formState.isValid;
// Export completion must lock onto a train. The button stays enabled (a
// disabled button with no explanation looks broken) — instead, once the
// customer tries to review, name the missing pick here in the always-visible
// footer, because the train picker itself is usually scrolled off-screen.
const requiresTrainPick =
contract.tradeDirection === "EXPORT" && Boolean(completeBookingId);
const watchedScheduledDate = form.watch("scheduledDate");
const watchedTrainId = form.watch("trainScheduleId");
const validationSummaryText = !requiresTrainPick
? "Fix the highlighted fields to review the price."
: !watchedScheduledDate?.trim()
? "Select a shipment day and a train to review the price."
: !watchedTrainId?.trim()
? "Select a train for your shipment day to review the price."
: "Fix the highlighted fields to review the price.";
const handleConfirm = () => { const handleConfirm = () => {
if (!pendingValues) return; if (!pendingValues) return;
@@ -838,7 +853,7 @@ function NewShipmentBookingForm({
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
/> />
<Text fz={13} fw={500} c="#C0392B"> <Text fz={13} fw={500} c="#C0392B">
Fix the highlighted fields to review the price. {validationSummaryText}
</Text> </Text>
</> </>
)} )}
@@ -2309,7 +2324,7 @@ function ContainerLineEditor({
Container number * Container number *
</Text> </Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}> <Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Seal number Seal number *
</Text> </Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}> <Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
VGM (tons) * VGM (tons) *
@@ -2355,10 +2370,11 @@ function ContainerLineEditor({
<Controller <Controller
name={`containers.${index}.units.${u}.sealNumber`} name={`containers.${index}.units.${u}.sealNumber`}
control={form.control} control={form.control}
render={({ field }) => ( render={({ field, fieldState }) => (
<TextInput <TextInput
{...field} {...field}
placeholder="Optional" placeholder="e.g. SL0123456"
error={fieldState.error?.message}
radius={10} radius={10}
styles={fieldStyles} styles={fieldStyles}
style={{ flex: 1 }} style={{ flex: 1 }}

View File

@@ -61,7 +61,12 @@ const containerUnitSchema = z.object({
(v) => ISO_CONTAINER_NUMBER_REGEX.test(v.trim().toUpperCase()), (v) => ISO_CONTAINER_NUMBER_REGEX.test(v.trim().toUpperCase()),
"Enter a valid ISO container number (e.g. ABCD1234567).", "Enter a valid ISO container number (e.g. ABCD1234567).",
), ),
sealNumber: z.string().default(""), // Every physical container is sealed before it ships; the yard checks the
// seal against the booking, so it is required alongside number and VGM.
sealNumber: z
.string()
.default("")
.refine((v) => v.trim().length > 0, "Seal number is required."),
vgmTons: z vgmTons: z
.string() .string()
.refine((v) => v.trim().length > 0, "VGM is required.") .refine((v) => v.trim().length > 0, "VGM is required.")

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { createShipmentFormSchema } from "./schema";
const schema = createShipmentFormSchema({
isContainer: true,
isHazardous: false,
isReefer: false,
withReturnService: false,
requiresDate: true,
});
const unit = (overrides: Partial<{ sealNumber: string }> = {}) => ({
containerNumber: "MSCU1234567",
sealNumber: "SL0123456",
vgmTons: "24.5",
isHazardous: false,
isReefer: false,
isReturn: false,
...overrides,
});
const values = (sealNumber: string) => ({
contractRouteId: "route-1",
scheduledDate: "2026-09-10",
paymentCurrency: "USD" as const,
containers: [
{
containerSize: "40ft" as const,
quantity: "1",
units: [unit({ sealNumber })],
},
],
});
const sealIssue = (v: ReturnType<typeof values>) => {
const result = schema.safeParse(v);
return result.success
? undefined
: result.error.issues.find((i) => i.path.at(-1) === "sealNumber");
};
describe("container unit seal number", () => {
it("rejects a missing seal number", () => {
expect(sealIssue(values(""))?.message).toBe("Seal number is required.");
});
it("rejects a whitespace-only seal number", () => {
expect(sealIssue(values(" "))?.message).toBe("Seal number is required.");
});
it("accepts a filled seal number", () => {
expect(sealIssue(values("SL0123456"))).toBeUndefined();
});
});