mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 09:28:19 +00:00
fix(freight): gate loading on booking paymentStatus only; default schedule voyage no. to train's voyage number
This commit is contained in:
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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")),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user