mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 07:23:40 +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> {
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
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),
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 { 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}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")),
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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