mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1097 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -89,6 +89,7 @@ import { CargoesModule } from "./modules/cargoes/cargoes.module";
|
||||
import { RoutesModule } from "./modules/routes/routes.module";
|
||||
import { WarehousesModule } from "./modules/warehouses/warehouses.module";
|
||||
import { OverviewModule } from "./modules/overview/overview.module";
|
||||
import { ReportsModule } from "./modules/reports/reports.module";
|
||||
import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module";
|
||||
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
|
||||
import { DriversModule } from "./modules/drivers/drivers.module";
|
||||
@@ -207,6 +208,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
|
||||
RoutesModule,
|
||||
WarehousesModule,
|
||||
OverviewModule,
|
||||
ReportsModule,
|
||||
UserTradeAccessModule,
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Marks a train schedule whose booking-window rule was configured by staff at
|
||||
* creation rather than inherited from the live global rules.
|
||||
*
|
||||
* Without this flag `restampPendingWindows` — which re-derives EVERY still
|
||||
* PRE_WINDOW schedule from the current global config after a global-rules edit —
|
||||
* would silently overwrite those hand-picked settings, which is precisely what
|
||||
* the per-schedule configuration exists to prevent.
|
||||
*
|
||||
* Defaults false, so every existing schedule keeps following the global rules.
|
||||
*/
|
||||
export class AddScheduleWindowRuleCustom3200000000000 implements MigrationInterface {
|
||||
name = 'AddScheduleWindowRuleCustom3200000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."train_schedules" ADD COLUMN IF NOT EXISTS "window_rule_custom" boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."train_schedules" DROP COLUMN IF EXISTS "window_rule_custom"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class OverviewBookingKpisDto {
|
||||
@ApiProperty() total!: number;
|
||||
@ApiProperty() totalActive!: number;
|
||||
@ApiProperty() needsAction!: number;
|
||||
@ApiProperty() urgent!: number;
|
||||
@@ -9,6 +10,7 @@ export class OverviewBookingKpisDto {
|
||||
}
|
||||
|
||||
export class OverviewContractKpisDto {
|
||||
@ApiProperty() total!: number;
|
||||
@ApiProperty() totalActive!: number;
|
||||
@ApiProperty() needsAction!: number;
|
||||
@ApiProperty() inApproval!: number;
|
||||
@@ -21,6 +23,8 @@ export class OverviewOperationsKpisDto {
|
||||
@ApiProperty() wagonsAvailable!: number;
|
||||
@ApiProperty() containersInTransit!: number;
|
||||
@ApiProperty() cargoesLoaded!: number;
|
||||
@ApiProperty() schedulesUpcoming!: number;
|
||||
@ApiProperty() dispatchedToday!: number;
|
||||
}
|
||||
|
||||
export class OverviewCustomerKpisDto {
|
||||
|
||||
@@ -104,10 +104,40 @@ export class OverviewBillingTabDto {
|
||||
generatedAt!: string;
|
||||
}
|
||||
|
||||
export class OverviewDirectionTrendPointDto {
|
||||
@ApiProperty({ example: '2026-08-01' }) date!: string;
|
||||
@ApiProperty() importCount!: number;
|
||||
@ApiProperty() exportCount!: number;
|
||||
@ApiProperty() domesticCount!: number;
|
||||
}
|
||||
|
||||
export class OverviewTonnagePointDto {
|
||||
@ApiProperty() label!: string;
|
||||
@ApiProperty() tons!: number;
|
||||
}
|
||||
|
||||
export class OverviewOperationsTabDto {
|
||||
@ApiProperty({ type: OverviewOperationsKpisDto })
|
||||
kpis!: OverviewOperationsKpisDto;
|
||||
|
||||
@ApiProperty({ type: [OverviewDirectionTrendPointDto] })
|
||||
departureTrend!: OverviewDirectionTrendPointDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
scheduleStatusBreakdown!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||
wagonsByType!: OverviewLabelCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||
wagonsByYard!: OverviewLabelCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||
containersBySize!: OverviewLabelCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewTonnagePointDto] })
|
||||
cargoTonnageByType!: OverviewTonnagePointDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
trainStatusBreakdown!: OverviewStatusCountDto[];
|
||||
|
||||
|
||||
@@ -99,8 +99,10 @@ export class OverviewController {
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: 'Operations tab metrics and charts' })
|
||||
@ApiOkResponse({ type: OverviewOperationsTabDto })
|
||||
getOperationsTab(): Promise<OverviewOperationsTabDto> {
|
||||
return this.overviewService.getOperationsTab();
|
||||
getOperationsTab(
|
||||
@Query() query: OverviewQueryDto,
|
||||
): Promise<OverviewOperationsTabDto> {
|
||||
return this.overviewService.getOperationsTab(query.range ?? '30d');
|
||||
}
|
||||
|
||||
@Get('customers')
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Container } from "../container-management/entities/container.entity";
|
||||
import { Company } from "../companies/entities/company.entity";
|
||||
import { Contract } from "../contracts/entities/contract.entity";
|
||||
import { PaymentEntity } from "../payment/entities/payment.entity";
|
||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||
import { Train } from "../trains/entities/train.entity";
|
||||
import { Wagon } from "../wagons/entities/wagon.entity";
|
||||
import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module";
|
||||
@@ -23,6 +24,7 @@ import { OverviewService } from "./overview.service";
|
||||
PaymentEntity,
|
||||
Company,
|
||||
Train,
|
||||
TrainSchedule,
|
||||
Wagon,
|
||||
Container,
|
||||
Cargo,
|
||||
|
||||
@@ -11,7 +11,12 @@ import { Cargo } from "../cargoes/entities/cargoes.entity";
|
||||
import { Container } from "../container-management/entities/container.entity";
|
||||
import { Contract } from "../contracts/entities/contract.entity";
|
||||
import { PaymentEntity } from "../payment/entities/payment.entity";
|
||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||
import { ContainerType } from "../rule-engine/entities/container-type.entity";
|
||||
import { Yard } from "../rule-engine/entities/yard.entity";
|
||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||
import { Train } from "../trains/entities/train.entity";
|
||||
import { WagonType } from "../wagon-types/entities/wagon-type.entity";
|
||||
import { Wagon } from "../wagons/entities/wagon.entity";
|
||||
import {
|
||||
OVERVIEW_CLOSED_STATUSES,
|
||||
@@ -34,6 +39,7 @@ const EXCLUDE_GENERAL_CONTRACT_BOOKINGS =
|
||||
"(booking.contract_kind IS NULL OR booking.contract_kind <> 'GENERAL')";
|
||||
|
||||
export type OverviewBookingKpisRow = {
|
||||
total: number;
|
||||
totalActive: number;
|
||||
needsAction: number;
|
||||
urgent: number;
|
||||
@@ -53,6 +59,7 @@ export type OverviewRecentBookingRow = {
|
||||
};
|
||||
|
||||
export type OverviewContractKpisRow = {
|
||||
total: number;
|
||||
totalActive: number;
|
||||
needsAction: number;
|
||||
inApproval: number;
|
||||
@@ -83,6 +90,8 @@ export class OverviewRepository {
|
||||
private readonly companyRepository: Repository<Company>,
|
||||
@InjectRepository(Train)
|
||||
private readonly trainRepository: Repository<Train>,
|
||||
@InjectRepository(TrainSchedule)
|
||||
private readonly trainScheduleRepository: Repository<TrainSchedule>,
|
||||
@InjectRepository(Wagon)
|
||||
private readonly wagonRepository: Repository<Wagon>,
|
||||
@InjectRepository(Container)
|
||||
@@ -101,7 +110,8 @@ export class OverviewRepository {
|
||||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||
const row = await this.bookingRepository
|
||||
.createQueryBuilder("booking")
|
||||
.select(
|
||||
.select("COUNT(*)::int", "total")
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`,
|
||||
"totalActive",
|
||||
)
|
||||
@@ -133,6 +143,7 @@ export class OverviewRepository {
|
||||
.getRawOne<Record<string, string>>();
|
||||
|
||||
return {
|
||||
total: Number(row?.total ?? 0),
|
||||
totalActive: Number(row?.totalActive ?? 0),
|
||||
needsAction: Number(row?.needsAction ?? 0),
|
||||
urgent: Number(row?.urgent ?? 0),
|
||||
@@ -146,9 +157,17 @@ export class OverviewRepository {
|
||||
wagonsAvailable: number;
|
||||
containersInTransit: number;
|
||||
cargoesLoaded: number;
|
||||
schedulesUpcoming: number;
|
||||
dispatchedToday: number;
|
||||
}> {
|
||||
const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] =
|
||||
await Promise.all([
|
||||
const [
|
||||
trainsActive,
|
||||
wagonsAvailable,
|
||||
containersInTransit,
|
||||
cargoesLoaded,
|
||||
schedulesUpcoming,
|
||||
dispatchedToday,
|
||||
] = await Promise.all([
|
||||
this.trainRepository
|
||||
.createQueryBuilder("train")
|
||||
.where("train.deleted_at IS NULL")
|
||||
@@ -178,6 +197,22 @@ export class OverviewRepository {
|
||||
statuses: ["LOADED", "IN_TRANSIT"],
|
||||
})
|
||||
.getCount(),
|
||||
this.trainScheduleRepository
|
||||
.createQueryBuilder("schedule")
|
||||
.where("schedule.deleted_at IS NULL")
|
||||
.andWhere("schedule.status = :status", {
|
||||
status: Freight.TrainScheduleStatus.Scheduled,
|
||||
})
|
||||
.andWhere("schedule.scheduled_departure_date >= CURRENT_DATE")
|
||||
.getCount(),
|
||||
this.trainScheduleRepository
|
||||
.createQueryBuilder("schedule")
|
||||
.where("schedule.deleted_at IS NULL")
|
||||
.andWhere("schedule.status = :status", {
|
||||
status: Freight.TrainScheduleStatus.Dispatched,
|
||||
})
|
||||
.andWhere("schedule.scheduled_departure_date::date = CURRENT_DATE")
|
||||
.getCount(),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -185,6 +220,8 @@ export class OverviewRepository {
|
||||
wagonsAvailable,
|
||||
containersInTransit,
|
||||
cargoesLoaded,
|
||||
schedulesUpcoming,
|
||||
dispatchedToday,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -533,6 +570,141 @@ export class OverviewRepository {
|
||||
return this.statusBreakdown(this.cargoRepository, "cargo");
|
||||
}
|
||||
|
||||
async getScheduleStatusBreakdown(): Promise<
|
||||
{ status: string; count: number }[]
|
||||
> {
|
||||
return this.statusBreakdown(this.trainScheduleRepository, "schedule");
|
||||
}
|
||||
|
||||
/** Scheduled departures per day over the range, split by trade direction. */
|
||||
async getDepartureTrend(days: number): Promise<
|
||||
{
|
||||
date: string;
|
||||
importCount: number;
|
||||
exportCount: number;
|
||||
domesticCount: number;
|
||||
}[]
|
||||
> {
|
||||
const rows = await this.trainScheduleRepository
|
||||
.createQueryBuilder("schedule")
|
||||
.select(
|
||||
`to_char(schedule.scheduled_departure_date::date, 'YYYY-MM-DD')`,
|
||||
"date",
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE schedule.direction = 'IMPORT')::int`,
|
||||
"importCount",
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE schedule.direction = 'EXPORT')::int`,
|
||||
"exportCount",
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE schedule.direction NOT IN ('IMPORT', 'EXPORT') OR schedule.direction IS NULL)::int`,
|
||||
"domesticCount",
|
||||
)
|
||||
.where("schedule.deleted_at IS NULL")
|
||||
.andWhere("schedule.status != :draft", {
|
||||
draft: Freight.TrainScheduleStatus.Draft,
|
||||
})
|
||||
.andWhere(
|
||||
`schedule.scheduled_departure_date >= CURRENT_DATE - :days::int + 1`,
|
||||
{ days },
|
||||
)
|
||||
.andWhere(
|
||||
`schedule.scheduled_departure_date < CURRENT_DATE + :ahead::int`,
|
||||
{ ahead: 8 },
|
||||
)
|
||||
.groupBy("schedule.scheduled_departure_date::date")
|
||||
.orderBy("schedule.scheduled_departure_date::date", "ASC")
|
||||
.getRawMany<{
|
||||
date: string;
|
||||
importCount: string;
|
||||
exportCount: string;
|
||||
domesticCount: string;
|
||||
}>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
date: row.date,
|
||||
importCount: Number(row.importCount),
|
||||
exportCount: Number(row.exportCount),
|
||||
domesticCount: Number(row.domesticCount),
|
||||
}));
|
||||
}
|
||||
|
||||
async getWagonsByType(): Promise<{ label: string; count: number }[]> {
|
||||
const rows = await this.wagonRepository
|
||||
.createQueryBuilder("wagon")
|
||||
.leftJoin(WagonType, "wagon_type", "wagon_type.id = wagon.wagon_type_id")
|
||||
.select(`COALESCE(wagon_type.name, 'Unknown')`, "label")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("wagon.deleted_at IS NULL")
|
||||
.groupBy("wagon_type.name")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
||||
}
|
||||
|
||||
async getWagonsByYard(limit: number): Promise<
|
||||
{ label: string; count: number }[]
|
||||
> {
|
||||
const rows = await this.wagonRepository
|
||||
.createQueryBuilder("wagon")
|
||||
.innerJoin(Yard, "yard", "yard.id = wagon.current_yard_id")
|
||||
.select("yard.label", "label")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("wagon.deleted_at IS NULL")
|
||||
.groupBy("yard.label")
|
||||
.orderBy("count", "DESC")
|
||||
.limit(limit)
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
||||
}
|
||||
|
||||
async getContainersBySize(): Promise<{ label: string; count: number }[]> {
|
||||
const rows = await this.containerRepository
|
||||
.createQueryBuilder("container")
|
||||
.leftJoin(
|
||||
ContainerType,
|
||||
"container_type",
|
||||
"container_type.id = container.container_type_id",
|
||||
)
|
||||
.select(
|
||||
`COALESCE(container_type.size_ft::text || ' ft', container_type.code, 'Unknown')`,
|
||||
"label",
|
||||
)
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("container.deleted_at IS NULL")
|
||||
.groupBy("container_type.size_ft")
|
||||
.addGroupBy("container_type.code")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
||||
}
|
||||
|
||||
/** Total cargo weight (tons) grouped by cargo type, heaviest first. */
|
||||
async getCargoTonnageByType(limit: number): Promise<
|
||||
{ label: string; tons: number }[]
|
||||
> {
|
||||
const rows = await this.cargoRepository
|
||||
.createQueryBuilder("cargo")
|
||||
.leftJoin(CargoType, "cargo_type", "cargo_type.id = cargo.cargo_type_id")
|
||||
.select(`COALESCE(cargo_type.cargo_type_name, 'Other')`, "label")
|
||||
.addSelect(`ROUND(COALESCE(SUM(cargo.weight), 0) / 1000, 1)`, "tons")
|
||||
.where("cargo.deleted_at IS NULL")
|
||||
.groupBy("cargo_type.cargo_type_name")
|
||||
.orderBy("tons", "DESC")
|
||||
.limit(limit)
|
||||
.getRawMany<{ label: string; tons: string }>();
|
||||
|
||||
return rows
|
||||
.map((row) => ({ label: row.label, tons: Number(row.tons) }))
|
||||
.filter((row) => row.tons > 0);
|
||||
}
|
||||
|
||||
private async statusBreakdown(
|
||||
repository: Repository<ObjectLiteral>,
|
||||
alias: string,
|
||||
@@ -677,7 +849,8 @@ export class OverviewRepository {
|
||||
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||||
const row = await this.contractRepository
|
||||
.createQueryBuilder("contract")
|
||||
.select(
|
||||
.select("COUNT(*)::int", "total")
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE contract.status NOT IN (:...closedStatuses) AND contract.status != 'DRAFT')::int`,
|
||||
"totalActive",
|
||||
)
|
||||
@@ -708,6 +881,7 @@ export class OverviewRepository {
|
||||
.getRawOne<Record<string, string>>();
|
||||
|
||||
return {
|
||||
total: Number(row?.total ?? 0),
|
||||
totalActive: Number(row?.totalActive ?? 0),
|
||||
needsAction: Number(row?.needsAction ?? 0),
|
||||
inApproval: Number(row?.inApproval ?? 0),
|
||||
|
||||
@@ -202,15 +202,31 @@ export class OverviewService {
|
||||
};
|
||||
}
|
||||
|
||||
async getOperationsTab(): Promise<OverviewOperationsTabDto> {
|
||||
async getOperationsTab(
|
||||
range: OverviewRangeQuery = '30d',
|
||||
): Promise<OverviewOperationsTabDto> {
|
||||
const days = OVERVIEW_RANGE_DAYS[range];
|
||||
|
||||
const [
|
||||
kpis,
|
||||
departureTrend,
|
||||
scheduleStatusBreakdown,
|
||||
wagonsByType,
|
||||
wagonsByYard,
|
||||
containersBySize,
|
||||
cargoTonnageByType,
|
||||
trainStatusBreakdown,
|
||||
wagonStatusBreakdown,
|
||||
containerStatusBreakdown,
|
||||
cargoStatusBreakdown,
|
||||
] = await Promise.all([
|
||||
this.overviewRepository.getOperationsKpis(),
|
||||
this.overviewRepository.getDepartureTrend(days),
|
||||
this.overviewRepository.getScheduleStatusBreakdown(),
|
||||
this.overviewRepository.getWagonsByType(),
|
||||
this.overviewRepository.getWagonsByYard(8),
|
||||
this.overviewRepository.getContainersBySize(),
|
||||
this.overviewRepository.getCargoTonnageByType(8),
|
||||
this.overviewRepository.getTrainStatusBreakdown(),
|
||||
this.overviewRepository.getWagonStatusBreakdown(),
|
||||
this.overviewRepository.getContainerStatusBreakdown(),
|
||||
@@ -219,6 +235,12 @@ export class OverviewService {
|
||||
|
||||
return {
|
||||
kpis,
|
||||
departureTrend,
|
||||
scheduleStatusBreakdown,
|
||||
wagonsByType,
|
||||
wagonsByYard,
|
||||
containersBySize,
|
||||
cargoTonnageByType,
|
||||
trainStatusBreakdown,
|
||||
wagonStatusBreakdown,
|
||||
containerStatusBreakdown,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class ReportQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Inclusive start date (YYYY-MM-DD). Default: 30 days ago.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dateFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Inclusive end date (YYYY-MM-DD). Default: today.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dateTo?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['day', 'week', 'month'], default: 'day' })
|
||||
@IsOptional()
|
||||
@IsIn(['day', 'week', 'month'])
|
||||
granularity?: 'day' | 'week' | 'month';
|
||||
|
||||
@ApiPropertyOptional({ description: 'Comma-separated company UUIDs' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyIds?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Comma-separated route UUIDs' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
routeIds?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Comma-separated yard UUIDs (matches origin or destination)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
yardIds?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Comma-separated cargo type UUIDs' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cargoTypeIds?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Comma-separated status values (report-specific)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
statuses?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Trade direction filter' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
direction?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['CONTAINER', 'BULK'] })
|
||||
@IsOptional()
|
||||
@IsIn(['CONTAINER', 'BULK'])
|
||||
freightType?: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ReportKpiDto {
|
||||
@ApiProperty()
|
||||
label!: string;
|
||||
|
||||
@ApiProperty()
|
||||
value!: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export class ReportResultDto {
|
||||
@ApiProperty({ type: [ReportKpiDto] })
|
||||
kpis!: ReportKpiDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: 'array',
|
||||
items: { type: 'object', additionalProperties: true },
|
||||
description: 'Report rows; columns vary per report key',
|
||||
})
|
||||
rows!: Record<string, unknown>[];
|
||||
}
|
||||
669
apps/edr-freight-api/src/modules/reports/report-queries.ts
Normal file
669
apps/edr-freight-api/src/modules/reports/report-queries.ts
Normal file
@@ -0,0 +1,669 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
export interface ReportFilters {
|
||||
/** ISO timestamp, inclusive lower bound. null = no lower bound (all time). */
|
||||
dateFrom: string | null;
|
||||
/** ISO timestamp, exclusive upper bound. null = no upper bound. */
|
||||
dateTo: string | null;
|
||||
granularity: 'day' | 'week' | 'month';
|
||||
companyIds: string[] | null;
|
||||
routeIds: string[] | null;
|
||||
yardIds: string[] | null;
|
||||
cargoTypeIds: string[] | null;
|
||||
statuses: string[] | null;
|
||||
/** Trade-scope-resolved directions. null = unrestricted, [] = show nothing. */
|
||||
directions: string[] | null;
|
||||
freightType: string | null;
|
||||
}
|
||||
|
||||
export interface ReportKpi {
|
||||
label: string;
|
||||
value: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface ReportResult {
|
||||
kpis: ReportKpi[];
|
||||
rows: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
type ReportQuery = (ds: DataSource, f: ReportFilters) => Promise<ReportResult>;
|
||||
|
||||
// For PER_ITEM bulk bookings cargo_total_weight_vgm holds an item COUNT, and
|
||||
// the real tonnage lives in bulk_total_weight_tons — hence the COALESCE order.
|
||||
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||
// adjusted_total_amount silently overrides total_amount when set.
|
||||
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
||||
// GENERAL contract_kind rows are umbrella contracts, not shipments; counting
|
||||
// them double-counts every child booking (same guard as overview.repository).
|
||||
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
|
||||
const DEAD_STATUSES = "'DRAFT','CANCELLED','REJECTED','EXPIRED'";
|
||||
|
||||
const num = (v: unknown): number => (v === null || v === undefined ? 0 : Number(v));
|
||||
const sum = (rows: Record<string, unknown>[], col: string): number =>
|
||||
rows.reduce((acc, r) => acc + num(r[col]), 0);
|
||||
|
||||
/**
|
||||
* Shared WHERE for booking-based reports (alias `b`).
|
||||
* Params occupy $1..$8 in this fixed order; report SQL continues at $9.
|
||||
*/
|
||||
function bookingWhere(f: ReportFilters): { where: string; params: unknown[] } {
|
||||
return {
|
||||
where: `
|
||||
b.deleted_at IS NULL
|
||||
AND ${NOT_UMBRELLA}
|
||||
AND ($1::timestamptz IS NULL OR b.created_at >= $1)
|
||||
AND ($2::timestamptz IS NULL OR b.created_at < $2)
|
||||
AND ($3::uuid[] IS NULL OR b.company_id = ANY($3))
|
||||
AND ($4::uuid[] IS NULL OR b.cargo_type_id = ANY($4))
|
||||
AND ($5::text[] IS NULL OR b.trade_direction = ANY($5))
|
||||
AND ($6::text IS NULL OR b.freight_type = $6)
|
||||
AND (CASE WHEN $7::text[] IS NULL
|
||||
THEN b.status NOT IN (${DEAD_STATUSES})
|
||||
ELSE b.status = ANY($7) END)
|
||||
AND ($8::uuid[] IS NULL OR b.origin_yard_id = ANY($8) OR b.destination_yard_id = ANY($8))`,
|
||||
params: [
|
||||
f.dateFrom,
|
||||
f.dateTo,
|
||||
f.companyIds,
|
||||
f.cargoTypeIds,
|
||||
f.directions,
|
||||
f.freightType,
|
||||
f.statuses,
|
||||
f.yardIds,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Direction scope for rows that reference a booking through a varchar id
|
||||
* column (invoices.source_id, payments.ref_id). Rows not pointing at a
|
||||
* booking stay visible — they carry no direction to scope by.
|
||||
* (Positional-param port of trade-scope.util's bookingRefScopeSql.)
|
||||
*/
|
||||
const refDirScope = (refColumn: string, param: string): string => `
|
||||
(${param}::text[] IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM freight.bookings sb
|
||||
WHERE sb.id::text = ${refColumn} AND NOT (sb.trade_direction = ANY(${param}))))`;
|
||||
|
||||
const bookingsTrend: ReportQuery = async (ds, f) => {
|
||||
const { where, params } = bookingWhere(f);
|
||||
const rows = await ds.query(
|
||||
`SELECT to_char(date_trunc($9, b.created_at), 'YYYY-MM-DD') AS period,
|
||||
COUNT(*)::int AS bookings,
|
||||
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
|
||||
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
|
||||
FROM freight.bookings b
|
||||
WHERE ${where}
|
||||
GROUP BY 1 ORDER BY 1`,
|
||||
[...params, f.granularity],
|
||||
);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Bookings', value: sum(rows, 'bookings') },
|
||||
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
|
||||
{ label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const revenueByCustomer: ReportQuery = async (ds, f) => {
|
||||
const { where, params } = bookingWhere(f);
|
||||
const rows = await ds.query(
|
||||
`SELECT c.name AS customer,
|
||||
COUNT(*)::int AS bookings,
|
||||
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
|
||||
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
|
||||
FROM freight.bookings b
|
||||
JOIN freight.companies c ON c.id = b.company_id
|
||||
WHERE ${where}
|
||||
GROUP BY c.name ORDER BY revenue DESC LIMIT 100`,
|
||||
params,
|
||||
);
|
||||
const total = sum(rows, 'revenue');
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Customers', value: rows.length },
|
||||
{ label: 'Revenue', value: total, unit: 'ETB' },
|
||||
{
|
||||
label: 'Top customer share',
|
||||
value: total > 0 ? Math.round((num(rows[0]?.revenue) / total) * 100) : 0,
|
||||
unit: '%',
|
||||
},
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const revenueByLane: ReportQuery = async (ds, f) => {
|
||||
const { where, params } = bookingWhere(f);
|
||||
const rows = await ds.query(
|
||||
`SELECT o.label AS origin, d.label AS destination,
|
||||
COUNT(*)::int AS bookings,
|
||||
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
|
||||
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
|
||||
FROM freight.bookings b
|
||||
JOIN freight.yards o ON o.id = b.origin_yard_id
|
||||
JOIN freight.yards d ON d.id = b.destination_yard_id
|
||||
WHERE ${where}
|
||||
GROUP BY 1, 2 ORDER BY revenue DESC LIMIT 100`,
|
||||
params,
|
||||
);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Lanes', value: rows.length },
|
||||
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
|
||||
{ label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const contractUtilization: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT ct.reference, c.name AS customer, ct.status, ct.contract_kind AS kind,
|
||||
to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from,
|
||||
to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until,
|
||||
cap.committed::float8 AS committed,
|
||||
booked.tons::float8 AS booked_tons,
|
||||
booked.cnt AS bookings,
|
||||
CASE WHEN cap.committed > 0
|
||||
THEN ROUND(booked.tons / cap.committed * 100)::float8 END AS utilization_pct
|
||||
FROM freight.contracts ct
|
||||
LEFT JOIN freight.companies c ON c.id = ct.company_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(s.quantity_cap), 0) AS committed
|
||||
FROM freight.contract_cargo_scope s
|
||||
WHERE s.contract_id = ct.id AND s.deleted_at IS NULL) cap ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(${TONS}), 0) AS tons, COUNT(*)::int AS cnt
|
||||
FROM freight.bookings b
|
||||
WHERE b.contract_id = ct.id AND b.deleted_at IS NULL
|
||||
AND b.status NOT IN (${DEAD_STATUSES})) booked ON true
|
||||
WHERE ct.deleted_at IS NULL
|
||||
AND ct.status NOT IN ('DRAFT')
|
||||
AND ct.contract_valid_from < COALESCE($2::timestamptz, 'infinity')
|
||||
AND (ct.contract_valid_until IS NULL
|
||||
OR ct.contract_valid_until >= COALESCE($1::timestamptz, '-infinity'))
|
||||
AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3))
|
||||
AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4))
|
||||
AND ($5::text[] IS NULL OR ct.status = ANY($5))
|
||||
ORDER BY utilization_pct DESC NULLS LAST LIMIT 200`,
|
||||
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses],
|
||||
);
|
||||
const capped = rows.filter((r: Record<string, unknown>) => num(r.committed) > 0);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Contracts', value: rows.length },
|
||||
{
|
||||
label: 'Avg utilization',
|
||||
value: capped.length
|
||||
? Math.round(sum(capped, 'utilization_pct') / capped.length)
|
||||
: 0,
|
||||
unit: '%',
|
||||
},
|
||||
{ label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
// ponytail: 60-min departure grace is a constant; make it a query param if ops
|
||||
// ever wants a configurable threshold.
|
||||
const trainOnTime: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT o.label AS origin, d.label AS destination,
|
||||
COUNT(*)::int AS trips,
|
||||
COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL)::int AS departed,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_departure_at - ts.scheduled_departure_date)) / 60)
|
||||
FILTER (WHERE ts.actual_departure_at IS NOT NULL))::float8 AS avg_dep_delay_min,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.scheduled_arrival_date)) / 60)
|
||||
FILTER (WHERE ts.actual_arrival_at IS NOT NULL
|
||||
AND ts.scheduled_arrival_date IS NOT NULL))::float8 AS avg_arr_delay_min,
|
||||
ROUND(100.0 * COUNT(*) FILTER (WHERE ts.actual_departure_at
|
||||
<= ts.scheduled_departure_date + interval '60 minutes')
|
||||
/ NULLIF(COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL), 0))::float8 AS on_time_pct
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards o ON o.id = ts.origin_station_id
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DISPATCHED', 'ARRIVED')
|
||||
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
||||
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
||||
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
|
||||
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
|
||||
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
||||
GROUP BY 1, 2 ORDER BY trips DESC`,
|
||||
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
|
||||
);
|
||||
const departed = sum(rows, 'departed');
|
||||
const weighted = rows.reduce(
|
||||
(acc: number, r: Record<string, unknown>) =>
|
||||
acc + (num(r.on_time_pct) * num(r.departed)) / 100,
|
||||
0,
|
||||
);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Trips', value: sum(rows, 'trips') },
|
||||
{
|
||||
label: 'On-time departures',
|
||||
value: departed > 0 ? Math.round((weighted / departed) * 100) : 0,
|
||||
unit: '%',
|
||||
},
|
||||
{
|
||||
label: 'Avg departure delay',
|
||||
value: rows.length ? Math.round(sum(rows, 'avg_dep_delay_min') / rows.length) : 0,
|
||||
unit: 'min',
|
||||
},
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const scheduleFillRate: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT ts.train_number, ts.reference,
|
||||
to_char(ts.scheduled_departure_date, 'YYYY-MM-DD') AS departure,
|
||||
o.label AS origin, d.label AS destination, ts.direction, ts.status,
|
||||
ts.max_wagons, tset.wagon_count,
|
||||
ROUND(w.cap_tons)::float8 AS capacity_tons,
|
||||
ROUND(w.booked_tons)::float8 AS booked_tons,
|
||||
CASE WHEN w.cap_tons > 0
|
||||
THEN ROUND(w.booked_tons / w.cap_tons * 100)::float8 END AS fill_pct
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards o ON o.id = ts.origin_station_id
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id
|
||||
LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(tw.capacity_tons), 0) AS cap_tons,
|
||||
COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons
|
||||
FROM freight.train_set_wagons tw
|
||||
WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status <> 'CANCELLED'
|
||||
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
||||
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
||||
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
|
||||
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
|
||||
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
||||
ORDER BY ts.scheduled_departure_date DESC LIMIT 200`,
|
||||
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
|
||||
);
|
||||
const withCap = rows.filter((r: Record<string, unknown>) => num(r.capacity_tons) > 0);
|
||||
const capTons = sum(withCap, 'capacity_tons');
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Schedules', value: rows.length },
|
||||
{
|
||||
label: 'Avg fill rate',
|
||||
value: capTons > 0 ? Math.round((sum(withCap, 'booked_tons') / capTons) * 100) : 0,
|
||||
unit: '%',
|
||||
},
|
||||
{ label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const tripsPerRoute: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT o.label AS origin, d.label AS destination, ts.direction,
|
||||
COUNT(*)::int AS trips,
|
||||
ROUND(COALESCE(SUM(w.booked_tons), 0))::float8 AS tons_hauled,
|
||||
ROUND(COALESCE(AVG(w.booked_tons), 0))::float8 AS avg_tons_per_trip
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards o ON o.id = ts.origin_station_id
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons
|
||||
FROM freight.train_set_wagons tw
|
||||
WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DISPATCHED', 'ARRIVED')
|
||||
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
||||
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
||||
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
|
||||
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
|
||||
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
||||
GROUP BY 1, 2, 3 ORDER BY trips DESC`,
|
||||
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
|
||||
);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Trips', value: sum(rows, 'trips') },
|
||||
{ label: 'Routes served', value: rows.length },
|
||||
{ label: 'Tonnage hauled', value: sum(rows, 'tons_hauled'), unit: 't' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const invoicedVsCollected: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT to_char(date_trunc($5, COALESCE(i.issued_at, i.created_at)), 'YYYY-MM-DD') AS period,
|
||||
COUNT(*)::int AS invoices,
|
||||
ROUND(SUM(i.total_amount))::float8 AS invoiced,
|
||||
ROUND(SUM(i.paid_amount))::float8 AS collected,
|
||||
ROUND(SUM(i.balance_amount))::float8 AS outstanding
|
||||
FROM freight.invoices i
|
||||
WHERE i.deleted_at IS NULL
|
||||
AND i.status NOT IN ('DRAFT', 'CANCELLED')
|
||||
AND ($1::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) >= $1)
|
||||
AND ($2::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) < $2)
|
||||
AND ($3::uuid[] IS NULL OR i.company_id = ANY($3))
|
||||
AND ${refDirScope('i.source_id', '$4')}
|
||||
GROUP BY 1 ORDER BY 1`,
|
||||
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.granularity],
|
||||
);
|
||||
const invoiced = sum(rows, 'invoiced');
|
||||
const collected = sum(rows, 'collected');
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Invoiced', value: invoiced, unit: 'ETB' },
|
||||
{ label: 'Collected', value: collected, unit: 'ETB' },
|
||||
{
|
||||
label: 'Collection rate',
|
||||
value: invoiced > 0 ? Math.round((collected / invoiced) * 100) : 0,
|
||||
unit: '%',
|
||||
},
|
||||
{ label: 'Outstanding', value: sum(rows, 'outstanding'), unit: 'ETB' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
// Aging is an as-of snapshot: dateTo is the as-of moment (default now),
|
||||
// dateFrom is ignored.
|
||||
const agingReceivables: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT c.name AS customer,
|
||||
COUNT(*)::int AS invoices,
|
||||
ROUND(SUM(i.balance_amount))::float8 AS outstanding,
|
||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at >= COALESCE($1::timestamptz, now())), 0))::float8 AS current,
|
||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now())
|
||||
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '30 days'), 0))::float8 AS overdue_0_30,
|
||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '30 days'
|
||||
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '60 days'), 0))::float8 AS overdue_31_60,
|
||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '60 days'
|
||||
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_61_90,
|
||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_90_plus
|
||||
FROM freight.invoices i
|
||||
JOIN freight.companies c ON c.id = i.company_id
|
||||
WHERE i.deleted_at IS NULL
|
||||
AND i.status IN ('ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE')
|
||||
AND i.balance_amount > 0
|
||||
AND ($1::timestamptz IS NULL OR i.created_at < $1)
|
||||
AND ($2::uuid[] IS NULL OR i.company_id = ANY($2))
|
||||
AND ${refDirScope('i.source_id', '$3')}
|
||||
GROUP BY 1 ORDER BY outstanding DESC LIMIT 200`,
|
||||
[f.dateTo, f.companyIds, f.directions],
|
||||
);
|
||||
const outstanding = sum(rows, 'outstanding');
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Outstanding', value: outstanding, unit: 'ETB' },
|
||||
{ label: 'Overdue', value: outstanding - sum(rows, 'current'), unit: 'ETB' },
|
||||
{ label: 'Customers with balance', value: rows.length },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const revenueByPaymentMethod: ReportQuery = async (ds, f) => {
|
||||
// payments.status values are lowercase-hyphenated ('success'), unlike every
|
||||
// other status enum in the schema. No deleted_at on this table.
|
||||
const rows = await ds.query(
|
||||
`SELECT p.method::text AS method,
|
||||
COUNT(*)::int AS payments,
|
||||
ROUND(SUM(p.amount))::float8 AS amount
|
||||
FROM freight.payments p
|
||||
WHERE p.status = 'success'
|
||||
AND ($1::timestamptz IS NULL OR p.created_at >= $1)
|
||||
AND ($2::timestamptz IS NULL OR p.created_at < $2)
|
||||
AND ${refDirScope('p.ref_id', '$3')}
|
||||
GROUP BY 1 ORDER BY amount DESC`,
|
||||
[f.dateFrom, f.dateTo, f.directions],
|
||||
);
|
||||
const total = sum(rows, 'amount');
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Collected', value: total, unit: 'ETB' },
|
||||
{ label: 'Payments', value: sum(rows, 'payments') },
|
||||
{
|
||||
label: 'Top method share',
|
||||
value: total > 0 ? Math.round((num(rows[0]?.amount) / total) * 100) : 0,
|
||||
unit: '%',
|
||||
},
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Record-level list exports. Same engine, raw rows instead of aggregates.
|
||||
// ponytail: flat LIMIT 5000 per list — stream/paginate the export if a table
|
||||
// ever outgrows that.
|
||||
const LIST_LIMIT = 5000;
|
||||
|
||||
const bookingsList: ReportQuery = async (ds, f) => {
|
||||
const { where, params } = bookingWhere(f);
|
||||
const rows = await ds.query(
|
||||
`SELECT b.reference,
|
||||
to_char(b.created_at, 'YYYY-MM-DD') AS created,
|
||||
c.name AS customer, b.status, b.freight_type,
|
||||
b.trade_direction AS direction,
|
||||
o.label AS origin, d.label AS destination,
|
||||
COALESCE(cty.cargo_type_name, b.cargo_free_text) AS cargo,
|
||||
ROUND(${TONS})::float8 AS tons,
|
||||
ROUND(${REVENUE})::float8 AS amount,
|
||||
b.payment_status, b.scheduling_status
|
||||
FROM freight.bookings b
|
||||
JOIN freight.companies c ON c.id = b.company_id
|
||||
JOIN freight.yards o ON o.id = b.origin_yard_id
|
||||
JOIN freight.yards d ON d.id = b.destination_yard_id
|
||||
LEFT JOIN freight.cargo_types cty ON cty.id = b.cargo_type_id
|
||||
WHERE ${where}
|
||||
ORDER BY b.created_at DESC LIMIT ${LIST_LIMIT}`,
|
||||
params,
|
||||
);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Bookings', value: rows.length },
|
||||
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
|
||||
{ label: 'Amount', value: sum(rows, 'amount'), unit: 'ETB' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const contractsList: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT ct.reference, c.name AS customer, ct.contract_kind AS kind,
|
||||
ct.status, ct.trade_direction AS direction, ct.freight_type,
|
||||
to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from,
|
||||
to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until,
|
||||
to_char(ct.created_at, 'YYYY-MM-DD') AS created
|
||||
FROM freight.contracts ct
|
||||
LEFT JOIN freight.companies c ON c.id = ct.company_id
|
||||
WHERE ct.deleted_at IS NULL
|
||||
AND ($1::timestamptz IS NULL OR ct.created_at >= $1)
|
||||
AND ($2::timestamptz IS NULL OR ct.created_at < $2)
|
||||
AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3))
|
||||
AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4))
|
||||
AND ($5::text[] IS NULL OR ct.status = ANY($5))
|
||||
ORDER BY ct.created_at DESC LIMIT ${LIST_LIMIT}`,
|
||||
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses],
|
||||
);
|
||||
const active = rows.filter((r: Record<string, unknown>) =>
|
||||
['CONTRACT_ACTIVE', 'ACTIVE_SHIPMENT_IN_PROGRESS'].includes(String(r.status)),
|
||||
).length;
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Contracts', value: rows.length },
|
||||
{ label: 'Active', value: active },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const schedulesList: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT ts.train_number, ts.reference, ts.direction, ts.status,
|
||||
o.label AS origin, d.label AS destination,
|
||||
to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI') AS scheduled_departure,
|
||||
to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI') AS actual_departure,
|
||||
to_char(ts.scheduled_arrival_date, 'YYYY-MM-DD HH24:MI') AS scheduled_arrival,
|
||||
to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI') AS actual_arrival,
|
||||
ts.max_wagons, tset.wagon_count
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards o ON o.id = ts.origin_station_id
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id
|
||||
LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
||||
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
||||
AND ($3::text[] IS NULL OR ts.direction = ANY($3))
|
||||
AND ($4::text[] IS NULL OR ts.status = ANY($4))
|
||||
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
||||
ORDER BY ts.scheduled_departure_date DESC LIMIT ${LIST_LIMIT}`,
|
||||
[f.dateFrom, f.dateTo, f.directions, f.statuses, f.yardIds],
|
||||
);
|
||||
const count = (s: string) =>
|
||||
rows.filter((r: Record<string, unknown>) => r.status === s).length;
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Schedules', value: rows.length },
|
||||
{ label: 'Dispatched', value: count('DISPATCHED') },
|
||||
{ label: 'Arrived', value: count('ARRIVED') },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const fleetWagons: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT w.wagon_number, wt.name AS type,
|
||||
wt.capacity_tons::float8 AS capacity_tons,
|
||||
w.status, y.label AS current_yard
|
||||
FROM freight.wagons w
|
||||
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
||||
LEFT JOIN freight.yards y ON y.id = w.current_yard_id
|
||||
WHERE w.deleted_at IS NULL
|
||||
AND ($1::text[] IS NULL OR w.status = ANY($1))
|
||||
AND ($2::uuid[] IS NULL OR w.current_yard_id = ANY($2))
|
||||
ORDER BY w.wagon_number LIMIT ${LIST_LIMIT}`,
|
||||
[f.statuses, f.yardIds],
|
||||
);
|
||||
const count = (s: string) =>
|
||||
rows.filter((r: Record<string, unknown>) => r.status === s).length;
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Wagons', value: rows.length },
|
||||
{ label: 'Available', value: count('AVAILABLE') },
|
||||
{ label: 'Assigned', value: count('ASSIGNED') },
|
||||
{ label: 'Maintenance', value: count('MAINTENANCE') },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const fleetLocomotives: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT l.code, l.name, l.locomotive_type,
|
||||
l.max_pull_weight_tons::float8 AS max_pull_tons,
|
||||
l.status, y.label AS current_yard
|
||||
FROM freight.locomotives l
|
||||
LEFT JOIN freight.yards y ON y.id = l.current_yard_id
|
||||
WHERE l.deleted_at IS NULL
|
||||
AND ($1::text[] IS NULL OR l.status = ANY($1))
|
||||
AND ($2::uuid[] IS NULL OR l.current_yard_id = ANY($2))
|
||||
ORDER BY l.code LIMIT ${LIST_LIMIT}`,
|
||||
[f.statuses, f.yardIds],
|
||||
);
|
||||
const available = rows.filter(
|
||||
(r: Record<string, unknown>) => r.status === 'AVAILABLE',
|
||||
).length;
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Locomotives', value: rows.length },
|
||||
{ label: 'Available', value: available },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const customersList: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT c.name, c.type, c.kind, c.status, c.tin,
|
||||
to_char(c.approved_at, 'YYYY-MM-DD') AS approved,
|
||||
to_char(c.created_at, 'YYYY-MM-DD') AS created
|
||||
FROM freight.companies c
|
||||
WHERE c.deleted_at IS NULL
|
||||
AND ($1::timestamptz IS NULL OR c.created_at >= $1)
|
||||
AND ($2::timestamptz IS NULL OR c.created_at < $2)
|
||||
AND ($3::text[] IS NULL OR c.status = ANY($3))
|
||||
ORDER BY c.created_at DESC LIMIT ${LIST_LIMIT}`,
|
||||
[f.dateFrom, f.dateTo, f.statuses],
|
||||
);
|
||||
const active = rows.filter(
|
||||
(r: Record<string, unknown>) => r.status === 'active',
|
||||
).length;
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Customers', value: rows.length },
|
||||
{ label: 'Active', value: active },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const paymentsList: ReportQuery = async (ds, f) => {
|
||||
// No deleted_at on freight.payments; statuses are lowercase-hyphenated.
|
||||
const rows = await ds.query(
|
||||
`SELECT to_char(p.created_at, 'YYYY-MM-DD HH24:MI') AS created,
|
||||
p.method::text AS method, p.status::text AS status,
|
||||
p.currency::text AS currency,
|
||||
ROUND(p.amount)::float8 AS amount,
|
||||
p.transaction_id, p.merchant_order_id,
|
||||
to_char(p.paid_at, 'YYYY-MM-DD') AS paid
|
||||
FROM freight.payments p
|
||||
WHERE ($1::timestamptz IS NULL OR p.created_at >= $1)
|
||||
AND ($2::timestamptz IS NULL OR p.created_at < $2)
|
||||
AND ($3::text[] IS NULL OR p.status::text = ANY($3))
|
||||
AND ${refDirScope('p.ref_id', '$4')}
|
||||
ORDER BY p.created_at DESC LIMIT ${LIST_LIMIT}`,
|
||||
[f.dateFrom, f.dateTo, f.statuses, f.directions],
|
||||
);
|
||||
const success = rows.filter(
|
||||
(r: Record<string, unknown>) => r.status === 'success',
|
||||
);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Payments', value: rows.length },
|
||||
{ label: 'Successful', value: success.length },
|
||||
{ label: 'Collected', value: sum(success, 'amount'), unit: 'ETB' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
export const REPORT_QUERIES: Record<string, ReportQuery> = {
|
||||
'bookings-list': bookingsList,
|
||||
'contracts-list': contractsList,
|
||||
'schedules-list': schedulesList,
|
||||
'fleet-wagons': fleetWagons,
|
||||
'fleet-locomotives': fleetLocomotives,
|
||||
'customers-list': customersList,
|
||||
'payments-list': paymentsList,
|
||||
'bookings-trend': bookingsTrend,
|
||||
'revenue-by-customer': revenueByCustomer,
|
||||
'revenue-by-lane': revenueByLane,
|
||||
'contract-utilization': contractUtilization,
|
||||
'train-on-time': trainOnTime,
|
||||
'schedule-fill-rate': scheduleFillRate,
|
||||
'trips-per-route': tripsPerRoute,
|
||||
'invoiced-vs-collected': invoicedVsCollected,
|
||||
'aging-receivables': agingReceivables,
|
||||
'revenue-by-payment-method': revenueByPaymentMethod,
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { BookingView } from '../../common/booking-guards';
|
||||
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
||||
import { ReportQueryDto } from './dto/report-query.dto';
|
||||
import { ReportResultDto } from './dto/report-result.dto';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
@ApiTags('Reports')
|
||||
@ApiBearerAuth()
|
||||
@Controller('reports')
|
||||
export class ReportsController {
|
||||
constructor(
|
||||
private readonly reportsService: ReportsService,
|
||||
private readonly userTradeAccessService: UserTradeAccessService,
|
||||
) {}
|
||||
|
||||
@Get(':key')
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: 'Run a canned report by key with optional filters' })
|
||||
@ApiOkResponse({ type: ReportResultDto })
|
||||
async run(
|
||||
@Param('key') key: string,
|
||||
@Query() query: ReportQueryDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
): Promise<ReportResultDto> {
|
||||
const allowed = await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
return this.reportsService.run(key, query, allowed);
|
||||
}
|
||||
}
|
||||
13
apps/edr-freight-api/src/modules/reports/reports.module.ts
Normal file
13
apps/edr-freight-api/src/modules/reports/reports.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||
import { ReportsController } from './reports.controller';
|
||||
import { ReportsRepository } from './reports.repository';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
@Module({
|
||||
imports: [UserTradeAccessModule],
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService, ReportsRepository],
|
||||
})
|
||||
export class ReportsModule {}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { REPORT_QUERIES, ReportFilters, ReportResult } from './report-queries';
|
||||
|
||||
@Injectable()
|
||||
export class ReportsRepository {
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
run(key: keyof typeof REPORT_QUERIES, filters: ReportFilters): Promise<ReportResult> {
|
||||
return REPORT_QUERIES[key](this.dataSource, filters);
|
||||
}
|
||||
}
|
||||
46
apps/edr-freight-api/src/modules/reports/reports.service.ts
Normal file
46
apps/edr-freight-api/src/modules/reports/reports.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { scopedDirections } from '../user-trade-access/trade-scope.util';
|
||||
import { ReportQueryDto } from './dto/report-query.dto';
|
||||
import { REPORT_QUERIES, ReportFilters, ReportResult } from './report-queries';
|
||||
import { ReportsRepository } from './reports.repository';
|
||||
import type { Freight } from '@edr/types';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const list = (csv?: string): string[] | null => {
|
||||
const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? [];
|
||||
return items.length ? items : null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
constructor(private readonly repository: ReportsRepository) {}
|
||||
|
||||
run(
|
||||
key: string,
|
||||
dto: ReportQueryDto,
|
||||
allowedDirections: Freight.ScheduleTradeDirection[] | null,
|
||||
): Promise<ReportResult> {
|
||||
if (!(key in REPORT_QUERIES)) {
|
||||
throw new NotFoundException(`Unknown report: ${key}`);
|
||||
}
|
||||
// No default range: absent dates mean all time, so exports cover everything.
|
||||
const to = dto.dateTo ? new Date(dto.dateTo) : null;
|
||||
const from = dto.dateFrom ? new Date(dto.dateFrom) : null;
|
||||
const filters: ReportFilters = {
|
||||
dateFrom: from ? from.toISOString() : null,
|
||||
// dateTo is inclusive in the API; queries treat the bound as exclusive.
|
||||
dateTo: to ? new Date(to.getTime() + DAY_MS).toISOString() : null,
|
||||
granularity: dto.granularity ?? 'day',
|
||||
companyIds: list(dto.companyIds),
|
||||
routeIds: list(dto.routeIds),
|
||||
yardIds: list(dto.yardIds),
|
||||
cargoTypeIds: list(dto.cargoTypeIds),
|
||||
statuses: list(dto.statuses),
|
||||
directions: scopedDirections(allowedDirections, dto.direction),
|
||||
freightType: dto.freightType ?? null,
|
||||
};
|
||||
return this.repository.run(key, filters);
|
||||
}
|
||||
}
|
||||
@@ -161,6 +161,15 @@ export class TrainSchedule extends BaseEntity {
|
||||
@Column({ name: 'rule_payment_window_minutes', type: 'int', nullable: true })
|
||||
rulePaymentWindowMinutes?: number | null;
|
||||
|
||||
/**
|
||||
* Staff configured this schedule's booking window by hand (at creation or via
|
||||
* the per-schedule override) instead of inheriting the live global rules.
|
||||
* `restampPendingWindows` skips these, so a later global-rules edit cannot
|
||||
* silently overwrite the hand-picked settings.
|
||||
*/
|
||||
@Column({ name: 'window_rule_custom', type: 'boolean', default: false })
|
||||
windowRuleCustom!: boolean;
|
||||
|
||||
@Column({ name: 'rule_import_window_lead_days', type: 'int', nullable: true })
|
||||
ruleImportWindowLeadDays?: number | null;
|
||||
|
||||
|
||||
@@ -151,6 +151,14 @@ export interface ExportTrainOption {
|
||||
}>;
|
||||
}
|
||||
|
||||
/** A train a paid-unallocated booking can board (route + capacity verified). */
|
||||
export interface AllocationCandidate {
|
||||
id: string;
|
||||
reference: string | null;
|
||||
direction: string | null;
|
||||
scheduledDepartureDate: Date;
|
||||
}
|
||||
|
||||
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
||||
interface RouteDayGroup {
|
||||
originYardId: string;
|
||||
@@ -3040,6 +3048,104 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.notifyBoardChanged(newScheduleId, "booking_moved");
|
||||
}
|
||||
|
||||
/**
|
||||
* Trains a paid-but-unallocated booking can board right now: OPEN window,
|
||||
* future departure, route covers the booking's leg, and remaining corridor
|
||||
* capacity fits it. Split by the booking's own scheduled day so the UI can
|
||||
* offer one-click same-day allocation vs an explicit "another date" choice.
|
||||
*/
|
||||
async allocationCandidates(bookingId: string): Promise<{
|
||||
sameDay: AllocationCandidate[];
|
||||
otherDays: AllocationCandidate[];
|
||||
}> {
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: bookingId },
|
||||
relations: {
|
||||
bookingContainers: { containerType: true },
|
||||
// wagonTypes drives the break-bulk items-per-wagon fit — size the
|
||||
// booking exactly as the intercity accept check does.
|
||||
cargoType: { wagonTypes: true },
|
||||
},
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
],
|
||||
});
|
||||
const today = eatDay(new Date());
|
||||
const bookingDay = booking.scheduledDate ? eatDay(booking.scheduledDate) : null;
|
||||
const sameDay: AllocationCandidate[] = [];
|
||||
const otherDays: AllocationCandidate[] = [];
|
||||
for (const s of schedules) {
|
||||
if (!s.scheduledDepartureDate || eatDay(s.scheduledDepartureDate) < today) continue;
|
||||
if (s.bookingWindowStatus !== "OPEN") continue;
|
||||
if (s.id === booking.trainScheduleId) continue;
|
||||
const stops = await this.stopsForSchedule(s);
|
||||
const fromIdx = stops.indexOf(booking.originYardId);
|
||||
const toIdx = stops.indexOf(booking.destinationYardId);
|
||||
if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) continue;
|
||||
// ponytail: full capacity build per candidate is heavy; the set is small
|
||||
// (future OPEN trains on the booking's route) — precompute if it grows.
|
||||
const cap = await this.intercityCapacity(s.id);
|
||||
if (!cap) continue;
|
||||
const leg = cap.budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||
if (!cap.budget.fits(cap.needFor(booking), leg)) continue;
|
||||
const candidate: AllocationCandidate = {
|
||||
id: s.id,
|
||||
reference: s.reference ?? s.trainNumber ?? null,
|
||||
direction: s.direction ?? null,
|
||||
scheduledDepartureDate: s.scheduledDepartureDate,
|
||||
};
|
||||
(eatDay(s.scheduledDepartureDate) === bookingDay ? sameDay : otherDays).push(candidate);
|
||||
}
|
||||
const byDate = (a: AllocationCandidate, b: AllocationCandidate) =>
|
||||
new Date(a.scheduledDepartureDate).getTime() - new Date(b.scheduledDepartureDate).getTime();
|
||||
sameDay.sort(byDate);
|
||||
otherDays.sort(byDate);
|
||||
return { sameDay, otherDays };
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a PAID booking that lost (or never got) its train: re-point via
|
||||
* moveToSchedule (window/route validation + day sync), then allocate it
|
||||
* immediately — payment already landed, so no new pay window opens. The
|
||||
* customer gets an in-app notice when the new train departs on a different
|
||||
* day than their original choice.
|
||||
*/
|
||||
async allocatePaid(bookingId: string, scheduleId: string): Promise<void> {
|
||||
const before = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId } });
|
||||
if (!before) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
if (before.paymentStatus !== "PAID" && before.status !== "PAID") {
|
||||
throw new BadRequestException(
|
||||
"Booking is not paid — use the regular scheduling flow",
|
||||
);
|
||||
}
|
||||
const previousDay = before.scheduledDate ? eatDay(before.scheduledDate) : null;
|
||||
await this.moveToSchedule(bookingId, scheduleId);
|
||||
const fresh = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: bookingId },
|
||||
relations: { bookingContainers: { containerType: true }, cargoType: true },
|
||||
});
|
||||
if (!fresh) return;
|
||||
if (!(await this.holdIfWagonShort(scheduleId, fresh))) {
|
||||
await this.allocate(scheduleId, fresh, "paid");
|
||||
}
|
||||
const schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: scheduleId } });
|
||||
if (
|
||||
previousDay &&
|
||||
schedule?.scheduledDepartureDate &&
|
||||
eatDay(schedule.scheduledDepartureDate) !== previousDay
|
||||
) {
|
||||
this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One reminder per hold, shortly before its pay deadline (the window tick
|
||||
* calls this every pass; `payment_reminder_sent_at` dedups). Skips paid
|
||||
@@ -3526,6 +3632,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Paid but detached from any train (staff removed it from an allocation,
|
||||
// or a sweep caught it unpinned): money was taken, so it must board — it
|
||||
// stays paid-unallocated for staff to place via the allocate action.
|
||||
if (paid) {
|
||||
this.logger.log(
|
||||
`[BATCH] expire skipped for ${booking.reference} — payment landed ` +
|
||||
`but no train attached; left paid-unallocated for manual placement`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Reconcile-before-expire (only when a pay window was actually open):
|
||||
// no webhook arrived, so ask the gateway DIRECTLY whether the money
|
||||
// landed. A late capture found there is registered as SUCCEEDED and
|
||||
@@ -3854,12 +3970,26 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// booking can use — don't kill it for nothing.
|
||||
const overlaps = victimLeg.fromEdge < leg.toEdge && leg.fromEdge < victimLeg.toEdge;
|
||||
if (!overlaps) continue;
|
||||
const victimPaid =
|
||||
victim.paymentStatus === "PAID" || victim.status === "PAID";
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
||||
scheduleId,
|
||||
victim.id,
|
||||
manager,
|
||||
);
|
||||
if (victimPaid) {
|
||||
// Paid bookings are never expired — money was taken, so it boards.
|
||||
// Detach it so it surfaces in the paid-unallocated queue for staff
|
||||
// to re-place; the settled invoice stays untouched.
|
||||
await manager.getRepository(Booking).update(victim.id, {
|
||||
trainScheduleId: null,
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
return;
|
||||
}
|
||||
await manager.getRepository(Booking).update(victim.id, {
|
||||
status: "EXPIRED",
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
|
||||
@@ -275,6 +275,19 @@ export class BookingNotifierService {
|
||||
this.inApp(b, 'Booking rescheduled', msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff placed a paid booking onto a train departing on a DIFFERENT day than
|
||||
* the customer's original choice. In-app only — staff drove the change and
|
||||
* the allocation itself already notifies through the secured path.
|
||||
*/
|
||||
allocatedOtherDay(b: Booking, newDeparture: Date): void {
|
||||
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
|
||||
const msg =
|
||||
`Booking ${b.reference ?? b.id} has been allocated to a train on a different date. ` +
|
||||
`New departure date: ${when}.`;
|
||||
this.inApp(b, 'Booking allocated to another date', msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking was removed from its train during a staff reschedule (not a government
|
||||
* pre-empt). It returns to eligible — the customer must rebook or reschedule.
|
||||
|
||||
@@ -9,9 +9,107 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
Max,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* Per-schedule booking-window rule chosen AT CREATION, instead of inheriting the
|
||||
* live global rules. Mirrors {@link UpdateScheduleWindowRuleDto}, plus the
|
||||
* booking-close offset (which the post-creation override deliberately never
|
||||
* touches). Every field is optional — an omitted field falls back to the global
|
||||
* value, so staff can override just the one knob they care about.
|
||||
*/
|
||||
export class CreateScheduleWindowRuleDto {
|
||||
@ApiPropertyOptional({ example: 8, description: 'Local EAT hour the booking desk opens each day' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(23)
|
||||
windowOpenHour?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 17,
|
||||
description:
|
||||
'Local EAT hour the booking desk shuts each day. Equal to windowOpenHour = 24-hour desk',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(23)
|
||||
windowCloseHour?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 3, description: 'How long each booking cycle stays open, in hours' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.0166)
|
||||
@Max(12)
|
||||
windowDurationHours?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 30, description: 'Max staff document-review minutes after the window closes' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
docReviewMinutes?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 60, description: 'Customer payment window minutes' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
paymentWindowMinutes?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 3,
|
||||
description: 'Days before departure the IMPORT/DOMESTIC booking window starts',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
importWindowLeadDays?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 24,
|
||||
description: 'Hours before departure the single FCFS EXPORT window opens',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
exportBookingLeadHours?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 180,
|
||||
nullable: true,
|
||||
description:
|
||||
'Minutes before departure the booking window closes; 0/null = close at departure. ' +
|
||||
'Only the offset matching the schedule direction is used (import offset for ' +
|
||||
'IMPORT/DOMESTIC, export offset for EXPORT).',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
importCloseOffsetMinutes?: number | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 1440,
|
||||
nullable: true,
|
||||
description: 'Minutes before departure an EXPORT booking window closes; 0/null = at departure',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
exportCloseOffsetMinutes?: number | null;
|
||||
}
|
||||
|
||||
export class CreateContainerTrainScheduleDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
@@ -73,4 +171,19 @@ export class CreateContainerTrainScheduleDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
reverseWagonOrder?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: CreateScheduleWindowRuleDto,
|
||||
description:
|
||||
'Configure the booking window for THIS schedule instead of inheriting the live ' +
|
||||
'global rules. Omit to use the global rules (the default). The values sent are ' +
|
||||
'frozen onto the schedule as its rule snapshot, exactly as a post-creation ' +
|
||||
'override would. Rejected for an IMPORT/DOMESTIC train that joins an existing ' +
|
||||
'route+day group — those siblings share one window timeline, so edit the group ' +
|
||||
"window instead of giving one member its own.",
|
||||
})
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => CreateScheduleWindowRuleDto)
|
||||
windowRule?: CreateScheduleWindowRuleDto;
|
||||
}
|
||||
|
||||
@@ -856,6 +856,32 @@ export class TrainSchedulingController {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get("bookings/:bookingId/allocation-candidates")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Trains a paid-unallocated booking fits, split same-day vs other days",
|
||||
})
|
||||
getAllocationCandidates(
|
||||
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||
) {
|
||||
return this.bookingBatchService.allocationCandidates(bookingId);
|
||||
}
|
||||
|
||||
@Post("bookings/:bookingId/allocate")
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Staff: place a paid booking onto a fitting train (notifies customer on date change)",
|
||||
})
|
||||
async allocatePaidBooking(
|
||||
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||
@Body("trainScheduleId", ParseUUIDPipe) trainScheduleId: string,
|
||||
) {
|
||||
await this.bookingBatchService.allocatePaid(bookingId, trainScheduleId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get("schedules/:id/checkpoints")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -805,6 +805,51 @@ describe('TrainSchedulingService', () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
describe('restampPendingWindows (hand-configured windows are exempt)', () => {
|
||||
const future = new Date(Date.now() + 30 * 24 * 3600_000);
|
||||
const update = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
update.mockClear();
|
||||
// Global rules read + the TrainSchedule repo the restamp writes through.
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
const name = (entity as { name?: string })?.name;
|
||||
if (name === 'TrainSchedulingGlobalRules') {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
return { update };
|
||||
});
|
||||
});
|
||||
|
||||
it('re-stamps a schedule that follows the global rules', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
{
|
||||
id: 'sched-global',
|
||||
direction: 'IMPORT',
|
||||
scheduledDepartureDate: future,
|
||||
windowRuleCustom: false,
|
||||
},
|
||||
]);
|
||||
await expect(service.restampPendingWindows()).resolves.toBe(1);
|
||||
expect(update).toHaveBeenCalledWith('sched-global', expect.anything());
|
||||
});
|
||||
|
||||
it('leaves a hand-configured schedule alone', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
{
|
||||
id: 'sched-custom',
|
||||
direction: 'IMPORT',
|
||||
scheduledDepartureDate: future,
|
||||
windowRuleCustom: true,
|
||||
},
|
||||
]);
|
||||
// Staff picked these times deliberately — a global-rules edit must not
|
||||
// overwrite them, or the per-schedule configuration would be pointless.
|
||||
await expect(service.restampPendingWindows()).resolves.toBe(0);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUnassignedBookings', () => {
|
||||
const scheduleId = 'sched-unassigned-1';
|
||||
const trainSetId = 'train-set-unassigned';
|
||||
|
||||
@@ -181,6 +181,13 @@ import {
|
||||
|
||||
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
|
||||
|
||||
/** 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(
|
||||
Object.entries(source).filter(([, v]) => v !== undefined),
|
||||
) as Partial<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking-window rule fields frozen onto a train schedule at creation (and
|
||||
* refreshed by restampPendingWindows for not-yet-open schedules). The board draws
|
||||
@@ -906,6 +913,9 @@ export class TrainSchedulingService {
|
||||
windowClosesAt: cap(times.windowClosesAt, t.departure),
|
||||
...ruleFields,
|
||||
rulePaymentWindowMinutes,
|
||||
// Deliberately overridden — exempt from the global re-stamp, which would
|
||||
// otherwise revert this schedule the next time global rules are saved.
|
||||
windowRuleCustom: true,
|
||||
});
|
||||
}
|
||||
this.logger.log(
|
||||
@@ -1197,6 +1207,9 @@ export class TrainSchedulingService {
|
||||
let restamped = 0;
|
||||
for (const s of schedules) {
|
||||
if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue;
|
||||
// Hand-configured windows are not "pending the global rule" — staff picked
|
||||
// these times deliberately, so a global-rules edit must leave them alone.
|
||||
if (s.windowRuleCustom) continue;
|
||||
const times =
|
||||
s.direction === 'EXPORT'
|
||||
? computeExportWindowTimes(s.scheduledDepartureDate, cfg)
|
||||
@@ -1460,29 +1473,8 @@ export class TrainSchedulingService {
|
||||
// it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT
|
||||
// (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens
|
||||
// 24h before departure (FCFS). No schedule is ever always-open now.
|
||||
const windowCfg = await this.getWindowConfig();
|
||||
const globalCfg = await this.getWindowConfig();
|
||||
|
||||
// Staff cannot schedule inside the lead window — there must be room for a
|
||||
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT
|
||||
// days (lead 3, today 11th → first allowed departure is the 14th); EXPORT
|
||||
// lead is in hours (24h = 1 day ahead).
|
||||
const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date());
|
||||
if (departure.getTime() < earliest.getTime()) {
|
||||
const detail =
|
||||
direction === 'EXPORT'
|
||||
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
|
||||
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
|
||||
throw new BadRequestException(
|
||||
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
|
||||
`${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
|
||||
`(earliest ${earliest.toISOString()})`,
|
||||
);
|
||||
}
|
||||
// Freeze the rule this schedule is born with. A later global-rules edit
|
||||
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
|
||||
// already-open schedule keeps this snapshot, and the batch board draws its
|
||||
// windows from it rather than the live config.
|
||||
const ruleSnapshot = windowRuleSnapshot(windowCfg);
|
||||
// Route+day grouping (IMPORT/DOMESTIC only): if a schedule already exists
|
||||
// on this origin + destination + EAT departure day, this new train JOINS
|
||||
// its group and adopts the group's shared window timeline (open/close +
|
||||
@@ -1506,6 +1498,77 @@ export class TrainSchedulingService {
|
||||
route.destinationYardId,
|
||||
departure,
|
||||
);
|
||||
|
||||
// Per-schedule window rule chosen at creation. Refused for a train that
|
||||
// JOINS an existing route+day group: the group shares ONE window timeline,
|
||||
// so a joining train adopts the anchor's times verbatim and its own
|
||||
// settings would be silently discarded. Staff edit the group's window
|
||||
// instead (Booking window settings, which fans out to every sibling).
|
||||
if (dto.windowRule && groupAnchor) {
|
||||
throw new BadRequestException(
|
||||
'This train joins an existing booking group (same route and departure day), ' +
|
||||
'which shares one booking window across all its trains. Create it with the ' +
|
||||
'group settings, then use Booking window settings to change the window for ' +
|
||||
'the whole group.',
|
||||
);
|
||||
}
|
||||
|
||||
// The rule this schedule is born under: staff overrides on top of the live
|
||||
// global config, so an omitted field still follows the global value.
|
||||
const windowCfg: BookingWindowConfig = dto.windowRule
|
||||
? {
|
||||
...globalCfg,
|
||||
...pickDefined({
|
||||
windowOpenHour: dto.windowRule.windowOpenHour,
|
||||
windowCloseHour: dto.windowRule.windowCloseHour,
|
||||
windowDurationHours: dto.windowRule.windowDurationHours,
|
||||
docReviewMinutes: dto.windowRule.docReviewMinutes,
|
||||
importWindowLeadDays: dto.windowRule.importWindowLeadDays,
|
||||
exportBookingLeadHours: dto.windowRule.exportBookingLeadHours,
|
||||
}),
|
||||
// One pay-window override drives both directions (only the one
|
||||
// matching this schedule's direction is ever read).
|
||||
...(dto.windowRule.paymentWindowMinutes !== undefined
|
||||
? {
|
||||
paymentWindowMinutes: dto.windowRule.paymentWindowMinutes,
|
||||
exportPaymentWindowMinutes: dto.windowRule.paymentWindowMinutes,
|
||||
}
|
||||
: {}),
|
||||
// Close offsets are nullable-by-intent: null/0 means "close at
|
||||
// departure", which must override a non-null global, so these are
|
||||
// merged on presence rather than on definedness.
|
||||
...(dto.windowRule.importCloseOffsetMinutes !== undefined
|
||||
? { importCloseOffsetMinutes: dto.windowRule.importCloseOffsetMinutes ?? null }
|
||||
: {}),
|
||||
...(dto.windowRule.exportCloseOffsetMinutes !== undefined
|
||||
? { exportCloseOffsetMinutes: dto.windowRule.exportCloseOffsetMinutes ?? null }
|
||||
: {}),
|
||||
}
|
||||
: globalCfg;
|
||||
|
||||
// Staff cannot schedule inside the lead window — there must be room for a
|
||||
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT
|
||||
// days (lead 3, today 11th → first allowed departure is the 14th); EXPORT
|
||||
// lead is in hours (24h = 1 day ahead). Checked against the schedule's OWN
|
||||
// lead, so a custom lead is honoured rather than rejected by the global one.
|
||||
const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date());
|
||||
if (departure.getTime() < earliest.getTime()) {
|
||||
const detail =
|
||||
direction === 'EXPORT'
|
||||
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
|
||||
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
|
||||
throw new BadRequestException(
|
||||
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
|
||||
`${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
|
||||
`(earliest ${earliest.toISOString()})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Freeze the rule this schedule is born with. A later global-rules edit
|
||||
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
|
||||
// already-open schedule keeps this snapshot, and the batch board draws its
|
||||
// windows from it rather than the live config.
|
||||
const ruleSnapshot = windowRuleSnapshot(windowCfg);
|
||||
const computedTimes =
|
||||
direction === 'EXPORT'
|
||||
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
|
||||
@@ -1514,12 +1577,30 @@ export class TrainSchedulingService {
|
||||
...ruleSnapshot,
|
||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||
};
|
||||
if (
|
||||
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'These booking-window settings leave no window before departure — with the ' +
|
||||
'desk hours and close offset applied, the window would only open once the ' +
|
||||
'train has left.',
|
||||
);
|
||||
}
|
||||
const windowFields = {
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...(groupAnchor
|
||||
? this.groupWindowFieldsFrom(groupAnchor, departure)
|
||||
: computedTimes),
|
||||
// `windowRuleSnapshot` never stamps the pay window (NULL = follow the
|
||||
// live global value for the direction), so an explicit staff override is
|
||||
// persisted here — the same field the post-creation override writes.
|
||||
...(dto.windowRule?.paymentWindowMinutes !== undefined
|
||||
? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes }
|
||||
: {}),
|
||||
// Hand-configured windows opt OUT of the global re-stamp, or the next
|
||||
// global-rules edit would overwrite exactly what staff chose here.
|
||||
windowRuleCustom: dto.windowRule != null,
|
||||
};
|
||||
// A built train's own consist is the schedule's capacity: full when all
|
||||
// its wagons are allocated. Trains built without wagons yet fall back to
|
||||
|
||||
Reference in New Issue
Block a user