mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 07:51:02 +00:00
add reference field to train schedules and implement unique sequence generation
This commit is contained in:
@@ -217,6 +217,20 @@ export class ContractBookingService {
|
||||
await this.applyWeightResults(loaded);
|
||||
}
|
||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||
// Reject a zero-price booking outright. A total of 0 means no contract rate
|
||||
// matched the route/container (or the rate is unset), so the booking is not
|
||||
// valid to ship or invoice. Roll back the just-inserted row + its lines so it
|
||||
// does NOT occupy the one-time contract's single active-booking slot — else
|
||||
// the customer's retry hits "already has an active booking" against a broken
|
||||
// draft. The customer must fix the contract's rates, then rebook.
|
||||
if (!(computed.totalAmount > 0)) {
|
||||
await this.bookingsRepository.deleteContainers(booking.id);
|
||||
await this.bookingsRepository.hardDelete(booking.id);
|
||||
throw new BadRequestException(
|
||||
'Booking price came out as 0 — no contract rate matches this ' +
|
||||
'route/cargo. Set the contract rate and try again.',
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
totalAmount: computed.totalAmount,
|
||||
priorityScore: computed.priorityScore,
|
||||
|
||||
@@ -61,6 +61,12 @@ export class TrainSchedule extends BaseEntity {
|
||||
@Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true })
|
||||
trainNumber?: string | null;
|
||||
|
||||
// Human-facing unique schedule reference (S-YYYY-NNNNN). Shown on the schedule
|
||||
// list, booking windows, and load lists. Assigned at creation from the highest
|
||||
// sequence issued this year (see TrainSchedulesRepository.maxReferenceSequence).
|
||||
@Column({ name: 'reference', type: 'varchar', length: 20, nullable: true, unique: true })
|
||||
reference?: string | null;
|
||||
|
||||
@Column({ name: 'direction', type: 'varchar', length: 10, nullable: true })
|
||||
direction?: string | null;
|
||||
|
||||
|
||||
@@ -58,4 +58,22 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||
): Promise<void> {
|
||||
await this.repo(manager).update(id, { status, ...extra } as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* Highest NNNNN sequence already issued for `S-<year>-…` references. Includes
|
||||
* soft-deleted rows so the next number never reuses one still occupying the
|
||||
* unique index (see the same pattern on BookingsRepository).
|
||||
*/
|
||||
async maxReferenceSequence(year: number): Promise<number> {
|
||||
const row = await this.repository
|
||||
.createQueryBuilder('schedule')
|
||||
.withDeleted()
|
||||
.select(
|
||||
"COALESCE(MAX(CAST(SUBSTRING(schedule.reference FROM '[0-9]+$') AS int)), 0)",
|
||||
'max',
|
||||
)
|
||||
.where('schedule.reference LIKE :prefix', { prefix: `S-${year}-%` })
|
||||
.getRawOne<{ max: string | number | null }>();
|
||||
return Number(row?.max ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, In, IsNull, Not } from 'typeorm';
|
||||
import { DataSource, EntityManager, In, IsNull, Not, QueryFailedError } from 'typeorm';
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
@@ -242,6 +242,7 @@ const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
|
||||
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
|
||||
interface BookingWindowRow {
|
||||
schedule_id: string;
|
||||
reference: string | null;
|
||||
contract_id: string | null;
|
||||
contract_kind: string | null;
|
||||
direction: string | null;
|
||||
@@ -821,20 +822,24 @@ export class TrainSchedulingService {
|
||||
...ruleSnapshot,
|
||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||
};
|
||||
const schedule = manager.getRepository(TrainSchedule).create({
|
||||
trainSetId: trainSet.id,
|
||||
routeId: route.id,
|
||||
originStationId: route.originYardId,
|
||||
destinationStationId: route.destinationYardId,
|
||||
scheduledDepartureDate: departure,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
direction,
|
||||
maxWagons: (
|
||||
await this.resolveTrainLimitConfig(dto, limitLoco)
|
||||
).maxWagonsPerTrain,
|
||||
...windowFields,
|
||||
});
|
||||
const saved = await manager.getRepository(TrainSchedule).save(schedule);
|
||||
const maxWagons = (await this.resolveTrainLimitConfig(dto, limitLoco))
|
||||
.maxWagonsPerTrain;
|
||||
// Retry past a concurrent insert that grabbed the same S-<year> sequence
|
||||
// (the unique index rejects the loser; it re-reads the max and tries again).
|
||||
const saved = await this.insertScheduleWithReference(manager, (reference) =>
|
||||
manager.getRepository(TrainSchedule).create({
|
||||
reference,
|
||||
trainSetId: trainSet.id,
|
||||
routeId: route.id,
|
||||
originStationId: route.originYardId,
|
||||
destinationStationId: route.destinationYardId,
|
||||
scheduledDepartureDate: departure,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
direction,
|
||||
maxWagons,
|
||||
...windowFields,
|
||||
}),
|
||||
);
|
||||
// Locomotives stay in their current status until dispatch — advance scheduling
|
||||
// must not block the locomotive from serving earlier trains.
|
||||
return saved.id;
|
||||
@@ -2560,7 +2565,8 @@ export class TrainSchedulingService {
|
||||
destinationStation: true,
|
||||
scheduleBookings: { booking: true },
|
||||
},
|
||||
order: { scheduledDepartureDate: 'DESC', createdAt: 'DESC' },
|
||||
// Newest-created first (the client can re-sort; this is the default order).
|
||||
order: { createdAt: 'DESC', scheduledDepartureDate: 'DESC' },
|
||||
});
|
||||
return schedules.map((s) => this.mapScheduleListItem(s));
|
||||
}
|
||||
@@ -3785,9 +3791,41 @@ export class TrainSchedulingService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a schedule with a freshly generated S-<year>-NNNNN reference, retrying
|
||||
* past a concurrent insert that grabbed the same sequence (the unique index
|
||||
* rejects the loser). Mirrors insertWithGeneratedReference for bookings, but
|
||||
* runs inside the caller's transaction manager so the row joins the same commit.
|
||||
*/
|
||||
private async insertScheduleWithReference(
|
||||
manager: EntityManager,
|
||||
build: (reference: string) => TrainSchedule,
|
||||
): Promise<TrainSchedule> {
|
||||
const year = new Date().getFullYear();
|
||||
const repo = manager.getRepository(TrainSchedule);
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const seq = await this.trainSchedulesRepository.maxReferenceSequence(year);
|
||||
const reference = `S-${year}-${String(seq + 1).padStart(5, '0')}`;
|
||||
try {
|
||||
return await repo.save(build(reference));
|
||||
} catch (err) {
|
||||
// 23505 = unique_violation on ux_train_schedules_reference; re-read + retry.
|
||||
const code = (err as { driverError?: { code?: string } })?.driverError?.code;
|
||||
if (err instanceof QueryFailedError && code === '23505' && attempt < 4) {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
// Unreachable — the loop either returns or throws — but satisfies the compiler.
|
||||
throw new ConflictException('Could not allocate a unique schedule reference');
|
||||
}
|
||||
|
||||
private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) {
|
||||
return {
|
||||
id: schedule.id,
|
||||
reference: schedule.reference ?? null,
|
||||
createdAt: schedule.createdAt ?? null,
|
||||
scheduleDate: schedule.scheduledDepartureDate,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||||
@@ -3882,6 +3920,7 @@ export class TrainSchedulingService {
|
||||
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
||||
`SELECT DISTINCT ON (ts.id)
|
||||
ts.id AS schedule_id,
|
||||
ts.reference AS reference,
|
||||
cr.contract_id AS contract_id,
|
||||
c.contract_kind AS contract_kind,
|
||||
ts.direction,
|
||||
@@ -3934,6 +3973,7 @@ export class TrainSchedulingService {
|
||||
async getBookingWindowsForContract(contractId: string) {
|
||||
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
||||
`SELECT DISTINCT ts.id AS schedule_id,
|
||||
ts.reference AS reference,
|
||||
cr.contract_id AS contract_id,
|
||||
c.contract_kind AS contract_kind,
|
||||
ts.direction,
|
||||
@@ -3981,6 +4021,7 @@ export class TrainSchedulingService {
|
||||
}
|
||||
> = await this.dataSource.query(
|
||||
`SELECT ts.id AS schedule_id,
|
||||
ts.reference AS reference,
|
||||
ts.train_number,
|
||||
ts.direction,
|
||||
ts.window_phase,
|
||||
@@ -4016,6 +4057,7 @@ export class TrainSchedulingService {
|
||||
private mapBookingWindowRow(r: BookingWindowRow) {
|
||||
return {
|
||||
scheduleId: r.schedule_id,
|
||||
reference: r.reference ?? null,
|
||||
contractId: r.contract_id,
|
||||
contractKind: r.contract_kind,
|
||||
direction: r.direction,
|
||||
@@ -4377,6 +4419,7 @@ export class TrainSchedulingService {
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
reference: schedule.reference ?? null,
|
||||
status: schedule.status,
|
||||
freightType: this.resolveScheduleFreightType(schedule),
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
|
||||
Reference in New Issue
Block a user