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> {
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,
};

View File

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

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 =
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),
);

View File

@@ -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',

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 { 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<string> {
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<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 {
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<void> {
@@ -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}.`,
);
}
}

View File

@@ -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<string>();
@@ -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

View File

@@ -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:

View File

@@ -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',

View File

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

View File

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

View File

@@ -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<AutoLoadResult> {
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<string | null> {
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<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],
);
return row?.status ?? null;
if (!row) return null;
return row.paymentStatus ?? 'PENDING';
}
private async attachBookingSummaries(items: WarehouseInventory[]): Promise<void> {
@@ -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,
});
});

View File

@@ -118,6 +118,7 @@ const blockNegative = (event: KeyboardEvent<HTMLInputElement>) => {
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 *
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Seal number
Seal number *
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
VGM (tons) *
@@ -1901,8 +1915,13 @@ export default function GlCreateBookingForm() {
style={{ flex: 1 }}
/>
<TextInput
placeholder="Optional"
placeholder="e.g. SL0123456"
value={unit.sealNumber}
error={
showErrors
? unitErrors[lineIdx]?.[unitIdx]?.sealNumber
: undefined
}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
sealNumber: e.currentTarget.value,
@@ -2305,12 +2324,19 @@ export default function GlCreateBookingForm() {
</Text>
)}
{isExportPick && scheduledDate ? (
<ExportTrainPicker
options={exportTrainsQuery.data ?? []}
loading={exportTrainsQuery.isLoading}
value={trainScheduleId}
onChange={setTrainScheduleId}
/>
<>
<ExportTrainPicker
options={exportTrainsQuery.data ?? []}
loading={exportTrainsQuery.isLoading}
value={trainScheduleId}
onChange={setTrainScheduleId}
/>
{showErrors && trainError && (
<Text fz="xs" c="red" mt={6}>
{trainError}
</Text>
)}
</>
) : null}
</Box>
</StepCard>
@@ -2401,7 +2427,11 @@ export default function GlCreateBookingForm() {
style={{ flexShrink: 0 }}
/>
<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>
</>
)}

View File

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

View File

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

View File

@@ -382,7 +382,12 @@ export function IntercityRideAlongPanel({
</Table.Td>
<Table.Td>
<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
label={
canLoad

View File

@@ -534,7 +534,8 @@ function LocoDetailPanel({
<Text size="sm" c="gray.5">No locomotive assigned yet.</Text>
)}
<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"

View File

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

View File

@@ -158,6 +158,11 @@ export default function TrainScheduleV2ListPage() {
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = 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);
// "" = a normal customer train; an id dedicates the departure to that
// shipping line and hides it from every customer-facing view.
@@ -461,6 +466,13 @@ export default function TrainScheduleV2ListPage() {
});
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
// the global rules", which the API expresses as an absent windowRule.
let windowRule: CreateScheduleWindowRulePayload | undefined;
@@ -483,6 +495,7 @@ export default function TrainScheduleV2ListPage() {
routeId,
scheduleDate: new Date(scheduleDate).toISOString(),
trainId,
voyageNumber: voyageNumber.trim(),
reverseWagonOrder,
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
...(windowRule ? { windowRule } : {}),
@@ -490,6 +503,7 @@ export default function TrainScheduleV2ListPage() {
});
toast({ title: "Train schedule created" });
showScheduleWarnings(created.warnings);
setVoyageNumber("");
setReverseWagonOrder(false);
setShippingLineCompanyId("");
setConfigureWindow(false);
@@ -689,7 +703,20 @@ export default function TrainScheduleV2ListPage() {
};
})}
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
disabled={!routeId}
nothingFoundMessage={
@@ -698,6 +725,15 @@ export default function TrainScheduleV2ListPage() {
: "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
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."
@@ -930,7 +966,12 @@ function TrainIdentityCell({ schedule }: { schedule: TrainScheduleListItem }) {
let subtitle = "";
if (schedule.train) {
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)
.join(" · ");
} else if (locos.length) {

View File

@@ -205,7 +205,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
{/* 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}>
<Button
size="compact-xs"

View File

@@ -18,7 +18,9 @@ import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
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.

View File

@@ -192,6 +192,8 @@ export interface TrainScheduleListItem {
createdAt?: string | null;
scheduleDate: string;
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. */
direction?: string | null;
routeName?: string | null;
@@ -766,6 +768,8 @@ export interface TrainScheduleDetail {
/** Cargo only (VGM/bulk tons) — the booked weight without wagon tare. */
cargoWeightTons?: number;
status: string | null;
/** Payment status — the only signal that decides whether cargo may load. */
paymentStatus?: string | null;
schedulingStatus?: SchedulingStatus | null;
freightType?: FreightType | string | null;
/** DOMESTIC = intercity ride-along; rides only its own leg below. */
@@ -1021,6 +1025,12 @@ export interface ReschedulePlan {
export interface CreateTrainSchedulePayload {
routeId: 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. */
trainId?: string;
/** Hand-picked locomotives (minimum 2 — front and back). Ignored when trainId is set. */
@@ -1132,6 +1142,8 @@ export interface IntercityBookingRow {
id: string;
reference: string | null;
status: string;
/** Payment status — the only signal that decides whether cargo may load. */
paymentStatus?: string | null;
freightType: FreightType | null;
isGovernment: boolean;
customer: string;
@@ -1252,6 +1264,8 @@ export interface IntercityRideAlongRow {
bookingId: string;
reference: string | null;
status: string;
/** Payment status — the only signal that decides whether cargo may load. */
paymentStatus?: string | null;
freightType: string | null;
weightTons: number | null;
loadedAt: string | null;

View File

@@ -228,6 +228,8 @@ export interface WarehouseInventoryItem {
/** Flat booking summary fields attached by the inventory list (attachBookingSummaries). */
bookingReference?: string | null;
bookingStatus?: string | null;
/** Payment status of the booking — the only signal that decides "paid". */
bookingPaymentStatus?: 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. */
trainSchedule?: {
trainNumber: string | null;
/** The schedule's own voyage (sailing) number for this departure. */
voyageNumber: string | null;
reference: string | null;
scheduledDepartureDate: string | null;
} | null;

View File

@@ -106,12 +106,19 @@ export function ScheduleCard({
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
? [
{ label: "Consignment ID", value: booking.reference },
{ label: "Service", value: service },
{ label: "Equipment return", value: equipmentReturn },
assignedTrain,
...voyageRows,
{ label: "Scheduled", value: fmtDate(booking.scheduledDate) },
]
: [
@@ -120,6 +127,7 @@ export function ScheduleCard({
{ label: "Equipment return", value: equipmentReturn },
{ label: "Proposed date", value: fmtDate(booking.scheduledDate) },
assignedTrain,
...voyageRows,
];
return (

View File

@@ -660,6 +660,21 @@ function NewShipmentBookingForm({
// summary alert next to the submit button so the click never looks inert.
const showValidationSummary =
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 = () => {
if (!pendingValues) return;
@@ -838,7 +853,7 @@ function NewShipmentBookingForm({
style={{ flexShrink: 0 }}
/>
<Text fz={13} fw={500} c="#C0392B">
Fix the highlighted fields to review the price.
{validationSummaryText}
</Text>
</>
)}
@@ -2309,7 +2324,7 @@ function ContainerLineEditor({
Container number *
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Seal number
Seal number *
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
VGM (tons) *
@@ -2355,10 +2370,11 @@ function ContainerLineEditor({
<Controller
name={`containers.${index}.units.${u}.sealNumber`}
control={form.control}
render={({ field }) => (
render={({ field, fieldState }) => (
<TextInput
{...field}
placeholder="Optional"
placeholder="e.g. SL0123456"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}

View File

@@ -61,7 +61,12 @@ const containerUnitSchema = z.object({
(v) => ISO_CONTAINER_NUMBER_REGEX.test(v.trim().toUpperCase()),
"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
.string()
.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();
});
});