fix issues

This commit is contained in:
marshal
2026-09-06 13:50:07 +00:00
parent 75b75e3d4e
commit 19145306c9
26 changed files with 1716 additions and 14 deletions

View File

@@ -112,6 +112,7 @@ import {
UploadImportDjiboutiDocumentDto,
} from '../dto/import-djibouti-operation.dto';
import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto';
import { ReduceScheduleCloseOffsetDto } from '../dto/reduce-schedule-close-offset.dto';
import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto';
import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto';
import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto';
@@ -197,12 +198,15 @@ import { orderConsistWagons } from '../consist-order.util';
import {
bookingCloseCutoff,
clampCloseToOfficeHours,
closeOffsetReopenCheck,
computeExportWindowTimes,
computeImportWindowTimes,
earliestSchedulableDeparture,
eatDay,
eatDayToUtc,
nextCycleOpensAt,
shiftEatDay,
type OfficeHours,
} from '../batch-window.util';
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
import { BookingJourneyService } from '../booking-journey.service';
@@ -239,6 +243,19 @@ const HANDLING_FIELDS = [
type HandlingField = (typeof HANDLING_FIELDS)[number][0];
/** "3 days" / "2 hours" / "45 minutes" for an error message. */
function describeMinutes(minutes: number): string {
if (minutes % 1_440 === 0) {
const d = minutes / 1_440;
return `${d} day${d === 1 ? '' : 's'}`;
}
if (minutes % 60 === 0) {
const h = minutes / 60;
return `${h} hour${h === 1 ? '' : 's'}`;
}
return `${minutes} minute${minutes === 1 ? '' : 's'}`;
}
/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */
function pickDefined<T extends object>(source: T): Partial<T> {
return Object.fromEntries(
@@ -266,6 +283,16 @@ function windowRuleSnapshot(cfg: BookingWindowConfig) {
};
}
/** Wire shape of a close-offset reopen check (dates as ISO strings). */
function toCloseOffsetReopenInfo(check: ReturnType<typeof closeOffsetReopenCheck>) {
return {
eligible: check.eligible,
reason: check.reason,
offsetMinutes: check.offsetMinutes,
cutoffAt: check.cutoffAt ? check.cutoffAt.toISOString() : null,
};
}
/**
* The booking-window config a specific schedule runs under: its frozen rule
* snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live
@@ -1024,6 +1051,152 @@ export class TrainSchedulingService {
return fresh ?? schedule;
}
/**
* Shorten the booking-close offset of ONE schedule whose booking shut ONLY
* because of that offset, and re-arm its window so the desk reopens. A 3-day
* offset that closed booking with the train still days away can be cut to a
* day or a couple of hours; the window then opens at the next desk opening
* (now, if the desk is open) and runs its normal cycles until the new cutoff.
*
* Refused for every other kind of closed window (departed, full, no offset,
* still mid-cycle) — see `closeOffsetReopenCheck`. The new offset must be
* shorter than the current one and must leave room for a cycle before the
* new cutoff. The offset is frozen onto the schedule (the global value is
* untouched) and the row is marked custom so a later global-rules save does
* not re-stamp it.
*
* IMPORT/DOMESTIC: the same shorter offset is applied to every route+day
* sibling that is likewise shut only by its offset, so the group keeps its
* single shared timeline (each capped at its own new cutoff). EXPORT windows
* are per-train, so an export change touches only this schedule.
*/
async reduceScheduleCloseOffset(
id: string,
dto: ReduceScheduleCloseOffsetDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findById(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
const now = new Date();
const liveCfg = await this.getWindowConfig();
const cfg = effectiveWindowConfig(schedule, liveCfg);
const check = closeOffsetReopenCheck(schedule, cfg, now);
if (!check.eligible || check.offsetMinutes == null) {
throw new BadRequestException(
check.reason ?? 'This schedule cannot reopen by shortening its close offset.',
);
}
const newOffset = dto.closeOffsetMinutes;
if (newOffset >= check.offsetMinutes) {
throw new BadRequestException(
`The new close offset must be shorter than the current ${describeMinutes(
check.offsetMinutes,
)} before departure.`,
);
}
const isExport = schedule.direction === 'EXPORT';
// 0 is stored as null so "no offset" keeps its single canonical value.
const offsetPatch = isExport
? { ruleExportCloseOffsetMinutes: newOffset || null }
: { ruleImportCloseOffsetMinutes: newOffset || null };
const merged: BookingWindowConfig = {
...cfg,
...(isExport
? { exportCloseOffsetMinutes: newOffset || null }
: { importCloseOffsetMinutes: newOffset || null }),
};
const hours: OfficeHours = {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
};
const cutoff = bookingCloseCutoff(
schedule.scheduledDepartureDate,
schedule.direction,
merged,
);
// The desk reopens at the next office-hours opening (now, when it is open),
// exactly as a reopen cycle would — and only if that lands before the cutoff.
const opensAt = nextCycleOpensAt(now, hours, cutoff);
if (opensAt == null) {
throw new BadRequestException(
'Even with this offset the desk would not reopen before booking closes again ' +
`(new cutoff ${cutoff.toISOString()}) — shorten the offset further.`,
);
}
let closesAt: Date;
if (isExport) {
// Export runs one FCFS window: from the reopen until the cutoff.
closesAt = cutoff;
} else {
closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000);
closesAt = clampCloseToOfficeHours(opensAt, closesAt, hours);
if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff;
}
const cap = (d: Date, bound: Date): Date =>
d.getTime() > bound.getTime() ? bound : d;
const targets: Array<{ id: string; cutoff: Date }> = [{ id, cutoff }];
if (!isExport) {
const siblings = await this.findGroupSiblings(
this.dataSource.manager,
schedule.originStationId,
schedule.destinationStationId,
schedule.scheduledDepartureDate,
id,
);
for (const sib of siblings) {
const sibCfg = effectiveWindowConfig(sib, liveCfg);
const sibCheck = closeOffsetReopenCheck(sib, sibCfg, now);
// Only a sibling that is ALSO shut purely by an offset longer than the
// new one joins in; anything else keeps the state its customers saw.
if (
!sibCheck.eligible ||
sibCheck.offsetMinutes == null ||
sibCheck.offsetMinutes <= newOffset ||
!sib.scheduledDepartureDate
) {
continue;
}
const sibCutoff = bookingCloseCutoff(sib.scheduledDepartureDate, sib.direction, {
...sibCfg,
importCloseOffsetMinutes: newOffset || null,
});
if (opensAt.getTime() >= sibCutoff.getTime()) continue;
targets.push({ id: sib.id, cutoff: sibCutoff });
}
}
const repo = this.dataSource.getRepository(TrainSchedule);
for (const t of targets) {
await repo.update(t.id, {
...offsetPatch,
// Staff-set — exempt from the global re-stamp.
windowRuleCustom: true,
// Back to PRE_WINDOW: the window tick opens it at windowOpensAt and runs
// the normal cycle from there (bookingWindowStatus flips OPEN then).
windowPhase: 'PRE_WINDOW',
windowOpensAt: cap(opensAt, t.cutoff),
windowClosesAt: cap(closesAt, t.cutoff),
docReviewCompletedAt: null,
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
});
}
this.logger.log(
`Close offset of schedule ${schedule.reference ?? id} shortened ` +
`${check.offsetMinutes}${newOffset} min before departure` +
` (+${targets.length - 1} route+day sibling(s)) — booking reopens ` +
`${opensAt.toISOString()}, closes ${closesAt.toISOString()}`,
);
for (const t of targets) void this.emitWindowState(t.id);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
}
/**
* Correct a departure's operational run identifiers — the train number and
* voyage number yards and customs quote.
@@ -6313,8 +6486,11 @@ export class TrainSchedulingService {
skip,
take,
});
// Live window config: each row's frozen rule overlays it to decide whether
// the "shorten close offset" action applies (see closeOffsetReopenCheck).
const liveCfg = await this.getWindowConfig();
return {
items: schedules.map((s) => this.mapScheduleListItem(s)),
items: schedules.map((s) => this.mapScheduleListItem(s, liveCfg)),
meta: buildPaginationMeta(total, page, pageSize),
};
}
@@ -8857,7 +9033,11 @@ export class TrainSchedulingService {
throw new ConflictException('Could not allocate a unique schedule reference');
}
private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) {
private mapScheduleListItem(
schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule,
/** Live window config; when given, the row carries its close-offset reopen state. */
liveCfg?: BookingWindowConfig,
) {
// Wagon figures must match the detail page's wagon plan (WagonPlanGrid) —
// see computeScheduleWagonUsage for why the stored counter cannot be used.
const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } =
@@ -8921,6 +9101,18 @@ export class TrainSchedulingService {
freightType: this.resolveScheduleFreightType(schedule),
status: schedule.status,
bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN',
windowPhase: schedule.windowPhase ?? null,
// Whether booking shut ONLY because of the close offset — the board offers
// "shorten close offset" on exactly these rows.
closeOffsetReopen: liveCfg
? toCloseOffsetReopenInfo(
closeOffsetReopenCheck(
schedule,
effectiveWindowConfig(schedule, liveCfg),
new Date(),
),
)
: null,
cancellationReason: schedule.cancellationReason ?? null,
cancelledAt: schedule.cancelledAt ?? null,
maxWagons: schedule.maxWagons ?? 0,
@@ -10965,6 +11157,14 @@ export class TrainSchedulingService {
// settings" editor on the ops board (prefill + save one schedule's
// override). docReview/payment are not snapshotted per schedule (only their
// sum, as the frozen reopen gap), so the editor prefills them from live config.
// Shut only by its close offset? Drives the "shorten close offset" action.
closeOffsetReopen: toCloseOffsetReopenInfo(
closeOffsetReopenCheck(
schedule,
effectiveWindowConfig(schedule, windowCfg),
new Date(),
),
),
windowRule: {
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
@@ -10974,6 +11174,12 @@ export class TrainSchedulingService {
: null,
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
// The offsets this train actually runs under (its frozen snapshot, or
// the live global for a legacy row) — null = booking runs to departure.
importCloseOffsetMinutes:
effectiveWindowConfig(schedule, windowCfg).importCloseOffsetMinutes ?? null,
exportCloseOffsetMinutes:
effectiveWindowConfig(schedule, windowCfg).exportCloseOffsetMinutes ?? null,
docReviewMinutes: windowCfg.docReviewMinutes,
// Editor prefill: this schedule's own override when staff set one,
// else the live global for the schedule's direction (import/export