Merge pull request #524 from Tria-plc/freight_feature/usermanagement

add reference field to train schedules and implement unique sequence …
This commit is contained in:
marshal
2026-07-08 02:07:40 +03:00
committed by GitHub
9 changed files with 299 additions and 21 deletions

View File

@@ -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-<year>-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<void> {
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<void> {
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;
`);
}
}

View File

@@ -217,6 +217,20 @@ export class ContractBookingService {
await this.applyWeightResults(loaded); await this.applyWeightResults(loaded);
} }
const computed = await this.bookingPricingService.computePriceForBooking(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, { await this.bookingsRepository.update(booking.id, {
totalAmount: computed.totalAmount, totalAmount: computed.totalAmount,
priorityScore: computed.priorityScore, priorityScore: computed.priorityScore,

View File

@@ -61,6 +61,12 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true }) @Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true })
trainNumber?: string | null; 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 }) @Column({ name: 'direction', type: 'varchar', length: 10, nullable: true })
direction?: string | null; direction?: string | null;

View File

@@ -58,4 +58,22 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
): Promise<void> { ): Promise<void> {
await this.repo(manager).update(id, { status, ...extra } as never); 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);
}
} }

View File

@@ -17,7 +17,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm'; 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 { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity'; 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). */ /** Raw row shape for the booking-window queries (company- and contract-scoped). */
interface BookingWindowRow { interface BookingWindowRow {
schedule_id: string; schedule_id: string;
reference: string | null;
contract_id: string | null; contract_id: string | null;
contract_kind: string | null; contract_kind: string | null;
direction: string | null; direction: string | null;
@@ -821,20 +822,24 @@ export class TrainSchedulingService {
...ruleSnapshot, ...ruleSnapshot,
...computeImportWindowTimes(departure, windowCfg, new Date()), ...computeImportWindowTimes(departure, windowCfg, new Date()),
}; };
const schedule = manager.getRepository(TrainSchedule).create({ const maxWagons = (await this.resolveTrainLimitConfig(dto, limitLoco))
trainSetId: trainSet.id, .maxWagonsPerTrain;
routeId: route.id, // Retry past a concurrent insert that grabbed the same S-<year> sequence
originStationId: route.originYardId, // (the unique index rejects the loser; it re-reads the max and tries again).
destinationStationId: route.destinationYardId, const saved = await this.insertScheduleWithReference(manager, (reference) =>
scheduledDepartureDate: departure, manager.getRepository(TrainSchedule).create({
status: TrainScheduleStatusEnum.Draft, reference,
direction, trainSetId: trainSet.id,
maxWagons: ( routeId: route.id,
await this.resolveTrainLimitConfig(dto, limitLoco) originStationId: route.originYardId,
).maxWagonsPerTrain, destinationStationId: route.destinationYardId,
...windowFields, scheduledDepartureDate: departure,
}); status: TrainScheduleStatusEnum.Draft,
const saved = await manager.getRepository(TrainSchedule).save(schedule); direction,
maxWagons,
...windowFields,
}),
);
// Locomotives stay in their current status until dispatch — advance scheduling // Locomotives stay in their current status until dispatch — advance scheduling
// must not block the locomotive from serving earlier trains. // must not block the locomotive from serving earlier trains.
return saved.id; return saved.id;
@@ -2560,7 +2565,8 @@ export class TrainSchedulingService {
destinationStation: true, destinationStation: true,
scheduleBookings: { booking: 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)); return schedules.map((s) => this.mapScheduleListItem(s));
} }
@@ -3785,9 +3791,41 @@ export class TrainSchedulingService {
return null; 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) { private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) {
return { return {
id: schedule.id, id: schedule.id,
reference: schedule.reference ?? null,
createdAt: schedule.createdAt ?? null,
scheduleDate: schedule.scheduledDepartureDate, scheduleDate: schedule.scheduledDepartureDate,
trainNumber: schedule.trainNumber ?? null, trainNumber: schedule.trainNumber ?? null,
routeName: schedule.route ? formatRouteLabel(schedule.route) : null, routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
@@ -3882,6 +3920,7 @@ export class TrainSchedulingService {
const rows: Array<BookingWindowRow> = await this.dataSource.query( const rows: Array<BookingWindowRow> = await this.dataSource.query(
`SELECT DISTINCT ON (ts.id) `SELECT DISTINCT ON (ts.id)
ts.id AS schedule_id, ts.id AS schedule_id,
ts.reference AS reference,
cr.contract_id AS contract_id, cr.contract_id AS contract_id,
c.contract_kind AS contract_kind, c.contract_kind AS contract_kind,
ts.direction, ts.direction,
@@ -3934,6 +3973,7 @@ export class TrainSchedulingService {
async getBookingWindowsForContract(contractId: string) { async getBookingWindowsForContract(contractId: string) {
const rows: Array<BookingWindowRow> = await this.dataSource.query( const rows: Array<BookingWindowRow> = await this.dataSource.query(
`SELECT DISTINCT ts.id AS schedule_id, `SELECT DISTINCT ts.id AS schedule_id,
ts.reference AS reference,
cr.contract_id AS contract_id, cr.contract_id AS contract_id,
c.contract_kind AS contract_kind, c.contract_kind AS contract_kind,
ts.direction, ts.direction,
@@ -3981,6 +4021,7 @@ export class TrainSchedulingService {
} }
> = await this.dataSource.query( > = await this.dataSource.query(
`SELECT ts.id AS schedule_id, `SELECT ts.id AS schedule_id,
ts.reference AS reference,
ts.train_number, ts.train_number,
ts.direction, ts.direction,
ts.window_phase, ts.window_phase,
@@ -4016,6 +4057,7 @@ export class TrainSchedulingService {
private mapBookingWindowRow(r: BookingWindowRow) { private mapBookingWindowRow(r: BookingWindowRow) {
return { return {
scheduleId: r.schedule_id, scheduleId: r.schedule_id,
reference: r.reference ?? null,
contractId: r.contract_id, contractId: r.contract_id,
contractKind: r.contract_kind, contractKind: r.contract_kind,
direction: r.direction, direction: r.direction,
@@ -4377,6 +4419,7 @@ export class TrainSchedulingService {
return { return {
id: schedule.id, id: schedule.id,
reference: schedule.reference ?? null,
status: schedule.status, status: schedule.status,
freightType: this.resolveScheduleFreightType(schedule), freightType: this.resolveScheduleFreightType(schedule),
trainNumber: schedule.trainNumber ?? null, trainNumber: schedule.trainNumber ?? null,

View File

@@ -29,6 +29,7 @@ import { api } from "@/services/api";
*/ */
interface WindowRow { interface WindowRow {
scheduleId: string; scheduleId: string;
reference?: string | null;
trainNumber?: string | null; trainNumber?: string | null;
direction: string | null; direction: string | null;
windowPhase: string | null; windowPhase: string | null;
@@ -195,6 +196,11 @@ function WindowCard({ w }: { w: WindowRow }) {
{w.destination ?? "—"} {w.destination ?? "—"}
</Text> </Text>
</Group> </Group>
{w.reference ? (
<Text fz={12} fw={600} ff="monospace" c="edr-green.7" truncate>
{w.reference}
</Text>
) : null}
{w.trainNumber ? ( {w.trainNumber ? (
<Text fz={12} c="dimmed" truncate> <Text fz={12} c="dimmed" truncate>
Train {w.trainNumber} Train {w.trainNumber}

View File

@@ -864,6 +864,16 @@ export default function TrainScheduleV2DetailPage() {
</ThemeIcon> </ThemeIcon>
<Stack gap={6}> <Stack gap={6}>
<Group gap="sm" align="center" wrap="wrap"> <Group gap="sm" align="center" wrap="wrap">
{schedule.reference ? (
<Badge
variant="filled"
color="edr-green"
radius="sm"
style={{ fontWeight: 700, fontFamily: "monospace" }}
>
{schedule.reference}
</Badge>
) : null}
<Title order={2} fw={700} style={{ color: "#0f172a" }}> <Title order={2} fw={700} style={{ color: "#0f172a" }}>
{schedule.route?.name ?? "Train schedule"} {schedule.route?.name ?? "Train schedule"}
</Title> </Title>

View File

@@ -89,6 +89,13 @@ export default function TrainScheduleV2ListPage() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL"); const [statusFilter, setStatusFilter] = useState("ALL");
const [freightFilter, setFreightFilter] = 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 [createOpen, setCreateOpen] = useState(false);
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null); const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
const [editDateSchedule, setEditDateSchedule] = const [editDateSchedule, setEditDateSchedule] =
@@ -152,13 +159,32 @@ export default function TrainScheduleV2ListPage() {
return base; return base;
}, [allSchedules]); }, [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 filtered = useMemo(() => {
const query = search.trim().toLowerCase(); const query = search.trim().toLowerCase();
return allSchedules.filter((s) => { const matched = allSchedules.filter((s) => {
if (statusFilter !== "ALL" && s.status !== statusFilter) return false; if (statusFilter !== "ALL" && s.status !== statusFilter) return false;
if (freightFilter !== "ALL" && s.freightType !== freightFilter) 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; if (!query) return true;
const haystack = [ const haystack = [
s.reference,
s.trainNumber, s.trainNumber,
s.routeName, s.routeName,
s.origin, s.origin,
@@ -173,7 +199,31 @@ export default function TrainScheduleV2ListPage() {
.toLowerCase(); .toLowerCase();
return haystack.includes(query); 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 pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
const paged = useMemo(() => { const paged = useMemo(() => {
@@ -185,6 +235,16 @@ export default function TrainScheduleV2ListPage() {
const headerClassName = ruleEngineTable.headerCell; const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell; const cellClassName = ruleEngineTable.bodyCell;
return [ return [
{
id: "reference",
header: "Ref",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Text size="sm" fw={600} ff="monospace" c="edr-green.8">
{row.original.reference ?? "—"}
</Text>
),
},
{ {
id: "date", id: "date",
header: "Departure", header: "Departure",
@@ -471,6 +531,58 @@ export default function TrainScheduleV2ListPage() {
w={140} w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/> />
<Select
size="sm"
radius="lg"
placeholder="Origin"
searchable
value={originFilter}
onChange={(v) => setOriginFilter(v ?? "ALL")}
data={[
{ value: "ALL", label: "All origins" },
...originOptions.map((o) => ({ value: o, label: o })),
]}
w={160}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
placeholder="Destination"
searchable
value={destinationFilter}
onChange={(v) => 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)" } }}
/>
<Select
size="sm"
radius="lg"
value={`${sortBy}:${sortDir}`}
onChange={(v) => {
if (!v) return;
const [by, dir] = v.split(":") as [
typeof sortBy,
typeof sortDir,
];
setSortBy(by);
setSortDir(dir);
}}
data={[
{ value: "createdAt:desc", label: "Newest created" },
{ value: "createdAt:asc", label: "Oldest created" },
{ value: "scheduleDate:desc", label: "Departure ↓" },
{ value: "scheduleDate:asc", label: "Departure ↑" },
{ value: "reference:asc", label: "Reference ↑" },
{ value: "reference:desc", label: "Reference ↓" },
]}
w={170}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
</> </>
} }
/> />
@@ -693,9 +805,16 @@ function ScheduleCard({
<Train size={18} /> <Train size={18} />
</ThemeIcon> </ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}> <Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" lineClamp={1}> <Group gap={6} wrap="nowrap">
{schedule.routeName ?? "Train schedule"} {schedule.reference ? (
</Text> <Text size="xs" fw={700} ff="monospace" c="edr-green.8">
{schedule.reference}
</Text>
) : null}
<Text fw={600} size="sm" lineClamp={1}>
{schedule.routeName ?? "Train schedule"}
</Text>
</Group>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{day} · {time} {day} · {time}
</Text> </Text>

View File

@@ -152,6 +152,8 @@ export interface LocomotiveRecord {
export interface TrainScheduleListItem { export interface TrainScheduleListItem {
id: string; id: string;
reference?: string | null;
createdAt?: string | null;
scheduleDate: string; scheduleDate: string;
trainNumber?: string | null; trainNumber?: string | null;
routeName?: string | null; routeName?: string | null;
@@ -237,6 +239,7 @@ export interface BatchBoardBooking {
*/ */
export interface StaffBookingWindow { export interface StaffBookingWindow {
scheduleId: string; scheduleId: string;
reference: string | null;
trainNumber: string | null; trainNumber: string | null;
direction: "IMPORT" | "EXPORT" | null; direction: "IMPORT" | "EXPORT" | null;
windowPhase: BookingWindowPhase | string | null; windowPhase: BookingWindowPhase | string | null;
@@ -432,6 +435,7 @@ export interface UpdateScheduleWindowRulePayload {
export interface TrainScheduleDetail { export interface TrainScheduleDetail {
id: string; id: string;
reference?: string | null;
status: TrainScheduleStatus | string; status: TrainScheduleStatus | string;
deferredBookings?: DeferredBookingRow[]; deferredBookings?: DeferredBookingRow[];
freightType?: FreightType | null; freightType?: FreightType | null;