mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: enhance booking and scheduling features with shipping line support and improved search functionality
This commit is contained in:
@@ -16,6 +16,7 @@ import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-company.entity';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||
@@ -783,16 +784,20 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
// by TypeORM and crashes).
|
||||
.leftJoin(Contract, 'contract', 'contract.id = booking.contract_id')
|
||||
.addSelect('contract.reference', 'contract_reference')
|
||||
// Shipping-line owner name for search only (no relation, see entity) —
|
||||
// the list rows get `shippingLineCompany` hydrated by the service.
|
||||
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = booking.shipping_line_company_id')
|
||||
.where('booking.deleted_at IS NULL');
|
||||
|
||||
this.applyListFilters(qb, options);
|
||||
|
||||
// Free-text search spans joined columns (company, contract) that only this
|
||||
// list query joins — so it lives here, not in applyListFilters (shared
|
||||
// with getListSummaryMetrics, whose query builder has no joins).
|
||||
// Free-text search spans joined columns (company, shipping line, contract)
|
||||
// that only this list query joins — so it lives here, not in
|
||||
// applyListFilters (shared with getListSummaryMetrics, whose query builder
|
||||
// has no joins).
|
||||
if (options.search) {
|
||||
qb.andWhere(
|
||||
'(booking.reference ILIKE :search OR company.name ILIKE :search OR contract.reference ILIKE :search)',
|
||||
'(booking.reference ILIKE :search OR company.name ILIKE :search OR slc.name ILIKE :search OR contract.reference ILIKE :search)',
|
||||
{ search: `%${options.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ export class FilterBookingDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Free-text search across booking reference, company name, and contract reference.',
|
||||
'Free-text search across booking reference, customer / shipping-line company name, and contract reference.',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
|
||||
@@ -72,6 +72,8 @@ describe('SchedulingRescheduleService', () => {
|
||||
previewTrainSchedule: jest.fn(),
|
||||
unassignBooking: jest.fn(),
|
||||
assignBookingsToSchedule: jest.fn(),
|
||||
windowFieldsForNewDeparture: jest.fn().mockResolvedValue({}),
|
||||
emitWindowState: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
schedulingRescheduleRepository = {
|
||||
createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }),
|
||||
@@ -213,6 +215,13 @@ describe('SchedulingRescheduleService', () => {
|
||||
});
|
||||
trainSchedulesRepository.updateStatus.mockResolvedValue(undefined);
|
||||
trainSchedulingService.assignBookingsToSchedule.mockResolvedValue({ id: 'sched-1' });
|
||||
// An OPEN window's close must follow the new departure (this is the
|
||||
// portal's "closes in" countdown) — the derived fields ride along with the
|
||||
// date write.
|
||||
const newCloses = new Date('2099-06-22T08:00:00.000Z');
|
||||
trainSchedulingService.windowFieldsForNewDeparture.mockResolvedValue({
|
||||
windowClosesAt: newCloses,
|
||||
});
|
||||
|
||||
const result = await service.maintenanceReschedule(
|
||||
'sched-1',
|
||||
@@ -228,9 +237,16 @@ describe('SchedulingRescheduleService', () => {
|
||||
expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith(
|
||||
'sched-1',
|
||||
'DRAFT',
|
||||
{ scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z') },
|
||||
{
|
||||
scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z'),
|
||||
windowClosesAt: newCloses,
|
||||
},
|
||||
txManager,
|
||||
);
|
||||
expect(trainSchedulingService.windowFieldsForNewDeparture).toHaveBeenCalledWith(
|
||||
schedule,
|
||||
new Date('2099-06-22T10:00:00.000Z'),
|
||||
);
|
||||
expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
trigger: 'TRAIN_MAINTENANCE',
|
||||
|
||||
@@ -227,17 +227,25 @@ export class SchedulingRescheduleService {
|
||||
// through this manager without editing TrainSchedulingService. A failure
|
||||
// between those steps and this block can still leave partial state; a human
|
||||
// must finish the full cross-service transaction threading.
|
||||
// The booking window must follow the new departure (an OPEN window's
|
||||
// "closes in" countdown is capped at departure − close offset; PRE_WINDOW /
|
||||
// DONE re-derive their open/close). Same math as maintenanceReschedule.
|
||||
const windowFields = newDeparture
|
||||
? await this.trainSchedulingService.windowFieldsForNewDeparture(
|
||||
schedule,
|
||||
newDeparture,
|
||||
)
|
||||
: {};
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
if (newDeparture) {
|
||||
// M7: raw write of scheduledDepartureDate. We deliberately do NOT
|
||||
// delegate to TrainSchedulingService.updateScheduleDate, which only
|
||||
// permits a date change while windowPhase === 'PRE_WINDOW' and would
|
||||
// reject reschedules of already-open (SCHEDULED) trains. Consequence:
|
||||
// the booking-window fields are NOT re-derived for the new date here.
|
||||
// Raw write of scheduledDepartureDate: updateScheduleDate only permits a
|
||||
// date change while windowPhase === 'PRE_WINDOW' and would reject
|
||||
// reschedules of already-open (SCHEDULED) trains.
|
||||
await this.trainSchedulesRepository.updateStatus(
|
||||
scheduleId,
|
||||
schedule.status as TrainScheduleStatus,
|
||||
{ scheduledDepartureDate: newDeparture },
|
||||
{ scheduledDepartureDate: newDeparture, ...windowFields },
|
||||
manager,
|
||||
);
|
||||
}
|
||||
@@ -263,6 +271,7 @@ export class SchedulingRescheduleService {
|
||||
// `newDeparture` is null when the date was unchanged, so retained customers
|
||||
// are not falsely told the train was rescheduled.
|
||||
await this.notifyRescheduleOutcome(dto, newDeparture);
|
||||
if (newDeparture) void this.trainSchedulingService.emitWindowState(scheduleId);
|
||||
|
||||
return { plan, schedule: assignResult };
|
||||
}
|
||||
|
||||
@@ -1821,5 +1821,31 @@ describe('TrainSchedulingService', () => {
|
||||
expect(written.windowPhase).toBeUndefined();
|
||||
expect(written.windowOpensAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('moves an OPEN export close to the new departure but keeps the open', async () => {
|
||||
const opensAt = new Date('2027-06-19T03:00:00.000Z');
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
|
||||
doneExportSchedule({
|
||||
windowPhase: 'OPEN',
|
||||
bookingWindowStatus: 'OPEN',
|
||||
windowOpensAt: opensAt,
|
||||
windowClosesAt: new Date('2027-06-20T03:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
|
||||
// Departure pushed 3 days later → close = new departure − 120min; the
|
||||
// open customers already booked against stays untouched.
|
||||
await service.maintenanceReschedule('sch-done', {
|
||||
newDepartureDate: '2027-06-23T05:00:00.000Z',
|
||||
} as never);
|
||||
|
||||
const written = scheduleUpdate.mock.calls[0][1];
|
||||
expect(written.scheduledDepartureDate).toEqual(
|
||||
new Date('2027-06-23T05:00:00.000Z'),
|
||||
);
|
||||
expect(written.windowClosesAt).toEqual(new Date('2027-06-23T03:00:00.000Z'));
|
||||
expect(written.windowOpensAt).toBeUndefined();
|
||||
expect(written.windowPhase).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,6 +164,8 @@ import {
|
||||
} from '../booking-batch.constants';
|
||||
import { orderConsistWagons } from '../consist-order.util';
|
||||
import {
|
||||
bookingCloseCutoff,
|
||||
clampCloseToOfficeHours,
|
||||
computeExportWindowTimes,
|
||||
computeImportWindowTimes,
|
||||
earliestSchedulableDeparture,
|
||||
@@ -473,7 +475,7 @@ export class TrainSchedulingService {
|
||||
* used for lifecycle changes outside the window tick (create, cancel,
|
||||
* finalize, restamp). A push failure must never break the mutation.
|
||||
*/
|
||||
private async emitWindowState(scheduleId: string): Promise<void> {
|
||||
async emitWindowState(scheduleId: string): Promise<void> {
|
||||
try {
|
||||
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
// Dedicated shipping-line departures are never announced to the portal —
|
||||
@@ -1159,66 +1161,73 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintenance reschedule: the admin moves a train (with everything aboard) to
|
||||
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
|
||||
* phase and inside the booking lead window — a maintenance move is an
|
||||
* operational fact, not a planning choice. What moves and what stays:
|
||||
* Booking-window fields that must follow a train's departure moving to
|
||||
* `departure` (any window phase). Shared by every reschedule path so the
|
||||
* "closes in" countdown always tracks the real departure.
|
||||
*
|
||||
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
|
||||
* aboard/targeted booking's scheduledDate (the day-pool queries key on it,
|
||||
* so a booking left on the old day would fall out of its own train's pool).
|
||||
* - STAYS: train set, wagon assignments, schedule↔booking links, route,
|
||||
* maxWagons, and the window RULE snapshot. Stamped window times are only
|
||||
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); a
|
||||
* schedule mid- or post-window keeps its timeline untouched.
|
||||
* PRE_WINDOW: the stamped open/close were derived from the old departure
|
||||
* and the window hasn't opened yet, so re-derive them from the schedule's
|
||||
* own rule snapshot against the new date (joining the target day's route
|
||||
* group timeline when one exists, exactly like updateScheduleDate).
|
||||
*
|
||||
* Customers of every moved booking are notified (maintenanceMoved).
|
||||
* DONE: the window already finished (e.g. the close offset hit and then the
|
||||
* train was moved to a later departure). The window must follow the new
|
||||
* departure, so it REOPENS: re-derive open/close the same way, reset the
|
||||
* phase to PRE_WINDOW and clamp a past open into the present so the tick
|
||||
* opens it immediately. A FULL train stays closed — there is nothing left
|
||||
* to sell — and so does one whose re-derived window would already be over.
|
||||
*
|
||||
* OPEN: customers are already booking against the open they were shown, so
|
||||
* the open stays put — but the close was capped at the OLD departure's
|
||||
* cutoff, so it must follow the new one (import: open + duration under
|
||||
* office hours, capped at the cutoff; export: the cutoff itself). Moving
|
||||
* the train later extends the "closes in" countdown, moving it earlier
|
||||
* shortens it (a close now in the past is picked up by the next tick).
|
||||
*
|
||||
* DOC_REVIEW/PAYMENT keep their running timeline.
|
||||
*/
|
||||
async maintenanceReschedule(
|
||||
id: string,
|
||||
dto: MaintenanceRescheduleDto,
|
||||
): Promise<TrainSchedule> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||
}
|
||||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||||
throw new BadRequestException(
|
||||
`Cannot reschedule a ${schedule.status.toLowerCase()} train`,
|
||||
);
|
||||
}
|
||||
|
||||
const departure = new Date(dto.newDepartureDate);
|
||||
if (Number.isNaN(departure.getTime())) {
|
||||
throw new BadRequestException('Invalid departure date.');
|
||||
}
|
||||
if (departure.getTime() <= Date.now()) {
|
||||
throw new BadRequestException('New departure must be in the future.');
|
||||
}
|
||||
|
||||
const deltaMs =
|
||||
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
|
||||
const scheduledArrivalDate = schedule.scheduledArrivalDate
|
||||
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
|
||||
: undefined;
|
||||
|
||||
// PRE_WINDOW: the stamped open/close were derived from the old departure
|
||||
// and the window hasn't opened yet, so re-derive them from the schedule's
|
||||
// own rule snapshot against the new date (joining the target day's route
|
||||
// group timeline when one exists, exactly like updateScheduleDate).
|
||||
//
|
||||
// DONE: the window already finished (e.g. the close offset hit and then the
|
||||
// train was moved to a later departure). The window must follow the new
|
||||
// departure, so it REOPENS: re-derive open/close the same way, reset the
|
||||
// phase to PRE_WINDOW and clamp a past open into the present so the tick
|
||||
// opens it immediately. A FULL train stays closed — there is nothing left
|
||||
// to sell — and so does one whose re-derived window would already be over.
|
||||
//
|
||||
// Mid-window phases (OPEN/DOC_REVIEW/PAYMENT) keep their running timeline.
|
||||
async windowFieldsForNewDeparture(
|
||||
schedule: TrainSchedule,
|
||||
departure: Date,
|
||||
): Promise<
|
||||
Partial<
|
||||
Pick<
|
||||
TrainSchedule,
|
||||
| 'windowOpensAt'
|
||||
| 'windowClosesAt'
|
||||
| 'windowPhase'
|
||||
| 'bookingWindowStatus'
|
||||
| 'docReviewCompletedAt'
|
||||
| 'docReviewEndsAt'
|
||||
| 'paymentPhaseEndsAt'
|
||||
>
|
||||
>
|
||||
> {
|
||||
const reopenFromDone =
|
||||
schedule.windowPhase === 'DONE' && schedule.bookingWindowStatus !== 'FULL';
|
||||
const shiftOpenClose =
|
||||
schedule.windowPhase === 'OPEN' && schedule.windowOpensAt != null;
|
||||
const windowFields =
|
||||
schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone
|
||||
shiftOpenClose
|
||||
? await (async () => {
|
||||
const merged = effectiveWindowConfig(
|
||||
schedule,
|
||||
await this.getWindowConfig(),
|
||||
);
|
||||
const opensAt = schedule.windowOpensAt!;
|
||||
const cutoff = bookingCloseCutoff(departure, schedule.direction, merged);
|
||||
let closesAt = cutoff;
|
||||
if (schedule.direction !== 'EXPORT') {
|
||||
closesAt = clampCloseToOfficeHours(
|
||||
opensAt,
|
||||
new Date(opensAt.getTime() + merged.windowDurationHours * 3_600_000),
|
||||
merged,
|
||||
);
|
||||
if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff;
|
||||
}
|
||||
return { windowClosesAt: closesAt };
|
||||
})()
|
||||
: schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone
|
||||
? await (async () => {
|
||||
const merged = effectiveWindowConfig(
|
||||
schedule,
|
||||
@@ -1266,6 +1275,55 @@ export class TrainSchedulingService {
|
||||
};
|
||||
})()
|
||||
: {};
|
||||
return windowFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintenance reschedule: the admin moves a train (with everything aboard) to
|
||||
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
|
||||
* phase and inside the booking lead window — a maintenance move is an
|
||||
* operational fact, not a planning choice. What moves and what stays:
|
||||
*
|
||||
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
|
||||
* aboard/targeted booking's scheduledDate (the day-pool queries key on it,
|
||||
* so a booking left on the old day would fall out of its own train's pool).
|
||||
* - STAYS: train set, wagon assignments, schedule↔booking links, route,
|
||||
* maxWagons, and the window RULE snapshot. Stamped window times are
|
||||
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); an
|
||||
* OPEN schedule keeps its open but its close follows the new departure;
|
||||
* DOC_REVIEW/PAYMENT keep their timeline untouched.
|
||||
*
|
||||
* Customers of every moved booking are notified (maintenanceMoved).
|
||||
*/
|
||||
async maintenanceReschedule(
|
||||
id: string,
|
||||
dto: MaintenanceRescheduleDto,
|
||||
): Promise<TrainSchedule> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||
}
|
||||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||||
throw new BadRequestException(
|
||||
`Cannot reschedule a ${schedule.status.toLowerCase()} train`,
|
||||
);
|
||||
}
|
||||
|
||||
const departure = new Date(dto.newDepartureDate);
|
||||
if (Number.isNaN(departure.getTime())) {
|
||||
throw new BadRequestException('Invalid departure date.');
|
||||
}
|
||||
if (departure.getTime() <= Date.now()) {
|
||||
throw new BadRequestException('New departure must be in the future.');
|
||||
}
|
||||
|
||||
const deltaMs =
|
||||
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
|
||||
const scheduledArrivalDate = schedule.scheduledArrivalDate
|
||||
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
|
||||
: undefined;
|
||||
|
||||
const windowFields = await this.windowFieldsForNewDeparture(schedule, departure);
|
||||
|
||||
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
||||
scheduledDepartureDate: departure,
|
||||
|
||||
Reference in New Issue
Block a user