mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 13:35:03 +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,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user