diff --git a/apps/edr-freight-api/src/migrations/2030000000000-AddTrainScheduleReference.ts b/apps/edr-freight-api/src/migrations/2030000000000-AddTrainScheduleReference.ts new file mode 100644 index 000000000..a05465c53 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2030000000000-AddTrainScheduleReference.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds train_schedules.reference: a human-facing unique schedule number + * S-YYYY-NNNNN (per-year sequence, like bookings' BK-YYYY-NNNNNN). + * + * - Adds the nullable column. + * - Backfills existing rows: within each created-at year, numbers rows by + * created_at ascending (oldest → S--00001). Deterministic order. + * - Adds a partial unique index (NULLs allowed so a future insert can stage + * the row before the app stamps its reference). + */ +export class AddTrainScheduleReference2030000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS reference VARCHAR(20); + `); + + // Backfill per-year, ordered by created_at (oldest = 00001). Uses the row's + // own created-at year as the reference year so historical rows keep a + // sensible number. + await queryRunner.query(` + WITH numbered AS ( + SELECT + id, + EXTRACT(YEAR FROM created_at)::int AS yr, + ROW_NUMBER() OVER ( + PARTITION BY EXTRACT(YEAR FROM created_at) + ORDER BY created_at ASC, id ASC + ) AS seq + FROM freight.train_schedules + WHERE reference IS NULL + ) + UPDATE freight.train_schedules ts + SET reference = 'S-' || numbered.yr || '-' || LPAD(numbered.seq::text, 5, '0') + FROM numbered + WHERE ts.id = numbered.id; + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_train_schedules_reference + ON freight.train_schedules (reference) + WHERE reference IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight.ux_train_schedules_reference; + `); + await queryRunner.query(` + ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS reference; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 062bbd5d0..55dd5c29f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index f82d9696d..899b302fc 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index 58e71143c..700a38983 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -58,4 +58,22 @@ export class TrainSchedulesRepository extends BaseRepository { ): Promise { await this.repo(manager).update(id, { status, ...extra } as never); } + + /** + * Highest NNNNN sequence already issued for `S--…` 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 { + 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); + } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 326f8e204..2c901b1ff 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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 = { /** 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- 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--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 { + 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 = 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 = 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, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx index 3e3aa27ad..c35537d9e 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -29,6 +29,7 @@ import { api } from "@/services/api"; */ interface WindowRow { scheduleId: string; + reference?: string | null; trainNumber?: string | null; direction: string | null; windowPhase: string | null; @@ -195,6 +196,11 @@ function WindowCard({ w }: { w: WindowRow }) { {w.destination ?? "—"} + {w.reference ? ( + + {w.reference} + + ) : null} {w.trainNumber ? ( Train {w.trainNumber} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index ca2b7c39e..f4cd3be02 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -864,6 +864,16 @@ export default function TrainScheduleV2DetailPage() { + {schedule.reference ? ( + + {schedule.reference} + + ) : null} {schedule.route?.name ?? "Train schedule"} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index f36ce8394..14432d8ed 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -89,6 +89,13 @@ export default function TrainScheduleV2ListPage() { const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("ALL"); const [freightFilter, setFreightFilter] = useState("ALL"); + const [originFilter, setOriginFilter] = useState("ALL"); + const [destinationFilter, setDestinationFilter] = useState("ALL"); + // Default: newest-created first, matching the API's default order. + const [sortBy, setSortBy] = useState<"createdAt" | "scheduleDate" | "reference">( + "createdAt", + ); + const [sortDir, setSortDir] = useState<"desc" | "asc">("desc"); const [createOpen, setCreateOpen] = useState(false); const [windowSettingsId, setWindowSettingsId] = useState(null); const [editDateSchedule, setEditDateSchedule] = @@ -152,13 +159,32 @@ export default function TrainScheduleV2ListPage() { return base; }, [allSchedules]); + // Distinct origins/destinations present in the loaded schedules, for the + // corridor filters. Sorted A→Z; "ALL" prepended by the Select data below. + const originOptions = useMemo( + () => + [...new Set(allSchedules.map((s) => s.origin).filter(Boolean))].sort() as string[], + [allSchedules], + ); + const destinationOptions = useMemo( + () => + [ + ...new Set(allSchedules.map((s) => s.destination).filter(Boolean)), + ].sort() as string[], + [allSchedules], + ); + const filtered = useMemo(() => { const query = search.trim().toLowerCase(); - return allSchedules.filter((s) => { + const matched = allSchedules.filter((s) => { if (statusFilter !== "ALL" && s.status !== statusFilter) return false; if (freightFilter !== "ALL" && s.freightType !== freightFilter) return false; + if (originFilter !== "ALL" && s.origin !== originFilter) return false; + if (destinationFilter !== "ALL" && s.destination !== destinationFilter) + return false; if (!query) return true; const haystack = [ + s.reference, s.trainNumber, s.routeName, s.origin, @@ -173,7 +199,31 @@ export default function TrainScheduleV2ListPage() { .toLowerCase(); return haystack.includes(query); }); - }, [allSchedules, search, statusFilter, freightFilter]); + + const dir = sortDir === "asc" ? 1 : -1; + const sorted = [...matched].sort((a, b) => { + let cmp = 0; + if (sortBy === "reference") { + cmp = (a.reference ?? "").localeCompare(b.reference ?? ""); + } else { + // createdAt or scheduleDate — compare as timestamps (missing sorts last). + const av = new Date(a[sortBy] ?? 0).getTime(); + const bv = new Date(b[sortBy] ?? 0).getTime(); + cmp = av - bv; + } + return cmp * dir; + }); + return sorted; + }, [ + allSchedules, + search, + statusFilter, + freightFilter, + originFilter, + destinationFilter, + sortBy, + sortDir, + ]); const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize)); const paged = useMemo(() => { @@ -185,6 +235,16 @@ export default function TrainScheduleV2ListPage() { const headerClassName = ruleEngineTable.headerCell; const cellClassName = ruleEngineTable.bodyCell; return [ + { + id: "reference", + header: "Ref", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => ( + + {row.original.reference ?? "—"} + + ), + }, { id: "date", header: "Departure", @@ -471,6 +531,58 @@ export default function TrainScheduleV2ListPage() { w={140} styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} /> + setDestinationFilter(v ?? "ALL")} + data={[ + { value: "ALL", label: "All destinations" }, + ...destinationOptions.map((d) => ({ value: d, label: d })), + ]} + w={170} + styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} + /> +