Merge pull request #1098 from Tria-plc/dev

merge staging to dev
This commit is contained in:
Abubeker Yasin
2026-08-04 09:58:16 +03:00
committed by GitHub
107 changed files with 12273 additions and 462 deletions

View File

@@ -65,7 +65,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
// import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder";
// import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder";
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
// import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
@@ -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,
@@ -228,7 +230,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
EdrOrgSeeder,
FreightPositionsSeeder,
FileUploadSettingsSeeder,
YardFacilitiesSeeder,
// YardFacilitiesSeeder,
FreightPermissionKeyMigrationSeeder,
// Disabled seeds — providers commented out (imports/injection/run too):
// DemoUsersSeeder,
@@ -255,7 +257,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
// private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
// Disabled seeds — injections commented out (imports/provider/run too):
// private readonly demoUsersSeeder: DemoUsersSeeder,
@@ -304,7 +306,7 @@ export class AppModule implements OnApplicationBootstrap {
// Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama,
// Dire Dawa). Idempotent; creates no yards.
await this.yardFacilitiesSeeder.run();
// await this.yardFacilitiesSeeder.run();
// Dropdown settings are not seeded on boot; run them with
// `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts).
@@ -335,9 +337,11 @@ export class AppModule implements OnApplicationBootstrap {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes("*");
consumer.apply(LoginAudienceMiddleware).forRoutes(
{ path: "auth/login", method: RequestMethod.POST },
{ path: "auth/mfa-verify", method: RequestMethod.POST },
);
consumer
.apply(LoginAudienceMiddleware)
.forRoutes(
{ path: "auth/login", method: RequestMethod.POST },
{ path: "auth/mfa-verify", method: RequestMethod.POST },
);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* PER_TON (bulk) cargo can have a loading limit BELOW the wagon's rated
* capacity: sugar rides 50T on a 70T wagon (density/stowage/policy), so 200T
* needs 4 wagons, not the 3 that raw capacity implies. Stored as a jsonb map
* { [wagonTypeId]: maxTons } on cargo_types — the PER_TON mirror of
* items_per_wagon_map. Unset (or no key) means the wagon's full rated capacity,
* so existing cargo types keep their current behaviour with no backfill.
*/
export class AddCargoTypeTonsPerWagonMap3190000000000 implements MigrationInterface {
name = 'AddCargoTypeTonsPerWagonMap3190000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."cargo_types" ADD COLUMN IF NOT EXISTS "tons_per_wagon_map" jsonb`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."cargo_types" DROP COLUMN IF EXISTS "tons_per_wagon_map"`,
);
}
}

View File

@@ -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"`,
);
}
}

View File

@@ -0,0 +1,38 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { EUserStatus, EUserType } from '@tria-plc/api-common/utils/enums/user.enum';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsBoolean, IsEnum, IsIn, IsOptional } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
/** Query-string booleans arrive as strings; implicit conversion is off app-wide. */
const toOptionalBoolean = ({ value }: TransformFnParams): boolean | undefined =>
value === undefined || value === null || value === ''
? undefined
: value === true || value === 'true';
export class ListUsersQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: EUserType })
@IsOptional()
@IsEnum(EUserType)
userType?: EUserType;
@ApiPropertyOptional({ enum: EUserStatus })
@IsOptional()
@IsEnum(EUserStatus)
userStatus?: EUserStatus;
@ApiPropertyOptional({ description: 'Filter by active flag.' })
@IsOptional()
@Transform(toOptionalBoolean)
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({
enum: ['username', 'email', 'createdAt'],
default: 'username',
})
@IsOptional()
@IsIn(['username', 'email', 'createdAt'])
sortBy?: string;
}

View File

@@ -19,6 +19,8 @@ import { ForgotPasswordController } from './forgot-password.controller';
import { ForgotPasswordService } from './forgot-password.service';
import { FreightMeController } from './freight-me.controller';
import { FreightMeService } from './freight-me.service';
import { ListUsersController } from './list-users.controller';
import { ListUsersService } from './list-users.service';
@Module({
imports: [
@@ -39,8 +41,10 @@ import { FreightMeService } from './freight-me.service';
CheckAvailabilityController,
ForgotPasswordController,
CustomerResetController,
ListUsersController,
],
providers: [
ListUsersService,
FreightMeService,
AccountService,
CheckAvailabilityService,

View File

@@ -0,0 +1,22 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { ListUsersService } from './list-users.service';
import { StaffReference } from '../../common/booking-guards';
@ApiTags('auth')
@Controller('staff/users')
@ApiBearerAuth()
export class ListUsersController {
constructor(private readonly service: ListUsersService) {}
@Get()
@StaffReference()
@ApiOperation({
summary: 'List IAM users (paginated) for backoffice pickers',
})
findAll(@Query() query: ListUsersQueryDto) {
return this.service.findAll(query);
}
}

View File

@@ -0,0 +1,69 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { PaginatedResponse } from '@edr/types';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
import { Repository } from 'typeorm';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { paginateQuery } from '../../common/utils/pagination.util';
/**
* Read-only listing of `iam.users` for backoffice pickers.
*
* Exists because `@tria-plc/iamapi-common@1.0.0`'s `GET /users/filter` pairs a
* `@QueryParams()` pagination DTO with a plain `@Query()` DTO that does not
* declare `skip`/`take`/`orderBy`; the global whitelist pipe then 400s on the
* very params the route's own paginator reads. Drop this once IAM ships a fix.
*/
@Injectable()
export class ListUsersService {
constructor(
@InjectRepository(User) private readonly users: Repository<User>,
) {}
findAll(query: ListUsersQueryDto): Promise<PaginatedResponse<User>> {
const sortBy = query.sortBy ?? 'username';
const qb = this.users
.createQueryBuilder('user')
// Explicit select: never widen this to `user` — the entity's lazy
// relations include credentials and sessions.
.select([
'user.id',
'user.name',
'user.username',
'user.email',
'user.phoneNumber',
'user.userType',
'user.status',
'user.isActive',
'user.createdAt',
])
.orderBy(`user.${sortBy}`, query.sortOrder ?? 'ASC');
if (query.userType) {
qb.andWhere('user.userType = :userType', { userType: query.userType });
}
if (query.userStatus) {
qb.andWhere('user.status = :userStatus', { userStatus: query.userStatus });
}
if (query.isActive !== undefined) {
qb.andWhere('user.isActive = :isActive', { isActive: query.isActive });
}
if (query.search) {
// `name` is localized jsonb ({ en, am, … }), not a string — match its
// values rather than casting the whole object to text.
qb.andWhere(
`(user.username ILIKE :search
OR user.email ILIKE :search
OR user.phone_number ILIKE :search
OR EXISTS (
SELECT 1 FROM jsonb_each_text(user.name) AS n(k, v)
WHERE n.v ILIKE :search
))`,
{ search: `%${query.search}%` },
);
}
return paginateQuery(qb, query);
}
}

View File

@@ -16,7 +16,7 @@ import {
containersPerWagonForSize,
wagonsPerUnitForSize,
} from '../rule-engine/container-type.util';
import { bulkItemWagonsForAllowedTypes } from '../train-scheduling/train-capacity.util';
import { bulkWagonsForAllowedTypes } from '../train-scheduling/train-capacity.util';
import { BookingsRepository } from './bookings.repository';
import { wagonRemainder } from './consolidation.service';
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
@@ -1190,8 +1190,10 @@ export class BookingPricingService {
// Break-bulk (PER_ITEM): `tons` above is the item count; size by
// indivisible items instead of pretending the count is tonnage. Best
// count across allowed wagon types, each capped by its items-fit.
const byItems = bulkItemWagonsForAllowedTypes(booking, cargo, capacity);
if (byItems > 0) return byItems;
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a
// 70T wagon) — 200T then prices 4 wagons, not 3.
const byWagons = bulkWagonsForAllowedTypes(booking, cargo, capacity);
if (byWagons > 0) return byWagons;
return Math.max(1, Math.ceil(tons / capacity));
} catch {
return null;

View File

@@ -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 {

View File

@@ -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[];

View File

@@ -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')

View File

@@ -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,

View File

@@ -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),

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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>[];
}

View 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,
};

View File

@@ -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);
}
}

View 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 {}

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -44,6 +44,19 @@ export class CreateCargoTypeDto {
@IsObject()
itemsPerWagonMap?: Record<string, number> | null;
@ApiPropertyOptional({
description:
'PER_TON cargo only: the most tons of this cargo one wagon may carry, keyed by ' +
'wagon-type id (e.g. { "<nw5-id>": 50 } loads sugar 50T on a 70T wagon, so 200T ' +
'takes 4 wagons). Optional — omit a wagon type to use its full rated capacity. ' +
'Rejected when it exceeds that wagon type\'s rated capacity.',
type: 'object',
additionalProperties: { type: 'number', minimum: 0.001 },
})
@IsOptional()
@IsObject()
tonsPerWagonMap?: Record<string, number> | null;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()

View File

@@ -60,6 +60,17 @@ export class CargoType extends BaseEntity {
@Column({ name: 'items_per_wagon_map', type: 'jsonb', nullable: true })
itemsPerWagonMap?: Record<string, number> | null;
/**
* PER_TON (bulk) only: the most tons of THIS cargo that may ride one wagon of
* each allowed type, keyed by wagon-type id (e.g. sugar → { NW5: 50 } on a
* 70T wagon). Caps both the wagon count and how much each wagon is loaded, so
* 200T of sugar takes 4 wagons at 50T rather than 3 at 70T. A missing key (or
* a null map) means the wagon's full rated capacity — unlike itemsPerWagonMap
* this is optional, so cargo without a loading limit is unaffected.
*/
@Column({ name: 'tons_per_wagon_map', type: 'jsonb', nullable: true })
tonsPerWagonMap?: Record<string, number> | null;
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
requiresDirectorApproval!: boolean;

View File

@@ -68,6 +68,7 @@ import { YardFacilitiesService } from './services/yard-facilities.service';
import { RuleEngineService } from './rule-engine.service';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
@@ -96,6 +97,9 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
]),
// Team notifications for the priority-rule approval workflow.
NotificationInboxModule,
// Rated wagon capacities — cargo types validate their per-wagon tonnage cap
// against them (a cap above the rating is a typo, not a policy).
WagonTypesModule,
],
controllers: [
CargoTypesController,

View File

@@ -6,6 +6,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { In } from 'typeorm';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
@@ -13,6 +14,7 @@ import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
import { CargoType } from '../entities/cargo-type.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
import {
CARGO_TYPES_REPOSITORY,
ICargoTypesRepository,
@@ -27,6 +29,7 @@ export class CargoTypesService {
private readonly repository: ICargoTypesRepository,
@Inject(RATES_REPOSITORY)
private readonly ratesRepository: IRatesRepository,
private readonly wagonTypesRepository: WagonTypesRepository,
private readonly displayOrder: DisplayOrderService,
) {}
@@ -75,6 +78,63 @@ export class CargoTypesService {
return map;
}
/**
* PER_TON (bulk) cargo may cap how many tons ride one wagon, BELOW that
* wagon's rated capacity: sugar at 50T on a 70T wagon means 200T takes 4
* wagons, not 3. Unlike the PER_ITEM fit this is optional — an absent key
* means the full rated capacity, so existing cargo types are unaffected.
*
* A cap ABOVE the rated capacity is rejected: nobody loads 90T on a 70T
* wagon, so it is a typo, and silently clamping it would leave the config
* screen showing a number the trains never honour. (Allocation clamps too, via
* `bulkTonsPerWagon`, for caps left stale by a later wagon-type edit — this
* check cannot see those, since the cargo type is never re-saved.)
*
* Returns the map trimmed to the allowed wagon types, or null when the cargo
* is not PER_TON / nothing is capped.
*/
private async resolveTonsPerWagonMap(input: {
unitOfMeasure?: CargoUnitOfMeasure | null;
wagonTypeIds: string[];
tonsPerWagonMap?: Record<string, number> | null;
}): Promise<Record<string, number> | null> {
if (input.unitOfMeasure !== CargoUnitOfMeasure.PerTon || !input.wagonTypeIds.length) {
return null;
}
const capped = input.wagonTypeIds.filter(
(id) => input.tonsPerWagonMap?.[id] !== undefined && input.tonsPerWagonMap[id] !== null,
);
if (!capped.length) return null;
const wagonTypes = await this.wagonTypesRepository.findAll({
where: { id: In(capped) },
});
const capacityById = new Map(
wagonTypes.map((wt) => [wt.id, Number(wt.capacityTons) || 0]),
);
const map: Record<string, number> = {};
for (const wagonTypeId of capped) {
const tons = Number(input.tonsPerWagonMap?.[wagonTypeId]);
if (!Number.isFinite(tons) || tons <= 0) {
throw new BadRequestException(
`tonsPerWagonMap for wagon type ${wagonTypeId} must be a number greater than 0`,
);
}
const capacity = capacityById.get(wagonTypeId);
if (capacity === undefined) {
throw new BadRequestException(`Wagon type ${wagonTypeId} not found`);
}
if (capacity > 0 && tons > capacity) {
throw new BadRequestException(
`Max tons per wagon (${tons}T) exceeds wagon type ${wagonTypeId} rated capacity ${capacity}T`,
);
}
map[wagonTypeId] = tons;
}
return map;
}
/** Create a new cargo type. */
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
const code = generateCode(dto.cargoTypeName);
@@ -90,6 +150,12 @@ export class CargoTypesService {
insertAfterId: dto.insertAfterId,
});
const tonsPerWagonMap = await this.resolveTonsPerWagonMap({
unitOfMeasure: dto.unitOfMeasure ?? null,
wagonTypeIds: dto.wagonTypeIds ?? [],
tonsPerWagonMap: dto.tonsPerWagonMap,
});
return this.repository.create({
code,
cargoTypeName: dto.cargoTypeName,
@@ -104,6 +170,7 @@ export class CargoTypesService {
wagonTypeIds: dto.wagonTypeIds ?? [],
itemsPerWagonMap: dto.itemsPerWagonMap,
}),
tonsPerWagonMap,
displayOrder,
});
}
@@ -116,12 +183,32 @@ export class CargoTypesService {
const parent = await this.repository.findById(dto.parentGroupId);
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
}
const { wagonTypeIds, itemsPerWagonMap, insertAfterId: _insertAfterId, ...columns } = dto;
const {
wagonTypeIds,
itemsPerWagonMap,
tonsPerWagonMap,
insertAfterId: _insertAfterId,
...columns
} = dto;
// Re-validate the fit map whenever anything it depends on moves — a partial
// update merges with the stored values so e.g. adding a wagon type without
// its fit still 400s. Untouched fields leave the stored map alone.
const touchesItemsFit =
wagonTypeIds !== undefined || itemsPerWagonMap !== undefined || dto.unitOfMeasure !== undefined;
// Same merge rule for the tonnage cap: re-resolve whenever the uom, the
// allowed wagon types, or the caps themselves move, so a wagon type added
// without a cap keeps its full rated capacity and a uom flip drops stale caps.
const touchesTonsCap =
wagonTypeIds !== undefined || tonsPerWagonMap !== undefined || dto.unitOfMeasure !== undefined;
const resolvedTonsPerWagonMap = touchesTonsCap
? await this.resolveTonsPerWagonMap({
unitOfMeasure:
dto.unitOfMeasure !== undefined ? dto.unitOfMeasure : existing.unitOfMeasure,
wagonTypeIds: wagonTypeIds ?? (existing.wagonTypes ?? []).map((wt) => wt.id),
tonsPerWagonMap:
tonsPerWagonMap !== undefined ? tonsPerWagonMap : existing.tonsPerWagonMap,
})
: undefined;
const updated = await this.repository.update(id, {
...columns,
...(wagonTypeIds
@@ -138,6 +225,7 @@ export class CargoTypesService {
}),
}
: {}),
...(touchesTonsCap ? { tonsPerWagonMap: resolvedTonsPerWagonMap } : {}),
});
if (!updated) throw new NotFoundException(`Cargo type ${id} not found`);
// A uom flip renames how existing rates bill (PER_TON ↔ PER_ITEM name the

View File

@@ -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;

View File

@@ -71,6 +71,7 @@ import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsRequired,
bulkTonsPerWagon,
bookingGrossWeightTons,
deriveTrainCapacityFromLocomotive,
sizePartialOfferWagons,
@@ -150,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;
@@ -3020,6 +3029,7 @@ export class BookingBatchService implements OnModuleInit {
: booking.status;
await manager.getRepository(Booking).update(bookingId, {
trainScheduleId: newScheduleId,
scheduledDate: schedule.scheduledDepartureDate,
status: restoredStatus,
// A paid booking still hunting for a wagon keeps its flag through the
// move — it only clears when wagons are actually assigned.
@@ -3038,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
@@ -3524,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
@@ -3852,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",
@@ -4099,8 +4231,15 @@ export class BookingBatchService implements OnModuleInit {
const capacityTons = this.dimsFor(booking, wagonDims).capacityTons;
const cargoTons = bookingCargoTons(booking);
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
// wagon), so divide by the cap where one is configured for this type.
const tonsPerWagon = bulkTonsPerWagon(
booking.cargoType,
booking.cargoType?.wagonTypes?.[0]?.id,
capacityTons,
);
const byWeight =
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
cargoTons > 0 && tonsPerWagon > 0 ? Math.ceil(cargoTons / tonsPerWagon) : 0;
// Break-bulk (PER_ITEM): indivisible items can need more wagons than raw
// tonnage suggests (floor items-per-wagon loses the fractional capacity).
@@ -4168,6 +4307,13 @@ export class BookingBatchService implements OnModuleInit {
.filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0)
.map((o) => {
const wagonTypeId = o.wagonTypeId as string;
// Each type sized on its OWN per-wagon tonnage cap, not just its rating
// — a type capped lower swallows less per wagon.
const tonsPerWagon = bulkTonsPerWagon(
booking.cargoType,
wagonTypeId,
o.dims.capacityTons,
);
const wagonsIfAlone = Math.max(
1,
bulkItemWagonsRequired(
@@ -4175,8 +4321,8 @@ export class BookingBatchService implements OnModuleInit {
o.dims.capacityTons,
bulkItemsFitFor(booking.cargoType, wagonTypeId),
) ||
(o.dims.capacityTons > 0
? Math.ceil(bookingCargoTons(booking) / o.dims.capacityTons)
(tonsPerWagon > 0
? Math.ceil(bookingCargoTons(booking) / tonsPerWagon)
: total),
);
return {

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -1,4 +1,4 @@
import { bookingCargoTons, bulkItemWagonsForAllowedTypes } from './train-capacity.util';
import { bookingCargoTons, bulkWagonsForAllowedTypes } from './train-capacity.util';
import type { Booking } from '../bookings/entities/booking.entity';
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
@@ -56,8 +56,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n
// holds the item count there, not tons. No wagon type is fixed yet, so use
// the best count across the cargo's allowed types (per-type items-fit
// respected); falls back to `capacity` when the relation isn't loaded.
const byItems = bulkItemWagonsForAllowedTypes(booking, booking.cargoType, capacity);
if (byItems > 0) return byItems;
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
// wagon), so tonnage divides by that cap, not by raw capacity.
const byWagons = bulkWagonsForAllowedTypes(booking, booking.cargoType, capacity);
if (byWagons > 0) return byWagons;
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
return Math.max(1, Math.ceil(weight / capacity));
}

View File

@@ -4,6 +4,10 @@ import {
bookingTrainLengthMeters,
bulkItemWagonsForAllowedTypes,
bulkItemWagonsRequired,
bulkTonsPerWagon,
bulkTonWagonsForAllowedTypes,
bulkTonWagonsRequired,
bulkWagonsForAllowedTypes,
consistUsage,
consistViolations,
deriveTrainCapacityFromLocomotive,
@@ -135,6 +139,61 @@ describe('train-capacity.util', () => {
});
});
describe('bulkTonsPerWagon / bulkTonWagonsRequired (PER_TON loading cap)', () => {
// Sugar is loaded 50T per wagon even on a 70T wagon.
const sugar = { wagonTypes: [{ id: 'nw5', capacityTons: 70 }], tonsPerWagonMap: { nw5: 50 } };
const bulk = (tons: number) => ({ freightType: 'BULK', cargoTotalWeightVgm: tons });
it('uses the configured cap instead of the rated capacity', () => {
expect(bulkTonsPerWagon(sugar, 'nw5', 70)).toBe(50);
});
it('falls back to rated capacity when the cargo type caps nothing', () => {
expect(bulkTonsPerWagon(null, 'nw5', 70)).toBe(70);
expect(bulkTonsPerWagon({ wagonTypes: [] }, 'nw5', 70)).toBe(70);
expect(bulkTonsPerWagon({ tonsPerWagonMap: { other: 50 } }, 'nw5', 70)).toBe(70);
});
it('clamps a stale cap that now exceeds the rating (wagon type edited down)', () => {
// Saved when NW5 was rated 70T; the type was later re-rated to 45T.
expect(bulkTonsPerWagon(sugar, 'nw5', 45)).toBe(45);
});
it('sizes 200T of capped sugar at 4 wagons, not the 3 raw capacity implies', () => {
expect(bulkTonWagonsRequired(bulk(200), sugar, 'nw5', 70)).toBe(4);
// Same booking, no cap → the old 3-wagon answer.
expect(bulkTonWagonsRequired(bulk(200), null, 'nw5', 70)).toBe(3);
});
it('picks the fewest-wagon allowed type, each on its own cap', () => {
const cargoType = {
wagonTypes: [
{ id: 'nw5', capacityTons: 70 },
{ id: 'nw7', capacityTons: 80 },
],
tonsPerWagonMap: { nw5: 50 },
};
// NW5 capped 50 → 4 wagons; NW7 uncapped 80 → 3 wagons. Best = 3.
expect(bulkTonWagonsForAllowedTypes(bulk(200), cargoType, 70)).toBe(3);
});
it('routes PER_ITEM and PER_TON through one call', () => {
expect(bulkWagonsForAllowedTypes(bulk(200), sugar, 70)).toBe(4);
// PER_ITEM still wins where an item count is present.
const cars = {
wagonTypes: [{ id: 'nw5', capacityTons: 70 }],
itemsPerWagonMap: { nw5: 4 },
};
expect(
bulkWagonsForAllowedTypes(
{ freightType: 'BULK', cargoTotalWeightVgm: 50, bulkTotalWeightTons: 1000 },
cars,
70,
),
).toBe(17);
});
});
describe('bookingCargoTons (break-bulk weight preference)', () => {
it('prefers bulkTotalWeightTons over the item-count VGM column', () => {
expect(

View File

@@ -152,8 +152,98 @@ export function bulkItemWagonsRequired(
type ItemFitCargoType = {
wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null;
itemsPerWagonMap?: Record<string, number> | null;
tonsPerWagonMap?: Record<string, number> | null;
} | null;
/**
* Tons of THIS cargo one wagon of this type may carry: the cargo type's
* configured loading limit when set, else the wagon's full rated capacity.
* Sugar capped at 50T rides 50T on a 70T wagon, so 200T needs 4 wagons and each
* is loaded to 50 — both the count and the fill follow from this one number.
*
* The configured cap is CLAMPED to the rated capacity rather than trusted: the
* cargo-types service rejects a cap above capacity at save time, but a wagon
* type edited DOWN afterwards would leave a stale cap that overloads the wagon.
* Clamping here means no call site can ever load past the physical rating.
*/
export function bulkTonsPerWagon(
cargoType: ItemFitCargoType | undefined,
wagonTypeId: string | null | undefined,
capacityTons: number | string | null | undefined,
): number {
const capacity = num(capacityTons);
const cap = wagonTypeId ? num(cargoType?.tonsPerWagonMap?.[wagonTypeId]) : 0;
if (!(cap > 0)) return capacity;
return capacity > 0 ? Math.min(cap, capacity) : cap;
}
/**
* Wagons a PER_TON bulk booking needs on one wagon type, respecting the cargo
* type's per-wagon loading limit: 200T of sugar capped at 50T → 4 wagons even
* though the wagon is rated 70T. Returns 0 when there is no tonnage or no
* usable per-wagon figure, so callers can fall back as before.
*/
export function bulkTonWagonsRequired(
booking: Parameters<typeof bookingCargoTons>[0],
cargoType: ItemFitCargoType | undefined,
wagonTypeId: string | null | undefined,
capacityTons: number | string | null | undefined,
): number {
const perWagon = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons);
const tons = bookingCargoTons(booking);
if (!(perWagon > 0) || !(tons > 0)) return 0;
return Math.max(1, Math.ceil(tons / perWagon));
}
/**
* Best (fewest-wagon) PER_TON count across the cargo type's allowed wagon
* types, each sized on its OWN loading limit — the tonnage twin of
* {@link bulkItemWagonsForAllowedTypes}, for the call sites that have no single
* wagon type fixed yet. Falls back to `fallbackCapacityTons` when the cargo
* type has no usable allowed types.
*/
export function bulkTonWagonsForAllowedTypes(
booking: Parameters<typeof bookingCargoTons>[0],
cargoType: ItemFitCargoType | undefined,
fallbackCapacityTons: number,
): number {
const allowed = (cargoType?.wagonTypes ?? []).filter((wt) => num(wt.capacityTons) > 0);
if (!allowed.length) {
return bulkTonWagonsRequired(booking, cargoType, null, fallbackCapacityTons);
}
let best = 0;
for (const wagonType of allowed) {
const wagons = bulkTonWagonsRequired(
booking,
cargoType,
wagonType.id,
wagonType.capacityTons,
);
if (wagons > 0 && (best === 0 || wagons < best)) best = wagons;
}
return best;
}
/**
* Wagons a BULK booking needs, whichever way its cargo is measured: PER_ITEM
* sizes by indivisible items, everything else by tonnage under the cargo type's
* per-wagon loading limit. One call so no site has to remember both paths.
*/
export function bulkWagonsForAllowedTypes(
booking: Parameters<typeof bookingCargoTons>[0] & {
freightType?: string | null;
cargoTotalWeightVgm?: number | string | null;
bulkTotalWeightTons?: number | string | null;
},
cargoType: ItemFitCargoType | undefined,
fallbackCapacityTons: number,
): number {
return (
bulkItemWagonsForAllowedTypes(booking, cargoType, fallbackCapacityTons) ||
bulkTonWagonsForAllowedTypes(booking, cargoType, fallbackCapacityTons)
);
}
/** Configured whole-items fit of one wagon type for a cargo type; null if unset. */
export function bulkItemsFitFor(
cargoType: ItemFitCargoType | undefined,

View File

@@ -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({

View File

@@ -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';

View File

@@ -138,6 +138,7 @@ import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsRequired,
bulkTonsPerWagon,
deriveTrainCapacityFromLocomotive,
combinedLocomotiveLimits,
trainSetLocomotiveLimits,
@@ -180,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
@@ -905,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(
@@ -1196,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)
@@ -1459,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 +
@@ -1505,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) }
@@ -1513,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
@@ -7452,8 +7534,10 @@ export class TrainSchedulingService {
? Math.ceil(booking.wagonsRequired)
: 0;
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
const byWeight =
cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0;
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
// wagon) — more wagons for the same cargo, so more tare to pull.
const tonsPerWagon = bulkTonsPerWagon(booking.cargoType, wagonTypeId, dims.capacityTons);
const byWeight = cargo > 0 && tonsPerWagon > 0 ? Math.ceil(cargo / tonsPerWagon) : 0;
// Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw
// tonnage suggests — their tare must be pulled too (batch dimsFor parity).
const byItems = bulkItemWagonsRequired(

View File

@@ -5,7 +5,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsForAllowedTypes,
bulkWagonsForAllowedTypes,
} from './train-capacity.util';
import {
sortBookingsForScheduling,
@@ -122,10 +122,11 @@ const shortageFor = (
? Math.max(
1,
// Break-bulk (PER_ITEM) sizes by indivisible items (items-fit map
// respected); PER_TON falls through to tonnage over the largest
// candidate. bookingCargoTons, not raw VGM — for PER_ITEM that
// column is the item count, not tons.
bulkItemWagonsForAllowedTypes(
// respected); PER_TON divides by its per-wagon tonnage cap where one
// is configured, else the largest candidate's rating.
// bookingCargoTons, not raw VGM — for PER_ITEM that column is the
// item count, not tons.
bulkWagonsForAllowedTypes(
booking,
booking.cargoType,
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),

View File

@@ -7,6 +7,8 @@ import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsRequired,
bulkTonsPerWagon,
bulkTonWagonsRequired,
consistViolations,
} from './train-capacity.util';
@@ -186,15 +188,29 @@ export function buildBulkWagonPlan(
bulkItemWagonsRequired(b, capacity, bulkItemsFitFor(b.cargoType, wagonType.id)),
);
const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0);
// PER_TON cargo with a per-wagon tonnage cap (sugar 50T on a 70T wagon) can't
// pool with uncapped tonnage either: its wagons stop at the cap, so 200T needs
// 4 wagons and pooling it at 70T would plan 3. Capped bookings are sized on
// their own cap; only genuinely uncapped tonnage pools at rated capacity.
const cappedTonSlotsByBooking = bookings.map((b, i) =>
itemSlotsByBooking[i] > 0 || bulkTonsPerWagon(b.cargoType, wagonType.id, capacity) >= capacity
? 0
: bulkTonWagonsRequired(b, b.cargoType, wagonType.id, capacity),
);
const cappedTonSlots = cappedTonSlotsByBooking.reduce((sum, n) => sum + n, 0);
const totalWeight = roundTons(
bookings.reduce(
(sum, b, i) =>
itemSlotsByBooking[i] > 0 ? sum : sum + Number(b.cargoTotalWeightVgm ?? 0),
itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0
? sum
: sum + Number(b.cargoTotalWeightVgm ?? 0),
0,
),
);
const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0;
const slots = Math.max(1, tonSlots + itemSlots);
const slots = Math.max(1, tonSlots + itemSlots + cappedTonSlots);
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
sequenceNo: index + 1,
@@ -315,6 +331,7 @@ function allocateBookingsToSlots(
// bookingCargoTons, not the raw VGM column: for break-bulk (PER_ITEM)
// bookings that column is an item COUNT, not tons.
remainingWeightTons: roundTons(bookingCargoTons(booking)),
cargoType: booking.cargoType,
}));
let bookingIndex = 0;
@@ -326,8 +343,15 @@ function allocateBookingsToSlots(
while (wagonRemaining > 0 && bookingIndex < remaining.length) {
const booking = remaining[bookingIndex];
// A PER_TON loading cap (sugar 50T on a 70T wagon) binds the FILL as well
// as the wagon count — the plan reserved a wagon per capped chunk, so
// pouring rated capacity into it would leave the last wagon empty.
const takeCap = Math.min(
wagonRemaining,
bulkTonsPerWagon(booking.cargoType, slot.wagonTypeId, slot.capacityTons),
);
const allocatedWeightTons = roundTons(
Math.min(wagonRemaining, booking.remainingWeightTons),
Math.min(takeCap, booking.remainingWeightTons),
);
if (allocatedWeightTons <= 0) {
@@ -350,6 +374,12 @@ function allocateBookingsToSlots(
if (booking.remainingWeightTons <= 0) {
bookingIndex += 1;
} else if (allocatedWeightTons >= takeCap) {
// The cap stopped this wagon short of its rating and the booking has
// more to load. The leftover room is NOT free: `buildBulkWagonPlan`
// already reserved a wagon for the rest, so backfilling another booking
// here would double-book the consist. Close the wagon.
break;
}
}

View File

@@ -0,0 +1,64 @@
import { applyDirectionScope, scopedDirections } from './trade-scope.util';
/**
* The scope decides what a restricted user may see, so the cases that matter
* are the ones where a wrong answer widens access: an unrestricted fallback
* where a restriction was configured, or an out-of-scope explicit filter
* being honoured instead of denied.
*/
describe('scopedDirections', () => {
it('leaves an unrestricted user unfiltered', () => {
expect(scopedDirections(null)).toBeNull();
});
it('honours an explicit filter for an unrestricted user', () => {
expect(scopedDirections(null, 'EXPORT')).toEqual(['EXPORT']);
});
it('falls back to the full scope when no filter is requested', () => {
expect(scopedDirections(['EXPORT'])).toEqual(['EXPORT']);
});
it('narrows to the intersection when the filter is in scope', () => {
expect(scopedDirections(['IMPORT', 'EXPORT'], 'EXPORT')).toEqual(['EXPORT']);
});
it('denies an out-of-scope filter instead of widening access', () => {
expect(scopedDirections(['EXPORT'], 'IMPORT')).toEqual([]);
});
});
describe('applyDirectionScope', () => {
const makeQb = () => {
const calls: { sql: string; params?: object }[] = [];
const qb = {
calls,
andWhere(sql: string, params?: object) {
calls.push({ sql, params });
return qb;
},
};
return qb;
};
it('does not touch the query when unrestricted', () => {
const qb = makeQb();
applyDirectionScope(qb as never, 'booking.trade_direction', null);
expect(qb.calls).toHaveLength(0);
});
it('matches nothing on an empty scope rather than everything', () => {
const qb = makeQb();
applyDirectionScope(qb as never, 'booking.trade_direction', []);
expect(qb.calls[0].sql).toBe('1 = 0');
});
it('filters to the allowed directions', () => {
const qb = makeQb();
applyDirectionScope(qb as never, 'booking.trade_direction', ['EXPORT']);
expect(qb.calls[0].sql).toContain('booking.trade_direction IN');
expect(qb.calls[0].params).toEqual({
scopeDirs_booking_trade_direction: ['EXPORT'],
});
});
});

View File

@@ -29,11 +29,13 @@ type SeedOrganization = {
export class EdrOrgSeeder {
private readonly logger = new Logger(EdrOrgSeeder.name);
constructor(private readonly dataSource: DataSource) {}
constructor(private readonly dataSource: DataSource) { }
async run() {
if (!this.shouldSeed()) {
this.logger.log(`Skipping EDR org seed because ${SEED_FLAG} is not enabled`);
this.logger.log(
`Skipping EDR org seed because ${SEED_FLAG} is not enabled`,
);
return;
}
@@ -95,20 +97,22 @@ export class EdrOrgSeeder {
manager: EntityManager,
organizationId: string,
) {
const organizationConfigurationRepository =
manager.getRepository(OrganizationConfiguration);
await organizationConfigurationRepository.upsert({
organizationId,
canCreateBranchByItself: true,
canStartReceivingRecord: true,
}, {
conflictPaths: { organizationId: true },
});
this.logger.log(
`Ensured organization configuration for '${EDR_ORG_KEY}'`,
const organizationConfigurationRepository = manager.getRepository(
OrganizationConfiguration,
);
await organizationConfigurationRepository.upsert(
{
organizationId,
canCreateBranchByItself: true,
canStartReceivingRecord: true,
},
{
conflictPaths: { organizationId: true },
},
);
this.logger.log(`Ensured organization configuration for '${EDR_ORG_KEY}'`);
}
private async ensureDefaultUnit(
@@ -152,7 +156,9 @@ export class EdrOrgSeeder {
key: EDR_FREIGHT_APPLICATION.key,
name: { ...EDR_FREIGHT_APPLICATION.name },
});
this.logger.log(`Seeded EDR application '${EDR_FREIGHT_APPLICATION.key}'`);
this.logger.log(
`Seeded EDR application '${EDR_FREIGHT_APPLICATION.key}'`,
);
return { id: insertResult.identifiers[0]?.id as string };
}
@@ -181,7 +187,8 @@ export class EdrOrgSeeder {
applicationId,
})),
)
.orUpdate(["name", "application_id"], ["key"])
.orIgnore()
// .orUpdate(["name", "application_id"], ["key"])
.execute();
this.logger.log(

View File

@@ -2,6 +2,7 @@ import {
ArrowLeftRight,
Boxes,
Building2,
BarChart3,
Container,
FileSignature,
FileText,
@@ -71,6 +72,8 @@ import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
import InvoicesPage from "./pages/invoices/InvoicesPage";
import MyProfilePage from "./pages/dashboard/MyProfilePage";
import OverviewPage from "./pages/dashboard/OverviewPage";
import ReportsHubPage from "./pages/reports/ReportsHubPage";
import ReportPage from "./pages/reports/ReportPage";
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
import PaymentsPage from "./pages/payments/PaymentsPage";
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
@@ -154,6 +157,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.overview.view,
},
{
label: "Reports",
href: "/dashboard/reports",
icon: <BarChart3 />,
permission: FREIGHT_PERMS.bookings.view,
},
{
label: "Customers",
href: "/dashboard/customers",
@@ -823,6 +832,8 @@ const App = () => {
/>
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="reports" element={<ReportsHubPage />} />
<Route path="reports/:reportKey" element={<ReportPage />} />
{/* Dev/testing page for the mock AI booking assistant. */}
<Route
path="ai-booking-mock-test"

View File

@@ -1,90 +0,0 @@
import { useNavigate } from "react-router-dom";
import { ArrowRight, FileText, Train, Users } from "lucide-react";
import { Card, Group, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
const links = [
{
title: "Booking requests",
description: "Review and action incoming freight bookings",
href: "/dashboard/booking-requests",
icon: FileText,
permission: [FREIGHT_PERMS.bookings.view],
},
{
title: "Train scheduling v2",
description: "Full allocation workflow — assign, pin wagons, finalize",
href: "/dashboard/operations/train-scheduling-v2",
icon: Train,
permission: [FREIGHT_PERMS.trainScheduling.view],
},
{
title: "Trains",
description: "Manage train master data and fleet status",
href: "/dashboard/trains",
icon: Train,
permission: [FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.trains.view],
},
{
title: "User management",
description: "Employees, roles, and permissions",
href: "/user-management",
icon: Users,
permission: [
FREIGHT_PERMS.admin,
FREIGHT_PERMS.staff.roles.view,
FREIGHT_PERMS.staff.employeeRegistration.view,
FREIGHT_PERMS.staff.roleAssignment.view,
],
},
];
export function OverviewQuickLinks() {
const navigate = useNavigate();
const { user } = useAuth();
const visible = links.filter((link) =>
link.permission.some((key) => hasPermission(user, key)),
);
if (!visible.length) return null;
return (
<Stack gap="md" h="100%">
<Text fw={600}>Quick links</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
{visible.map((link) => {
const Icon = link.icon;
return (
<Card
key={link.href}
p="md"
radius="lg"
withBorder
style={{ cursor: "pointer" }}
onClick={() => navigate(link.href)}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group align="flex-start" gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="edr-green" size="lg" radius="md">
<Icon size={18} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={600} size="sm">
{link.title}
</Text>
<Text size="xs" c="dimmed">
{link.description}
</Text>
</Stack>
</Group>
<ArrowRight size={16} color="var(--mantine-color-gray-5)" />
</Group>
</Card>
);
})}
</SimpleGrid>
</Stack>
);
}

View File

@@ -0,0 +1,82 @@
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Paper, Stack, Text } from "@mantine/core";
export interface StackedBarSeries {
/** Key into each data row holding this series' value. */
key: string;
label: string;
color: string;
}
interface OverviewStackedBarChartProps<T extends object> {
title: string;
data: T[];
/** Fixed order + fixed color per series — colors follow the entity, not the rank. */
series: StackedBarSeries[];
xKey?: string;
emptyMessage?: string;
formatXLabel?: (value: string) => string;
}
export function OverviewStackedBarChart<T extends object>({
title,
data,
series,
xKey = "date",
emptyMessage = "No data available",
formatXLabel,
}: OverviewStackedBarChartProps<T>) {
const hasData = data.some((row) =>
series.some((s) => Number((row as Record<string, unknown>)[s.key]) > 0),
);
return (
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
<Stack gap="sm" h="100%">
<Text fw={600}>{title}</Text>
{!hasData ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
{emptyMessage}
</Text>
) : (
<ResponsiveContainer width="100%" height={230}>
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" vertical={false} />
<XAxis
dataKey={xKey}
tickFormatter={formatXLabel}
tick={{ fontSize: 11 }}
stroke="#94a3b8"
/>
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} stroke="#94a3b8" />
<Tooltip labelFormatter={formatXLabel && ((v) => formatXLabel(String(v)))} />
<Legend iconType="circle" iconSize={9} />
{series.map((s, index) => (
<Bar
key={s.key}
dataKey={s.key}
name={s.label}
stackId="stack"
fill={s.color}
stroke="#ffffff"
strokeWidth={1}
barSize={18}
radius={index === series.length - 1 ? [4, 4, 0, 0] : undefined}
/>
))}
</BarChart>
</ResponsiveContainer>
)}
</Stack>
</Paper>
);
}

View File

@@ -14,6 +14,7 @@ import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel";
import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel";
import { OverviewContractsTabPanel } from "./tabs/OverviewContractsTabPanel";
import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel";
import { OverviewFleetTabPanel } from "./tabs/OverviewFleetTabPanel";
import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel";
import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel";
@@ -36,7 +37,12 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
const bookings = useOverviewBookingsTab(range, tab === "bookings");
const contracts = useOverviewContractsTab(range, tab === "contracts");
const billing = useOverviewBillingTab(range, tab === "billing");
const operations = useOverviewOperationsTab(tab === "operations");
// Fleet reuses the operations dataset — same query key, so switching between
// the two tabs costs one fetch.
const operations = useOverviewOperationsTab(
range,
tab === "operations" || tab === "fleet",
);
const customers = useOverviewCustomersTab(range, tab === "customers");
const staff = useOverviewStaffTab(range, tab === "staff");
@@ -47,7 +53,7 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
? contracts
: tab === "billing"
? billing
: tab === "operations"
: tab === "operations" || tab === "fleet"
? operations
: tab === "customers"
? customers
@@ -99,6 +105,9 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
{tab === "operations" && operations.data && (
<OverviewOperationsTabPanel data={operations.data} />
)}
{tab === "fleet" && operations.data && (
<OverviewFleetTabPanel data={operations.data} />
)}
{tab === "customers" && customers.data && (
<OverviewCustomersTabPanel data={customers.data} />
)}

View File

@@ -24,6 +24,13 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
<Stack gap="lg">
<OverviewKpiStrip
items={[
{
label: "Total bookings",
value: data.kpis.total,
icon: FileText,
accent: "gold",
hint: "All time",
},
{
label: "Active bookings",
value: data.kpis.totalActive,
@@ -71,7 +78,7 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="By status"
data={data.bookingsByStatus.map((item) => ({
@@ -81,10 +88,20 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
emptyMessage="No bookings yet"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewHorizontalBarChart
title="By freight type"
data={data.bookingsByFreightType.map((item) => ({
label: item.label,
value: item.count,
}))}
valueLabel="Bookings"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="By payment currency"
data={data.bookingsByCurrency.map((item) => ({
name: item.label,
value: item.count,
}))}
@@ -92,14 +109,6 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
</Grid.Col>
</Grid>
<OverviewHorizontalBarChart
title="By payment currency"
data={data.bookingsByCurrency.map((item) => ({
label: item.label,
value: item.count,
}))}
/>
<OverviewRecentBookingsTable bookings={data.recentBookings} />
</Stack>
);

View File

@@ -41,6 +41,13 @@ export function OverviewContractsTabPanel({
<Stack gap="lg">
<OverviewKpiStrip
items={[
{
label: "Total contracts",
value: data.kpis.total,
icon: FileSignature,
accent: "gold",
hint: "All time",
},
{
label: "Active contracts",
value: data.kpis.totalActive,
@@ -94,7 +101,7 @@ export function OverviewContractsTabPanel({
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="By status"
data={data.contractsByStatus.map((item) => ({
@@ -104,7 +111,7 @@ export function OverviewContractsTabPanel({
emptyMessage="No contracts yet"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="By kind"
data={data.contractsByKind.map((item) => ({
@@ -113,16 +120,17 @@ export function OverviewContractsTabPanel({
}))}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="By freight type"
data={data.contractsByFreightType.map((item) => ({
name: item.label === "CONTAINER" ? "Container" : "Bulk",
value: item.count,
}))}
/>
</Grid.Col>
</Grid>
<OverviewHorizontalBarChart
title="By freight type"
data={data.contractsByFreightType.map((item) => ({
label: item.label === "CONTAINER" ? "Container" : "Bulk",
value: item.count,
}))}
/>
<OverviewRecentContractsTable contracts={data.recentContracts} />
</Stack>
);

View File

@@ -0,0 +1,115 @@
import { Train, Truck, Wrench } from "lucide-react";
import { Grid, Stack } from "@mantine/core";
import type { IOverviewOperationsTab, IOverviewStatusCount } from "@/types/overview";
import { OverviewDonutChart } from "../OverviewDonutChart";
import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart";
import { OverviewKpiStrip } from "../OverviewKpiStrip";
function formatStatusLabel(status: string) {
return status
.replace(/_/g, " ")
.toLowerCase()
.replace(/\b\w/g, (char) => char.toUpperCase());
}
function sumCounts(items: IOverviewStatusCount[]) {
return items.reduce((sum, item) => sum + item.count, 0);
}
function countByStatus(items: IOverviewStatusCount[], status: string) {
return items.find((item) => item.status === status)?.count ?? 0;
}
function toDonutData(items: IOverviewStatusCount[]) {
return items.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
}));
}
interface OverviewFleetTabPanelProps {
data: IOverviewOperationsTab;
}
export function OverviewFleetTabPanel({ data }: OverviewFleetTabPanelProps) {
return (
<Stack gap="lg">
<OverviewKpiStrip
items={[
{
label: "Total trains",
value: sumCounts(data.trainStatusBreakdown),
icon: Train,
accent: "gold",
hint: "All time",
},
{
label: "Active trains",
value: data.kpis.trainsActive,
icon: Train,
accent: "emerald",
},
{
label: "Total wagons",
value: sumCounts(data.wagonStatusBreakdown),
icon: Truck,
accent: "gold",
hint: "All time",
},
{
label: "Wagons available",
value: data.kpis.wagonsAvailable,
icon: Truck,
accent: "emerald",
},
{
label: "In maintenance",
value: countByStatus(data.wagonStatusBreakdown, "MAINTENANCE"),
icon: Wrench,
accent: "amber",
},
]}
/>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewHorizontalBarChart
title="Wagon fleet by type"
data={data.wagonsByType.map((item) => ({
label: item.label,
value: item.count,
}))}
valueLabel="Wagons"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewHorizontalBarChart
title="Wagons by yard"
data={data.wagonsByYard.map((item) => ({
label: item.label,
value: item.count,
}))}
valueLabel="Wagons"
emptyMessage="No wagons assigned to yards"
/>
</Grid.Col>
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Train status"
data={toDonutData(data.trainStatusBreakdown)}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Wagon status"
data={toDonutData(data.wagonStatusBreakdown)}
/>
</Grid.Col>
</Grid>
</Stack>
);
}

View File

@@ -1,13 +1,25 @@
import { Box, Container as ContainerIcon, Train, Truck } from "lucide-react";
import {
Box,
CalendarClock,
Container as ContainerIcon,
Send,
Train,
Truck,
} from "lucide-react";
import { Grid, Stack } from "@mantine/core";
import type { IOverviewOperationsTab } from "@/types/overview";
import { OverviewDonutChart } from "../OverviewDonutChart";
import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart";
import { OverviewKpiStrip } from "../OverviewKpiStrip";
import { OverviewStackedBarChart } from "../OverviewStackedBarChart";
interface OverviewOperationsTabPanelProps {
data: IOverviewOperationsTab;
}
/** Fixed direction colors (CVD-validated pair + violet): color follows the entity. */
const DIRECTION_SERIES = [
{ key: "exportCount", label: "Export", color: "#D98A0B" },
{ key: "importCount", label: "Import", color: "#0369a1" },
{ key: "domesticCount", label: "Domestic", color: "#7c3aed" },
];
function formatStatusLabel(status: string) {
return status
@@ -16,6 +28,22 @@ function formatStatusLabel(status: string) {
.replace(/\b\w/g, (char) => char.toUpperCase());
}
function formatDateLabel(date: string) {
const parsed = new Date(`${date}T00:00:00`);
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
function toDonutData(items: { status: string; count: number }[]) {
return items.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
}));
}
interface OverviewOperationsTabPanelProps {
data: IOverviewOperationsTab;
}
export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelProps) {
return (
<Stack gap="lg">
@@ -27,6 +55,19 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP
icon: Train,
accent: "emerald",
},
{
label: "Upcoming departures",
value: data.kpis.schedulesUpcoming,
icon: CalendarClock,
accent: "sky",
hint: "Scheduled, not yet departed",
},
{
label: "Dispatched today",
value: data.kpis.dispatchedToday,
icon: Send,
accent: "amber",
},
{
label: "Wagons available",
value: data.kpis.wagonsAvailable,
@@ -46,40 +87,58 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP
/>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<Grid.Col span={{ base: 12, lg: 8 }}>
<OverviewStackedBarChart
title="Train departures by direction"
data={data.departureTrend}
series={DIRECTION_SERIES}
formatXLabel={formatDateLabel}
emptyMessage="No scheduled departures in this period"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewDonutChart
title="Train status"
data={data.trainStatusBreakdown.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
title="Schedule status"
data={toDonutData(data.scheduleStatusBreakdown)}
emptyMessage="No train schedules yet"
/>
</Grid.Col>
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewHorizontalBarChart
title="Cargo tonnage by type"
data={data.cargoTonnageByType.map((item) => ({
label: item.label,
value: item.tons,
}))}
valueLabel="Tons"
emptyMessage="No cargo recorded"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Wagon status"
data={data.wagonStatusBreakdown.map((item) => ({
name: formatStatusLabel(item.status),
title="Containers by size"
data={data.containersBySize.map((item) => ({
name: item.label,
value: item.count,
}))}
/>
</Grid.Col>
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Container status"
data={data.containerStatusBreakdown.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
}))}
data={toDonutData(data.containerStatusBreakdown)}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Cargo status"
data={data.cargoStatusBreakdown.map((item) => ({
name: formatStatusLabel(item.status),
value: item.count,
}))}
data={toDonutData(data.cargoStatusBreakdown)}
/>
</Grid.Col>
</Grid>

View File

@@ -0,0 +1,366 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Divider,
Group,
Loader,
NumberInput,
Select,
Stack,
Switch,
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Info, Moon, Sun } from "lucide-react";
import DurationField from "@/components/trainScheduling/DurationField";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import type { CreateScheduleWindowRulePayload } from "@/types/trainScheduling";
/** Fallbacks matching the API's global-rules defaults (used if the fetch fails). */
const DEFAULTS = {
windowOpenHour: 8,
windowCloseHour: 17,
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
importWindowLeadDays: 3,
exportBookingLeadHours: 24,
};
/** 12-hour label for an EAT hour 023, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
function hourLabel(hour: number): string {
const period = hour < 12 ? "AM" : "PM";
const h12 = hour % 12 === 0 ? 12 : hour % 12;
return `${h12}:00 ${period}`;
}
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({
value: String(h),
label: `${hourLabel(h)} · ${String(h).padStart(2, "0")}:00`,
}));
export interface WindowFormState {
windowOpenHour: number;
windowCloseHour: number;
windowDurationHours: number | "";
docReviewMinutes: number | "";
paymentWindowMinutes: number | "";
importWindowLeadDays: number | "";
exportBookingLeadHours: number | "";
/** Blank = close exactly at departure. */
closeOffsetMinutes: number | "";
}
/**
* Builds the create payload from form state, or returns an error message when a
* required field was left blank. The close offset is direction-scoped: only the
* offset matching this schedule's direction is sent, since the other is never read.
*/
export function buildWindowRulePayload(
form: WindowFormState,
isExport: boolean,
): { payload: CreateScheduleWindowRulePayload } | { error: string } {
const duration = Number(form.windowDurationHours);
const doc = Number(form.docReviewMinutes);
const pay = Number(form.paymentWindowMinutes);
const lead = Number(form.importWindowLeadDays);
const exportLead = Number(form.exportBookingLeadHours);
const leadInvalid = isExport
? form.exportBookingLeadHours === "" || !Number.isFinite(exportLead) || exportLead < 1
: form.importWindowLeadDays === "" || !Number.isFinite(lead);
if (
form.windowDurationHours === "" ||
form.docReviewMinutes === "" ||
form.paymentWindowMinutes === "" ||
!Number.isFinite(duration) ||
!Number.isFinite(doc) ||
!Number.isFinite(pay) ||
leadInvalid
) {
return { error: "Fill every booking-window field, or turn the toggle off" };
}
// Blank offset = close at departure. Sent as null (not omitted) so it wins
// over a non-null global offset.
const offset = form.closeOffsetMinutes === "" ? null : Number(form.closeOffsetMinutes);
return {
payload: {
windowOpenHour: form.windowOpenHour,
windowCloseHour: form.windowCloseHour,
windowDurationHours: duration,
docReviewMinutes: doc,
paymentWindowMinutes: pay,
...(isExport
? { exportBookingLeadHours: exportLead, exportCloseOffsetMinutes: offset }
: { importWindowLeadDays: lead, importCloseOffsetMinutes: offset }),
},
};
}
export interface CreateScheduleWindowFieldsProps {
/** Direction of the selected route — picks lead/offset semantics. */
isExport: boolean;
form: WindowFormState | null;
onChange: (next: WindowFormState) => void;
}
/**
* Booking-window settings for a schedule being created. Prefills from the live
* global rules (so the fields show what the schedule WOULD inherit), then lets
* staff tune them for this one train. Mirrors BookingWindowSettingsModal, plus
* the booking-close offset.
*/
export default function CreateScheduleWindowFields({
isExport,
form,
onChange,
}: CreateScheduleWindowFieldsProps) {
const rulesQuery = useQuery({
queryKey: ["train-scheduling", "global-rules"],
queryFn: () => trainSchedulingService.getGlobalRules(),
staleTime: 5 * 60_000,
});
// Seed once from the global rules, so the toggle opens on the values this
// schedule would otherwise inherit rather than on hardcoded guesses.
const [seeded, setSeeded] = useState(false);
useEffect(() => {
if (seeded || form != null) return;
const r = rulesQuery.data;
if (!r && rulesQuery.isLoading) return;
const num = (v: unknown, fallback: number) => {
const n = v == null || v === "" ? NaN : Number(v);
return Number.isFinite(n) ? n : fallback;
};
const offset = isExport
? (r as { exportCloseOffsetMinutes?: number | null } | undefined)
?.exportCloseOffsetMinutes
: (r as { importCloseOffsetMinutes?: number | null } | undefined)
?.importCloseOffsetMinutes;
onChange({
windowOpenHour: num(r?.windowOpenHour, DEFAULTS.windowOpenHour),
windowCloseHour: num(r?.windowCloseHour, DEFAULTS.windowCloseHour),
windowDurationHours: num(r?.windowDurationHours, DEFAULTS.windowDurationHours),
docReviewMinutes: num(r?.docReviewMinutes, DEFAULTS.docReviewMinutes),
paymentWindowMinutes: num(
isExport
? (r as { exportPaymentWindowMinutes?: number } | undefined)
?.exportPaymentWindowMinutes
: r?.paymentWindowMinutes,
DEFAULTS.paymentWindowMinutes,
),
importWindowLeadDays: num(r?.importWindowLeadDays, DEFAULTS.importWindowLeadDays),
exportBookingLeadHours: num(
r?.exportBookingLeadHours,
DEFAULTS.exportBookingLeadHours,
),
closeOffsetMinutes: offset == null || offset === 0 ? "" : Number(offset),
});
setSeeded(true);
}, [seeded, form, rulesQuery.data, rulesQuery.isLoading, isExport, onChange]);
const set = (patch: Partial<WindowFormState>) => {
if (form) onChange({ ...form, ...patch });
};
const is24h = form != null && form.windowOpenHour === form.windowCloseHour;
// Close < open is a valid OVERNIGHT desk (e.g. 08:00 → 07:00 next morning).
const isOvernight = form != null && form.windowCloseHour < form.windowOpenHour;
const reopenSummary = useMemo(() => {
if (!form) return "";
const total = (Number(form.docReviewMinutes) || 0) + (Number(form.paymentWindowMinutes) || 0);
const h = Math.floor(total / 60);
const m = total % 60;
const parts = [h ? `${h}h` : "", m ? `${m}m` : ""].filter(Boolean);
return parts.length ? parts.join(" ") : "0m";
}, [form]);
if (!form) {
return (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
);
}
return (
<Stack gap="lg">
{isExport ? (
<Alert variant="light" color="blue" icon={<Info size={16} />}>
Export schedules use a single first-come-first-served window: it opens the
export lead time before departure shifted to the next desk opening if that
lands outside desk hours and stays open until it closes. Cycle timing below
doesn&apos;t apply.
</Alert>
) : (
<Alert variant="light" color="orange" icon={<Info size={16} />}>
These settings apply to this train only, and can be set only for the FIRST
train on a route and departure day. Later trains that day join its booking
group and share the same window.
</Alert>
)}
{/* ── Daily desk hours ─────────────────────────────────────────── */}
<Box>
<Group justify="space-between" align="center" mb={6}>
<Text size="sm" fw={600}>
Daily desk hours (EAT)
</Text>
{is24h ? (
<Badge variant="light" color="grape" leftSection={<Moon size={12} />}>
24-hour desk
</Badge>
) : (
<Badge variant="light" color="edr-green" leftSection={<Sun size={12} />}>
{hourLabel(form.windowOpenHour)} {hourLabel(form.windowCloseHour)}
</Badge>
)}
</Group>
<Group grow align="flex-start">
<Select
label="Opens"
data={HOUR_OPTIONS}
value={String(form.windowOpenHour)}
onChange={(v) => v != null && set({ windowOpenHour: Number(v) })}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
<Select
label="Closes"
data={HOUR_OPTIONS}
value={String(form.windowCloseHour)}
onChange={(v) => v != null && set({ windowCloseHour: Number(v) })}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
</Group>
{isOvernight && !is24h ? (
<Text size="xs" c="dimmed" mt={4}>
Overnight desk opens {form.windowOpenHour}:00 and runs past midnight,
closing {form.windowCloseHour}:00 the next morning.
</Text>
) : null}
<Switch
mt="sm"
size="sm"
color="grape"
label="Run 24 hours a day (never pause overnight)"
checked={is24h}
onChange={(e) =>
set({
// On → close == open (24h desk). Off → restore a normal ~9h day.
windowCloseHour: e.currentTarget.checked
? form.windowOpenHour
: Math.min(23, form.windowOpenHour + 9),
})
}
/>
</Box>
<Divider />
{/* ── Cycle timing ─────────────────────────────────────────────── */}
<Box>
<Text size="sm" fw={600} mb={6}>
Cycle timing
</Text>
<Stack gap="sm">
<DurationField
label="Window duration"
description="How long each booking cycle stays open before it closes for review"
value={form.windowDurationHours}
nativeUnit="hours"
onChange={(v) => set({ windowDurationHours: v })}
min={0.0166}
disabled={isExport}
/>
<Group grow align="flex-start">
<DurationField
label="Document review"
description="Staff time to accept documents after the window closes"
value={form.docReviewMinutes}
nativeUnit="minutes"
onChange={(v) => set({ docReviewMinutes: v })}
min={0}
disabled={isExport}
/>
<DurationField
label="Payment window"
description="Time a selected customer has to pay"
value={form.paymentWindowMinutes}
nativeUnit="minutes"
onChange={(v) => set({ paymentWindowMinutes: v })}
min={1}
/>
</Group>
{!isExport ? (
<Text size="xs" c="dimmed">
Reopen gap after each cycle = document review + payment ={" "}
<b>{reopenSummary}</b>.
</Text>
) : null}
</Stack>
</Box>
<Divider />
{/* ── Lead time ────────────────────────────────────────────────── */}
{isExport ? (
<NumberInput
label="Export booking lead (hours)"
description="How many hours before departure the export booking window opens"
value={form.exportBookingLeadHours}
onChange={(v) => set({ exportBookingLeadHours: v === "" ? "" : Number(v) })}
min={1}
clampBehavior="none"
allowNegative={false}
allowDecimal={false}
/>
) : (
<NumberInput
label="Window lead (days)"
description="How many days before departure the booking window starts"
value={form.importWindowLeadDays}
onChange={(v) => set({ importWindowLeadDays: v === "" ? "" : Number(v) })}
min={0}
clampBehavior="none"
allowNegative={false}
allowDecimal={false}
/>
)}
<Divider />
{/* ── Booking close offset ─────────────────────────────────────── */}
<Box>
<Text size="sm" fw={600} mb={2}>
Booking close offset
</Text>
<Text size="xs" c="dimmed" mb={8}>
How long before departure this schedule stops accepting bookings. e.g. a
3-hour import offset closes a 17:00 departure&apos;s window at 14:00; a 1-day
export offset closes a Jul-10 16:00 departure at Jul-9 16:00. Leave blank to
close exactly at departure.
</Text>
<DurationField
label={isExport ? "Export close offset" : "Import close offset"}
description={
isExport
? "This export booking window closes this long before departure (blank = at departure)"
: "This import booking window closes this long before departure (blank = at departure)"
}
value={form.closeOffsetMinutes}
nativeUnit="minutes"
onChange={(v) => set({ closeOffsetMinutes: v })}
min={0}
/>
</Box>
</Stack>
);
}

View File

@@ -184,7 +184,8 @@ export const QUERY_KEYS = {
["overview", "contracts", range ?? "30d"] as const,
billingTab: (range?: string) =>
["overview", "billing", range ?? "30d"] as const,
operationsTab: () => ["overview", "operations"] as const,
operationsTab: (range?: string) =>
["overview", "operations", range ?? "30d"] as const,
customersTab: (range?: string) =>
["overview", "customers", range ?? "30d"] as const,
staffTab: (range?: string) =>

View File

@@ -109,6 +109,10 @@ export const URL_CONSTANTS = {
BY_USER_ID: (id: string) => `/api/customers/user/${id}`,
},
REPORTS: {
RUN: (key: string) => `/reports/${key}`,
},
OVERVIEW: {
BASE: "/overview",
BOOKINGS: "/overview/bookings",
@@ -343,6 +347,10 @@ export const URL_CONSTANTS = {
`/train-scheduling/bookings/${bookingId}/expire`,
MOVE_BOOKING_SCHEDULE: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/move-schedule`,
ALLOCATION_CANDIDATES: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/allocation-candidates`,
ALLOCATE_BOOKING: (bookingId: string) =>
`/train-scheduling/bookings/${bookingId}/allocate`,
GLOBAL_RULES: "/train-scheduling/global-rules",
BOOKING_WINDOWS: "/train-scheduling/booking-windows",
PREVIEW: "/train-scheduling/preview",

View File

@@ -35,10 +35,10 @@ export function useOverviewBillingTab(range: OverviewRange, enabled: boolean) {
});
}
export function useOverviewOperationsTab(enabled: boolean) {
export function useOverviewOperationsTab(range: OverviewRange, enabled: boolean) {
return useQuery({
queryKey: QUERY_KEYS.OVERVIEW.operationsTab(),
queryFn: () => overviewService.getOperationsTab(),
queryKey: QUERY_KEYS.OVERVIEW.operationsTab(range),
queryFn: () => overviewService.getOperationsTab(range),
enabled,
});
}

View File

@@ -4,7 +4,9 @@ import {
Box,
Button,
Card,
Checkbox,
Group,
Modal,
MultiSelect,
Select,
Stack,
@@ -50,7 +52,10 @@ import {
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import type { AllocationCandidate } from "@/types/trainScheduling";
import type { BookingListRow } from "@/types/booking";
import { useToast } from "@/hooks/use-toast";
import {
Badge,
DataTable,
@@ -155,6 +160,15 @@ export default function BookingRequestsPage() {
const [scheduledTo, setScheduledTo] = useState<Date | null>(null);
const [allocateOpen, setAllocateOpen] = useState(false);
const [allocateIds, setAllocateIds] = useState<string[]>([]);
// Paid bookings with no train attached (staff removed them or a sweep
// detached them) — the queue the per-row Allocate action works through.
const [paidUnallocated, setPaidUnallocated] = useState(false);
const [allocatingId, setAllocatingId] = useState<string | null>(null);
const [otherDayModal, setOtherDayModal] = useState<{
booking: BookingListRow;
candidates: AllocationCandidate[];
} | null>(null);
const { toast } = useToast();
const suppressRowClickRef = useRef(false);
const suppressRowClick = useCallback(() => {
suppressRowClickRef.current = true;
@@ -187,6 +201,10 @@ export default function BookingRequestsPage() {
...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
...(paymentStatusFilter ? { paymentStatus: paymentStatusFilter } : {}),
// Wins over the payment-status select — the queue is by definition PAID.
...(paidUnallocated
? { paymentStatus: "PAID", assignedToSchedule: "false" as const }
: {}),
...(ownershipFilter
? { isGovernment: ownershipFilter as "true" | "false" }
: {}),
@@ -208,6 +226,7 @@ export default function BookingRequestsPage() {
directionFilter,
freightTypeFilter,
paymentStatusFilter,
paidUnallocated,
ownershipFilter,
originYardFilter,
destinationYardFilter,
@@ -251,6 +270,7 @@ export default function BookingRequestsPage() {
(directionFilter ? 1 : 0) +
(freightTypeFilter ? 1 : 0) +
(paymentStatusFilter ? 1 : 0) +
(paidUnallocated ? 1 : 0) +
(ownershipFilter ? 1 : 0) +
(originYardFilter ? 1 : 0) +
(destinationYardFilter ? 1 : 0) +
@@ -263,6 +283,7 @@ export default function BookingRequestsPage() {
setDirectionFilter(null);
setFreightTypeFilter(null);
setPaymentStatusFilter(null);
setPaidUnallocated(false);
setOwnershipFilter(null);
setOriginYardFilter(null);
setDestinationYardFilter(null);
@@ -301,6 +322,67 @@ export default function BookingRequestsPage() {
[navigate],
);
// One click: same-day fit → allocate straight away. No same-day fit but a
// train on another date fits → let staff pick it (customer is notified of
// the date change by the API). Nothing fits → say so.
const handleAllocatePaid = useCallback(
async (row: BookingListRow) => {
setAllocatingId(row.id);
try {
const candidates =
await trainSchedulingService.getAllocationCandidates(row.id);
if (candidates.sameDay.length > 0) {
const target = candidates.sameDay[0];
await trainSchedulingService.allocatePaidBooking(row.id, target.id);
toast({
title: `Allocated ${row.reference}`,
description: `Placed on ${target.reference ?? "train"} departing ${formatDate(target.scheduledDepartureDate)}.`,
});
void refetch();
} else if (candidates.otherDays.length > 0) {
setOtherDayModal({ booking: row, candidates: candidates.otherDays });
} else {
toast({
title: "No fitting train",
description:
"No open schedule covers this booking's route with enough capacity.",
variant: "destructive",
});
}
} catch {
toast({ title: "Allocation failed", variant: "destructive" });
} finally {
setAllocatingId(null);
}
},
[refetch, toast],
);
const handleAllocateOtherDay = useCallback(
async (candidate: AllocationCandidate) => {
if (!otherDayModal) return;
const { booking } = otherDayModal;
setAllocatingId(booking.id);
try {
await trainSchedulingService.allocatePaidBooking(
booking.id,
candidate.id,
);
toast({
title: `Allocated ${booking.reference}`,
description: `Placed on ${candidate.reference ?? "train"} departing ${formatDate(candidate.scheduledDepartureDate)}. Customer notified of the date change.`,
});
setOtherDayModal(null);
void refetch();
} catch {
toast({ title: "Allocation failed", variant: "destructive" });
} finally {
setAllocatingId(null);
}
},
[otherDayModal, refetch, toast],
);
const columns: ColumnDef<BookingListRow>[] = [
{
id: "booking",
@@ -435,13 +517,33 @@ export default function BookingRequestsPage() {
{
id: "actions",
size: 140,
cell: ({ row }) => (
<BookingActionsMenu
row={row.original}
variant="table"
onSuppressRowClick={suppressRowClick}
/>
),
cell: ({ row }) => {
const b = row.original;
const needsAllocation = b.paymentStatus === "PAID" && !b.trainScheduleId;
return (
<Group gap="xs" wrap="nowrap">
{needsAllocation ? (
<Button
size="compact-xs"
color="edr-green"
loading={allocatingId === b.id}
onClick={(e) => {
e.stopPropagation();
suppressRowClick();
void handleAllocatePaid(b);
}}
>
Allocate
</Button>
) : null}
<BookingActionsMenu
row={b}
variant="table"
onSuppressRowClick={suppressRowClick}
/>
</Group>
);
},
},
];
@@ -640,6 +742,16 @@ export default function BookingRequestsPage() {
radius="lg"
style={{ minWidth: 180 }}
/>
<Checkbox
label="Paid, not allocated"
checked={paidUnallocated}
onChange={(e) => {
setPaidUnallocated(e.currentTarget.checked);
resetPage();
}}
radius="sm"
style={{ alignSelf: "center" }}
/>
<Select
placeholder="Gov / Private"
data={OWNERSHIP_OPTIONS}
@@ -751,6 +863,42 @@ export default function BookingRequestsPage() {
</Card>
</Stack>
<Modal
opened={otherDayModal !== null}
onClose={() => setOtherDayModal(null)}
title="Allocate to another date"
centered
>
<Stack gap="sm">
<Text size="sm" c="dimmed">
No train on {otherDayModal ? formatDate(otherDayModal.booking.scheduledDate) : "the booking's day"}{" "}
fits booking {otherDayModal?.booking.reference}. These trains on
other dates do the customer will be notified of the date change.
</Text>
{otherDayModal?.candidates.map((c) => (
<Group key={c.id} justify="space-between" wrap="nowrap">
<div>
<Text size="sm" fw={500}>
{c.reference ?? "Train"}
</Text>
<Text size="xs" c="dimmed">
Departs {formatDate(c.scheduledDepartureDate)}
{c.direction ? ` · ${c.direction}` : ""}
</Text>
</div>
<Button
size="compact-sm"
color="edr-green"
loading={allocatingId === otherDayModal.booking.id}
onClick={() => void handleAllocateOtherDay(c)}
>
Allocate
</Button>
</Group>
))}
</Stack>
</Modal>
{allocateBooking ? (
<AllocateBookingWizard
booking={allocateBooking}

View File

@@ -2,10 +2,6 @@ import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import {
useAllExternalUsers,
userTypeEnum,
} from "@/super-admin/hooks/useExternalUsers";
import {
ALL_TRADE_DIRECTIONS,
TRADE_DIRECTION_LABELS,
@@ -39,9 +35,9 @@ export default function TradeAccessPage() {
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const { data: usersResponse, isLoading: usersLoading } = useAllExternalUsers({
userType: userTypeEnum.employee,
take: 3000,
const { data: usersResponse, isLoading: usersLoading } = useQuery({
queryKey: ["staff-users", "employees"],
queryFn: userTradeAccessService.employees,
});
const { data: configs, isLoading: configsLoading } = useQuery({

View File

@@ -5,6 +5,7 @@ import {
FileSignature,
FileText,
Train,
TrainFront,
UserCheck,
Users,
} from "lucide-react";
@@ -13,7 +14,6 @@ import {
Badge,
Button,
Container,
Paper,
Skeleton,
Stack,
Tabs,
@@ -22,7 +22,6 @@ import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/auth/useAuth";
import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader";
import { OverviewQuickLinks } from "@/components/overview/OverviewQuickLinks";
import { OverviewTabContent } from "@/components/overview/OverviewTabContent";
import "@/components/overview/overview.css";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
@@ -82,6 +81,18 @@ const TAB_ITEMS: Array<{
FREIGHT_PERMS.lastMile.view,
],
},
{
value: "fleet",
label: "Fleet",
icon: TrainFront,
kpiKey: "operations",
metricKey: "wagonsAvailable",
permission: [
FREIGHT_PERMS.fleet.view,
FREIGHT_PERMS.wagons.view,
FREIGHT_PERMS.trainScheduling.view,
],
},
{
value: "customers",
label: "Customers",
@@ -174,6 +185,12 @@ const OverviewPage = () => {
</Alert>
)}
{visibleTabs.length === 0 && !isLoading && !isError && (
<Alert color="gray" variant="light" title="No dashboard sections available">
Your role has no access to any overview section.
</Alert>
)}
{visibleTabs.length > 0 && (
<Tabs
value={currentTab}
@@ -220,10 +237,6 @@ const OverviewPage = () => {
))}
</Tabs>
)}
<Paper p="lg" radius="lg" withBorder>
<OverviewQuickLinks />
</Paper>
</Stack>
</Container>
);

View File

@@ -0,0 +1,445 @@
import {
Button,
Card,
Group,
MultiSelect,
Select,
Text,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { Download, FileSpreadsheet, Printer, RotateCcw } from "lucide-react";
import { useMemo } from "react";
import { useParams, useSearchParams, Link } from "react-router-dom";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import * as XLSX from "xlsx";
import { ALL_TRADE_DIRECTIONS, TRADE_DIRECTION_LABELS } from "@edr/types";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { overviewChartColors } from "@/components/overview/overview.styles";
import { api } from "@/services/api";
import type { ReportQueryInput, ReportRow } from "@/types/reports";
import {
REPORT_CONFIG_BY_KEY,
type ReportColumn,
type ReportConfig,
} from "./reportConfigs";
const compact = new Intl.NumberFormat("en", { notation: "compact" });
const UNIT_SUFFIX = { ETB: " ETB", t: " t", "%": "%", min: " min" } as const;
function formatCell(value: unknown, col: ReportColumn): string {
if (value === null || value === undefined || value === "") return "—";
if (col.unit || col.numeric) {
const n = Number(value);
if (!Number.isNaN(n)) {
return `${n.toLocaleString()}${col.unit ? UNIT_SUFFIX[col.unit] : ""}`;
}
}
return String(value);
}
const toDate = (s: string | null): Date | null => (s ? new Date(s) : null);
// Mantine DateInput onChange emits a date string (or null).
const toParam = (d: Date | string | null): string | null => {
if (!d) return null;
return typeof d === "string" ? d.slice(0, 10) : d.toISOString().slice(0, 10);
};
function downloadBlob(content: BlobPart, type: string, filename: string) {
const url = URL.createObjectURL(new Blob([content], { type }));
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function exportCsv(config: ReportConfig, rows: ReportRow[]) {
const esc = (v: unknown) => `"${String(v ?? "").replace(/"/g, '""')}"`;
const lines = [
config.columns.map((c) => esc(c.label)).join(","),
...rows.map((r) => config.columns.map((c) => esc(r[c.key])).join(",")),
];
downloadBlob(lines.join("\n"), "text/csv;charset=utf-8", `${config.key}.csv`);
}
function exportXlsx(config: ReportConfig, rows: ReportRow[]) {
const sheetRows = rows.map((r) =>
Object.fromEntries(config.columns.map((c) => [c.label, r[c.key] ?? ""])),
);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(
wb,
XLSX.utils.json_to_sheet(sheetRows),
config.title.slice(0, 31),
);
XLSX.writeFile(wb, `${config.key}.xlsx`);
}
function ReportChartView({
config,
rows,
}: {
config: ReportConfig;
rows: ReportRow[];
}) {
const chart = config.chart;
const data = useMemo(() => {
if (!chart) return [];
const sliced = chart.topN ? rows.slice(0, chart.topN) : rows;
// xKey "a+b" concatenates columns (e.g. origin+destination → "A → B").
const keys = chart.xKey.split("+");
return sliced.map((r) => ({
...r,
__x:
keys.length > 1
? keys.map((k) => String(r[k] ?? "")).join(" → ")
: String(r[chart.xKey] ?? ""),
}));
}, [chart, rows]);
if (!chart) return null;
if (data.length === 0) {
return (
<Card withBorder shadow="sm">
<Text c="dimmed" ta="center" py="xl">
No data for the selected filters
</Text>
</Card>
);
}
const ChartComponent =
chart.type === "bar" ? BarChart : chart.type === "line" ? LineChart : AreaChart;
return (
<Card withBorder shadow="sm">
<ResponsiveContainer width="100%" height={280}>
<ChartComponent data={data} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="__x" tick={{ fontSize: 12 }} interval="preserveStartEnd" />
<YAxis
tick={{ fontSize: 12 }}
tickFormatter={(v: number) => compact.format(v)}
width={56}
/>
<Tooltip formatter={(value) => Number(value ?? 0).toLocaleString()} />
{chart.series.length > 1 ? <Legend /> : null}
{chart.series.map((s, i) => {
const color =
overviewChartColors.pipeline[i % overviewChartColors.pipeline.length];
if (chart.type === "bar") {
return (
<Bar key={s.key} dataKey={s.key} name={s.label} fill={color} radius={[4, 4, 0, 0]} />
);
}
if (chart.type === "line") {
return (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={color}
strokeWidth={2}
dot={false}
/>
);
}
return (
<Area
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={color}
fill={color}
fillOpacity={0.15}
strokeWidth={2}
/>
);
})}
</ChartComponent>
</ResponsiveContainer>
</Card>
);
}
export default function ReportPage() {
const { reportKey = "" } = useParams<{ reportKey: string }>();
const config = REPORT_CONFIG_BY_KEY.get(reportKey);
const [params, setParams] = useSearchParams();
const { pagination, setPagination } = usePagination({ pageSize: 20 });
const setParam = (name: string, value: string | null) => {
setParams(
(prev) => {
if (value) prev.set(name, value);
else prev.delete(name);
return prev;
},
{ replace: true },
);
setPagination((p) => ({ ...p, pageIndex: 0 }));
};
const input: ReportQueryInput = {
key: reportKey,
dateFrom: params.get("dateFrom") ?? undefined,
dateTo: params.get("dateTo") ?? undefined,
granularity:
(params.get("granularity") as ReportQueryInput["granularity"]) ?? undefined,
yardIds: params.get("yardIds") ?? undefined,
statuses: params.get("statuses") ?? undefined,
direction: params.get("direction") ?? undefined,
freightType: params.get("freightType") ?? undefined,
};
const reportQuery = useQuery(
api.reports.run.queryOptions({
input,
placeholderData: keepPreviousData,
staleTime: 30_000,
enabled: Boolean(config),
}),
);
const yardsQuery = useQuery(
api.routes.yards.queryOptions({
staleTime: 5 * 60_000,
enabled: Boolean(config?.filters.includes("yards")),
}),
);
if (!config) {
return (
<PageContainer>
<PageHeader title="Unknown report" backTo="/dashboard/reports" />
<Text>
This report does not exist. <Link to="/dashboard/reports">Back to reports</Link>
</Text>
</PageContainer>
);
}
const rows = reportQuery.data?.rows ?? [];
const kpis = reportQuery.data?.kpis ?? [];
const pageCount = Math.max(1, Math.ceil(rows.length / pagination.pageSize));
const columns: ColumnDef<ReportRow, unknown>[] = config.columns.map((col) => ({
accessorKey: col.key,
header: col.label,
cell: (info) => formatCell(info.getValue(), col),
}));
const tableStatus = reportQuery.isLoading
? "loading"
: reportQuery.isError
? "error"
: "success";
return (
<PageContainer>
<PageHeader
title={config.title}
subtitle={config.description}
backTo="/dashboard/reports"
action={
<Group gap="xs">
<Button
variant="default"
size="xs"
leftSection={<Download size={14} />}
onClick={() => exportCsv(config, rows)}
disabled={rows.length === 0}
>
CSV
</Button>
<Button
variant="default"
size="xs"
leftSection={<FileSpreadsheet size={14} />}
onClick={() => exportXlsx(config, rows)}
disabled={rows.length === 0}
>
Excel
</Button>
<Button
variant="default"
size="xs"
leftSection={<Printer size={14} />}
onClick={() => window.print()}
>
Print
</Button>
</Group>
}
/>
<Card withBorder shadow="sm">
<Group gap="sm" align="flex-end" wrap="wrap">
<DateInput
label="From"
size="xs"
clearable
value={toDate(params.get("dateFrom"))}
maxDate={toDate(params.get("dateTo")) ?? undefined}
onChange={(d) => setParam("dateFrom", toParam(d))}
placeholder="All time"
/>
<DateInput
label="To"
size="xs"
clearable
value={toDate(params.get("dateTo"))}
minDate={toDate(params.get("dateFrom")) ?? undefined}
onChange={(d) => setParam("dateTo", toParam(d))}
placeholder="All time"
/>
{config.filters.includes("granularity") ? (
<Select
label="Group by"
size="xs"
data={[
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
]}
value={params.get("granularity") ?? "day"}
onChange={(v) => setParam("granularity", v)}
allowDeselect={false}
/>
) : null}
{config.filters.includes("yards") ? (
<MultiSelect
label="Yards"
size="xs"
searchable
clearable
w={220}
data={(yardsQuery.data ?? []).map((y) => ({
value: y.id,
label: y.label,
}))}
value={params.get("yardIds")?.split(",").filter(Boolean) ?? []}
onChange={(v) => setParam("yardIds", v.length ? v.join(",") : null)}
placeholder="All yards"
/>
) : null}
{config.filters.includes("direction") ? (
<Select
label="Direction"
size="xs"
clearable
data={ALL_TRADE_DIRECTIONS.map((d) => ({
value: d,
label: TRADE_DIRECTION_LABELS[d],
}))}
value={params.get("direction")}
onChange={(v) => setParam("direction", v)}
placeholder="All"
/>
) : null}
{config.filters.includes("freightType") ? (
<Select
label="Freight type"
size="xs"
clearable
data={["CONTAINER", "BULK"]}
value={params.get("freightType")}
onChange={(v) => setParam("freightType", v)}
placeholder="All"
/>
) : null}
{config.filters.includes("statuses") && config.statusOptions ? (
<MultiSelect
label="Status"
size="xs"
searchable
clearable
w={220}
data={config.statusOptions}
value={params.get("statuses")?.split(",").filter(Boolean) ?? []}
onChange={(v) => setParam("statuses", v.length ? v.join(",") : null)}
placeholder="Default (active)"
/>
) : null}
<Button
variant="subtle"
size="xs"
leftSection={<RotateCcw size={14} />}
onClick={() => setParams({}, { replace: true })}
>
Reset
</Button>
</Group>
</Card>
<KpiStrip
loading={reportQuery.isLoading}
items={kpis.map((k) => ({
label: k.label,
value: k.value.toLocaleString(),
hint: k.unit,
}))}
/>
<ReportChartView config={config} rows={rows} />
<DataTable
columns={columns}
data={rows}
status={tableStatus}
emptyMessage="No data for the selected filters"
error={
reportQuery.isError
? {
message: "Failed to load report",
onRetry: () => void reportQuery.refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: rows.length,
}}
tableOptions={{
manualPagination: false,
state: { pagination },
onPaginationChange: setPagination,
autoResetPageIndex: false,
}}
footer={({ table, pagination: p }) => (
<DataTableFooter
table={table}
pagination={p}
options={{ labels: { items: "rows" } }}
/>
)}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,159 @@
import {
ActionIcon,
Badge,
Card,
Group,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { Search, Star } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
import {
REPORT_CONFIGS,
REPORT_DOMAINS,
type ReportConfig,
} from "./reportConfigs";
const FAVORITES_KEY = "reports.favorites";
const loadFavorites = (): string[] => {
try {
return JSON.parse(localStorage.getItem(FAVORITES_KEY) ?? "[]");
} catch {
return [];
}
};
function ReportCard({
config,
favorite,
onToggleFavorite,
}: {
config: ReportConfig;
favorite: boolean;
onToggleFavorite: () => void;
}) {
const navigate = useNavigate();
return (
<Card
withBorder
shadow="sm"
className="cursor-pointer transition-colors hover:bg-gray-50"
onClick={() => navigate(`/dashboard/reports/${config.key}`)}
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div style={{ minWidth: 0 }}>
<Text fw={600} truncate>
{config.title}
</Text>
<Text size="sm" c="dimmed" lineClamp={2}>
{config.description}
</Text>
</div>
<ActionIcon
variant="subtle"
color={favorite ? "yellow" : "gray"}
aria-label={favorite ? "Remove from favorites" : "Add to favorites"}
onClick={(e) => {
e.stopPropagation();
onToggleFavorite();
}}
>
<Star size={16} fill={favorite ? "currentColor" : "none"} />
</ActionIcon>
</Group>
<Badge mt="sm" size="sm" variant="light">
{config.domain}
</Badge>
</Card>
);
}
export default function ReportsHubPage() {
const [search, setSearch] = useState("");
const [favorites, setFavorites] = useState<string[]>(loadFavorites);
const toggleFavorite = (key: string) => {
setFavorites((prev) => {
const next = prev.includes(key)
? prev.filter((k) => k !== key)
: [...prev, key];
localStorage.setItem(FAVORITES_KEY, JSON.stringify(next));
return next;
});
};
const visible = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return REPORT_CONFIGS;
return REPORT_CONFIGS.filter(
(c) =>
c.title.toLowerCase().includes(q) ||
c.description.toLowerCase().includes(q),
);
}, [search]);
const pinned = visible.filter((c) => favorites.includes(c.key));
const renderGrid = (configs: ReportConfig[]) => (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{configs.map((c) => (
<ReportCard
key={c.key}
config={c}
favorite={favorites.includes(c.key)}
onToggleFavorite={() => toggleFavorite(c.key)}
/>
))}
</SimpleGrid>
);
return (
<PageContainer>
<PageHeader
title="Reports"
subtitle="Operational, commercial and financial reporting"
action={
<TextInput
size="xs"
w={240}
leftSection={<Search size={14} />}
placeholder="Search reports…"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
}
/>
{pinned.length ? (
<Stack gap="sm">
<Title order={4}>Favorites</Title>
{renderGrid(pinned)}
</Stack>
) : null}
{REPORT_DOMAINS.map((domain) => {
const configs = visible.filter((c) => c.domain === domain);
if (!configs.length) return null;
return (
<Stack key={domain} gap="sm">
<Title order={4}>{domain}</Title>
{renderGrid(configs)}
</Stack>
);
})}
{visible.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No reports match {search}
</Text>
) : null}
</PageContainer>
);
}

View File

@@ -0,0 +1,444 @@
import { BookingStatus } from "@edr/types";
export type ReportDomain = "Commercial" | "Operations" | "Finance" | "Data";
export type ReportColumnUnit = "ETB" | "t" | "%" | "min";
export interface ReportColumn {
key: string;
label: string;
/** Numeric unit — formats the cell (thousands separators, suffix). */
unit?: ReportColumnUnit;
numeric?: boolean;
}
export interface ReportChart {
type: "area" | "line" | "bar";
xKey: string;
series: { key: string; label: string }[];
/** Chart only the first N rows (rows arrive sorted by the backend). */
topN?: number;
}
export type ReportFilterKey =
| "granularity"
| "yards"
| "direction"
| "freightType"
| "statuses";
export interface ReportConfig {
key: string;
title: string;
description: string;
domain: ReportDomain;
filters: ReportFilterKey[];
/** Options for the `statuses` filter, when enabled. */
statusOptions?: string[];
chart?: ReportChart;
columns: ReportColumn[];
}
// Full enum from @edr/types; Set dedupes the deprecated AwaitingPayment alias.
const BOOKING_STATUSES = [...new Set(Object.values(BookingStatus))];
// Full list mirroring CONTRACT_STATUSES in contract.entity.ts (no shared enum
// in @edr/types yet).
const CONTRACT_STATUSES = [
"DRAFT",
"SUBMITTED",
"PRICE_CHANGED_PENDING_CONFIRM",
"CHANGES_REQUESTED",
"PENDING_APPROVAL",
"APPROVED",
"APPROVED_PENDING_SIGNATURE",
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
"ACTIVE_SHIPMENT_IN_PROGRESS",
"SUSPENDED",
"CONTRACT_CLOSED",
"EXPIRED",
"REJECTED",
"CANCELLED",
"RENEWAL_DRAFT",
"RENEWAL_SUBMITTED",
"RENEWAL_PENDING_APPROVAL",
"AMENDMENTS_PROPOSED",
"ARCHIVED",
];
const INVOICE_STATUSES = [
"ISSUED",
"PENDING",
"PARTIALLY_PAID",
"PAID",
"OVERDUE",
"REFUNDED",
];
export const REPORT_CONFIGS: ReportConfig[] = [
{
key: "bookings-trend",
title: "Bookings Trend",
description: "Booking volume, tonnage and revenue over time",
domain: "Commercial",
filters: ["granularity", "yards", "direction", "freightType", "statuses"],
statusOptions: BOOKING_STATUSES,
chart: {
type: "area",
xKey: "period",
series: [{ key: "revenue", label: "Revenue (ETB)" }],
},
columns: [
{ key: "period", label: "Period" },
{ key: "bookings", label: "Bookings", numeric: true },
{ key: "tons", label: "Tonnage", unit: "t" },
{ key: "revenue", label: "Revenue", unit: "ETB" },
],
},
{
key: "revenue-by-customer",
title: "Revenue by Customer",
description: "Ranked customers by booking revenue",
domain: "Commercial",
filters: ["yards", "direction", "freightType", "statuses"],
statusOptions: BOOKING_STATUSES,
chart: {
type: "bar",
xKey: "customer",
series: [{ key: "revenue", label: "Revenue (ETB)" }],
topN: 10,
},
columns: [
{ key: "customer", label: "Customer" },
{ key: "bookings", label: "Bookings", numeric: true },
{ key: "tons", label: "Tonnage", unit: "t" },
{ key: "revenue", label: "Revenue", unit: "ETB" },
],
},
{
key: "revenue-by-lane",
title: "Revenue by Lane",
description: "Origin → destination lanes by tonnage and revenue",
domain: "Commercial",
filters: ["direction", "freightType", "statuses"],
statusOptions: BOOKING_STATUSES,
chart: {
type: "bar",
xKey: "origin+destination",
series: [{ key: "revenue", label: "Revenue (ETB)" }],
topN: 10,
},
columns: [
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "bookings", label: "Bookings", numeric: true },
{ key: "tons", label: "Tonnage", unit: "t" },
{ key: "revenue", label: "Revenue", unit: "ETB" },
],
},
{
key: "contract-utilization",
title: "Contract Utilization",
description: "Committed scope caps vs booked tonnage per contract",
domain: "Commercial",
filters: ["direction", "statuses"],
statusOptions: CONTRACT_STATUSES,
columns: [
{ key: "reference", label: "Contract" },
{ key: "customer", label: "Customer" },
{ key: "status", label: "Status" },
{ key: "kind", label: "Kind" },
{ key: "valid_from", label: "Valid from" },
{ key: "valid_until", label: "Valid until" },
{ key: "committed", label: "Committed", unit: "t" },
{ key: "booked_tons", label: "Booked", unit: "t" },
{ key: "bookings", label: "Bookings", numeric: true },
{ key: "utilization_pct", label: "Utilization", unit: "%" },
],
},
{
key: "train-on-time",
title: "Train On-Time Performance",
description: "Departure punctuality and delays by lane (60-min grace)",
domain: "Operations",
filters: ["yards", "direction"],
chart: {
type: "bar",
xKey: "origin+destination",
series: [{ key: "on_time_pct", label: "On-time %" }],
topN: 15,
},
columns: [
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "trips", label: "Trips", numeric: true },
{ key: "departed", label: "Departed", numeric: true },
{ key: "avg_dep_delay_min", label: "Avg dep. delay", unit: "min" },
{ key: "avg_arr_delay_min", label: "Avg arr. delay", unit: "min" },
{ key: "on_time_pct", label: "On-time", unit: "%" },
],
},
{
key: "schedule-fill-rate",
title: "Schedule Fill Rate",
description: "Booked tonnage vs wagon capacity per train schedule",
domain: "Operations",
filters: ["yards", "direction"],
chart: {
type: "line",
xKey: "departure",
series: [{ key: "fill_pct", label: "Fill %" }],
},
columns: [
{ key: "train_number", label: "Train" },
{ key: "departure", label: "Departure" },
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "direction", label: "Direction" },
{ key: "status", label: "Status" },
{ key: "wagon_count", label: "Wagons", numeric: true },
{ key: "capacity_tons", label: "Capacity", unit: "t" },
{ key: "booked_tons", label: "Booked", unit: "t" },
{ key: "fill_pct", label: "Fill", unit: "%" },
],
},
{
key: "trips-per-route",
title: "Trips per Route",
description: "Completed trips and tonnage hauled per lane",
domain: "Operations",
filters: ["yards", "direction"],
chart: {
type: "bar",
xKey: "origin+destination",
series: [{ key: "trips", label: "Trips" }],
topN: 15,
},
columns: [
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "direction", label: "Direction" },
{ key: "trips", label: "Trips", numeric: true },
{ key: "tons_hauled", label: "Tonnage hauled", unit: "t" },
{ key: "avg_tons_per_trip", label: "Avg per trip", unit: "t" },
],
},
{
key: "invoiced-vs-collected",
title: "Invoiced vs Collected",
description: "Billing issued vs payments received over time",
domain: "Finance",
filters: ["granularity", "direction"],
chart: {
type: "line",
xKey: "period",
series: [
{ key: "invoiced", label: "Invoiced (ETB)" },
{ key: "collected", label: "Collected (ETB)" },
],
},
columns: [
{ key: "period", label: "Period" },
{ key: "invoices", label: "Invoices", numeric: true },
{ key: "invoiced", label: "Invoiced", unit: "ETB" },
{ key: "collected", label: "Collected", unit: "ETB" },
{ key: "outstanding", label: "Outstanding", unit: "ETB" },
],
},
{
key: "aging-receivables",
title: "Aging Receivables",
description: "Outstanding invoice balances by age bucket per customer",
domain: "Finance",
filters: ["direction", "statuses"],
statusOptions: INVOICE_STATUSES,
chart: {
type: "bar",
xKey: "customer",
series: [{ key: "outstanding", label: "Outstanding (ETB)" }],
topN: 10,
},
columns: [
{ key: "customer", label: "Customer" },
{ key: "invoices", label: "Invoices", numeric: true },
{ key: "outstanding", label: "Outstanding", unit: "ETB" },
{ key: "current", label: "Current", unit: "ETB" },
{ key: "overdue_0_30", label: "030d", unit: "ETB" },
{ key: "overdue_31_60", label: "3160d", unit: "ETB" },
{ key: "overdue_61_90", label: "6190d", unit: "ETB" },
{ key: "overdue_90_plus", label: "90d+", unit: "ETB" },
],
},
{
key: "revenue-by-payment-method",
title: "Revenue by Payment Method",
description: "Successful payments broken down by method",
domain: "Finance",
filters: ["direction"],
chart: {
type: "bar",
xKey: "method",
series: [{ key: "amount", label: "Amount (ETB)" }],
},
columns: [
{ key: "method", label: "Method" },
{ key: "payments", label: "Payments", numeric: true },
{ key: "amount", label: "Amount", unit: "ETB" },
],
},
// --- Record-level list exports (Data domain) — filtered or full dumps ---
{
key: "bookings-list",
title: "Bookings Export",
description: "Booking records with customer, lane, cargo, amounts",
domain: "Data",
filters: ["yards", "direction", "freightType", "statuses"],
statusOptions: BOOKING_STATUSES,
columns: [
{ key: "reference", label: "Reference" },
{ key: "created", label: "Created" },
{ key: "customer", label: "Customer" },
{ key: "status", label: "Status" },
{ key: "freight_type", label: "Freight" },
{ key: "direction", label: "Direction" },
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "cargo", label: "Cargo" },
{ key: "tons", label: "Tonnage", unit: "t" },
{ key: "amount", label: "Amount", unit: "ETB" },
{ key: "payment_status", label: "Payment" },
{ key: "scheduling_status", label: "Scheduling" },
],
},
{
key: "contracts-list",
title: "Contracts Export",
description: "Contract records with validity, status, customer",
domain: "Data",
filters: ["direction", "statuses"],
statusOptions: CONTRACT_STATUSES,
columns: [
{ key: "reference", label: "Reference" },
{ key: "customer", label: "Customer" },
{ key: "kind", label: "Kind" },
{ key: "status", label: "Status" },
{ key: "direction", label: "Direction" },
{ key: "freight_type", label: "Freight" },
{ key: "valid_from", label: "Valid from" },
{ key: "valid_until", label: "Valid until" },
{ key: "created", label: "Created" },
],
},
{
key: "schedules-list",
title: "Train Schedules Export",
description: "Schedule records with planned vs actual times",
domain: "Data",
filters: ["yards", "direction", "statuses"],
statusOptions: ["DRAFT", "SCHEDULED", "DISPATCHED", "ARRIVED", "CANCELLED"],
columns: [
{ key: "train_number", label: "Train" },
{ key: "reference", label: "Reference" },
{ key: "direction", label: "Direction" },
{ key: "status", label: "Status" },
{ key: "origin", label: "Origin" },
{ key: "destination", label: "Destination" },
{ key: "scheduled_departure", label: "Sched. departure" },
{ key: "actual_departure", label: "Actual departure" },
{ key: "scheduled_arrival", label: "Sched. arrival" },
{ key: "actual_arrival", label: "Actual arrival" },
{ key: "max_wagons", label: "Max wagons", numeric: true },
{ key: "wagon_count", label: "Wagons", numeric: true },
],
},
{
key: "fleet-wagons",
title: "Wagons Export",
description: "Wagon fleet with type, capacity, status, location",
domain: "Data",
filters: ["yards", "statuses"],
statusOptions: ["AVAILABLE", "ASSIGNED", "MAINTENANCE"],
columns: [
{ key: "wagon_number", label: "Wagon" },
{ key: "type", label: "Type" },
{ key: "capacity_tons", label: "Capacity", unit: "t" },
{ key: "status", label: "Status" },
{ key: "current_yard", label: "Current yard" },
],
},
{
key: "fleet-locomotives",
title: "Locomotives Export",
description: "Locomotive fleet with type, pull capacity, status",
domain: "Data",
filters: ["yards", "statuses"],
statusOptions: ["AVAILABLE", "OUT_OF_SERVICE"],
columns: [
{ key: "code", label: "Code" },
{ key: "name", label: "Name" },
{ key: "locomotive_type", label: "Type" },
{ key: "max_pull_tons", label: "Max pull", unit: "t" },
{ key: "status", label: "Status" },
{ key: "current_yard", label: "Current yard" },
],
},
{
key: "customers-list",
title: "Customers Export",
description: "Company records with type, status, TIN",
domain: "Data",
filters: ["statuses"],
statusOptions: ["pending", "active"],
columns: [
{ key: "name", label: "Name" },
{ key: "type", label: "Type" },
{ key: "kind", label: "Kind" },
{ key: "status", label: "Status" },
{ key: "tin", label: "TIN" },
{ key: "approved", label: "Approved" },
{ key: "created", label: "Created" },
],
},
{
key: "payments-list",
title: "Payments Export",
description: "Payment transactions with method, status, references",
domain: "Data",
filters: ["direction", "statuses"],
statusOptions: [
"action-required",
"processing",
"success",
"failed",
"canceled",
"refunded",
],
columns: [
{ key: "created", label: "Created" },
{ key: "method", label: "Method" },
{ key: "status", label: "Status" },
{ key: "currency", label: "Currency" },
{ key: "amount", label: "Amount", unit: "ETB" },
{ key: "transaction_id", label: "Transaction" },
{ key: "merchant_order_id", label: "Merchant order" },
{ key: "paid", label: "Paid" },
],
},
];
export const REPORT_CONFIG_BY_KEY = new Map(
REPORT_CONFIGS.map((c) => [c.key, c]),
);
export const REPORT_DOMAINS: ReportDomain[] = [
"Commercial",
"Operations",
"Finance",
"Data",
];

View File

@@ -60,12 +60,16 @@ interface CargoNode extends RuleEngineRecord {
wagonTypes?: { id: string; code?: string; name?: string }[];
/** PER_ITEM only: whole items that physically fit one wagon, keyed by wagon-type id. */
itemsPerWagonMap?: Record<string, number> | null;
/** PER_TON only: max tons of this cargo per wagon, keyed by wagon-type id. */
tonsPerWagonMap?: Record<string, number> | null;
isActive?: boolean;
displayOrder?: number;
}
/** Form-value prefix for the per-wagon-type items-fit inputs (PER_ITEM cargo). */
const ITEMS_FIT_PREFIX = "itemsFit__";
/** Form-value prefix for the per-wagon-type tonnage-cap inputs (PER_TON cargo). */
const TONS_CAP_PREFIX = "tonsCap__";
const str = (v: unknown): string => (v == null ? "" : String(v));
const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0);
@@ -159,8 +163,26 @@ const CargoTypesPage = () => {
getInitialValue: (record) =>
(record as CargoNode).itemsPerWagonMap?.[opt.value],
}));
// PER_TON cargo: an OPTIONAL "max tons per wagon" per selected wagon type —
// how much of this commodity actually rides one wagon, which can be less
// than its rating (sugar 50T on a 70T wagon, so 200T takes 4 wagons not 3).
// Left blank the wagon's full rated capacity applies, so existing cargo is
// unaffected; the API rejects a value above the rating.
const tonsCapFields: FormFieldDef[] = (wagonTypeOptions ?? []).map((opt) => ({
name: `${TONS_CAP_PREFIX}${opt.value}`,
label: `Max tons per ${opt.label} wagon`,
type: "number",
optional: true,
placeholder: "Blank = full wagon capacity",
showIf: (values) =>
values.unitOfMeasure === "PER_TON" &&
Array.isArray(values.wagonTypeIds) &&
(values.wagonTypeIds as string[]).includes(opt.value),
getInitialValue: (record) =>
(record as CargoNode).tonsPerWagonMap?.[opt.value],
}));
const wagonTypesAt = base.findIndex((field) => field.name === "wagonTypeIds");
base.splice(wagonTypesAt + 1, 0, ...fitFields);
base.splice(wagonTypesAt + 1, 0, ...fitFields, ...tonsCapFields);
return base;
}, [wagonTypeOptions]);
@@ -230,14 +252,22 @@ const CargoTypesPage = () => {
// none are visible (not PER_ITEM) so an update clears stale fits.
const payload: Record<string, unknown> = {};
const itemsPerWagonMap: Record<string, number> = {};
const tonsPerWagonMap: Record<string, number> = {};
for (const [key, value] of Object.entries(values)) {
if (key.startsWith(ITEMS_FIT_PREFIX)) {
itemsPerWagonMap[key.slice(ITEMS_FIT_PREFIX.length)] = Number(value);
} else if (key.startsWith(TONS_CAP_PREFIX)) {
// Blank means "no cap" (use the full rated capacity), so an empty input
// must stay OUT of the map — sending 0 would be a zero-ton wagon.
if (value !== "" && value !== null && value !== undefined) {
tonsPerWagonMap[key.slice(TONS_CAP_PREFIX.length)] = Number(value);
}
} else {
payload[key] = value;
}
}
payload.itemsPerWagonMap = Object.keys(itemsPerWagonMap).length ? itemsPerWagonMap : null;
payload.tonsPerWagonMap = Object.keys(tonsPerWagonMap).length ? tonsPerWagonMap : null;
// Add always attaches to the page we're on; edit keeps the node's parent.
if (formMode?.kind === "create" && current) {
payload.parentGroupId = current.id;

View File

@@ -6,12 +6,14 @@ import {
Button,
Card,
Checkbox,
Divider,
Group,
Menu,
Modal,
Select,
SimpleGrid,
Stack,
Switch,
Text,
TextInput,
ThemeIcon,
@@ -46,6 +48,10 @@ import {
directionRowStyle,
} from "@/components/trainBuilder/trainStatus";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import CreateScheduleWindowFields, {
buildWindowRulePayload,
type WindowFormState,
} from "@/components/trainScheduling/CreateScheduleWindowFields";
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
import {
@@ -59,6 +65,7 @@ import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { canCreateSchedule, canUpdateSchedule } from "@/lib/permissions";
import type {
CreateScheduleWindowRulePayload,
FreightType,
TrainScheduleListFilters,
TrainScheduleListItem,
@@ -133,6 +140,10 @@ export default function TrainScheduleV2ListPage() {
const [scheduleDate, setScheduleDate] = useState("");
const [trainId, setTrainId] = useState("");
const [reverseWagonOrder, setReverseWagonOrder] = useState(false);
// Booking window for the schedule being created: off = inherit the live global
// rules (the default), on = the values in `windowForm` are frozen onto it.
const [configureWindow, setConfigureWindow] = useState(false);
const [windowForm, setWindowForm] = useState<WindowFormState | null>(null);
// Recomputed each time the create modal opens so a long-lived tab can't keep
// offering a stale "now" as the earliest selectable departure.
const minScheduleDate = useMemo(
@@ -535,6 +546,25 @@ export default function TrainScheduleV2ListPage() {
});
return;
}
// Only build the window override when the toggle is on — off means "inherit
// the global rules", which the API expresses as an absent windowRule.
let windowRule: CreateScheduleWindowRulePayload | undefined;
if (configureWindow) {
if (!windowForm) {
toast({ title: "Booking window settings are still loading", variant: "destructive" });
return;
}
const built = buildWindowRulePayload(
windowForm,
selectedRoute?.direction === "EXPORT",
);
if ("error" in built) {
toast({ title: built.error, variant: "destructive" });
return;
}
windowRule = built.payload;
}
try {
const created = await create.mutateAsync({
payload: {
@@ -542,11 +572,14 @@ export default function TrainScheduleV2ListPage() {
scheduleDate: new Date(scheduleDate).toISOString(),
trainId,
reverseWagonOrder,
...(windowRule ? { windowRule } : {}),
},
});
toast({ title: "Train schedule created" });
showScheduleWarnings(created.warnings);
setReverseWagonOrder(false);
setConfigureWindow(false);
setWindowForm(null);
setCreateOpen(false);
navigate(`/dashboard/operations/train-scheduling-v2/${created.id}`);
} catch (err) {
@@ -843,6 +876,22 @@ export default function TrainScheduleV2ListPage() {
checked={reverseWagonOrder}
onChange={(e) => setReverseWagonOrder(e.currentTarget.checked)}
/>
<Divider />
<Switch
label="Configure booking window for this schedule"
description="Off, this train follows the global booking rules. On, the settings below are frozen onto it and a later global-rules change won't move them."
checked={configureWindow}
onChange={(e) => setConfigureWindow(e.currentTarget.checked)}
/>
{configureWindow ? (
<CreateScheduleWindowFields
isExport={selectedRoute?.direction === "EXPORT"}
form={windowForm}
onChange={setWindowForm}
/>
) : null}
<Group justify="flex-end">
<Button variant="default" onClick={() => setCreateOpen(false)}>
Cancel

View File

@@ -163,6 +163,8 @@ import {
type SaveLocomotivePayload,
} from "./locomotives.service";
import { overviewService } from "./overview.service";
import { reportsService } from "./reports.service";
import type { ReportQueryInput, ReportResult } from "@/types/reports";
import {
paymentsService,
type PaginatedPayments,
@@ -2880,4 +2882,13 @@ export const api = {
({ range }) => overviewService.getDashboard(range),
),
},
reports: {
run: endpoint<ReportQueryInput, ReportResult>(
"reports",
"run",
(input) => reportsService.run(input),
(input) => ["reports", input.key, input],
),
},
};

View File

@@ -43,8 +43,12 @@ export const overviewService = {
return unwrap(response);
},
getOperationsTab: async (): Promise<IOverviewOperationsTab> => {
const response = await client.get<IOverviewOperationsTab>(O.OPERATIONS);
getOperationsTab: async (
range?: OverviewRange,
): Promise<IOverviewOperationsTab> => {
const response = await client.get<IOverviewOperationsTab>(O.OPERATIONS, {
params: range ? { range } : undefined,
});
return unwrap(response);
},

View File

@@ -0,0 +1,14 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ReportQueryInput, ReportResult } from "@/types/reports";
export const reportsService = {
run: async ({ key, ...params }: ReportQueryInput): Promise<ReportResult> => {
const response = await client.get<ReportResult>(
URL_CONSTANTS.REPORTS.RUN(key),
{ params },
);
return unwrap(response.data);
},
};

View File

@@ -3,6 +3,7 @@ import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
AllocationCandidates,
BatchBoardFilters,
BatchBoardListResponse,
BatchBoardScheduleDetail,
@@ -319,6 +320,25 @@ export const trainSchedulingService = {
);
},
getAllocationCandidates: async (
bookingId: string,
): Promise<AllocationCandidates> => {
const response = await client.get<AllocationCandidates>(
URL_CONSTANTS.TRAIN_SCHEDULING.ALLOCATION_CANDIDATES(bookingId),
);
return unwrap(response.data);
},
allocatePaidBooking: async (
bookingId: string,
trainScheduleId: string,
): Promise<void> => {
await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.ALLOCATE_BOOKING(bookingId),
{ trainScheduleId },
);
},
getScheduleById: async (
id: string,
freightType?: FreightType,

View File

@@ -25,7 +25,27 @@ export interface MyTradeAccess {
directions: TradeDirection[];
}
export interface StaffUser {
id: string;
/** Localized jsonb on iam.users — not a plain string. */
name: { en?: string; am?: string } | null;
username: string;
email: string | null;
}
export const userTradeAccessService = {
/**
* Employees to assign scopes to. Served by the freight API rather than IAM's
* `/users/filter`, which 400s on its own pagination params (its @Query() DTO
* omits skip/take/orderBy while the global whitelist pipe rejects them).
*/
employees: async (): Promise<{ items: StaffUser[] }> =>
(
await client.get("/staff/users", {
params: { userType: "employee", pageSize: 100 },
})
).data,
/** All configured per-user scopes (admin only). */
list: async (): Promise<UserTradeAccessRow[]> =>
(await client.get("/user-trade-access")).data,

View File

@@ -8,6 +8,8 @@ export type {
IOverviewBillingKpis,
IOverviewStaffKpis,
IOverviewTrendPoint,
IOverviewDirectionTrendPoint,
IOverviewTonnagePoint,
IOverviewStatusCount,
IOverviewPipelineCount,
IOverviewPaymentTrendPoint,

View File

@@ -0,0 +1,27 @@
export interface ReportKpi {
label: string;
value: number;
unit?: string;
}
export type ReportRow = Record<string, unknown>;
export interface ReportResult {
kpis: ReportKpi[];
rows: ReportRow[];
}
/** Query params for GET /reports/:key. List filters are comma-separated. */
export interface ReportQueryInput {
key: string;
dateFrom?: string;
dateTo?: string;
granularity?: "day" | "week" | "month";
companyIds?: string;
routeIds?: string;
yardIds?: string;
cargoTypeIds?: string;
statuses?: string;
direction?: string;
freightType?: string;
}

View File

@@ -103,6 +103,21 @@ export interface BookingWagonShortage {
wagonsShort: number;
}
/** A train a paid-unallocated booking can board (route + capacity verified). */
export interface AllocationCandidate {
id: string;
reference: string | null;
direction: string | null;
scheduledDepartureDate: string;
}
export interface AllocationCandidates {
/** Trains departing on the booking's own scheduled day. */
sameDay: AllocationCandidate[];
/** Fitting trains on other days — allocating to one notifies the customer. */
otherDays: AllocationCandidate[];
}
export interface DeferredBookingRow {
id: string;
reference: string;
@@ -891,6 +906,23 @@ export interface CreateTrainSchedulePayload {
maxWagonsPerTrain?: number;
/** Reverse the wagon order on this train: physically-last wagon becomes position 1. */
reverseWagonOrder?: boolean;
/**
* Configure the booking window for THIS schedule instead of inheriting the
* live global rules. Omit to follow the global rules (the default).
*/
windowRule?: CreateScheduleWindowRulePayload;
}
/**
* Booking-window rule chosen at creation. Mirrors the per-schedule override plus
* the booking-close offset; omitted fields fall back to the global rule.
*/
export interface CreateScheduleWindowRulePayload
extends UpdateScheduleWindowRulePayload {
/** Minutes before departure an IMPORT/DOMESTIC window closes; null = at departure. */
importCloseOffsetMinutes?: number | null;
/** Minutes before departure an EXPORT window closes; null = at departure. */
exportCloseOffsetMinutes?: number | null;
}
export interface AssignBookingsPayload {

165
docker-compose.it.yaml Normal file
View File

@@ -0,0 +1,165 @@
# EDR Freight — API integration stack (headless).
#
# Overlay on docker-compose.e2e.yaml. Same base services (postgres, minio,
# mocks, freight-api), except the payment microservice is REAL here instead of
# `payment-mock-e2e`, and only the bank gateways are stubbed:
#
# freight-api-it ──HTTP──> payment-api-it ──HTTP──> gateway-mock-it
# ^ │
# └────── RabbitMQ ───────┘ (outbox → payment.events → consumer)
#
# Its own compose project (`name:` below overrides the base) and its own host
# ports, so it can run side by side with the Cypress e2e stack.
#
# node integration/scripts/it.mjs up|test|down|logs
#
# Never start it with plain `docker compose -f docker-compose.it.yaml` — it is
# an OVERLAY and needs the base file first:
# docker compose -f docker-compose.e2e.yaml -f docker-compose.it.yaml ...
name: edr-freight-it
services:
# Outbox transport. The payment API publishes payment.succeeded/failed here
# and freight consumes it — the production path. Copied from the passenger
# harness (e2e/docker-compose.yml).
rabbitmq-it:
image: rabbitmq:3-management
environment:
RABBITMQ_DEFAULT_USER: edr
RABBITMQ_DEFAULT_PASS: edr_secret
RABBITMQ_DEFAULT_VHOST: payment
ports:
- "${IT_RABBIT_PORT:-5772}:5672"
- "${IT_RABBIT_UI_PORT:-15772}:15672"
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 5s
timeout: 5s
retries: 20
# Stand-in for every bank/wallet gateway the payment API talks to, plus a
# control plane the tests drive (force a provider to fail/hang, fire a
# correctly-signed webhook, read back what was called). See
# integration/gateway-mock/server.js.
gateway-mock-it:
image: node:20-alpine
volumes:
- ./integration/gateway-mock:/app:ro
working_dir: /app
environment:
PORT: "4600"
# Same secrets the payment API gets — so webhooks the mock signs pass the
# API's REAL signature verification instead of bypassing it.
CBE_SECRET_KEY: it-cbe-secret
CBE_MERCHANT_ID: it-cbe-merchant
PAYMENT_API_URL: http://payment-api-it:3003
command: ["node", "server.js"]
ports:
- "${IT_GATEWAY_PORT:-4600}:4600"
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://localhost:4600/__control/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 3s
timeout: 3s
retries: 10
payment-api-it:
build:
context: .
dockerfile: apps/edr-payment-api/Dockerfile
secrets:
- npmrc
depends_on:
postgres-freight-e2e:
condition: service_healthy
rabbitmq-it:
condition: service_healthy
gateway-mock-it:
condition: service_healthy
environment:
PORT: "3003"
NODE_ENV: test
# Payment tables live in their own schema of the same throwaway DB;
# main.ts ensurePaymentSchema() creates it, migrationsRun does the rest.
DB_HOST: postgres-freight-e2e
DB_PORT: "5432"
DB_USER: edr_e2e
DB_PASSWORD: edr_e2e
DB_NAME: edr_freight_e2e
DB_SCHEMA: edr_payment
# Same token freight already uses in the base stack.
SERVICE_AUTH_TOKEN: e2e-service-token
PUBLISHER_TRANSPORT: rabbitmq
PAYMENT_RABBITMQ_URL: amqp://edr:edr_secret@rabbitmq-it:5672/payment
# HTTP fallback targets (only used with PUBLISHER_TRANSPORT=http).
PAYMENT_NOTIFY_FREIGHT_URL: http://freight-api-e2e:3001/api/internal/payments/mark-paid
# Fast relay + sweep so retry/reconciliation are observable inside a test
# rather than a minute later.
OUTBOX_RELAY_INTERVAL_MS: "1000"
RECONCILE_STALE_AFTER_MS: "5000"
# Every gateway points at the one mock. Paths are per-provider prefixes.
CBE_BASE_URL: http://gateway-mock-it:4600/cbe-birr
CBE_MERCHANT_ID: it-cbe-merchant
CBE_SECRET_KEY: it-cbe-secret
CBE_NOTIFY_URL: http://payment-api-it:3003/webhooks/cbe-birr
CBE_RETURN_URL: http://localhost/return
TELEBIRR_BASE_URL: http://gateway-mock-it:4600/telebirr
TELEBIRR_WEB_BASE_URL: http://gateway-mock-it:4600/telebirr/web
TELEBIRR_FABRIC_APP_ID: it-fabric
TELEBIRR_APP_SECRET: it-secret
TELEBIRR_MERCHANT_APP_ID: it-merchant-app
TELEBIRR_MERCHANT_CODE: "999999"
TELEBIRR_NOTIFY_URL: http://payment-api-it:3003/webhooks/telebirr
# Telebirr PSS-signs every request object — a throwaway key generated per
# launch by it.mjs (nothing key-shaped lives in git).
TELEBIRR_PRIVATE_KEY: ${IT_TELEBIRR_PRIVATE_KEY}
EBIRR_BASE_URL: http://gateway-mock-it:4600/ebirr
DMONEY_BASE_URL: http://gateway-mock-it:4600/dmoney
CARD_BASE_URL: http://gateway-mock-it:4600/card
WAAFI_BASE_URL: http://gateway-mock-it:4600/waafi
CAC_BASE_URL: http://gateway-mock-it:4600/cac
CAC_USERNAME: it-cac
CAC_PASSWORD: it-cac
CAC_APP_KEY: it-cac-key
CAC_API_KEY: it-cac-api
CAC_COMPANY_SERVICES_ID: "1"
# Inbound CBE Unified Bill — we are the biller; bill-query hops back into
# the freight API, so this direction runs real code on both sides.
CBE_BILL_ENABLED: "true"
CBE_BILL_CLIENT_ID: it-cbe-bill
CBE_BILL_CLIENT_SECRET: it-cbe-bill-secret
CBE_BILL_JWT_SECRET: it-cbe-bill-jwt
FREIGHT_API_BASE_URL: http://freight-api-e2e:3001/api
ports:
- "${IT_PAYMENT_PORT:-3113}:3003"
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://localhost:3003/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 5s
timeout: 5s
retries: 12
start_period: 40s
# Base-stack service, re-pointed at the real payment API.
freight-api-e2e:
depends_on:
payment-api-it:
condition: service_healthy
environment:
PAYMENT_API_URL: http://payment-api-it:3003
# Freight's payment module skips RabbitMQModule entirely when this is
# unset (payment.module.ts) — without it, outbox events never arrive.
PAYMENT_RABBITMQ_URL: amqp://edr:edr_secret@rabbitmq-it:5672/payment
# RABBITMQ_ENABLED stays "false" (base stack) — it only gates the SMS/email
# clients, which must remain off. The payment consumer is wired by
# PAYMENT_RABBITMQ_URL alone.

177
integration/README.md Normal file
View File

@@ -0,0 +1,177 @@
# Freight API integration suite
Headless, API-level tests for the freight API running against the **real**
`edr-payment-api`. Only the bank/wallet gateways are stubbed.
```
pnpm it:up # build + start the stack (first run ~5 min)
pnpm it:test # vitest run (auto-ups the stack if needed)
pnpm it:test -- --reporter=basic src/payment-happy.it.ts
pnpm it:logs payment-api-it
pnpm it:down # -v, wipes the throwaway DB
```
## Stack
`docker-compose.it.yaml` is an **overlay** on `docker-compose.e2e.yaml` — same
freight API, Postgres (tmpfs), MinIO, Fayda/eTrade mocks; plus RabbitMQ, the
real payment API, and one gateway mock. It is a separate compose project
(`edr-freight-it`) on offset ports, so the Cypress e2e stack can run alongside.
```
freight-api-e2e :3111 ──HTTP──> payment-api-it :3113 ──HTTP──> gateway-mock-it :4600
^ │
└────────── RabbitMQ :5772 ─────┘ (outbox → payment.events → consumer)
```
Never `docker compose -f docker-compose.it.yaml` on its own — it needs the base
file first. Use `it.mjs`.
## Gateway mock
`gateway-mock/server.js` — one zero-dep `node:http` process serving every
provider under a path prefix, plus a control plane the tests drive:
| call | effect |
| --- | --- |
| `POST /__control/provider/:name` `{mode, times}` | `ok` / `fail` / `timeout` / `pending` / `paid` |
| `POST /__control/webhook` `{merchantOrderId, status, eventId, signature}` | fires a **correctly signed** callback at the payment API |
| `POST /__control/settle` `{merchantOrderId}` | pays at the bank with no callback (reconciliation path) |
| `GET /__control/calls` | every inbound provider call |
| `POST /__control/reset` | clear modes, orders and calls |
Signatures are real: the mock shares `CBE_SECRET_KEY` with the API, so
`verifyWebhookSignature` runs for real and `signature: "bad"` is a genuine
negative test. The suite drives **CBE Birr** end to end (plain HMAC, no key
material); other providers answer a generic stub until a scenario needs them.
Editing `server.js` needs a container restart (`docker compose … restart
gateway-mock-it`) — the code is a read-only mount, not baked into an image.
## Files
| file | covers |
| --- | --- |
| `src/payment-happy.it.ts` | initiate → webhook → outbox → broker → invoice PAID → booking advances |
| `src/payment-failure.it.ts` | provider down, decline, forged signature, reconciliation sweep, `unverifiable`, late capture |
| `src/concurrency.it.ts` | duplicate callbacks, two intents on one invoice, wagon budget, pay-window gate |
| `src/authz.it.ts` | cross-tenant isolation, login audience, service-token gates |
| `src/cbe-bill.it.ts` | inbound CBE Unified Bill: token → query (hops into freight) → payment |
| `src/bulk-import-full-train.it.ts` | six wheat bookings fill 54 wagons, then gate pass → T1 → dispatch → corridor → arrival → customs tail |
| `src/bulk-import-waiting-expiry.it.ts` | exact-fill trio selected, waiting three expire with the day |
| `src/bulk-import-split-promote.it.ts` | partial offer, split on settlement, expiry promotion, exact-remainder rebooking |
| `src/bulk-import-window-reopen.it.ts` | nobody pays → cycle 2 opens on the same train |
| `src/bulk-import-matrix.it.ts` | no-window day, sub-corridor, ride-along, whole-train giant |
| `src/bulk-export-full-train.it.ts` | FCFS accept = reservation, deadlines clamped to window close, export tail |
| `src/bulk-export-fcfs-space.it.ts` | reservations hold capacity, whole-or-nothing giant |
| `src/bulk-export-pay-or-lose.it.ts` | expiry frees space; window close expires the unpaid, no reopen |
| `src/bulk-export-matrix.it.ts` | mid-route boarding, leg occupancy, ride-along, sibling train windows |
| `src/bulk-b1-priority-expiry-refill.it.ts` | rule-engine priority band, expiry refill |
| `src/bulk-b2-per-item-floor.it.ts` | PER_ITEM wagon floor vs tonnage math |
| `src/bulk-b3-per-item-giant.it.ts` | PER_ITEM giant + line quantities (pins two defects) |
| `src/g1-s1-expiry-promotes-waitlist.it.ts` | 53-wagon built train: expiry frees exactly the waiting list's space |
| `src/g1-s2-exact-fill.it.ts` | exact 53/53 fill, every one of 68 containers mapped to a slot |
| `src/g1-s3-underfill-day-open.it.ts` | 28/53 is NOT FULL — the day still offers 25 wagons |
| `src/g1-s4-split-closes-gap.it.ts` | container split closes the last 3-wagon gap, 14-box remainder |
| `src/g1-s5-cascading-expiry.it.ts` | one settle promotes twice; expiries terminal, invoices closed |
| `src/g1-s6-s8-offers-government-tiers.it.ts` | ignored offer, government preemption, USD/customs/plain tiers |
| `src/g2-weight.it.ts` | weight before slots: base pull, overage tolerance, split sized on base only, light cargo |
| `src/flows.ts` | freight business steps, ported from `e2e/freight/cypress/e2e/flows/import-utils.ts` |
## Findings pinned by these tests
Where the platform's live behaviour differs from the scenario, the test asserts
what it actually does and says so in a comment, so a fix fails loudly:
- **Refill never supersedes an open partial offer** (`bulk-b1`, `g1-s5`). After
the giant expires and frees 28 wagons, the offered booking is re-selected but
keeps its stale 16-wagon offer; `applySplit` applies it, so the customer ships
16 of 20 with room to spare. `g1-s5` pins the container half: an expiry frees
8 more wagons and the 2-wagon offer beside them is never resized.
- **PER_ITEM bookings never get a partial offer** (`bulk-b3`). `sizeOffer` sizes
bulk offers by weight off `cargoTotalWeightVgm`, which for PER_ITEM holds the
ITEM COUNT — a 240-auto booking needing 60 wagons looks like 4, gets no offer,
allocates nothing, and expires with the day.
- **The contract booking path drops bulk `hazardousQuantity` / `reeferQuantity`**
(`bulk-b3`). Only `POST /api/bookings` maps and clamps them.
- **Paid intercity ride-alongs are unpinned back to the pool** (both matrices) —
staff must place them again. The Cypress twin never sees this because its
staff mark-paid shortcut leaves the reservation pinned.
- **Bulk priority is recomputed at doc-review** (`bulk-b1`), so writing
`priority_score` directly is a no-op; ranking has to come from a WAGON
priority config.
- **Freight sends a dev-shortcut amount** (1 minor unit, 10 for CAC) for every
non-`CBE_BILL` provider, with no short-payment guard.
- **A BUILT train's batch is blind to the pull limit** (`g2-weight`, last
describe). `remainingBudget` replaces the locomotive limits with
`{wagons: physicalWagons, weightTons: Infinity, lengthMeters: Infinity}` the
moment a schedule has a built train, so the batch reserves — and invoices —
a load the locomotives cannot pull. The only check left is at wagon
allocation, which then fails every tick with "Train set locomotives cannot
pull the gross weight … limit 3500T incl. tolerance". The customer is PAID
with zero wagons. Group 2 therefore runs its real weight scenarios on
locomotive PAIRS, where the limits survive.
- **The allocator weighs the whole consist, the batch weighs the booking.**
Allocation charges the tare of every wagon in the train set (53 × 22.4 T on
the G2 consist), while `needFor` charges only the tare of the wagons the
booking occupies — so the same 45-wagon load reads 3 528 T at reservation and
3 707.2 T at allocation. Two capacity models, one train.
- **Government preemption cannot reach a FULL train** (`g1-s6-s8`). `isFillable`
rejects a schedule whose `booking_window_status` is FULL before any budget or
victim is considered, and `refreshWindowStatus` re-derives that flag from live
capacity — so a genuinely full train is skipped and no commercial booking is
ever displaced. S7 therefore commits 52 of 53 slots.
- **The CUSTOMS priority band is dead in the shipped fixture.** It applies only
when the booking's SERVICE TYPE has `includes_customs`, and the corridor seed
ships one service type that does not — so the two CUSTOMS bands never score.
`seed-customs-service-type.sql` adds `RAIL_CUSTOMS` so the tier can be tested.
- **A customs SERVICE TYPE without a customs CONTRACT cannot finalize
clearance.** `finalizeClearance` looks up `clearance_output_<op>_<freight>`,
which the seeder deliberately leaves commented out, so `getByCode` 404s. Only
the phased path (`customs_clearing_enabled` on the contract) avoids it — which
is why S8's customs tenant is Path B.
## Gotchas
- **One unpaid hold per company.** `assertNoUnpaidHold` blocks a company with a
`SELECTED_FOR_BATCH` booking from creating another. Every file starts with
`releaseUnpaidHolds()`, which also hard-deletes retired
`train_schedule_bookings` rows (`booking_id` is UNIQUE and the constraint
ignores `deleted_at`, so a soft-unlinked booking can never be re-batched).
- **Arrange is slow.** Contract → booking → clearance → ops accept → batch is
3060s of real API work per booking, so files share one schedule day.
- Files run sequentially (one DB); concurrency is exercised inside a test with
`Promise.all`.
- **A retired fixture keeps its shipment day at its peril.**
`rescueStrandedPaidForDay` sweeps every unlinked booking whose
`payment_status` is PAID and whose `scheduled_date` falls on the day being
filled, and re-places it on the fresh train. A previous run's paid bookings
therefore climb back aboard — 18 stowaway wagons on a 28-wagon day, until
`releaseUnpaidHolds` / `resetCorridorDay` started nulling `scheduled_date`.
- **Only a FULL train rests at DONE.** An under-filled day CONCLUDES and
REOPENS (`window_phase` back to OPEN, `booking_cycle_no` 2), so waiting for
DONE there waits forever — use `pollCycleConcluded`.
- **Wagon stock is finite and shared.** Paid bookings keep their wagons, so
`releaseUnpaidHolds()` also frees every earlier `CTR-IT-%` allocation —
without it the fifth or sixth file on a warm stack silently gets a short
consist.
- **One tenant per booking.** `seedTenantContracts` mints a company per booking
because a company may hold only one unpaid reservation at a time; staff book
and pay on their behalf, which is also the real Path B flow.
- **The weight axis is GROSS, but the column is not.**
`wagon_booking_allocations.allocated_weight_tons` holds CARGO only;
`allocatedGrossTons` adds each wagon type's tare, because the pull limit is
spent on both. Two 20ft at 28 T ride one wagon at 78.4 T gross — 35 of those
spend a 3 500 T locomotive pair, and reading the raw column would report
1 960 T and hide it.
- **Group 1 rides a BUILT train, not a loco pair.** `maxWagonsPerTrain` is not a
cap: `syncScheduleMaxWagons` recomputes it from locomotive length (54 here)
every fill pass. A built train's coupled consist wins outright, so
`seed-g1-train.sql`'s 53 wagons ARE the capacity — the number every G1
scenario's arithmetic is written in. `createBuiltTrainSchedule` asserts it.
- **Customs bookings walk the phased chain** (`clearBookingPhasedCustoms`):
transit assignee → declaration draft → accept → declaration → duty →
transit permit → pre-clearance → delivery order. `clearance/finalize` refuses
them outright.
- Freight sends a dev-shortcut amount (1 minor unit, 10 for CAC) for every
non-`CBE_BILL` provider. The tests assert that as-is.

View File

@@ -0,0 +1,299 @@
// Stand-in for every bank/wallet gateway the payment API calls, plus a control
// plane the integration tests drive.
//
// WHY ONE PROCESS
//
// Each provider's base URL is env-configurable (packages/payment-providers/…),
// so pointing them all at one server with a per-provider path prefix stubs the
// whole outbound surface without touching a line of app code. The payment API
// itself, its state machine, its webhook pipeline and its signature checks all
// run for real.
//
// Signatures are REAL: this server holds the same CBE_SECRET_KEY the API does
// and signs the callbacks it fires, so the API's verifyWebhookSignature runs in
// anger instead of being bypassed. That also makes the negative test possible —
// ask for a bad signature and the API must refuse to move any money.
//
// No dependencies (node:http + node:crypto), same shape as e2e/freight/*-mock.
const http = require("node:http");
const crypto = require("node:crypto");
const PORT = Number(process.env.PORT || 4600);
const PAYMENT_API_URL = process.env.PAYMENT_API_URL || "http://payment-api-it:3003";
const CBE_SECRET = process.env.CBE_SECRET_KEY || "it-cbe-secret";
const CBE_MERCHANT = process.env.CBE_MERCHANT_ID || "it-cbe-merchant";
const CAC_OTP = "123456";
/**
* Per-provider behaviour, set by POST /__control/provider/:name.
* ok — succeed (default)
* fail — answer 502, so the provider call throws inside the API
* timeout — never answer (the API's own 10s axios timeout fires)
* pending — succeed on initiate, but report "not paid yet" on every query
* paid — report SUCCESS on query without any webhook (reconciliation path)
* `remaining` counts down when set, then the provider reverts to ok.
*/
const modes = new Map();
/** Every inbound call, for "the provider was queried exactly once" assertions. */
let calls = [];
/** merchantOrderId → what the mock believes the payment did. */
const orders = new Map();
function modeFor(provider) {
const entry = modes.get(provider);
if (!entry) return "ok";
if (entry.remaining != null) {
if (entry.remaining <= 0) {
modes.delete(provider);
return "ok";
}
entry.remaining -= 1;
}
return entry.mode;
}
/** CBE Birr signs `k=v` pairs over sorted keys with HMAC-SHA256 (hex). */
function cbeSign(data) {
const signString = Object.keys(data)
.sort()
.map((k) => `${k}=${data[k]}`)
.join("&");
return crypto.createHmac("sha256", CBE_SECRET).update(signString).digest("hex");
}
async function postJson(url, body, headers = {}) {
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json", ...headers },
body: JSON.stringify(body),
});
const text = await res.text();
return { status: res.status, body: text };
}
/**
* Fire a provider callback at the payment API, signed the way the real gateway
* would. `signature: "bad"` deliberately produces a well-formed but wrong
* signature (same length — the API compares with timingSafeEqual, which throws
* on a length mismatch and would mask what we are testing).
*/
async function fireWebhook(opts) {
const {
provider = "CBE_BIRR",
merchantOrderId,
status = "SUCCESS",
transactionId,
eventId,
signature,
} = opts;
if (provider !== "CBE_BIRR") {
throw new Error(`webhook not implemented for provider ${provider}`);
}
const order = orders.get(merchantOrderId) ?? {};
const payload = {
merchantId: CBE_MERCHANT,
merchantOrderId,
// orderId doubles as the dedupe key upstream: externalEventId is
// `${orderId}_${status}` (cbe-birr-webhook.service.ts), so a caller-supplied
// eventId is how a test replays the SAME event.
orderId: eventId ?? order.orderId ?? `CBEORD-${merchantOrderId}`,
status,
transactionId: transactionId ?? order.transactionId ?? `CBETXN-${merchantOrderId}`,
amount: order.amount ?? "1.00",
currency: order.currency ?? "ETB",
paidAt: new Date().toISOString(),
};
payload.signature =
signature === "bad" ? crypto.randomBytes(32).toString("hex") : cbeSign(payload);
return postJson(`${PAYMENT_API_URL}/webhooks/cbe-birr`, payload);
}
// ---------------------------------------------------------------------------
// provider routes
// ---------------------------------------------------------------------------
/** @returns {[number, unknown] | "hang"} */
function handleProvider(provider, path, body, url) {
const mode = modeFor(provider);
if (mode === "timeout") return "hang";
if (mode === "fail") return [502, { error: `${provider} unavailable (forced)` }];
switch (`${provider}/${path}`) {
// --- CBE Birr (the suite's primary provider: plain HMAC, no key material)
case "cbe-birr/api/v1/payment/initiate": {
const orderId = `CBEORD-${body.merchantOrderId}`;
orders.set(body.merchantOrderId, {
orderId,
amount: body.amount,
currency: body.currency,
transactionId: `CBETXN-${body.merchantOrderId}`,
paid: false,
});
return [
200,
{
success: true,
orderId,
paymentUrl: `http://gateway-mock-it:${PORT}/cbe-birr/pay/${orderId}`,
expiresIn: 900,
},
];
}
case "cbe-birr/api/v1/payment/query": {
const order = orders.get(body.merchantOrderId);
if (!order) return [200, { success: false, status: "NOT_FOUND" }];
const paid = mode === "paid" || order.paid;
return [
200,
{
success: true,
orderId: order.orderId,
status: paid ? "SUCCESS" : mode === "pending" ? "PENDING" : "PROCESSING",
transactionId: order.transactionId,
amount: order.amount,
paidAt: paid ? new Date().toISOString() : undefined,
},
];
}
// --- Telebirr (fabric token + createOrder + queryOrder)
case "telebirr/payment/v1/token":
return [200, { token: "it-fabric-token" }];
case "telebirr/payment/v1/inapp/createOrder": {
const merchOrderId = body?.biz_content?.merch_order_id;
const prepayId = `PREPAY-${merchOrderId ?? Date.now()}`;
orders.set(merchOrderId, { orderId: prepayId, paid: false });
return [
200,
{
result: "SUCCESS",
code: "0",
biz_content: { prepay_id: prepayId, merch_order_id: merchOrderId },
},
];
}
case "telebirr/payment/v1/merchant/queryOrder": {
const order = orders.get(body?.biz_content?.merch_order_id);
const paid = mode === "paid" || order?.paid;
return [
200,
{
result: "SUCCESS",
code: "0",
biz_content: {
order_status: paid ? "Completed" : "Paying",
trans_id: order?.orderId,
},
},
];
}
// --- CAC Bank (OTP debit)
case "cac/paymentapi/auth/signin":
return [200, { token: "it-cac-token", expiresIn: 86400 }];
case "cac/paymentapi/PaymentInitiateRequest": {
const id = `${Date.now()}00000`;
orders.set(String(id), { orderId: String(id), paid: false });
return [200, { status: true, message: "OTP sent", data: { id, otpRequired: true } }];
}
default:
// Unimplemented gateway paths answer a generic OK rather than 404: the
// suite only drives CBE Birr / CAC end to end, and a 404 here would look
// like a bug in the API rather than an unused stub. Add real shapes when
// a scenario needs them.
calls.push({ provider, path, unimplemented: true });
return [200, { success: true, stub: true, path: `${provider}/${path}`, url }];
}
}
// ---------------------------------------------------------------------------
// server
// ---------------------------------------------------------------------------
const server = http.createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", async () => {
const raw = Buffer.concat(chunks).toString("utf8");
let body = {};
try {
body = raw ? JSON.parse(raw) : {};
} catch {
body = { raw };
}
const send = (status, payload) => {
const json = JSON.stringify(payload ?? {});
res.writeHead(status, {
"content-type": "application/json",
"content-length": Buffer.byteLength(json),
});
res.end(json);
};
const url = new URL(req.url, "http://mock");
const path = url.pathname.replace(/^\/+/, "");
// --- control plane -----------------------------------------------------
if (path.startsWith("__control")) {
const [, action, arg] = path.split("/");
if (action === "health") return send(200, { ok: true });
if (action === "reset") {
modes.clear();
orders.clear();
calls = [];
return send(200, { ok: true });
}
if (action === "calls") {
return send(200, { calls });
}
if (action === "provider" && req.method === "POST") {
modes.set(arg, { mode: body.mode ?? "ok", remaining: body.times ?? null });
console.log(`[gateway-mock] ${arg}${body.mode} (times=${body.times ?? "∞"})`);
return send(200, { ok: true, provider: arg, mode: body.mode });
}
if (action === "settle" && req.method === "POST") {
// Mark the order paid at the gateway WITHOUT notifying — the payment
// API can then only learn about it by polling (reconciliation path).
const order = orders.get(body.merchantOrderId);
if (!order) return send(404, { error: "unknown merchantOrderId" });
order.paid = true;
return send(200, { ok: true });
}
if (action === "webhook" && req.method === "POST") {
try {
const result = await fireWebhook(body);
console.log(
`[gateway-mock] webhook ${body.merchantOrderId} ${body.status ?? "SUCCESS"}${result.status}`,
);
return send(200, { ok: true, delivered: result.status, body: result.body });
} catch (err) {
return send(500, { error: String(err) });
}
}
return send(404, { error: `unknown control action ${action}` });
}
// --- gateway routes ----------------------------------------------------
const provider = path.split("/")[0];
const rest = path.slice(provider.length + 1);
calls.push({ provider, path: rest, method: req.method, body, at: Date.now() });
// CAC confirm carries the OTP; wrong code must fail the way the bank does.
if (rest.startsWith("paymentapi/") && rest.includes("Confirm")) {
const ok = String(body.otp ?? body.OTP ?? "") === CAC_OTP;
return send(200, ok
? { status: true, data: { id: body.id, status: "SUCCESS" } }
: { status: false, message: "Invalid OTP" });
}
const result = handleProvider(provider, rest, body, req.url);
if (result === "hang") {
console.log(`[gateway-mock] ${provider}/${rest} → hanging (forced timeout)`);
return; // never answer; the caller's own timeout fires
}
send(result[0], result[1]);
});
});
server.listen(PORT, () => console.log(`gateway-mock listening on ${PORT}`));

21
integration/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "@edr/freight-integration",
"version": "0.0.0",
"private": true,
"description": "API-level integration tests for the freight API against the real payment microservice",
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/pg": "^8.11.0",
"@types/supertest": "^6.0.2",
"pg": "^8.13.0",
"supertest": "^7.0.0",
"typescript": "^5.5.4",
"vitest": "^2.1.2"
}
}

173
integration/scripts/it.mjs Normal file
View File

@@ -0,0 +1,173 @@
#!/usr/bin/env node
/**
* Freight integration-suite launcher.
*
* node integration/scripts/it.mjs <up|test|down|logs> [vitest args...]
*
* Overlays docker-compose.it.yaml on docker-compose.e2e.yaml: same freight
* stack, but the payment microservice is real and only the bank gateways are
* stubbed. Web/Cypress containers are never started — this suite is HTTP only.
*
* Ports are fixed (and distinct from the Cypress e2e defaults) so both stacks
* can be up at once; they are separate compose projects.
*
* No dependencies — plain Node spawning `docker compose` and `pnpm`.
*/
import { execFileSync, spawnSync } from "node:child_process";
import { generateKeyPairSync } from "node:crypto";
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const itDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const repoRoot = resolve(itDir, "..");
const composeBase = [
"compose",
"-f",
join(repoRoot, "docker-compose.e2e.yaml"),
"-f",
join(repoRoot, "docker-compose.it.yaml"),
];
/** Deliberately offset from the Cypress stack's defaults (3101/5533/9310…). */
const PORTS = {
E2E_API_PORT: 3111,
E2E_DB_PORT: 5543,
E2E_MINIO_PORT: 9320,
E2E_MINIO_CONSOLE_PORT: 9321,
// Unused here (no web containers) but referenced by the base file's build args.
E2E_PORTAL_PORT: 5393,
E2E_BACKOFFICE_PORT: 5394,
IT_PAYMENT_PORT: 3113,
IT_GATEWAY_PORT: 4600,
IT_RABBIT_PORT: 5772,
IT_RABBIT_UI_PORT: 15772,
};
/** Everything the suite needs up — web + cypress are deliberately absent. */
const SERVICES = [
"postgres-freight-e2e",
"minio-e2e",
"minio-init-e2e",
"freight-migration-e2e",
"fayda-mock-e2e",
"etrade-mock-e2e",
// Still a base-stack dependency of freight-api-e2e (reconcile-before-expire
// has its own client); cheap to run alongside the real payment API.
"payment-mock-e2e",
"gateway-mock-it",
"rabbitmq-it",
"payment-api-it",
"freight-api-e2e",
];
const RUNNING = SERVICES.filter(
(s) => !["minio-init-e2e", "freight-migration-e2e"].includes(s),
);
function fail(msg) {
console.error(`\nit: ${msg}`);
process.exit(1);
}
function preflight() {
try {
execFileSync("docker", ["info"], { stdio: "ignore" });
} catch {
fail("docker is not running (or not installed) — start Docker and retry.");
}
if (!existsSync(join(repoRoot, ".npmrc"))) {
fail(".npmrc missing at repo root — image builds need GitHub Packages auth for @tria-plc.");
}
}
/** Throwaway RSA PEM — Telebirr PSS-signs every request object; the mock never
* verifies it, but the provider refuses to build a request without a real key. */
function fakeTelebirrPrivateKey() {
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
}
/** Throwaway RSA JWK for FAYDA_PRIVATE_KEY_BASE64 (see e2e.mjs — same reason). */
function fakeFaydaPrivateKeyBase64() {
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const jwk = privateKey.export({ format: "jwk" });
Object.assign(jwk, { kty: "RSA", use: "sig", alg: "RS256", kid: "it-fayda-mock" });
return Buffer.from(JSON.stringify(jwk)).toString("base64");
}
const env = {
...process.env,
...Object.fromEntries(Object.entries(PORTS).map(([k, v]) => [k, String(v)])),
IT_API_URL: `http://localhost:${PORTS.E2E_API_PORT}`,
IT_PAYMENT_URL: `http://localhost:${PORTS.IT_PAYMENT_PORT}`,
IT_GATEWAY_URL: `http://localhost:${PORTS.IT_GATEWAY_PORT}`,
IT_DB_URL: `postgres://edr_e2e:edr_e2e@localhost:${PORTS.E2E_DB_PORT}/edr_freight_e2e`,
FAYDA_PRIVATE_KEY_BASE64:
process.env.FAYDA_PRIVATE_KEY_BASE64 ?? fakeFaydaPrivateKeyBase64(),
IT_TELEBIRR_PRIVATE_KEY:
process.env.IT_TELEBIRR_PRIVATE_KEY ?? fakeTelebirrPrivateKey(),
};
function compose(args) {
const { status } = spawnSync("docker", [...composeBase, ...args], { stdio: "inherit", env });
return status ?? 1;
}
function stackRunning() {
try {
const out = execFileSync("docker", [...composeBase, "ps", "--services", "--status", "running"], {
encoding: "utf8",
env,
stdio: ["ignore", "pipe", "ignore"],
});
const running = new Set(out.split("\n").filter(Boolean));
return RUNNING.every((s) => running.has(s));
} catch {
return false;
}
}
function up() {
preflight();
console.log(
`it: starting stack — freight :${PORTS.E2E_API_PORT} payment :${PORTS.IT_PAYMENT_PORT} ` +
`gateway :${PORTS.IT_GATEWAY_PORT} db :${PORTS.E2E_DB_PORT}`,
);
if (compose(["up", "-d", "--build", "--wait", ...SERVICES]) !== 0) {
fail(
"stack failed to become healthy. Inspect with:\n" +
" node integration/scripts/it.mjs logs payment-api-it",
);
}
}
const [cmd, ...rawExtra] = process.argv.slice(2);
// `pnpm it:test -- src/foo.it.ts` hands us a literal "--" first. Forwarding it
// makes vitest treat everything after it as CLI options and ignore the file
// filter — the "one file" run silently becomes the whole suite.
const extra = rawExtra[0] === "--" ? rawExtra.slice(1) : rawExtra;
switch (cmd) {
case "up":
up();
break;
case "test": {
if (!stackRunning()) up();
const { status } = spawnSync(
"pnpm",
["--filter", "@edr/freight-integration", "run", "test", ...extra],
{ cwd: repoRoot, stdio: "inherit", env },
);
process.exit(status ?? 1);
}
case "logs":
process.exit(compose(["logs", "--tail", "200", ...extra]));
break;
case "down":
process.exit(compose(["down", "-v", "--remove-orphans"]));
break;
default:
fail(`unknown command "${cmd ?? ""}" — use up | test | logs | down`);
}

View File

@@ -0,0 +1,34 @@
-- Second tenant for the integration suite: user2@gmail.com gets its own ACTIVE
-- company + approved importer profile, mirroring seed-company.sql (which only
-- sets up user@gmail.com). Two real tenants are what make the cross-tenant
-- isolation and the "two companies race for the same train" scenarios honest.
-- Idempotent; TIN is the key.
INSERT INTO freight.companies
(id, name, type, status, tin, fan_number, country, address, phone, email,
nationality, kind, attributes)
SELECT gen_random_uuid(), 'IT Freight Partners PLC', 'customer', 'active',
'0102030406', '1234567890123457', 'Ethiopia', 'Adama, Ethiopia',
'+251911000011', 'ops@it-partners.test', 'ethiopian', 'commercial',
'{"contactPersonName":"IT Contact","contactPersonPhone":"+251911000012","generalManagerName":"IT GM","generalManagerEmail":"gm@it-partners.test","generalManagerPhone":"+251911000013"}'::jsonb
WHERE NOT EXISTS (SELECT 1 FROM freight.companies WHERE tin = '0102030406');
INSERT INTO freight.company_profiles (id, company_id, type, status, reference)
SELECT gen_random_uuid(), c.id, 'importer', 'active', 'IMP-IT-0002'
FROM freight.companies c
WHERE c.tin = '0102030406'
AND NOT EXISTS (
SELECT 1 FROM freight.company_profiles p
WHERE p.company_id = c.id AND p.type = 'importer'
);
INSERT INTO freight.external_profiles
(id, user_id, company_id, first_name, last_name, is_primary_contact,
onboarding_step, onboarding_completed)
SELECT gen_random_uuid(), u.id, c.id, 'Demo', 'User2', true, 'done', true
FROM iam.users u
JOIN freight.companies c ON c.tin = '0102030406'
WHERE u.email = 'user2@gmail.com'
AND NOT EXISTS (
SELECT 1 FROM freight.external_profiles ep WHERE ep.user_id = u.id
);

View File

@@ -0,0 +1,17 @@
-- A service type whose bundle INCLUDES customs, for the priority-tier scenario
-- (g1-s6-s8, S8). Idempotent.
--
-- The rule engine's CUSTOMS priority band only applies when the booking's
-- service type has `includes_customs = true` (rule-engine.service.ts:251), and
-- the corridor fixture ships exactly one service type — RAIL, which does not.
-- Without this row the customs tier can never fire and "customs outranks plain"
-- is untestable: both bookings score the same.
--
-- Contracts opt in with seedContract({ serviceTypeCode: 'RAIL_CUSTOMS' }).
INSERT INTO freight.service_types
(code, service_name, description, includes_customs, is_active, display_order)
SELECT 'RAIL_CUSTOMS', 'Rail Transport + Customs Clearance',
'IT fixture: the customs-bundled service tier', true, true, 2
WHERE NOT EXISTS (
SELECT 1 FROM freight.service_types WHERE code = 'RAIL_CUSTOMS'
);

View File

@@ -0,0 +1,92 @@
/**
* Who is allowed to touch a payment. Cheap to run (no booking chain), and the
* failures here are the expensive kind: a tenant reading another tenant's
* invoice, or an unauthenticated caller marking one paid.
*/
import { afterAll, describe, expect, it } from "vitest";
import request from "supertest";
import {
API,
PAYMENT_API,
api,
closeDb,
customerA,
customerB,
db,
login,
payment,
} from "./client";
describe("payment authorization boundaries", () => {
afterAll(closeDb);
it("hides one tenant's invoice from the other", async () => {
const rows = await db<{ id: string; company_id: string }>(
`SELECT i.id, i.company_id FROM freight.invoices i
JOIN freight.companies c ON c.id = i.company_id
WHERE c.tin = '0102030405' AND i.deleted_at IS NULL
ORDER BY i.created_at DESC LIMIT 1`,
);
if (!rows[0]) return; // nothing billed yet in this run — payment files cover it
const res = await api(customerB, "get", `/api/billing/my-invoices/${rows[0].id}`);
expect([403, 404]).toContain(res.status);
});
it("refuses to let one tenant pay the other's invoice", async () => {
const rows = await db<{ id: string }>(
`SELECT i.id FROM freight.invoices i
JOIN freight.companies c ON c.id = i.company_id
WHERE c.tin = '0102030405' AND i.status <> 'PAID' AND i.deleted_at IS NULL
ORDER BY i.created_at DESC LIMIT 1`,
);
if (!rows[0]) return;
const res = await api(customerB, "post", `/api/billing/my-invoices/${rows[0].id}/pay`, {
method: "CBE_BIRR",
platform: "web",
});
expect(res.status).toBeGreaterThanOrEqual(400);
});
it("keeps a portal customer out of backoffice payment operations", async () => {
const res = await api(customerA, "get", "/api/billing/invoices");
expect(res.status).toBeGreaterThanOrEqual(400);
});
it("rejects a portal account on the backoffice login audience", async () => {
const res = await login(customerA, "12345678", "backoffice");
expect(res.status).toBeGreaterThanOrEqual(400);
});
it("requires the service token on freight's mark-paid callback", async () => {
const body = {
version: 1,
eventId: "authz-probe",
eventType: "payment.succeeded",
occurredAt: new Date().toISOString(),
service: "FREIGHT",
intentId: "00000000-0000-0000-0000-000000000000",
referenceType: "SHIPMENT",
referenceId: "00000000-0000-0000-0000-000000000000",
provider: "CBE_BIRR",
amountMinor: 1,
currency: "ETB",
};
const res = await request(API).post("/api/internal/payments/mark-paid").send(body);
expect([401, 403]).toContain(res.status);
});
it("requires the service token on the payment API's internal surface", async () => {
const res = await request(PAYMENT_API).get("/payments/intents?service=FREIGHT");
expect([400, 401, 403]).toContain(res.status);
// …and accepts it when present (400 = bad query, not an auth failure).
const withToken = await payment("get", "/payments/intents?service=FREIGHT");
expect([401, 403]).not.toContain(withToken.status);
});
it("leaves the provider webhook surface public — trust is the signature", async () => {
// A garbage payload must be acked, not 401'd: providers do not authenticate.
const res = await request(PAYMENT_API).post("/webhooks/cbe-birr").send({ nonsense: true });
expect(res.status).toBe(200);
});
});

View File

@@ -0,0 +1,180 @@
/**
* BULK B1 — staff priority decides who rides; expiry refill promotes the
* offered booking WHOLE.
*
* Three wheat bookings that cannot all fit a 54-wagon CW4 train:
* BP1 1 960 T = 28 w (commercial giant)
* BP2 1 400 T = 20 w (commercial)
* BP3 700 T = 10 w (relief cargo — staff rank it FIRST)
*
* 58 wagons chase 54. With BP3 on top the batch reserves BP3 + BP1 whole
* (38 w) and leaves BP2 a whole-wagon offer for the remaining 16. BP1 then
* misses its pay window: its 28 wagons come back and the refill round must
* promote BP2 WHOLE — superseding the 16-wagon offer.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { api, apiOk, closeDb, db, gateway, superAdmin } from "./client";
import {
bookBulkReady,
allocatedWagons,
bookingRow,
closeBookingWindow,
completeDocReview,
createSchedule,
departureAt,
eatDayStr,
ensureCorridorRoute,
extendPayWindow,
expectWagonType,
forceReservationExpiry,
forceWindowOpen,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollPartialOffer,
releaseUnpaidHolds,
resetCorridorDay,
seedTenantContracts,
} from "./flows";
const DEPARTURE = departureAt(40);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const STAMP = String(Date.now());
/**
* Booked in arrival order; the RANKING comes from a real priority rule.
*
* NOTE — the Cypress twin ranks these by writing `priority_score` directly.
* That lever is dead for BULK: `recomputeBulkPriorities`
* (booking-batch.service.ts) re-derives every bulk booking's score from the
* rule engine when doc review closes, overwriting anything hand-written. So
* this file configures the product's own lever instead — a WAGON priority band
* that scores 110-wagon bookings above everything else, which is exactly how
* staff would push relief cargo to the front.
*/
const BOOKINGS = [
{ suffix: "BP1", tons: 1960, wagons: 28 }, // commercial giant
{ suffix: "BP2", tons: 1400, wagons: 20 }, // does not fit whole → offered 16
{ suffix: "BP3", tons: 700, wagons: 10 }, // relief cargo — ranked first by the rule
];
describe("bulk b1: priority ordering and expiry refill", () => {
const booking = new Map<string, string>();
let scheduleId: string;
let priorityConfigId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const contracts = await seedTenantContracts(
STAMP,
BOOKINGS.map((b) => ({ suffix: b.suffix, freight: "BULK" as const })),
);
scheduleId = (
await createSchedule({
departure: DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"],
})
).id;
await forceWindowOpen(scheduleId, 60);
// Staff rank relief-sized cargo first: a WAGON band worth the maximum 50
// points for 110 wagons. Ranges must be contiguous from 1, and this is
// the first WAGON rule in the stack. Removed again in afterAll so the
// other bulk files keep the default (unranked) engine.
const cfg = await apiOk(superAdmin, "post", "/api/priority-configs", {
type: "WAGON",
label: "IT relief cargo 1-10 wagons",
minWagonCount: 1,
maxWagonCount: 10,
scorePoints: 50,
isActive: true,
});
priorityConfigId = (cfg.body?.data?.id ?? cfg.body?.id) as string;
expect(priorityConfigId, "priority config created").toBeTruthy();
for (const b of BOOKINGS) {
booking.set(
b.suffix,
await bookBulkReady({
contractId: contracts.get(b.suffix)!,
tons: b.tons,
scheduledDate: BOOKING_DAY,
}),
);
}
}, 1_800_000);
afterAll(async () => {
if (priorityConfigId) {
await api(superAdmin, "delete", `/api/priority-configs/${priorityConfigId}`);
}
await closeDb();
});
it("ranks the relief cargo first — it and the giant reserve whole, the third is offered 16", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
// The batch re-scored the pool from the rule: relief 50, the other two 0.
const scores = await db<{ id: string; priority_score: string }>(
`SELECT id, priority_score FROM freight.bookings WHERE id = ANY($1::uuid[])`,
[[...booking.values()]],
);
const scoreOf = (suffix: string) =>
Number(scores.find((r) => r.id === booking.get(suffix))?.priority_score ?? 0);
expect(scoreOf("BP3"), "relief cargo outranks the commercial pair").toBeGreaterThan(
Math.max(scoreOf("BP1"), scoreOf("BP2")),
);
for (const suffix of ["BP3", "BP1"]) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
}
const offer = await pollPartialOffer(booking.get("BP2")!);
expect(Number(offer.offered_wagons), "BP2 offered the 16-wagon leftover").toBe(16);
});
it("the relief cargo pays and rides CW4; the giant misses its pay window", async () => {
await extendPayWindow(scheduleId, [booking.get("BP3")!]);
await payViaGateway(booking.get("BP3")!);
await pollAllocations(booking.get("BP3")!, 10);
await expectWagonType(booking.get("BP3")!, "CW4", 10);
await forceReservationExpiry(booking.get("BP1")!);
await pollBookingStatus(booking.get("BP1")!, "EXPIRED", 40);
});
it("the refill round re-selects the offered booking — but its 16-wagon offer still stands", async () => {
await pollBookingStatus(booking.get("BP2")!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 40);
await extendPayWindow(scheduleId, [booking.get("BP2")!]);
await payViaGateway(booking.get("BP2")!);
await pollAllocations(booking.get("BP2")!, 16);
const bp2 = await bookingRow(booking.get("BP2")!);
// FINDING — the scenario expects the refill to promote BP2 WHOLE (20 w)
// into the 28 wagons the expired giant just freed, superseding its
// 16-wagon offer. It does not: the refill flips the booking back to
// reserved but never issues a replacement offer, and `applySplit` then
// applies the ONLY open offer — the stale 16-wagon one
// (booking-split.service.ts: an offer is superseded only when a NEW offer
// is created). The customer ships 16 of 20 wagons with room to spare.
// This test pins today's behaviour so the fix flips it loudly.
expect(Number(bp2.wagons_required), "rides the stale offer, not the freed 20").toBe(16);
expect(bp2.is_split, "split against the stale offer").toBe(true);
const [offers] = await db<{ n: string }>(
`SELECT count(*)::text AS n FROM freight.booking_batch_offers
WHERE booking_id = $1 AND deleted_at IS NULL`,
[booking.get("BP2")!],
);
expect(Number(offers.n), "no replacement offer was issued after the refill").toBe(1);
// …and the train really did have the room: 10 (relief) + 16 = 26 of 54.
expect(await allocatedWagons(scheduleId), "28 wagons left unused").toBe(26);
});
});

View File

@@ -0,0 +1,141 @@
/**
* BULK B2 — break-bulk PER_ITEM wagon math on the CW4 fleet (70 T capacity).
*
* Cargo from seed-bulk-items.sql:
* E2E_IMP_AUTO automobiles — items-per-wagon floor of 4
* E2E_IMP_MACHINE machinery — no floor, tonnage-only fallback
*
* Three verdicts of the wagon-demand rule, end to end:
* BA1 16 autos @2.5 T (40 T) → the floor binds: 4 wagons (tonnage said 1)
* BA2 12 machines @20 T (240 T) → tonnage binds: 3/wagon → 4 wagons
* BA3 216 autos (540 T) → exactly 54 wagons: FULL from one booking, no split
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, gateway } from "./client";
import {
bookBulkItemsReady,
bookingRow,
closeBookingWindow,
completeDocReview,
createSchedule,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
extendPayWindow,
expectWagonType,
forceWindowOpen,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollWindow,
releaseUnpaidHolds,
resetCorridorDay,
seedTenantContracts,
} from "./flows";
const DEPARTURE = departureAt(41);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const FULL_DEPARTURE = departureAt(42);
const FULL_DAY = eatDayStr(FULL_DEPARTURE);
const STAMP = String(Date.now());
describe("bulk b2: PER_ITEM floor vs tonnage wagon math", () => {
const booking = new Map<string, string>();
let contracts: Map<string, string>;
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
await resetCorridorDay(FULL_DEPARTURE);
contracts = await seedTenantContracts(
STAMP,
["BA1", "BA2", "BA3"].map((suffix) => ({ suffix, freight: "BULK" as const })),
);
scheduleId = (
await createSchedule({
departure: DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"],
})
).id;
await forceWindowOpen(scheduleId, 60);
booking.set(
"BA1",
await bookBulkItemsReady({
contractId: contracts.get("BA1")!,
cargoCode: "E2E_IMP_AUTO",
items: 16,
tons: 40,
scheduledDate: BOOKING_DAY,
}),
);
booking.set(
"BA2",
await bookBulkItemsReady({
contractId: contracts.get("BA2")!,
cargoCode: "E2E_IMP_MACHINE",
items: 12,
tons: 240,
scheduledDate: BOOKING_DAY,
}),
);
}, 1_800_000);
afterAll(closeDb);
it("the 4-per-wagon floor binds for 16 autos → 4 CW4 wagons, not 1", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const suffix of ["BA1", "BA2"]) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
}
await extendPayWindow(scheduleId, [booking.get("BA1")!, booking.get("BA2")!]);
await payViaGateway(booking.get("BA1")!);
await pollAllocations(booking.get("BA1")!, 4);
await expectWagonType(booking.get("BA1")!, "CW4", 4);
});
it("machinery has no floor — 12 items @20 T take 4 wagons on tonnage alone", async () => {
await payViaGateway(booking.get("BA2")!);
await pollAllocations(booking.get("BA2")!, 4);
await expectWagonType(booking.get("BA2")!, "CW4", 4);
});
it("216 autos = exactly 54 wagons: FULL from one break-bulk booking, no split", async () => {
const fullSchedule = await createSchedule({
departure: FULL_DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-25", "LOCO-IMP-26"],
});
await forceWindowOpen(fullSchedule.id, 45);
const ba3 = await bookBulkItemsReady({
contractId: contracts.get("BA3")!,
cargoCode: "E2E_IMP_AUTO",
items: 216,
tons: 540,
scheduledDate: FULL_DAY,
});
await closeBookingWindow(fullSchedule.id);
await completeDocReview(fullSchedule.id);
await pollBookingStatus(ba3, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
await payViaGateway(ba3);
await pollAllocations(ba3, 54);
expect((await bookingRow(ba3)).is_split, "BA3 rides whole, not split").not.toBe(true);
await endPaymentPhase(fullSchedule.id);
await pollWindow(
fullSchedule.id,
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
"FULL + DONE",
);
}, 900_000);
});

View File

@@ -0,0 +1,173 @@
/**
* BULK B3 — PER_ITEM giant split and line quantities.
*
* The scenario: 240 automobiles (600 T) on a 54-wagon CW4 train. The
* 4-per-wagon floor needs 60 wagons, so the batch should offer the whole
* consist (216 autos / 54 wagons), the settlement should apply the split, and
* the 24-auto remainder should have to be rebooked exactly. Plus two field
* checks: hazardousQuantity above the line count clamps, reeferQuantity is
* stored.
*
* WHAT ACTUALLY HAPPENS — two defects this file pins:
*
* 1. PER_ITEM bookings never get a partial offer. `sizeOffer`
* (booking-split.service.ts) sizes a bulk offer by WEIGHT off
* `cargoTotalWeightVgm` — but for PER_ITEM cargo that column holds the
* ITEM COUNT (240), not tonnage (600, kept in `bulk_total_weight_tons`).
* 240 "tons" fits 54 wagons, so no offer is made; the booking is reserved
* without a wagon count, allocates nothing, and silently expires with the
* day. The 24-auto remainder step therefore cannot happen at all.
*
* 2. The contract booking path DROPS per-line `hazardousQuantity` /
* `reeferQuantity` for bulk. Only the direct booking path
* (`POST /api/bookings`, bookings.service.ts) maps them onto
* `bulk_hazardous_quantity` / `bulk_reefer_quantity` — and only that path
* clamps them to the cargo amount.
*
* Both are asserted as they behave today, so a fix fails here loudly.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, db, gateway } from "./client";
import {
bookBulkItems,
bookBulkItemsReady,
bookingFor,
bookingRow,
closeBookingWindow,
completeDocReview,
createSchedule,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
forceWindowOpen,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollPartialOffer,
pollWindow,
releaseUnpaidHolds,
resetCorridorDay,
seedTenantContracts,
} from "./flows";
const GIANT_DEPARTURE = departureAt(43);
const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
const REMAINDER_DEPARTURE = departureAt(44);
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
const STAMP = String(Date.now());
describe("bulk b3: per-item giant offer and line quantities", () => {
let contracts: Map<string, string>;
let giantScheduleId: string;
let bg1: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(GIANT_DEPARTURE);
await resetCorridorDay(REMAINDER_DEPARTURE);
contracts = await seedTenantContracts(
STAMP,
["BG1", "BQ1", "BQ2"].map((suffix) => ({ suffix, freight: "BULK" as const })),
);
giantScheduleId = (
await createSchedule({
departure: GIANT_DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-27", "LOCO-IMP-28"],
})
).id;
await forceWindowOpen(giantScheduleId, 45);
bg1 = await bookBulkItemsReady({
contractId: contracts.get("BG1")!,
cargoCode: "E2E_IMP_AUTO",
items: 240,
tons: 600,
scheduledDate: GIANT_DAY,
});
}, 1_800_000);
afterAll(closeDb);
it("a 240-auto booking (60 wagons' worth) gets NO partial offer — it is sized as 240 tons", async () => {
await closeBookingWindow(giantScheduleId);
await completeDocReview(giantScheduleId);
await pollBookingStatus(bg1, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
// DEFECT 1 (see header): the split sizer reads the item count as tonnage,
// so a booking needing 60 wagons looks like it needs 4 and no offer opens.
const offers = await db<{ n: string }>(
`SELECT count(*)::text AS n FROM freight.booking_batch_offers
WHERE booking_id = $1 AND deleted_at IS NULL`,
[bg1],
);
expect(Number(offers[0].n), "no partial offer for the per-item giant").toBe(0);
const row = await bookingRow(bg1);
expect(Number(row.cargo_total_weight_vgm), "cargoTotalWeightVgm holds ITEMS").toBe(240);
expect(row.wagons_required, "reserved without a wagon count").toBeNull();
// Nothing is allocated: the reservation cannot be honoured on a 54-wagon
// train, and no offer exists to shrink it.
const [alloc] = await db<{ n: string }>(
`SELECT count(*)::text AS n FROM freight.wagon_booking_allocations
WHERE booking_id = $1 AND deleted_at IS NULL`,
[bg1],
);
expect(Number(alloc.n), "no wagons allocated").toBe(0);
}, 900_000);
it.skip("the 24-auto outstanding must be rebooked EXACTLY on the later train", async () => {
// Unreachable while defect 1 stands: no split ever applies, so there is no
// outstanding remainder and the contract still carries a live booking
// (rebooking answers 409 "already has an active booking"). Un-skip with the
// fix to sizeOffer.
});
it("the contract path DROPS a per-line hazardousQuantity for bulk", async () => {
const res = await bookBulkItems({
contractId: contracts.get("BQ1")!,
cargoCode: "E2E_IMP_AUTO",
items: 10,
tons: 25,
scheduledDate: REMAINDER_DAY,
hazardousQuantity: 12,
});
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
const booking = await bookingFor(contracts.get("BQ1")!);
const [row] = await db<{ bulk_hazardous_quantity: string }>(
`SELECT bulk_hazardous_quantity FROM freight.bookings WHERE id = $1`,
[booking.id],
);
// DEFECT 2 (see header). The scenario expects the 12 to be CLAMPED to the
// 10-item line and stored; the contract path stores nothing at all, so the
// hazmat surcharge never fires for a contract booking. The direct booking
// path does clamp (clampToCargo, bookings.service.ts) — it is the mapping
// in contract-booking.service.ts that is missing.
expect(Number(row.bulk_hazardous_quantity), "hazmat dropped, not clamped").toBe(0);
});
it("the contract path DROPS a per-line reeferQuantity for bulk", async () => {
const res = await bookBulkItems({
contractId: contracts.get("BQ2")!,
cargoCode: "E2E_IMP_AUTO",
items: 8,
tons: 20,
scheduledDate: REMAINDER_DAY,
reeferQuantity: 3,
});
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
const booking = await bookingFor(contracts.get("BQ2")!);
const [row] = await db<{ bulk_reefer_quantity: string }>(
`SELECT bulk_reefer_quantity FROM freight.bookings WHERE id = $1`,
[booking.id],
);
expect(Number(row.bulk_reefer_quantity), "reefer dropped").toBe(0);
});
});

View File

@@ -0,0 +1,182 @@
/**
* BULK EXPORT — FCFS capacity truth.
*
* Day 1: three bookings (1 400 + 1 400 + 980 T = 54 wagons) accept first and
* hold the train BEFORE paying; three late 700 T exporters are rejected at
* submission by the whole-train space gate. The three pay → FULL.
*
* Day 2: whole-or-nothing — a 4 060 T giant (58 wagons) is rejected against
* an empty train (export never splits); rebooked at exactly 3 780 T it takes
* the whole consist alone; a 70 T afterthought bounces off FULL.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, gateway } from "./client";
import {
EXP_DEST,
EXP_ORIGIN,
acceptExport,
allocatedWagons,
bookBulk,
bookBulkReady,
expectDayRefused,
bookingFor,
bookingRow,
createSchedule,
departureAt,
eatDayStr,
ensureExportRoute,
extendPayWindow,
forceWindowOpen,
payViaGateway,
pollAllocations,
pollWindow,
releaseUnpaidHolds,
resetCorridorDay,
seedTenantContracts,
} from "./flows";
const DEPARTURE = departureAt(31);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const GIANT_DEPARTURE = departureAt(32);
const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
const STAMP = String(Date.now());
const FIRST = [
{ suffix: "YA", tons: 1400, wagons: 20 },
{ suffix: "YB", tons: 1400, wagons: 20 },
{ suffix: "YC", tons: 980, wagons: 14 },
];
const LATE = ["YL1", "YL2", "YL3"];
describe("bulk export FCFS: reservations hold capacity, whole-or-nothing gate", () => {
const booking = new Map<string, string>();
let contracts: Map<string, string>;
let scheduleId: string;
let giantScheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureExportRoute();
await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST);
await resetCorridorDay(GIANT_DEPARTURE, EXP_ORIGIN, EXP_DEST);
contracts = await seedTenantContracts(
STAMP,
[...FIRST.map((b) => b.suffix), ...LATE, "YGBIG", "YG", "YS"].map((suffix) => ({
suffix,
freight: "BULK" as const,
direction: "EXPORT" as const,
})),
);
scheduleId = (
await createSchedule({
departure: DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-EXP-3", "LOCO-EXP-4"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
})
).id;
await forceWindowOpen(scheduleId, 60);
for (const b of FIRST) {
booking.set(
b.suffix,
await bookBulkReady({
contractId: contracts.get(b.suffix)!,
tons: b.tons,
scheduledDate: BOOKING_DAY,
mode: "export",
}),
);
}
}, 1_800_000);
afterAll(closeDb);
it("holds 54 wagons on accept — before any payment", async () => {
for (const b of FIRST) {
const row = await bookingRow(booking.get(b.suffix)!);
expect(["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], `${b.suffix} reserved`).toContain(
row.status,
);
expect(row.train_schedule_id, `${b.suffix} pinned to the train`).toBe(scheduleId);
}
});
it("rejects three late exporters at submission — the space gate reports no room", async () => {
for (const suffix of LATE) {
const res = await expectDayRefused({
contractId: contracts.get(suffix)!,
tons: 700,
scheduledDate: BOOKING_DAY,
});
expect(res.status, `${suffix} rejected`).toBeGreaterThanOrEqual(400);
expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i);
}
});
it("the three reserved pay — 54/54 allocated and the window flips FULL", async () => {
await extendPayWindow(scheduleId, FIRST.map((b) => booking.get(b.suffix)!));
for (const b of FIRST) {
await payViaGateway(booking.get(b.suffix)!);
await pollAllocations(booking.get(b.suffix)!, b.wagons);
}
await pollWindow(scheduleId, (s) => s.booking_window_status === "FULL", "export window FULL");
expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54);
});
it("whole-or-nothing: a 4 060 T giant is rejected against the empty train", async () => {
giantScheduleId = (
await createSchedule({
departure: GIANT_DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-EXP-5", "LOCO-EXP-6"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
})
).id;
await forceWindowOpen(giantScheduleId, 60);
// 58 wagons on a 54-wagon consist — export never splits.
// Its own tenant: a refused probe still leaves a live booking on the
// one-time contract, which would block the 3 780 T rebooking below.
const res = await expectDayRefused({
contractId: contracts.get("YGBIG")!,
tons: 4060,
scheduledDate: GIANT_DAY,
});
expect(res.status).toBeGreaterThanOrEqual(400);
expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i);
});
it("rebooked at exactly 3 780 T the giant takes the whole train alone", async () => {
const yg = await bookBulkReady({
contractId: contracts.get("YG")!,
tons: 3780,
scheduledDate: GIANT_DAY,
mode: "export",
});
await payViaGateway(yg);
await pollAllocations(yg, 54);
await pollWindow(
giantScheduleId,
(s) => s.booking_window_status === "FULL",
"giant train FULL from one booking",
);
expect((await bookingRow(yg)).train_schedule_id, "giant rides its train").toBe(giantScheduleId);
expect(await allocatedWagons(giantScheduleId), "54 wagons allocated").toBe(54);
});
it("a 70 T afterthought bounces off the FULL train", async () => {
const res = await expectDayRefused({
contractId: contracts.get("YS")!,
tons: 70,
scheduledDate: GIANT_DAY,
});
expect(res.status).toBeGreaterThanOrEqual(400);
expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i);
});
});

View File

@@ -0,0 +1,169 @@
/**
* BULK EXPORT — six wheat bookings fill the 54-wagon CW4 train on the reversed
* corridor KALITY → MOJO → E2E_AWASH → DIRE_DAWA → NAGAD → DJIB_PORT, inside
* the ONE FCFS export window, then the full life of the train to Djibouti Port
* and the export customs tail.
*
* Export is FCFS: the staff accept IS the reservation — there is no batch — and
* a pay deadline may never outlive the window close. Both are asserted here.
*
* 70 T per CW4 wagon — Σ = 54 wagons / 3 780 T:
* XBF1 customs USD 560 T = 8 w · XBF2 customs ETB 420 T = 6 w
* XBF3 self ETB 420 T = 6 w · XBF4 self ETB 420 T = 6 w
* XBF5 customs USD 1 540 T = 22 w · XBF6 self USD 420 T = 6 w
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, gateway } from "./client";
import {
EXP_DEST,
EXP_ORIGIN,
allocatedWagons,
bookBulkReady,
bookingRow,
createSchedule,
departureAt,
dispatchSchedule,
eatDayStr,
ensureExportRoute,
extendPayWindow,
expectMilestoneDone,
finalizeSchedule,
forceWindowOpen,
gatePassGranted,
invoiceForBooking,
milestoneCount,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollWindow,
releaseUnpaidHolds,
resetCorridorDay,
runCorridor,
scheduleRow,
seedTenantContracts,
closeT1,
completeMilestone,
uploadTransportDocument,
} from "./flows";
const DEPARTURE = departureAt(30);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const STAMP = String(Date.now());
const BOOKINGS = [
{ suffix: "XBF1", customs: true, currency: "USD" as const, tons: 560, wagons: 8 },
{ suffix: "XBF2", customs: true, currency: "ETB" as const, tons: 420, wagons: 6 },
{ suffix: "XBF3", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 },
{ suffix: "XBF4", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 },
{ suffix: "XBF5", customs: true, currency: "USD" as const, tons: 1540, wagons: 22 },
{ suffix: "XBF6", customs: false, currency: "USD" as const, tons: 420, wagons: 6 },
];
const CUSTOMS = BOOKINGS.filter((b) => b.customs);
const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs);
describe("bulk export: six wheat bookings fill the 54-wagon CW4 train (FCFS)", () => {
const booking = new Map<string, string>();
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureExportRoute();
await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST);
const contracts = await seedTenantContracts(
STAMP,
BOOKINGS.map((b) => ({
suffix: b.suffix,
currency: b.currency,
customs: b.customs,
freight: "BULK" as const,
direction: "EXPORT" as const,
})),
);
scheduleId = (
await createSchedule({
departure: DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-EXP-1", "LOCO-EXP-2"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
})
).id;
await forceWindowOpen(scheduleId, 60);
for (const b of BOOKINGS) {
booking.set(
b.suffix,
await bookBulkReady({
contractId: contracts.get(b.suffix)!,
tons: b.tons,
scheduledDate: BOOKING_DAY,
mode: "export",
customs: b.customs,
}),
);
}
}, 1_800_000);
afterAll(closeDb);
it("each accept reserved immediately, with a deadline clamped to the window close", async () => {
const schedule = await scheduleRow(scheduleId);
const closesAt = new Date(String(schedule.window_closes_at)).getTime();
for (const b of BOOKINGS) {
const row = await bookingRow(booking.get(b.suffix)!);
expect(row.payment_deadline, `${b.suffix} pay deadline`).toBeTruthy();
expect(
new Date(row.payment_deadline!).getTime(),
`${b.suffix} deadline never outlives the window close`,
).toBeLessThanOrEqual(closesAt);
const invoice = await invoiceForBooking(booking.get(b.suffix)!);
expect(invoice.currency, `${b.suffix} invoice currency`).toBe(b.currency);
}
});
it("all six pay — 54/54 allocated, the export window flips FULL, staff finalize", async () => {
await extendPayWindow(scheduleId, [...booking.values()]);
for (const b of BOOKINGS) {
await payViaGateway(booking.get(b.suffix)!);
await pollAllocations(booking.get(b.suffix)!, b.wagons);
}
await pollWindow(scheduleId, (s) => s.booking_window_status === "FULL", "export window FULL");
expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54);
await finalizeSchedule(scheduleId);
await pollWindow(scheduleId, (s) => s.status === "SCHEDULED", "SCHEDULED");
});
it("gate pass + transport documents, then the train runs the corridor to the port", async () => {
await gatePassGranted(scheduleId);
for (const b of CUSTOMS) {
const res = await uploadTransportDocument(booking.get(b.suffix)!);
expect(res.status, `${b.suffix} transport document`).toBeLessThanOrEqual(201);
}
await dispatchSchedule(scheduleId);
await pollWindow(scheduleId, (s) => s.status === "DISPATCHED", "DISPATCHED");
await runCorridor(scheduleId);
for (const b of BOOKINGS) await pollBookingStatus(booking.get(b.suffix)!, "ARRIVED", 20);
});
it("GL Djibouti closes the export tail on every customs booking", async () => {
for (const b of CUSTOMS) {
const id = booking.get(b.suffix)!;
await closeT1(id);
await completeMilestone(id, "OFFLOADED");
await expectMilestoneDone(id, "T1_CLOSED");
await expectMilestoneDone(id, "OFFLOADED");
}
});
it("the self-clearing bookings arrived clean — no customs tail", async () => {
for (const b of SELF_CLEAR) {
const id = booking.get(b.suffix)!;
expect((await bookingRow(id)).status, `${b.suffix} final status`).toBe("ARRIVED");
expect(await milestoneCount(id, "T1_CLOSED"), `${b.suffix} has no T1 tail`).toBe(0);
}
});
});

View File

@@ -0,0 +1,183 @@
/**
* BULK EXPORT edge matrix (reversed corridor):
* a) a booking on a day with no open window is rejected
* b) mid-route boarding (DIRE_DAWA → port) shares the train with a KALITY
* through-booking
* c) directional FULL: the border edges are committed, so the window flips
* FULL while the home leg still has free wagons
* d) a dateless DOMESTIC ride-along boards the FULL train's free home leg —
* its pay window is clamped to the export close, it pays and links, and
* the window stays FULL
* e) a same-day sibling export train keeps its own independent window
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, db, gateway, poll } from "./client";
import {
EXP_DEST,
EXP_ORIGIN,
acceptIntercityOnto,
acceptOperation,
allocatedWagons,
bookBulk,
bookBulkReady,
expectDayRefused,
bookingFor,
bookingRow,
clearIntercityBooking,
createSchedule,
departureAt,
eatDayStr,
ensureExportRoute,
extendPayWindow,
forceWindowOpen,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollWindow,
releaseUnpaidHolds,
resetCorridorDay,
scheduleRow,
seedTenantContracts,
} from "./flows";
const DEPARTURE = departureAt(35);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const NO_WINDOW_DAY = eatDayStr(departureAt(39)); // no schedule exists there
const STAMP = String(Date.now());
describe("bulk export matrix: sub-corridor, directional FULL, ride-along, own windows", () => {
let contracts: Map<string, string>;
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureExportRoute();
await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST);
contracts = await seedTenantContracts(STAMP, [
{ suffix: "YM1", freight: "BULK", direction: "EXPORT" },
{ suffix: "YMNW", freight: "BULK", direction: "EXPORT" },
{ suffix: "YMSUB", freight: "BULK", direction: "EXPORT", originCode: "DIRE_DAWA", destCode: EXP_DEST },
{ suffix: "YMIC", freight: "BULK", direction: "DOMESTIC", originCode: EXP_ORIGIN, destCode: "MOJO" },
]);
scheduleId = (
await createSchedule({
departure: DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-EXP-11", "LOCO-EXP-12"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
})
).id;
await forceWindowOpen(scheduleId, 90);
}, 1_800_000);
afterAll(closeDb);
it("rejects a booking on a day with no open window", async () => {
const res = await expectDayRefused({
contractId: contracts.get("YMNW")!,
tons: 140,
scheduledDate: NO_WINDOW_DAY,
});
expect(res.status).toBeGreaterThanOrEqual(400);
expect(JSON.stringify(res.body)).toMatch(/booking window|no departures/i);
});
it("a through booking and a mid-route boarding commit the border edge", async () => {
const through = await bookBulkReady({
contractId: contracts.get("YM1")!,
tons: 2800,
scheduledDate: BOOKING_DAY,
mode: "export",
});
const sub = await bookBulkReady({
contractId: contracts.get("YMSUB")!,
tons: 980,
scheduledDate: BOOKING_DAY,
mode: "export",
});
await extendPayWindow(scheduleId, [through, sub]);
await payViaGateway(through);
await pollAllocations(through, 40);
await payViaGateway(sub);
await pollAllocations(sub, 14);
expect((await bookingRow(through)).train_schedule_id).toBe(scheduleId);
expect((await bookingRow(sub)).train_schedule_id).toBe(scheduleId);
// 40 + 14 = 54 wagons committed on the border edge (…→ DJIB_PORT), yet the
// home leg (KALITY → DIRE_DAWA) still has 14 free — and the window stays
// OPEN on that free leg. NOTE: the older Cypress twin asserts FULL here;
// the live engine is leg-granular instead, which is why the ride-along
// below can still board. Assert the occupancy invariant, not the flag.
expect(await allocatedWagons(scheduleId), "border edge committed at 54").toBe(54);
expect(
(await scheduleRow(scheduleId)).booking_window_status,
"window stays open on the free home leg",
).toBe("OPEN");
}, 1_800_000);
it("a ride-along boards the train's free home leg — clamped, paid, linked", async () => {
const res = await bookBulk({ contractId: contracts.get("YMIC")!, tons: 140 });
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
const ic = (await bookingFor(contracts.get("YMIC")!)).id;
await clearIntercityBooking(ic);
await acceptIntercityOnto(scheduleId, ic);
await pollBookingStatus(ic, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
const schedule = await scheduleRow(scheduleId);
expect(
new Date((await bookingRow(ic)).payment_deadline!).getTime(),
"ride-along deadline clamped to the export close",
).toBeLessThanOrEqual(new Date(String(schedule.window_closes_at)).getTime());
await payViaGateway(ic);
// A paid ride-along is unpinned back to the pool; staff place it again.
await acceptIntercityOnto(scheduleId, ic);
await poll(
"ride-along linked to the export train",
`SELECT train_schedule_id FROM freight.bookings WHERE id = $1`,
[ic],
(row) => (row as { train_schedule_id?: string })?.train_schedule_id === scheduleId,
{ attempts: 20 },
);
// The ride-along rides the home leg, so the border edge is untouched.
expect(await allocatedWagons(scheduleId), "border edge still 54").toBe(54);
}, 900_000);
it("a same-day sibling export train keeps its own window", async () => {
const sibling = new Date(DEPARTURE.getTime() + 90 * 60_000);
await createSchedule({
departure: sibling,
kind: "bulk",
locoPair: ["LOCO-EXP-3", "LOCO-EXP-4"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
const anchor = await scheduleRow(scheduleId);
const [row] = await db<{ id: string; window_phase: string; window_closes_at: string }>(
`SELECT ts.id, ts.window_phase, ts.window_closes_at
FROM freight.train_schedules ts
JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1
JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2
WHERE ts.deleted_at IS NULL AND ts.id <> $3
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $4::timestamptz))) < 7200
ORDER BY ts.created_at DESC LIMIT 1`,
[EXP_ORIGIN, EXP_DEST, scheduleId, DEPARTURE.toISOString()],
);
expect(row, "sibling export schedule").toBeTruthy();
expect(row.window_phase, "own fresh window").toBe("PRE_WINDOW");
expect(
new Date(row.window_closes_at).getTime(),
"own close, anchored to its own departure",
).not.toBe(new Date(String(anchor.window_closes_at)).getTime());
});
});
// `acceptOperation` is imported for symmetry with the import matrix; the export
// ride-along is accepted onto the train instead.
void acceptOperation;

View File

@@ -0,0 +1,196 @@
/**
* BULK EXPORT — pay or lose the seat.
*
* Day 1: ZA + ZB reserve and pay 40 wagons. ZC reserves the last 14 (980 T)
* and never pays; while that hold lives a late booking is rejected for
* space. ZC expires → the late customer immediately books the freed 980 T
* and pays.
*
* Day 2: five reservations fill the train, only three pay. The window close
* passes → phase DONE, the two unpaid expire, and export never reopens
* (the cycle counter stays 1 — unlike import, which reopens).
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, db, gateway } from "./client";
import {
EXP_DEST,
EXP_ORIGIN,
bookBulk,
bookBulkReady,
expectDayRefused,
bookingRow,
createSchedule,
departureAt,
eatDayStr,
ensureExportRoute,
extendPayWindow,
forceReservationExpiry,
forceWindowOpen,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollWindow,
releaseUnpaidHolds,
resetCorridorDay,
scheduleRow,
seedTenantContracts,
} from "./flows";
const DEPARTURE = departureAt(33);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const CLOSE_DEPARTURE = departureAt(34);
const CLOSE_DAY = eatDayStr(CLOSE_DEPARTURE);
const STAMP = String(Date.now());
const CLOSERS = [
{ suffix: "ZQA", tons: 840, wagons: 12, pays: true },
{ suffix: "ZQB", tons: 840, wagons: 12, pays: true },
{ suffix: "ZQC", tons: 840, wagons: 12, pays: true },
{ suffix: "ZQD", tons: 840, wagons: 12, pays: false },
{ suffix: "ZQE", tons: 420, wagons: 6, pays: false },
];
describe("bulk export pay-or-lose: expiry frees space; close expires the unpaid", () => {
const booking = new Map<string, string>();
let contracts: Map<string, string>;
let scheduleId: string;
let closeScheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureExportRoute();
await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST);
await resetCorridorDay(CLOSE_DEPARTURE, EXP_ORIGIN, EXP_DEST);
contracts = await seedTenantContracts(
STAMP,
["ZA", "ZB", "ZC", "ZD", "ZDLATE", ...CLOSERS.map((c) => c.suffix)].map((suffix) => ({
suffix,
freight: "BULK" as const,
direction: "EXPORT" as const,
})),
);
scheduleId = (
await createSchedule({
departure: DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-EXP-7", "LOCO-EXP-8"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
})
).id;
await forceWindowOpen(scheduleId, 60);
}, 1_800_000);
afterAll(closeDb);
it("ZA and ZB reserve and pay 40 wagons; ZC holds the last 14 unpaid", async () => {
for (const [suffix, wagons] of [
["ZA", 20],
["ZB", 20],
] as const) {
const id = await bookBulkReady({
contractId: contracts.get(suffix)!,
tons: 1400,
scheduledDate: BOOKING_DAY,
mode: "export",
});
booking.set(suffix, id);
await payViaGateway(id);
await pollAllocations(id, wagons);
}
const zc = await bookBulkReady({
contractId: contracts.get("ZC")!,
tons: 980,
scheduledDate: BOOKING_DAY,
mode: "export",
});
booking.set("ZC", zc);
const schedule = await scheduleRow(scheduleId);
expect(
new Date((await bookingRow(zc)).payment_deadline!).getTime(),
"ZC deadline clamped to the window close",
).toBeLessThanOrEqual(new Date(String(schedule.window_closes_at)).getTime());
}, 900_000);
it("a late exporter is rejected while ZC's unpaid reservation holds the space", async () => {
const res = await expectDayRefused({
contractId: contracts.get("ZDLATE")!,
tons: 980,
scheduledDate: BOOKING_DAY,
});
expect(res.status).toBeGreaterThanOrEqual(400);
expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i);
});
it("ZC misses its window — ZD immediately books the freed 980 T and pays", async () => {
await forceReservationExpiry(booking.get("ZC")!);
const zd = await bookBulkReady({
contractId: contracts.get("ZD")!,
tons: 980,
scheduledDate: BOOKING_DAY,
mode: "export",
});
booking.set("ZD", zd);
await payViaGateway(zd);
await pollAllocations(zd, 14);
expect((await bookingRow(zd)).train_schedule_id, "ZD took ZC's seat").toBe(scheduleId);
}, 900_000);
it("window-close day: five reservations, three payments", async () => {
closeScheduleId = (
await createSchedule({
departure: CLOSE_DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-EXP-9", "LOCO-EXP-10"],
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
})
).id;
await forceWindowOpen(closeScheduleId, 60);
for (const c of CLOSERS) {
booking.set(
c.suffix,
await bookBulkReady({
contractId: contracts.get(c.suffix)!,
tons: c.tons,
scheduledDate: CLOSE_DAY,
mode: "export",
}),
);
}
await extendPayWindow(
closeScheduleId,
CLOSERS.filter((x) => x.pays).map((c) => booking.get(c.suffix)!),
);
for (const c of CLOSERS.filter((x) => x.pays)) {
await payViaGateway(booking.get(c.suffix)!);
await pollAllocations(booking.get(c.suffix)!, c.wagons);
}
}, 1_800_000);
it("the window CLOSES — phase DONE, the unpaid expire, and export never reopens", async () => {
await db(
`UPDATE freight.train_schedules SET window_closes_at = now() - interval '1 second'
WHERE id = $1 AND window_phase = 'OPEN'`,
[closeScheduleId],
);
await pollWindow(
closeScheduleId,
(s) => s.window_phase === "DONE" && Number(s.booking_cycle_no) === 1,
"DONE, no reopen",
);
// A forced close needs the matching deadline clamp on the unpaid pair.
for (const c of CLOSERS.filter((x) => !x.pays)) {
await forceReservationExpiry(booking.get(c.suffix)!);
await pollBookingStatus(booking.get(c.suffix)!, "EXPIRED", 40);
}
for (const c of CLOSERS.filter((x) => x.pays)) {
expect((await bookingRow(booking.get(c.suffix)!)).status, `${c.suffix} rides`).toBe("PAID");
}
}, 900_000);
});

View File

@@ -0,0 +1,185 @@
/**
* BULK IMPORT — six wheat bookings fill the 54-wagon CW4 train on the long
* corridor DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY, all in
* the FIRST window, then the whole life of the train: payment through the real
* gateway, allocation, gate pass, T1, dispatch, checkpoint-by-checkpoint
* movement, arrival, and the post-arrival customs tail.
*
* 70 T per CW4 wagon — Σ = 54 wagons / 3 780 T:
* BF1 customs + USD 560 T = 8 w
* BF2 customs + ETB 420 T = 6 w
* BF3 self + ETB 420 T = 6 w
* BF4 self + ETB 420 T = 6 w
* BF5 customs + USD 1 540 T = 22 w (the ≥22-wagon giant)
* BF6 self + USD 420 T = 6 w
*
* Difference from the Cypress twin: every payment goes through the payment
* microservice and a signed gateway callback, not the staff mark-paid shortcut.
* Steps are sequential and not idempotent — the file runs as one journey.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, db, gateway } from "./client";
import {
allocatedWagons,
bookBulkReady,
closeBookingWindow,
completeDocReview,
createSchedule,
departureAt,
dispatchSchedule,
eatDayStr,
endPaymentPhase,
extendPayWindow,
ensureCorridorRoute,
expectMilestoneDone,
forceWindowOpen,
gatePassGranted,
invoiceForBooking,
milestoneCount,
payViaGateway,
pollBookingStatus,
pollWindow,
releaseUnpaidHolds,
resetCorridorDay,
runCorridor,
runImportCustomsTail,
scheduleRow,
seedTenantContracts,
uploadT1,
} from "./flows";
const DEPARTURE = departureAt(20);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const STAMP = String(Date.now());
const BOOKINGS = [
{ suffix: "BF1", customs: true, currency: "USD" as const, tons: 560, wagons: 8 },
{ suffix: "BF2", customs: true, currency: "ETB" as const, tons: 420, wagons: 6 },
{ suffix: "BF3", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 },
{ suffix: "BF4", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 },
{ suffix: "BF5", customs: true, currency: "USD" as const, tons: 1540, wagons: 22 },
{ suffix: "BF6", customs: false, currency: "USD" as const, tons: 420, wagons: 6 },
];
const CUSTOMS = BOOKINGS.filter((b) => b.customs);
const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs);
describe("bulk import: six wheat bookings fill the 54-wagon CW4 train", () => {
const booking = new Map<string, string>();
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const contracts = await seedTenantContracts(
STAMP,
BOOKINGS.map((b) => ({
suffix: b.suffix,
currency: b.currency,
customs: b.customs,
freight: "BULK" as const,
})),
);
const schedule = await createSchedule({
departure: DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"],
});
scheduleId = schedule.id;
// The cycle counter turns over when the window opens, not at creation.
await forceWindowOpen(scheduleId, 45);
expect((await scheduleRow(scheduleId)).booking_cycle_no, "FIRST window cycle").toBe(1);
for (const b of BOOKINGS) {
booking.set(
b.suffix,
await bookBulkReady({
contractId: contracts.get(b.suffix)!,
tons: b.tons,
scheduledDate: BOOKING_DAY,
customs: b.customs,
}),
);
}
}, 1_800_000);
afterAll(closeDb);
it("reserves all six with invoices in their contract currency — they fit exactly", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const b of BOOKINGS) {
const row = await pollBookingStatus(booking.get(b.suffix)!, [
"SELECTED_FOR_BATCH",
"AWAITING_PAYMENT",
]);
expect(row.payment_deadline ?? "pending", `${b.suffix} pay deadline`).toBeTruthy();
const invoice = await invoiceForBooking(booking.get(b.suffix)!);
expect(invoice.currency, `${b.suffix} invoice currency`).toBe(b.currency);
}
});
it("all six pay through the gateway — 54/54 wagons, window FULL, schedule finalized", async () => {
await extendPayWindow(scheduleId, [...booking.values()]);
for (const b of BOOKINGS) {
await payViaGateway(booking.get(b.suffix)!);
}
await endPaymentPhase(scheduleId);
await pollWindow(
scheduleId,
(s) =>
s.booking_window_status === "FULL" &&
s.window_phase === "DONE" &&
s.status === "SCHEDULED",
"FULL + DONE + finalized",
);
expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54);
});
it("GL Djibouti grants the gate pass and uploads T1 for the customs bookings", async () => {
await gatePassGranted(scheduleId);
for (const b of CUSTOMS) {
const res = await uploadT1(booking.get(b.suffix)!);
expect(res.status, `${b.suffix} T1 upload`).toBeLessThanOrEqual(201);
}
});
it("the train dispatches and runs the corridor checkpoint by checkpoint", async () => {
await dispatchSchedule(scheduleId);
await pollWindow(scheduleId, (s) => s.status === "DISPATCHED", "DISPATCHED");
for (const b of BOOKINGS) await pollBookingStatus(booking.get(b.suffix)!, "IN_TRANSIT", 15);
await runCorridor(scheduleId);
for (const b of BOOKINGS) await pollBookingStatus(booking.get(b.suffix)!, "ARRIVED", 20);
const [row] = await db<{ n: string }>(
`SELECT count(*)::text AS n FROM freight.wagon_movements WHERE train_schedule_id = $1`,
[scheduleId],
);
expect(Number(row.n), "wagon movement ledger rows").toBeGreaterThanOrEqual(54);
});
it("GL runs the customs tail on every customs booking", async () => {
for (const b of CUSTOMS) {
const id = booking.get(b.suffix)!;
await runImportCustomsTail(id);
await expectMilestoneDone(id, "T1_CLOSED");
await expectMilestoneDone(id, "RISK_ASSIGNED");
await expectMilestoneDone(id, "IMPORT_RELEASE_GRANTED");
await expectMilestoneDone(id, "IMPORT_PROCESS_COMPLETED");
}
});
it("the self-clearing bookings arrived clean — no customs tail", async () => {
for (const b of SELF_CLEAR) {
const id = booking.get(b.suffix)!;
const row = await pollBookingStatus(id, "ARRIVED", 5);
expect(row.status, `${b.suffix} final status`).toBe("ARRIVED");
expect(await milestoneCount(id, "T1_CLOSED"), `${b.suffix} has no T1 tail`).toBe(0);
}
});
});

View File

@@ -0,0 +1,224 @@
/**
* BULK IMPORT edge matrix:
* a) a wheat booking on a day with no open window is rejected at submission
* b) a sub-corridor booking (NAGAD → MOJO) rides the through-train next to a
* DJIB_PORT → KALITY booking — the batch is corridor-aware
* c) bulk intercity ride-along (MOJO → KALITY, DOMESTIC, dateless): staff
* assign it onto the import train's free leg, the pay window opens, it
* pays and links
* d) whole-train giant: 4 000 T (58 wagons' worth) alone on a 54-wagon train
* → partial offer of the FULL consist (3 780 T); the gateway settlement
* applies the split and the train is FULL from ONE booking; the 220 T
* outstanding must be rebooked EXACTLY on a later train
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, db, gateway, poll } from "./client";
import {
acceptIntercityOnto,
acceptOperation,
bookBulk,
bookBulkReady,
expectDayRefused,
bookingFor,
bookingRow,
clearIntercityBooking,
closeBookingWindow,
completeDocReview,
createSchedule,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
extendPayWindow,
forceWindowOpen,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollPartialOffer,
pollWindow,
releaseUnpaidHolds,
resetCorridorDay,
seedTenantContracts,
} from "./flows";
const DEPARTURE = departureAt(25);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const GIANT_DEPARTURE = departureAt(26);
const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
const REMAINDER_DEPARTURE = departureAt(27);
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
const NO_WINDOW_DAY = eatDayStr(departureAt(29)); // no schedule exists there
const STAMP = String(Date.now());
describe("bulk import matrix: gates, sub-corridor, ride-along, whole-train giant", () => {
let contracts: Map<string, string>;
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
await resetCorridorDay(GIANT_DEPARTURE);
await resetCorridorDay(REMAINDER_DEPARTURE);
contracts = await seedTenantContracts(STAMP, [
{ suffix: "BM1", freight: "BULK" },
{ suffix: "BMNW", freight: "BULK" },
{ suffix: "BMSUB", freight: "BULK", originCode: "NAGAD", destCode: "MOJO" },
{ suffix: "BMIC", freight: "BULK", direction: "DOMESTIC", originCode: "MOJO", destCode: "KALITY" },
{ suffix: "BMG", freight: "BULK" },
]);
scheduleId = (
await createSchedule({
departure: DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-25", "LOCO-IMP-26"],
})
).id;
await forceWindowOpen(scheduleId, 60);
}, 1_800_000);
afterAll(closeDb);
it("refuses a shipment day with no open window", async () => {
const res = await expectDayRefused({
contractId: contracts.get("BMNW")!,
tons: 140,
scheduledDate: NO_WINDOW_DAY,
});
expect(res.status).toBeGreaterThanOrEqual(400);
// Wording differs by gate: the day probe answers "No departures available
// on the selected day", the route probe "the import booking window …".
expect(JSON.stringify(res.body)).toMatch(/booking window|no departures/i);
});
it("a through booking and a NAGAD→MOJO sub-corridor booking share the train", async () => {
const bm1 = await bookBulkReady({
contractId: contracts.get("BM1")!,
tons: 1400,
scheduledDate: BOOKING_DAY,
});
const sub = await bookBulkReady({
contractId: contracts.get("BMSUB")!,
tons: 700,
scheduledDate: BOOKING_DAY,
});
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const id of [bm1, sub]) {
await pollBookingStatus(id, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
}
await extendPayWindow(scheduleId, [bm1, sub]);
await payViaGateway(bm1);
await pollAllocations(bm1, 20);
await payViaGateway(sub);
await pollAllocations(sub, 10);
expect((await bookingRow(bm1)).train_schedule_id).toBe(scheduleId);
expect((await bookingRow(sub)).train_schedule_id).toBe(scheduleId);
});
it("a dateless DOMESTIC ride-along boards the import train's free leg", async () => {
// 140 T = 2 wagons MOJO → KALITY, no shipment day of its own.
const res = await bookBulk({ contractId: contracts.get("BMIC")!, tons: 140 });
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
const ic = (await bookingFor(contracts.get("BMIC")!)).id;
await clearIntercityBooking(ic);
await acceptIntercityOnto(scheduleId, ic);
await pollBookingStatus(ic, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
expect((await bookingRow(ic)).payment_deadline, "ride-along pay window opened").toBeTruthy();
await payViaGateway(ic);
// Settlement UNPINS a paid ride-along back to the ride-along pool — staff
// then place it on the train they want (acceptIntercity's `paid` branch,
// booking-batch.service.ts). The Cypress twin never sees this: its staff
// mark-paid shortcut leaves the reservation pinned.
expect(
(await bookingRow(ic)).train_schedule_id,
"paid ride-along returns to the pool",
).toBeNull();
await acceptIntercityOnto(scheduleId, ic);
await poll(
"ride-along linked to the import train",
`SELECT train_schedule_id FROM freight.bookings WHERE id = $1`,
[ic],
(row) => (row as { train_schedule_id?: string })?.train_schedule_id === scheduleId,
{ attempts: 20 },
);
const [link] = await db<{ n: string }>(
`SELECT count(*)::text AS n FROM freight.train_schedule_bookings
WHERE booking_id = $1 AND train_schedule_id = $2 AND deleted_at IS NULL`,
[ic, scheduleId],
);
expect(Number(link.n), "link row").toBe(1);
});
it("a 4 000 T giant alone gets a FULL-consist partial offer and fills the train", async () => {
const giantSchedule = await createSchedule({
departure: GIANT_DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-27", "LOCO-IMP-28"],
});
await forceWindowOpen(giantSchedule.id, 45);
const bmg = await bookBulkReady({
contractId: contracts.get("BMG")!,
tons: 4000,
scheduledDate: GIANT_DAY,
});
await closeBookingWindow(giantSchedule.id);
await completeDocReview(giantSchedule.id);
await pollBookingStatus(bmg, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
const offer = await pollPartialOffer(bmg);
expect(Number(offer.offered_wagons), "offered the whole consist").toBe(54);
await payViaGateway(bmg);
await pollAllocations(bmg, 54);
expect((await bookingRow(bmg)).is_split, "BMG is split").toBe(true);
await endPaymentPhase(giantSchedule.id);
await pollWindow(
giantSchedule.id,
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
"FULL from one booking",
);
});
it("the giant's 220 T outstanding must be rebooked EXACTLY on a later train", async () => {
const remainder = await createSchedule({
departure: REMAINDER_DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-13", "LOCO-IMP-14"],
});
await forceWindowOpen(remainder.id, 45);
const wrong = await bookBulk({
contractId: contracts.get("BMG")!,
tons: 100,
scheduledDate: REMAINDER_DAY,
});
expect(wrong.status).toBeGreaterThanOrEqual(400);
expect(JSON.stringify(wrong.body)).toMatch(/must take the whole/i);
const exact = await bookBulk({
contractId: contracts.get("BMG")!,
tons: 220,
scheduledDate: REMAINDER_DAY,
});
expect(exact.status, JSON.stringify(exact.body)).toBeLessThanOrEqual(201);
await acceptOperationIfPossible(contracts.get("BMG")!);
});
});
/** The remainder booking only needs to exist; walking its gate is out of scope. */
async function acceptOperationIfPossible(contractId: string) {
const booking = await bookingFor(contractId);
if (booking.status === "OPERATION_REQUEST_PENDING") await acceptOperation(booking.id);
}

View File

@@ -0,0 +1,195 @@
/**
* BULK IMPORT — split offer, exact-remainder rebooking, pay-window expiry and
* priority-ordered waiting-list promotion on one 54-wagon CW4 train. Bulk
* splits are FULL-WAGONS-ONLY at the base 70 T cap.
*
* reserved (priority order): BSA 1 400 T = 20 w, BSB 980 T = 14 w,
* BSD 840 T = 12 w → 46 w. BSC 1 680 T = 24 w does NOT fit whole → PARTIAL
* offer of the remaining 8 wagons = 560 T. BSC settles through the payment
* service → the split applies (is_split + pre-split snapshot); the
* outstanding 1 120 T must later be rebooked EXACTLY.
* BSD never pays → EXPIRES; the freed 12 wagons promote BS1 (6 w) and
* BS2 (6 w) in priority order; BS3 (20 w) never fits and expires.
*
* Final consist: 20 + 14 + 8 + 6 + 6 = 54/54.
*
* The split is the reason this file pays through the gateway rather than the
* staff shortcut: applying a pending partial offer hangs off
* `booking.invoice.paid`, which only a real settlement emits.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, gateway } from "./client";
import {
allocatedWagons,
bookBulk,
bookBulkReady,
bookingRow,
closeBookingWindow,
completeDocReview,
createSchedule,
departureAt,
eatDayStr,
endPaymentPhase,
extendPayWindow,
ensureCorridorRoute,
forceReservationExpiry,
forceWindowOpen,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollPartialOffer,
pollWindow,
releaseUnpaidHolds,
resetCorridorDay,
seedTenantContracts,
setPriority,
} from "./flows";
const DEPARTURE = departureAt(23);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const REMAINDER_DEPARTURE = departureAt(24);
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
const STAMP = String(Date.now());
const ORDER = ["BSA", "BSB", "BSD", "BSC", "BS1", "BS2", "BS3"] as const;
const TONS: Record<string, number> = {
BSA: 1400,
BSB: 980,
BSD: 840,
BSC: 1680,
BS1: 420,
BS2: 420,
BS3: 1400,
};
describe("bulk import: split offer, remainder rebooking, expiry + promotion", () => {
const booking = new Map<string, string>();
let contracts: Map<string, string>;
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
await resetCorridorDay(REMAINDER_DEPARTURE);
contracts = await seedTenantContracts(
STAMP,
ORDER.map((suffix) => ({ suffix, freight: "BULK" as const })),
);
scheduleId = (
await createSchedule({
departure: DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-19", "LOCO-IMP-20"],
})
).id;
await forceWindowOpen(scheduleId, 45);
for (const [i, suffix] of ORDER.entries()) {
const id = await bookBulkReady({
contractId: contracts.get(suffix)!,
tons: TONS[suffix],
scheduledDate: BOOKING_DAY,
});
booking.set(suffix, id);
await setPriority(id, i + 1);
}
}, 1_800_000);
afterAll(closeDb);
it("reserves BSA/BSB/BSD whole and offers BSC a PARTIAL for the last 8 wagons", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const suffix of ["BSA", "BSB", "BSD", "BSC"]) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
}
const offer = await pollPartialOffer(booking.get("BSC")!);
expect(Number(offer.offered_wagons), "BSC offered the remaining 8 wagons").toBe(8);
for (const suffix of ["BS1", "BS2", "BS3"]) {
expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} waiting`).toBe(
"FULLY_EXECUTED",
);
}
});
it("BSA and BSB pay; BSC settles through the gateway — the split applies", async () => {
// Three sequential gateway settlements outlast the ~60s hold; BSD is left
// alone because the next test is about its expiry.
await extendPayWindow(scheduleId, [booking.get("BSA")!, booking.get("BSB")!, booking.get("BSC")!]);
await payViaGateway(booking.get("BSA")!);
await pollAllocations(booking.get("BSA")!, 20);
await payViaGateway(booking.get("BSB")!);
await pollAllocations(booking.get("BSB")!, 14);
await payViaGateway(booking.get("BSC")!);
await pollAllocations(booking.get("BSC")!, 8);
const bsc = await bookingRow(booking.get("BSC")!);
expect(bsc.is_split, "BSC is split").toBe(true);
expect(bsc.pre_split_quantities, "pre-split snapshot kept").toBeTruthy();
});
it("BSD misses its pay window — the freed wagons promote BS1 + BS2 in priority order", async () => {
await forceReservationExpiry(booking.get("BSD")!);
for (const suffix of ["BS1", "BS2"]) {
const row = await pollBookingStatus(booking.get(suffix)!, [
"SELECTED_FOR_BATCH",
"AWAITING_PAYMENT",
]);
expect(row.status, `${suffix} promoted`).not.toBe("FULLY_EXECUTED");
expect(
(await bookingRow(booking.get(suffix)!)).payment_deadline,
`${suffix} got a pay window`,
).toBeTruthy();
}
expect((await bookingRow(booking.get("BS3")!)).status, "BS3 still has no seat").toBe(
"FULLY_EXECUTED",
);
});
it("BS1 and BS2 pay — the train is FULL at 54; BS3 expires with the day", async () => {
await extendPayWindow(scheduleId, [booking.get("BS1")!, booking.get("BS2")!]);
await payViaGateway(booking.get("BS1")!);
await pollAllocations(booking.get("BS1")!, 6);
await payViaGateway(booking.get("BS2")!);
await pollAllocations(booking.get("BS2")!, 6);
await endPaymentPhase(scheduleId);
await pollWindow(
scheduleId,
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
"FULL + DONE",
);
expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54);
await pollBookingStatus(booking.get("BS3")!, "EXPIRED", 40);
});
it("the split customer must rebook EXACTLY the 1 120 T remainder", async () => {
const remainderSchedule = await createSchedule({
departure: REMAINDER_DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-21", "LOCO-IMP-22"],
});
await forceWindowOpen(remainderSchedule.id, 45);
// 1 680 booked 560 shipped by the split = 1 120 T outstanding.
const wrong = await bookBulk({
contractId: contracts.get("BSC")!,
tons: 560,
scheduledDate: REMAINDER_DAY,
});
expect(wrong.status, "partial remainder rejected").toBeGreaterThanOrEqual(400);
expect(JSON.stringify(wrong.body)).toMatch(/must take the whole/i);
const exact = await bookBulk({
contractId: contracts.get("BSC")!,
tons: 1120,
scheduledDate: REMAINDER_DAY,
});
expect(exact.status, JSON.stringify(exact.body)).toBeLessThanOrEqual(201);
});
});

View File

@@ -0,0 +1,125 @@
/**
* BULK IMPORT — the CW4 train fills from THREE wheat bookings; three more sit
* in the waiting pool of the same window. The selected trio pays through the
* gateway and allocates; when the cycle concludes FULL the waiting three
* expire with the day — they never ride and never pay.
*
* 70 T per CW4 wagon, 54-wagon consist:
* selected: BWA 1 400 T = 20 w, BWB 1 400 T = 20 w, BWC 980 T = 14 w → Σ 54
* waiting: BW1 / BW2 / BW3 700 T = 10 w each
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, gateway } from "./client";
import {
allocatedWagons,
bookBulkReady,
bookingRow,
closeBookingWindow,
completeDocReview,
createSchedule,
departureAt,
eatDayStr,
endPaymentPhase,
extendPayWindow,
ensureCorridorRoute,
forceWindowOpen,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollWindow,
releaseUnpaidHolds,
resetCorridorDay,
seedTenantContracts,
setPriority,
} from "./flows";
const DEPARTURE = departureAt(21);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const STAMP = String(Date.now());
const SELECTED = [
{ suffix: "BWA", tons: 1400, wagons: 20 },
{ suffix: "BWB", tons: 1400, wagons: 20 },
{ suffix: "BWC", tons: 980, wagons: 14 },
];
const WAITING = [
{ suffix: "BW1", tons: 700, wagons: 10 },
{ suffix: "BW2", tons: 700, wagons: 10 },
{ suffix: "BW3", tons: 700, wagons: 10 },
];
const ALL = [...SELECTED, ...WAITING];
describe("bulk import: three bookings fill the train, three wait and expire", () => {
const booking = new Map<string, string>();
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const contracts = await seedTenantContracts(
STAMP,
ALL.map((b) => ({ suffix: b.suffix, freight: "BULK" as const })),
);
scheduleId = (
await createSchedule({
departure: DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-17", "LOCO-IMP-18"],
})
).id;
await forceWindowOpen(scheduleId, 45);
for (const [i, b] of ALL.entries()) {
const id = await bookBulkReady({
contractId: contracts.get(b.suffix)!,
tons: b.tons,
scheduledDate: BOOKING_DAY,
});
booking.set(b.suffix, id);
// Priority order = the order above: the exact-fill trio picks first.
await setPriority(id, i + 1);
}
}, 1_800_000);
afterAll(closeDb);
it("the batch selects exactly the three that fill 54 wagons; the rest keep waiting", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const b of SELECTED) {
await pollBookingStatus(booking.get(b.suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
}
for (const b of WAITING) {
const row = await bookingRow(booking.get(b.suffix)!);
expect(row.status, `${b.suffix} still waiting`).toBe("FULLY_EXECUTED");
expect(row.payment_deadline, `${b.suffix} has no pay deadline`).toBeNull();
}
});
it("the three selected pay through the gateway and allocate — 54/54", async () => {
await extendPayWindow(scheduleId, SELECTED.map((b) => booking.get(b.suffix)!));
for (const b of SELECTED) {
await payViaGateway(booking.get(b.suffix)!);
await pollAllocations(booking.get(b.suffix)!, b.wagons);
}
expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54);
});
it("the cycle concludes FULL — the three waiting bookings expire with the day", async () => {
await endPaymentPhase(scheduleId);
await pollWindow(
scheduleId,
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
"FULL + DONE",
);
for (const b of WAITING) await pollBookingStatus(booking.get(b.suffix)!, "EXPIRED", 40);
for (const b of SELECTED) {
const row = await bookingRow(booking.get(b.suffix)!);
expect(row.status, `${b.suffix} stays PAID`).toBe("PAID");
}
});
});

View File

@@ -0,0 +1,127 @@
/**
* BULK IMPORT — nobody pays in the first cycle: both reserved wheat bookings
* expire, the cycle concludes NOT-full, and the window REOPENS for a second
* cycle on the same train. A fresh 700 T booking arrives in cycle 2, pays
* through the gateway, and allocates. Import days reopen; they don't die.
*
* The expiry itself is only possible because the payment service is real here:
* before expiring an unpaid hold the engine asks the gateway whether a late
* payment landed, and defers forever on an unverifiable answer.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, gateway } from "./client";
import {
bookBulkReady,
bookingRow,
closeBookingWindow,
completeDocReview,
createSchedule,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
forceReservationExpiry,
forceWindowOpen,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollWindow,
releaseUnpaidHolds,
resetCorridorDay,
scheduleRow,
seedTenantContracts,
} from "./flows";
const DEPARTURE = departureAt(22);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const STAMP = String(Date.now());
const SUFFIXES = ["BRA", "BRB", "BRC"] as const;
describe("bulk import: dead first cycle — expire all, reopen, book again", () => {
const booking = new Map<string, string>();
let contracts: Map<string, string>;
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
contracts = await seedTenantContracts(
STAMP,
SUFFIXES.map((suffix) => ({ suffix, freight: "BULK" as const })),
);
scheduleId = (
await createSchedule({
departure: DEPARTURE,
kind: "bulk",
locoPair: ["LOCO-IMP-23", "LOCO-IMP-24"],
})
).id;
await forceWindowOpen(scheduleId, 45);
expect((await scheduleRow(scheduleId)).booking_cycle_no, "cycle 1").toBe(1);
for (const suffix of ["BRA", "BRB"] as const) {
booking.set(
suffix,
await bookBulkReady({
contractId: contracts.get(suffix)!,
tons: 1400,
scheduledDate: BOOKING_DAY,
}),
);
}
}, 1_800_000);
afterAll(closeDb);
it("two customers are reserved in cycle 1", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const suffix of ["BRA", "BRB"] as const) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
}
});
it("nobody pays — both reservations expire and the cycle concludes not-full", async () => {
for (const suffix of ["BRA", "BRB"] as const) {
await forceReservationExpiry(booking.get(suffix)!);
}
await endPaymentPhase(scheduleId);
// When the reopen instant lands inside office hours the 10s tick chains
// PRE_WINDOW straight into OPEN, so PRE_WINDOW is not a reliably observable
// resting state — assert only that the cycle left PAYMENT without DONE.
await pollWindow(
scheduleId,
(s) => s.window_phase !== "PAYMENT" && s.window_phase !== "DONE",
"concluded not-full",
);
});
it("the second window opens (cycle 2) and a fresh 700 T booking pays and allocates", async () => {
await forceWindowOpen(scheduleId, 45);
expect((await scheduleRow(scheduleId)).booking_cycle_no, "cycle 2").toBe(2);
const brc = await bookBulkReady({
contractId: contracts.get("BRC")!,
tons: 700,
scheduledDate: BOOKING_DAY,
});
booking.set("BRC", brc);
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
await pollBookingStatus(brc, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
await payViaGateway(brc);
await pollAllocations(brc, 10);
for (const suffix of ["BRA", "BRB"] as const) {
const row = await bookingRow(booking.get(suffix)!);
expect(row.status, `${suffix} stays expired`).toBe("EXPIRED");
}
expect((await bookingRow(brc)).train_schedule_id, "BRC rides the reopened train").toBe(
scheduleId,
);
});
});

View File

@@ -0,0 +1,180 @@
/**
* CBE Unified Bill — the INBOUND direction, and the only flow where the
* payment service calls freight rather than the other way round:
*
* customer picks CBE_BILL → freight → payment API mints a bill reference
* CBE POST /cbe/oauth/token → bearer token we issued
* CBE POST /cbe/query → payment API → freight /internal/payments/bill-query
* → payer name + live balance
* CBE POST /cbe/payment → intent settles → freight invoice PAID
*
* Both hops run real code on both sides; nothing is stubbed here at all.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { PAYMENT_API, closeDb, db, gateway, poll } from "./client";
import {
createImportSchedule,
currentInvoice,
departureAt,
ensureCorridorRoute,
releaseUnpaidHolds,
forceWindowOpen,
gatewayIntent,
invoiceForBooking,
payInvoice,
prepareBooking,
resetCorridorDay,
runBatch,
type ReadyBooking,
} from "./flows";
const DEPARTURE = departureAt(10);
const STAMP = String(Date.now());
const API_NAME = "EDR_FREIGHT";
const txnId = (tag: string) => `IT-${tag}-${Date.now()}`;
async function cbeToken(): Promise<string> {
const res = await request(PAYMENT_API).post("/cbe/oauth/token").send({
grant_type: "client_credentials",
client_id: "it-cbe-bill",
client_secret: "it-cbe-bill-secret",
scope: "Unified_Outgoing",
});
const token = res.body?.access_token ?? res.body?.data?.access_token;
if (!token) throw new Error(`cbe token failed: ${res.status} ${JSON.stringify(res.body)}`);
return token;
}
const cbe = (token: string, path: string, body: object) =>
request(PAYMENT_API).post(path).set("Authorization", `Bearer ${token}`).send(body);
describe("CBE Unified Bill (payment service as biller)", () => {
let booking: ReadyBooking;
let invoiceId: string;
let billId: string;
let token: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const schedule = await createImportSchedule({ departure: DEPARTURE });
await forceWindowOpen(schedule.id, 45);
booking = await prepareBooking({
suffix: "BILL1",
departure: DEPARTURE,
runStamp: STAMP,
isoSeed: 300,
twenty: 2,
currency: "ETB",
});
await runBatch(schedule.id);
invoiceId = (await invoiceForBooking(booking.bookingId)).id;
const res = await payInvoice(invoiceId, { method: "CBE_BILL" });
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
const intent = await gatewayIntent(booking.bookingId);
const [row] = await db<{ bill_reference: string }>(
`SELECT bill_reference FROM edr_payment.payment_intent WHERE id = $1`,
[intent.id],
);
billId = row.bill_reference;
token = await cbeToken();
}, 600_000);
afterAll(closeDb);
it("mints a 12-digit bill reference the customer can quote at any CBE channel", () => {
expect(billId).toMatch(/^\d{12}$/);
});
it("refuses the query without a token we issued", async () => {
const res = await request(PAYMENT_API)
.post("/cbe/query")
.send({ Destination_Api_Name: API_NAME, End_To_End_Txn_Id: txnId("noauth"), Bill_Id: billId });
expect(res.status).toBe(401);
});
it("answers the bill lookup from live freight data", async () => {
const invoice = await currentInvoice(invoiceId);
const res = await cbe(token, "/cbe/query", {
Destination_Api_Name: API_NAME,
End_To_End_Txn_Id: txnId("q1"),
Bill_Id: billId,
});
expect(res.status).toBe(200);
expect(res.body.Response_Code).toBe("0");
expect(res.body.Bill_Id).toBe(billId);
// The amount and payer come from freight's billQuery, not from a cached
// copy in the payment service.
expect(Number(res.body.Total_Amount)).toBeCloseTo(Number(invoice.balance_amount), 2);
expect(res.body.Full_Name).toBe("E2E Logistics PLC");
expect(res.body.Payment_Reason).toMatch(/invoice/i);
});
it("reports an unknown bill as a business failure, not an error", async () => {
const res = await cbe(token, "/cbe/query", {
Destination_Api_Name: API_NAME,
End_To_End_Txn_Id: txnId("q404"),
Bill_Id: "000000000000",
});
// Business failures are HTTP 200 + Response_Code "3" — CBE treats a non-200
// as a channel fault and retries.
expect(res.status).toBe(200);
expect(res.body.Response_Code).toBe("3");
});
it("settles the freight invoice when CBE reports the debit", async () => {
const invoice = await currentInvoice(invoiceId);
const res = await cbe(token, "/cbe/payment", {
Destination_Api_Name: API_NAME,
End_To_End_Txn_Id: txnId("p1"),
Cbe_Txn_Ref: `CBE${Date.now()}`,
Timestamp: new Date().toISOString(),
Bill_Id: billId,
Amount: String(invoice.balance_amount),
Currency: "ETB",
Full_Name: "IT Payer",
Phone_No: "+251911000001",
});
expect(res.status).toBe(200);
expect(res.body.Response_Code).toBe("0");
const paid = await poll<{ status: string }>(
"invoice PAID via CBE bill",
`SELECT status FROM freight.invoices WHERE id = $1`,
[invoiceId],
(row) => row?.status === "PAID",
{ attempts: 30, intervalMs: 2000 },
);
expect(paid.status).toBe("PAID");
});
it("rejects a second debit on the same bill", async () => {
const res = await cbe(token, "/cbe/payment", {
Destination_Api_Name: API_NAME,
End_To_End_Txn_Id: txnId("p2"),
Cbe_Txn_Ref: `CBE${Date.now()}`,
Timestamp: new Date().toISOString(),
Bill_Id: billId,
Amount: "1.00",
Currency: "ETB",
});
expect(res.status).toBe(200);
expect(res.body.Response_Code).toBe("3");
});
it("reports an already-paid bill on a later query", async () => {
const res = await cbe(token, "/cbe/query", {
Destination_Api_Name: API_NAME,
End_To_End_Txn_Id: txnId("q2"),
Bill_Id: billId,
});
expect(res.status).toBe(200);
expect(res.body.Response_Code).toBe("3");
});
});

218
integration/src/client.ts Normal file
View File

@@ -0,0 +1,218 @@
/**
* Plumbing for the integration suite: HTTP against the containerized freight
* and payment APIs, SQL against their shared throwaway Postgres, and the
* gateway mock's control plane.
*
* This is the Cypress-free port of e2e/freight/cypress/e2e/flows/import-utils.ts —
* same request sequences, same SQL, `pg.Pool` instead of `cy.task`.
*/
import request from "supertest";
import { Pool, type QueryResultRow } from "pg";
export const API = process.env.IT_API_URL ?? "http://localhost:3111";
export const PAYMENT_API = process.env.IT_PAYMENT_URL ?? "http://localhost:3113";
export const GATEWAY = process.env.IT_GATEWAY_URL ?? "http://localhost:4600";
export const SERVICE_TOKEN = process.env.IT_SERVICE_TOKEN ?? "e2e-service-token";
const DB_URL =
process.env.IT_DB_URL ?? "postgres://edr_e2e:edr_e2e@localhost:5543/edr_freight_e2e";
// ---------------------------------------------------------------------------
// users (e2e/freight/cypress/fixtures/seed-users.sql + users.json)
// ---------------------------------------------------------------------------
export const customerA = "user@gmail.com";
export const customerB = "user2@gmail.com";
export const opsStaff = "operation@edr.local";
export const chief = "chief@edr.local";
/** isSuperAdmin bypasses assertFreightPermission — used for the GL/clearance steps. */
export const superAdmin = "superadmin@tria.com";
const STAFF_PASSWORD = "password@tria";
const CUSTOMER_PASSWORD = "12345678";
// ---------------------------------------------------------------------------
// database
// ---------------------------------------------------------------------------
const pool = new Pool({ connectionString: DB_URL, max: 12 });
export async function db<T extends QueryResultRow = Record<string, unknown>>(
sql: string,
params: unknown[] = [],
): Promise<T[]> {
const res = await pool.query<T>(sql, params);
return res.rows;
}
export async function closeDb(): Promise<void> {
await pool.end();
}
/** Poll a query until `check` passes. Async settlement here is broker-driven. */
export async function poll<T extends QueryResultRow = Record<string, unknown>>(
label: string,
sql: string,
params: unknown[],
check: (row: T | undefined) => boolean,
{ attempts = 40, intervalMs = 2000 } = {},
): Promise<T> {
let last: T | undefined;
for (let i = 0; i < attempts; i++) {
last = (await db<T>(sql, params))[0];
if (check(last)) return last as T;
await sleep(intervalMs);
}
throw new Error(
`timed out waiting for ${label} after ${attempts} attempts — last row: ${JSON.stringify(last)}`,
);
}
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// ---------------------------------------------------------------------------
// auth
// ---------------------------------------------------------------------------
const tokens = new Map<string, string>();
/**
* Bearer token for a seeded account. The login audience is not cosmetic:
* `user_type='individual'` accounts are rejected on the backoffice audience and
* vice versa (EDRFREIGHT-415), so it is derived from the address.
*/
export async function tokenFor(email: string): Promise<string> {
const cached = tokens.get(email);
if (cached) return cached;
const portal = email.endsWith("@gmail.com");
const res = await request(API)
.post("/api/auth/login")
.set("x-client-app", portal ? "portal" : "backoffice")
.send({ email, password: portal ? CUSTOMER_PASSWORD : STAFF_PASSWORD });
// The login response is flattened by the app (no `.data` envelope) and 201s.
const token = res.body?.token ?? res.body?.data?.token;
if (!token) {
throw new Error(`login failed for ${email}: ${res.status} ${JSON.stringify(res.body)}`);
}
tokens.set(email, token);
return token;
}
/** Login without caching, so audience-rejection can be asserted. */
export function login(email: string, password: string, app: "portal" | "backoffice") {
return request(API).post("/api/auth/login").set("x-client-app", app).send({ email, password });
}
// ---------------------------------------------------------------------------
// freight API
// ---------------------------------------------------------------------------
export type Method = "get" | "post" | "patch" | "delete";
/** Authenticated call to the freight API as `email`. Never throws on 4xx/5xx. */
export async function api(
email: string,
method: Method,
path: string,
body?: unknown,
): Promise<request.Response> {
const token = await tokenFor(email);
const req = request(API)[method](path).set("Authorization", `Bearer ${token}`);
return method === "get" || method === "delete" ? req.send() : req.send(body ?? {});
}
/** Same, but fails loudly on a non-2xx — for arrange steps that must succeed. */
export async function apiOk(
email: string,
method: Method,
path: string,
body?: unknown,
): Promise<request.Response> {
const res = await api(email, method, path, body);
if (res.status < 200 || res.status > 201) {
throw new Error(
`${method.toUpperCase()} ${path} as ${email}${res.status}: ${JSON.stringify(res.body)}`,
);
}
return res;
}
/** Multipart upload (clearance documents). supertest handles the encoding. */
export async function upload(
email: string,
path: string,
filePath: string,
field = "files",
fields: Record<string, string> = {},
): Promise<request.Response> {
const token = await tokenFor(email);
const req = request(API).post(path).set("Authorization", `Bearer ${token}`);
for (const [k, v] of Object.entries(fields)) req.field(k, v);
return req.attach(field, filePath);
}
// ---------------------------------------------------------------------------
// payment API (service-to-service surface)
// ---------------------------------------------------------------------------
export function payment(method: Method, path: string, body?: unknown) {
const req = request(PAYMENT_API)[method](path).set("x-service-token", SERVICE_TOKEN);
return method === "get" || method === "delete" ? req.send() : req.send(body ?? {});
}
/** Payment-side rows. The payment service owns its own schema in the same DB. */
export function paymentDb<T extends QueryResultRow = Record<string, unknown>>(
sql: string,
params: unknown[] = [],
) {
return db<T>(sql, params);
}
export interface IntentRow extends QueryResultRow {
id: string;
status: string;
provider: string;
merchant_order_id: string;
amount_minor: string;
currency: string;
reference_id: string;
provider_txn_id: string | null;
expires_at: string | null;
}
export function intentByMerchantOrderId(merchantOrderId: string) {
return db<IntentRow>(
`SELECT * FROM edr_payment.payment_intent WHERE merchant_order_id = $1`,
[merchantOrderId],
);
}
// ---------------------------------------------------------------------------
// gateway mock control plane
// ---------------------------------------------------------------------------
export const gateway = {
reset: () => request(GATEWAY).post("/__control/reset").send({}),
/** Force a provider's next `times` calls (or all of them) into a mode. */
mode: (provider: string, mode: "ok" | "fail" | "timeout" | "pending" | "paid", times?: number) =>
request(GATEWAY).post(`/__control/provider/${provider}`).send({ mode, times }),
/** Mark the order settled at the gateway WITHOUT a callback (polling path). */
settle: (merchantOrderId: string) =>
request(GATEWAY).post("/__control/settle").send({ merchantOrderId }),
/** Fire a signed provider callback at the payment API. */
webhook: (opts: {
merchantOrderId: string;
provider?: string;
status?: string;
eventId?: string;
transactionId?: string;
signature?: "bad";
}) => request(GATEWAY).post("/__control/webhook").send(opts),
calls: () => request(GATEWAY).get("/__control/calls").send(),
};

View File

@@ -0,0 +1,215 @@
/**
* Many users, at once. Each test drives one production race through the real
* HTTP surface and asserts the guard that is supposed to hold:
*
* - two settlements of one invoice → billing.markInvoiceAsPaid pessimistic lock
* - a replayed callback storm → webhook dedupe on externalEventId
* - two tenants, one wagon budget → reserveOnExport re-verify under lock (H8)
* - an invoice-number burst → pg_advisory_xact_lock in invoice-numbering
* - pay after the window closed → payInvoice dueAt gate
*
* These are the tests expected to find things. When one fails, read it as a
* finding, not as a flaky assertion.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
closeDb,
customerA,
customerB,
db,
gateway,
poll,
sleep,
} from "./client";
import {
TIN_B,
createImportSchedule,
currentInvoice,
departureAt,
ensureCorridorRoute,
releaseUnpaidHolds,
forceWindowOpen,
gatewayIntent,
invoiceForBooking,
payInvoice,
prepareBooking,
resetCorridorDay,
runBatch,
type ReadyBooking,
} from "./flows";
const DEPARTURE = departureAt(8);
const STAMP = String(Date.now());
describe("concurrency and multi-tenant races", () => {
let scheduleId: string;
let a: ReadyBooking;
let b: ReadyBooking;
let invoiceA: string;
let invoiceB: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const schedule = await createImportSchedule({ departure: DEPARTURE });
scheduleId = schedule.id;
await forceWindowOpen(scheduleId, 45);
// Two DIFFERENT tenants on the same train-day.
a = await prepareBooking({
suffix: "CON-A",
departure: DEPARTURE,
runStamp: STAMP,
isoSeed: 200,
twenty: 2,
});
b = await prepareBooking({
suffix: "CON-B",
departure: DEPARTURE,
runStamp: STAMP,
isoSeed: 210,
twenty: 2,
tin: TIN_B,
as: customerB,
});
await runBatch(scheduleId);
invoiceA = (await invoiceForBooking(a.bookingId)).id;
invoiceB = (await invoiceForBooking(b.bookingId)).id;
}, 900_000);
afterAll(closeDb);
it("settles once when two callbacks land simultaneously", async () => {
expect((await payInvoice(invoiceA, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
const intent = await gatewayIntent(a.bookingId);
// Five concurrent deliveries of the SAME provider event.
const results = await Promise.all(
Array.from({ length: 5 }, () =>
gateway.webhook({
merchantOrderId: intent.merchant_order_id,
eventId: `RACE-${intent.merchant_order_id}`,
}),
),
);
expect(results.every((r) => r.body.delivered === 200)).toBe(true);
await poll<{ status: string }>(
"invoice PAID under duplicate delivery",
`SELECT status FROM freight.invoices WHERE id = $1`,
[invoiceA],
(row) => row?.status === "PAID",
{ attempts: 30, intervalMs: 2000 },
);
await sleep(3000);
// Dedupe is at the webhook table: one row, one outbox event, one ledger entry.
const [{ n: events }] = await db<{ n: string }>(
`SELECT count(*)::text AS n FROM edr_payment.payment_webhook_event
WHERE merchant_order_id = $1`,
[intent.merchant_order_id],
);
expect(Number(events)).toBe(1);
const [{ n: outbox }] = await db<{ n: string }>(
`SELECT count(*)::text AS n FROM edr_payment.notification_outbox
WHERE intent_id = $1 AND event_type = 'payment.succeeded'`,
[intent.id],
);
expect(Number(outbox)).toBe(1);
const invoice = await currentInvoice(invoiceA);
expect((invoice.payments as unknown[]).length).toBe(1);
expect(Number(invoice.balance_amount)).toBe(0);
});
it("credits an invoice once even when two intents settle for it", async () => {
// The per-reference unique index was dropped (migration 1782200000000), so
// two intents on one booking are legal now. Both settling must still not
// double-credit the invoice.
const paid = await payInvoice(invoiceB, { method: "CBE_BIRR", as: customerB });
expect(paid.status, JSON.stringify(paid.body)).toBeLessThanOrEqual(201);
const first = await gatewayIntent(b.bookingId);
// A second initiate for the same reference, different provider.
const second = await payInvoice(invoiceB, { method: "TELEBIRR", as: customerB });
expect(second.status).toBeLessThanOrEqual(201);
await Promise.all([
gateway.webhook({ merchantOrderId: first.merchant_order_id }),
gateway.webhook({
merchantOrderId: first.merchant_order_id,
eventId: `SECOND-${first.merchant_order_id}`,
}),
]);
await poll<{ status: string }>(
"invoice PAID once",
`SELECT status FROM freight.invoices WHERE id = $1`,
[invoiceB],
(row) => row?.status === "PAID",
{ attempts: 30, intervalMs: 2000 },
);
await sleep(4000);
const invoice = await currentInvoice(invoiceB);
expect(Number(invoice.paid_amount)).toBeLessThanOrEqual(Number(invoice.total_amount));
expect(Number(invoice.balance_amount)).toBe(0);
});
it("gives two tenants distinct, gapless invoice numbers under a burst", async () => {
const numbers = await db<{ invoice_number: string }>(
`SELECT invoice_number FROM freight.invoices
WHERE created_at > now() - interval '30 minutes' AND deleted_at IS NULL`,
);
const seen = numbers.map((r) => r.invoice_number);
expect(new Set(seen).size).toBe(seen.length);
});
it("never over-reserves the train when both tenants push at once", async () => {
// The batch already ran for this day. Assert the invariant it must keep:
// reserved wagons never exceed the consist.
const [row] = await db<{ max_wagons: number; reserved: string }>(
`SELECT ts.max_wagons,
COALESCE(SUM(b.wagons_required), 0)::text AS reserved
FROM freight.train_schedules ts
LEFT JOIN freight.bookings b
ON b.train_schedule_id = ts.id AND b.deleted_at IS NULL
AND b.status NOT IN ('EXPIRED','CANCELLED','REJECTED')
WHERE ts.id = $1
GROUP BY ts.max_wagons`,
[scheduleId],
);
expect(Number(row.reserved)).toBeLessThanOrEqual(Number(row.max_wagons));
});
it("rejects a fresh payment once the pay window has closed", async () => {
// A booking whose deadline has passed must not be able to START a payment
// (billing.payInvoice dueAt gate) — a payment begun BEFORE the deadline is
// still honoured later by the expire-time gateway reconcile, which is why
// the gate lives on initiation and not on settlement.
const departure = departureAt(9);
await releaseUnpaidHolds();
await resetCorridorDay(departure);
const schedule = await createImportSchedule({ departure });
await forceWindowOpen(schedule.id, 45);
const third = await prepareBooking({
suffix: "CON-C",
departure,
runStamp: STAMP,
isoSeed: 220,
twenty: 2,
});
await runBatch(schedule.id);
const invoice = await invoiceForBooking(third.bookingId);
await db(`UPDATE freight.invoices SET due_at = now() - interval '1 minute' WHERE id = $1`, [
invoice.id,
]);
const res = await payInvoice(invoice.id, { method: "CBE_BIRR", as: customerA });
expect(res.status).toBe(400);
expect(JSON.stringify(res.body)).toMatch(/payment window/i);
}, 600_000);
});

1712
integration/src/flows.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,181 @@
/**
* GROUP 1 · S1 — a no-pay expiry frees exactly the space the waiting list needs.
*
* A 3×40ft = 3 wagons
* B 20×20ft + 10×40ft = 20 wagons
* C 30×40ft = 30 wagons
* ─────────
* 53 = the whole BUILT train → all three reserved
* D 6×20ft = 3 wagons → no room → WAITING LIST
*
* B and C pay through the real gateway. A never does: its deadline passes, A
* EXPIRES, and its 3 wagons return to the day's pool. The top-up pass then
* promotes D — an exact fit — and D pays.
*
* Final consist: B 20 + C 30 + D 3 = 53/53, FULL.
* A is recoverable: its contract is untouched, so it can rebook a later day
* with no re-approval.
*
* The 53 slots are the BUILT train's coupled consist (seed-g1-train.sql), not a
* locomotive-length figure — see {@link createBuiltTrainSchedule}.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, gateway } from "./client";
import {
G1_WAGONS,
allocatedWagons,
bookContainersReady,
bookingRow,
closeBookingWindow,
completeDocReview,
containerWagons,
createBuiltTrainSchedule,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
expectContractStillBookable,
extendPayWindow,
forceReservationExpiry,
forceWindowOpen,
linkedBookings,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollWindow,
releaseUnpaidHolds,
resetCorridorDay,
seedTenantContracts,
setPriority,
} from "./flows";
const DEPARTURE = departureAt(50);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const STAMP = String(Date.now());
/** Booking order is also PRIORITY order: A must be INSIDE the batch, because
* its expiry is what frees the space D needs. */
const SHAPES = {
A: { twenty: 0, forty: 3, wagons: 3 },
B: { twenty: 20, forty: 10, wagons: 20 },
C: { twenty: 0, forty: 30, wagons: 30 },
D: { twenty: 6, forty: 0, wagons: 3 },
} as const;
const ORDER = ["A", "B", "C", "D"] as const;
const IN_BATCH = ["A", "B", "C"] as const;
describe("g1 s1: expiry frees exactly the waiting list's space", () => {
const booking = new Map<string, string>();
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const contracts = await seedTenantContracts(
STAMP,
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
);
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
await forceWindowOpen(scheduleId, 60);
let isoSeed = 20_000;
for (const [i, suffix] of ORDER.entries()) {
const shape = SHAPES[suffix];
booking.set(
suffix,
await bookContainersReady({
contractId: contracts.get(suffix)!,
runStamp: STAMP,
isoSeed,
twenty: shape.twenty,
forty: shape.forty,
scheduledDate: BOOKING_DAY,
}),
);
isoSeed += shape.twenty + shape.forty;
await setPriority(booking.get(suffix)!, i + 1);
}
}, 1_800_000);
afterAll(closeDb);
it("the wagon math is exactly one trainload, and D fits exactly A's share", () => {
for (const suffix of ORDER) {
expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe(
SHAPES[suffix].wagons,
);
}
const booked = IN_BATCH.reduce((sum, s) => sum + SHAPES[s].wagons, 0);
expect(booked, "A+B+C fill the train exactly").toBe(G1_WAGONS);
expect(SHAPES.D.wagons, "D fits exactly the space A frees").toBe(SHAPES.A.wagons);
});
it("the batch reserves A, B and C; D holds a place in line rather than being rejected", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const suffix of IN_BATCH) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
expect(
(await bookingRow(booking.get(suffix)!)).payment_deadline,
`${suffix} got a pay window`,
).toBeTruthy();
}
const d = await bookingRow(booking.get("D")!);
expect(d.status, "D waitlisted").toBe("FULLY_EXECUTED");
expect(d.train_schedule_id, "D holds no seat").toBeNull();
});
it("B and C pay inside the window; A never does and EXPIRES, freeing 3 wagons", async () => {
// A is deliberately left out: the next assertion is about its expiry.
await extendPayWindow(scheduleId, [booking.get("B")!, booking.get("C")!]);
await payViaGateway(booking.get("B")!);
await pollAllocations(booking.get("B")!, SHAPES.B.wagons);
await payViaGateway(booking.get("C")!);
await pollAllocations(booking.get("C")!, SHAPES.C.wagons);
await forceReservationExpiry(booking.get("A")!);
expect((await bookingRow(booking.get("A")!)).status, "A expired unpaid").toBe("EXPIRED");
});
it("the freed 3 wagons promote D — an exact fit — and D pays", async () => {
// fillFromWaitingList runs on the tick that follows the expiry; no second
// staff action is needed.
const promoted = await pollBookingStatus(
booking.get("D")!,
["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"],
40,
);
expect(promoted.status, "D promoted off the waiting list").not.toBe("FULLY_EXECUTED");
expect((await bookingRow(booking.get("D")!)).payment_deadline, "D got a pay window").toBeTruthy();
await extendPayWindow(scheduleId, [booking.get("D")!]);
await payViaGateway(booking.get("D")!);
await pollAllocations(booking.get("D")!, SHAPES.D.wagons);
});
it("the train departs FULL at 53/53 — B 20 + C 30 + D 3, and A holds no seat", async () => {
await endPaymentPhase(scheduleId);
await pollWindow(
scheduleId,
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
"FULL + DONE",
);
expect(await allocatedWagons(scheduleId), "53 wagons allocated").toBe(G1_WAGONS);
expect(await linkedBookings(scheduleId), "3 bookings linked").toBe(3);
for (const suffix of ["B", "C", "D"] as const) {
expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} rides`).toBe("PAID");
}
const a = await bookingRow(booking.get("A")!);
expect(a.status, "A does not ride").toBe("EXPIRED");
expect(a.train_schedule_id, "A holds no seat").toBeNull();
});
it("A is recoverable — its contract needs no re-approval to rebook a later day", async () => {
await expectContractStillBookable(booking.get("A")!);
});
});

View File

@@ -0,0 +1,142 @@
/**
* GROUP 1 · S2 — four bookings pay and fill the train to the slot.
*
* A 3×40ft = 3 wagons · 3 containers
* B 20×20ft + 10×40ft = 20 wagons · 30 containers
* C 25×40ft = 25 wagons · 25 containers
* D 10×20ft = 5 wagons · 10 containers
* ─────────
* 53/53 → FULL
*
* Everyone is selected, everyone pays, nothing splits and nobody waits. What
* this really guards is the ALLOCATION rather than the arithmetic: the 53
* wagons carry 68 containers and every one must land on exactly one slot with
* its number on it. A booking that took wagons but never mapped its units would
* still read 53/53 on the board — hence the per-container assertion at the end.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, gateway } from "./client";
import {
G1_WAGONS,
allocatedWagons,
bookContainersReady,
bookingRow,
closeBookingWindow,
completeDocReview,
containerWagons,
createBuiltTrainSchedule,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
expectContainersPlaced,
expectNoPartialOffer,
extendPayWindow,
forceWindowOpen,
linkedBookings,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollWindow,
releaseUnpaidHolds,
resetCorridorDay,
seedTenantContracts,
} from "./flows";
const DEPARTURE = departureAt(51);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const STAMP = String(Date.now());
const SHAPES = {
A: { twenty: 0, forty: 3, wagons: 3, containers: 3 },
B: { twenty: 20, forty: 10, wagons: 20, containers: 30 },
C: { twenty: 0, forty: 25, wagons: 25, containers: 25 },
D: { twenty: 10, forty: 0, wagons: 5, containers: 10 },
} as const;
const ORDER = ["A", "B", "C", "D"] as const;
const TOTAL_CONTAINERS = ORDER.reduce((sum, s) => sum + SHAPES[s].containers, 0); // 68
describe("g1 s2: four bookings pay and fill the train exactly", () => {
const booking = new Map<string, string>();
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const contracts = await seedTenantContracts(
STAMP,
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
);
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
await forceWindowOpen(scheduleId, 60);
let isoSeed = 21_000;
for (const suffix of ORDER) {
const shape = SHAPES[suffix];
booking.set(
suffix,
await bookContainersReady({
contractId: contracts.get(suffix)!,
runStamp: STAMP,
isoSeed,
twenty: shape.twenty,
forty: shape.forty,
scheduledDate: BOOKING_DAY,
}),
);
isoSeed += shape.containers;
}
}, 1_800_000);
afterAll(closeDb);
it("the four bookings add up to exactly one trainload", () => {
for (const suffix of ORDER) {
expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe(
SHAPES[suffix].wagons,
);
}
expect(
ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0),
"A+B+C+D fill the train exactly",
).toBe(G1_WAGONS);
});
it("the batch reserves all four whole — an exact fit offers nobody a split", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const suffix of ORDER) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
await expectNoPartialOffer(booking.get(suffix)!, suffix);
}
});
it("all four pay through the gateway and are allocated onto the train", async () => {
await extendPayWindow(scheduleId, [...booking.values()]);
for (const suffix of ORDER) {
await payViaGateway(booking.get(suffix)!);
await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons);
}
});
it("the train is FULL at 53/53 and every one of the 68 containers has a slot", async () => {
await endPaymentPhase(scheduleId);
await pollWindow(
scheduleId,
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
"FULL + DONE",
);
expect(await allocatedWagons(scheduleId), "53 wagons allocated").toBe(G1_WAGONS);
expect(await linkedBookings(scheduleId), "4 bookings linked").toBe(4);
for (const suffix of ORDER) {
expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} rides`).toBe("PAID");
}
// Allocation is per CONTAINER, not just per wagon: 53 filled slots with
// only some units mapped would still read as a full train.
await expectContainersPlaced(scheduleId, TOTAL_CONTAINERS);
});
});

View File

@@ -0,0 +1,141 @@
/**
* GROUP 1 · S3 — an under-filled train keeps its day open.
*
* A 12×20ft = 6 wagons
* B 10×40ft = 10 wagons
* C 24×20ft = 12 wagons
* ─────────
* 28/53 → 25 slots still free
*
* Everyone pays, nobody splits, nobody waits. The assertion is the NEGATIVE
* one: the window must NOT be marked FULL, because the day has to stay on offer
* to customers who have not booked yet. A train that closed its day at 28/53
* would silently refuse 25 wagons of business — so the claim is checked the way
* a customer experiences it, through the portal's own day-availability query.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, gateway } from "./client";
import {
G1_WAGONS,
allocatedWagons,
bookContainersReady,
bookingRow,
closeBookingWindow,
completeDocReview,
containerWagons,
createBuiltTrainSchedule,
dayAvailability,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
expectNoPartialOffer,
extendPayWindow,
forceWindowOpen,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollCycleConcluded,
releaseUnpaidHolds,
resetCorridorDay,
scheduleRow,
seedTenantContracts,
} from "./flows";
const DEPARTURE = departureAt(52);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const STAMP = String(Date.now());
const SHAPES = {
A: { twenty: 12, forty: 0, wagons: 6 },
B: { twenty: 0, forty: 10, wagons: 10 },
C: { twenty: 24, forty: 0, wagons: 12 },
} as const;
const ORDER = ["A", "B", "C"] as const;
const BOOKED_WAGONS = ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0); // 28
const FREE_WAGONS = G1_WAGONS - BOOKED_WAGONS; // 25
describe("g1 s3: an under-filled train keeps its day open", () => {
const booking = new Map<string, string>();
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const contracts = await seedTenantContracts(
STAMP,
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
);
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
await forceWindowOpen(scheduleId, 60);
let isoSeed = 22_000;
for (const suffix of ORDER) {
const shape = SHAPES[suffix];
booking.set(
suffix,
await bookContainersReady({
contractId: contracts.get(suffix)!,
runStamp: STAMP,
isoSeed,
twenty: shape.twenty,
forty: shape.forty,
scheduledDate: BOOKING_DAY,
}),
);
isoSeed += shape.twenty + shape.forty;
}
}, 1_800_000);
afterAll(closeDb);
it("the three bookings leave 25 of the 53 slots free", () => {
for (const suffix of ORDER) {
expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe(
SHAPES[suffix].wagons,
);
}
expect(BOOKED_WAGONS, "A+B+C = 28 wagons").toBe(28);
expect(FREE_WAGONS, "25 slots unused").toBe(25);
});
it("the batch reserves all three whole — with room to spare nobody is offered a split", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const suffix of ORDER) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
await expectNoPartialOffer(booking.get(suffix)!, suffix);
}
});
it("all three pay — 28 of 53 wagons used and the window is NOT marked FULL", async () => {
await extendPayWindow(scheduleId, [...booking.values()]);
for (const suffix of ORDER) {
await payViaGateway(booking.get(suffix)!);
await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons);
}
await endPaymentPhase(scheduleId);
await pollCycleConcluded(scheduleId);
expect(await allocatedWagons(scheduleId), "28 wagons allocated").toBe(BOOKED_WAGONS);
expect(
(await scheduleRow(scheduleId)).booking_window_status,
"window not FULL at 28/53",
).not.toBe("FULL");
for (const suffix of ORDER) {
expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} rides`).toBe("PAID");
}
});
it("the day is still on offer to customers, with 25 free wagons", async () => {
// The customer-facing consequence, asked the way the portal asks it. A
// train that under-filled but stopped offering its day is the actual bug
// this scenario guards, and `freeWagons` is where it would show.
const day = await dayAvailability(booking.get("A")!, BOOKING_DAY);
expect(day.trainsForDay, "the day still runs a train").toBe(true);
expect(Number(day.freeWagons), "25 wagons still on offer").toBe(FREE_WAGONS);
});
});

View File

@@ -0,0 +1,172 @@
/**
* GROUP 1 · S4 — an over-subscribed day closes its last gap with a split.
*
* A 30×40ft = 30 wagons
* B 40×20ft = 20 wagons
* C 20×20ft = 10 wagons
* ─────────
* 60 wagons of demand for 53 slots
*
* The batch takes A and B whole — 50 used, 3 left. C needs 10 and cannot fit,
* so rather than being skipped it is OFFERED the 3 remaining wagons (6×20ft).
* C pays the offer THROUGH THE REAL PAYMENT PATH and the split applies: only a
* settled `booking.invoice.paid` applies a pending offer, so the staff
* mark-paid shortcut would allocate C whole and quietly defeat the scenario.
*
* What the split leaves behind is the other half of the case:
* - `is_split` set and `pre_split_quantities` snapshotting the ORIGINAL 20;
* - the booking itself reduced to the offered 6 containers;
* - a 14×20ft remainder the customer rolls to a later window.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, gateway } from "./client";
import {
G1_WAGONS,
allocatedWagons,
bookContainersReady,
bookingRow,
closeBookingWindow,
completeDocReview,
containerCount,
containerWagons,
createBuiltTrainSchedule,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
expectNoPartialOffer,
extendPayWindow,
forceWindowOpen,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollPartialOffer,
pollWindow,
releaseUnpaidHolds,
resetCorridorDay,
seedTenantContracts,
setPriority,
} from "./flows";
const DEPARTURE = departureAt(53);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const STAMP = String(Date.now());
const SHAPES = {
A: { twenty: 0, forty: 30, wagons: 30 },
B: { twenty: 40, forty: 0, wagons: 20 },
C: { twenty: 20, forty: 0, wagons: 10 },
} as const;
const ORDER = ["A", "B", "C"] as const;
/** A + B take 50 of 53; the gap is what C is offered. */
const GAP_WAGONS = G1_WAGONS - SHAPES.A.wagons - SHAPES.B.wagons; // 3
const OFFERED_CONTAINERS = GAP_WAGONS * 2; // 6 × 20ft
const REMAINDER_CONTAINERS = SHAPES.C.twenty - OFFERED_CONTAINERS; // 14
describe("g1 s4: a split closes the last 3-wagon gap", () => {
const booking = new Map<string, string>();
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const contracts = await seedTenantContracts(
STAMP,
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
);
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
await forceWindowOpen(scheduleId, 60);
let isoSeed = 23_000;
for (const [i, suffix] of ORDER.entries()) {
const shape = SHAPES[suffix];
booking.set(
suffix,
await bookContainersReady({
contractId: contracts.get(suffix)!,
runStamp: STAMP,
isoSeed,
twenty: shape.twenty,
forty: shape.forty,
scheduledDate: BOOKING_DAY,
}),
);
isoSeed += shape.twenty + shape.forty;
// Priority decides who gets a whole seat and who gets the offer.
await setPriority(booking.get(suffix)!, i + 1);
}
}, 1_800_000);
afterAll(closeDb);
it("demand exceeds the train by 7 wagons, leaving a 3-wagon gap after A and B", () => {
for (const suffix of ORDER) {
expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe(
SHAPES[suffix].wagons,
);
}
expect(
ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0),
"60 wagons of demand",
).toBe(60);
expect(GAP_WAGONS, "3-wagon gap after A+B").toBe(3);
expect(SHAPES.C.wagons, "C cannot fit whole").toBeGreaterThan(GAP_WAGONS);
});
it("the batch takes A and B whole and offers C exactly the 3 remaining wagons", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const suffix of ["A", "B"] as const) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
await expectNoPartialOffer(booking.get(suffix)!, suffix);
}
const offer = await pollPartialOffer(booking.get("C")!);
expect(Number(offer.offered_wagons), "offer sized to the gap").toBe(GAP_WAGONS);
});
it("A and B pay whole; C settles its partial and the split applies", async () => {
await extendPayWindow(scheduleId, [...booking.values()]);
await payViaGateway(booking.get("A")!);
await pollAllocations(booking.get("A")!, SHAPES.A.wagons);
await payViaGateway(booking.get("B")!);
await pollAllocations(booking.get("B")!, SHAPES.B.wagons);
await payViaGateway(booking.get("C")!);
await pollAllocations(booking.get("C")!, GAP_WAGONS);
});
it("C is flagged split, snapshotted at 20×20ft, and reduced to the offered 6", async () => {
const c = await bookingRow(booking.get("C")!);
expect(c.is_split, "C is split").toBe(true);
const snapshot = c.pre_split_quantities as { bySize?: Record<string, number> } | null;
expect(snapshot, "pre-split snapshot kept").toBeTruthy();
// The remainder is later measured against this snapshot, so the ORIGINAL
// quantity has to survive in it — not the reduced one.
expect(
Number(snapshot?.bySize?.["20FT"] ?? snapshot?.bySize?.["20ft"]),
"snapshot holds the original 20 × 20ft",
).toBe(SHAPES.C.twenty);
expect(await containerCount(booking.get("C")!), `C shrank to ${OFFERED_CONTAINERS} boxes`).toBe(
OFFERED_CONTAINERS,
);
});
it("the train is FULL at 53/53 and C's 14-container remainder is outstanding", async () => {
await endPaymentPhase(scheduleId);
await pollWindow(
scheduleId,
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
"FULL + DONE",
);
expect(await allocatedWagons(scheduleId), "53 wagons allocated").toBe(G1_WAGONS);
// 20 booked 6 shipped = 14 still owed; the engine holds the customer to
// rebooking exactly that (asserted for bulk in bulk-import-split-promote).
expect(REMAINDER_CONTAINERS, "14 × 20ft outstanding").toBe(14);
});
});

View File

@@ -0,0 +1,190 @@
/**
* GROUP 1 · S5 — one expiry cascades into a second promotion.
*
* In the batch: A 10w + B 20w + C 23w = 53/53
* Waiting: D 8w, E 5w (priority order D before E)
*
* A never pays and EXPIRES → 10 wagons freed. ONE settle then serves BOTH
* waiting bookings in the same pass: D is reserved whole (8w) and E, which no
* longer fits in the 2 wagons left, is OFFERED a partial of exactly those 2.
* That is the assertion the scenario exists for — fillFromWaitingList loops
* until a pass places nothing, so a single-pass top-up would leave E untouched
* until the next window cycle.
*
* D then expires too, freeing 8 more wagons — and E's offer is NOT resized.
* FINDING (the same one bulk-b1 pins): a refill never supersedes an open
* partial offer, so E pays its stale 2-wagon offer and ships 2 of its 5 wagons
* while 8 sit idle. The train settles at B 20 + C 23 + E 2 = 45/53.
*
* Every expiry must also leave an audit trail — a terminal EXPIRED booking with
* its invoice closed out, never a silent disappearance. An open invoice on an
* expired seat is money the customer could still pay for a train they are no
* longer on.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, gateway } from "./client";
import {
G1_WAGONS,
allocatedWagons,
bookContainersReady,
bookingRow,
closeBookingWindow,
completeDocReview,
containerWagons,
createBuiltTrainSchedule,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
extendPayWindow,
forceReservationExpiry,
forceWindowOpen,
livePayableInvoices,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollCycleConcluded,
pollPartialOffer,
releaseUnpaidHolds,
resetCorridorDay,
scheduleRow,
seedTenantContracts,
setPriority,
} from "./flows";
const DEPARTURE = departureAt(54);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const STAMP = String(Date.now());
const SHAPES = {
A: { twenty: 20, forty: 0, wagons: 10 },
B: { twenty: 40, forty: 0, wagons: 20 },
C: { twenty: 0, forty: 23, wagons: 23 },
D: { twenty: 16, forty: 0, wagons: 8 },
E: { twenty: 10, forty: 0, wagons: 5 },
} as const;
const ORDER = ["A", "B", "C", "D", "E"] as const;
const IN_BATCH = ["A", "B", "C"] as const;
const WAITING = ["D", "E"] as const;
/** What A's expiry leaves loose once D takes its 8 — E's offer is sized to it. */
const E_OFFER = SHAPES.A.wagons - SHAPES.D.wagons; // 2
const RIDING = SHAPES.B.wagons + SHAPES.C.wagons + E_OFFER; // 45
describe("g1 s5: expiry cascades into a second promotion", () => {
const booking = new Map<string, string>();
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const contracts = await seedTenantContracts(
STAMP,
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
);
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
await forceWindowOpen(scheduleId, 60);
let isoSeed = 24_000;
for (const [i, suffix] of ORDER.entries()) {
const shape = SHAPES[suffix];
booking.set(
suffix,
await bookContainersReady({
contractId: contracts.get(suffix)!,
runStamp: STAMP,
isoSeed,
twenty: shape.twenty,
forty: shape.forty,
scheduledDate: BOOKING_DAY,
}),
);
isoSeed += shape.twenty + shape.forty;
await setPriority(booking.get(suffix)!, i + 1);
}
}, 1_800_000);
afterAll(closeDb);
it("A+B+C fill the train; D and E queue behind them, each fitting the hole above", () => {
for (const suffix of ORDER) {
expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe(
SHAPES[suffix].wagons,
);
}
expect(
IN_BATCH.reduce((sum, s) => sum + SHAPES[s].wagons, 0),
"A+B+C fill the train exactly",
).toBe(G1_WAGONS);
expect(SHAPES.D.wagons, "D fits inside A's 10").toBeLessThan(SHAPES.A.wagons);
expect(SHAPES.E.wagons, "E fits inside D's 8").toBeLessThan(SHAPES.D.wagons);
});
it("the batch reserves A, B and C; D and E wait in priority order", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const suffix of IN_BATCH) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
}
for (const suffix of WAITING) {
const row = await bookingRow(booking.get(suffix)!);
expect(row.status, `${suffix} waitlisted`).toBe("FULLY_EXECUTED");
expect(row.train_schedule_id, `${suffix} holds no seat`).toBeNull();
}
});
it("B and C pay; A expires and ONE pass serves both waiting bookings", async () => {
await extendPayWindow(scheduleId, [booking.get("B")!, booking.get("C")!]);
await payViaGateway(booking.get("B")!);
await pollAllocations(booking.get("B")!, SHAPES.B.wagons);
await payViaGateway(booking.get("C")!);
await pollAllocations(booking.get("C")!, SHAPES.C.wagons);
await forceReservationExpiry(booking.get("A")!);
// D fits A's hole whole…
await pollBookingStatus(booking.get("D")!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 40);
// …and the same pass keeps going: E no longer fits in the 2 wagons left, so
// it is offered exactly those rather than being left for the next cycle.
const offer = await pollPartialOffer(booking.get("E")!);
expect(Number(offer.offered_wagons), "E offered the 2 loose wagons").toBe(E_OFFER);
});
it("D expires too — but E's stale 2-wagon offer is never resized", async () => {
await forceReservationExpiry(booking.get("D")!);
// FINDING (bulk-b1 pins the same gap): the refill re-selects a booking that
// already holds an OFFER without re-sizing it, so the 8 wagons D just freed
// stay unsold and E ships 2 of the 5 it asked for. Assert what the engine
// really does, so a fix to the offer path fails loudly here.
await pollBookingStatus(booking.get("E")!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 40);
expect((await bookingRow(booking.get("E")!)).payment_deadline, "E got a pay window").toBeTruthy();
await extendPayWindow(scheduleId, [booking.get("E")!]);
await payViaGateway(booking.get("E")!);
await pollAllocations(booking.get("E")!, E_OFFER);
expect((await bookingRow(booking.get("E")!)).is_split, "E rode split, 2 of 5").toBe(true);
});
it("both expiries are terminal and auditable — nothing vanished silently", async () => {
for (const suffix of ["A", "D"] as const) {
const row = await bookingRow(booking.get(suffix)!);
expect(row.status, `${suffix} terminal EXPIRED`).toBe("EXPIRED");
expect(row.train_schedule_id, `${suffix} holds no seat`).toBeNull();
expect(
await livePayableInvoices(booking.get(suffix)!),
`${suffix} has no live payable invoice`,
).toBe(0);
}
});
it("the train settles at 45/53 — eight wagons unsold, short of FULL", async () => {
await endPaymentPhase(scheduleId);
await pollCycleConcluded(scheduleId);
expect(await allocatedWagons(scheduleId), "45 wagons allocated").toBe(RIDING);
expect(
(await scheduleRow(scheduleId)).booking_window_status,
"window not FULL at 45/53",
).not.toBe("FULL");
});
});

View File

@@ -0,0 +1,420 @@
/**
* GROUP 1 · S6S8 — who gets the last wagons on the 53-wagon built train.
*
* S6 a split offer nobody takes: the offer lapses, the booking expires
* WHOLE, and the wagons it was offered go unsold.
* S7 a government booking jumps the queue — by PREEMPTION, not by ranking:
* it displaces the lowest-priority commercial reservation and rides
* unpaid, carrying the +50 000 bonus.
* S8 commercial priority tiers decide who is offered the remainder:
* USD payer > customs service > plain, with no per-booking priority set.
*
* TWO NOTES ON HOW THE PRODUCT REALLY WORKS
*
* S7 — the +50 000 bonus (GOVERNMENT_PRIORITY_BONUS) keys off
* `bookings.is_government`, not a government-institution lookup, and
* government does not merely outrank: `preemptForGovernment` EXPIRES the
* lowest-priority commercial booking whose leg overlaps and allocates in its
* place. Government bookings are created with POST /api/bookings against a
* kind='government' company and promoted with /government-expedite — never
* through the contract wizard.
*
* FINDING — preemption cannot reach a train whose window already reads FULL:
* `isFillable` rejects FULL outright, before any budget or victim is
* considered, and `refreshWindowStatus` re-derives FULL from live capacity, so
* a genuinely full train stays skipped. The scenario therefore books 52 of 53
* slots: committed, one slot short, which is the closest reachable state to
* "a full train" and still exercises the displacement.
*
* S8 — the retired USD_PAYER / RAIL_AND_FORWARDING priority RULES are gone
* (ReplacePriorityRulesWithPriorityConfigs). The live model is
* `priority_configs`, typed WAGON | CURRENCY | CUSTOMS and scored by
* wagon-count band. The scenario's intent — tiered ordering, lowest tier gets
* the split — is preserved against that mechanism. The CUSTOMS band only
* applies when the booking's SERVICE TYPE bundles customs, which is why S8's
* customs tenant is sold RAIL_CUSTOMS (seed-customs-service-type.sql).
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, gateway } from "./client";
import {
G1_WAGONS,
allocatedWagons,
bookContainersReady,
bookingRow,
closeBookingWindow,
completeDocReview,
containerCount,
containerWagons,
createBuiltTrainSchedule,
createGovernmentBooking,
createPriorityConfig,
departureAt,
dropPriorityConfig,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
expectNoPartialOffer,
extendPayWindow,
forceOfferLapse,
forceWindowOpen,
governmentExpedite,
linkedBookings,
livePayableInvoices,
payViaGateway,
pinToSchedule,
pollAllocations,
pollBookingStatus,
pollCycleConcluded,
pollPartialOffer,
pollWindow,
releaseGovernmentBookings,
releaseUnpaidHolds,
resetCorridorDay,
scheduleRow,
seedTenantContracts,
setPriority,
triggerBatchRun,
} from "./flows";
const STAMP = String(Date.now());
// ───────────────────────────────────────────────────────────────────────────
// S6 — a split offer nobody takes
// ───────────────────────────────────────────────────────────────────────────
describe("g1 s6: an ignored split offer expires the booking whole", () => {
const DEPARTURE = departureAt(55);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const SHAPES = {
SA: { twenty: 0, forty: 30, wagons: 30 },
SB: { twenty: 40, forty: 0, wagons: 20 },
SC: { twenty: 20, forty: 0, wagons: 10 },
} as const;
const ORDER = ["SA", "SB", "SC"] as const;
const GAP = G1_WAGONS - SHAPES.SA.wagons - SHAPES.SB.wagons; // 3
const RIDING = SHAPES.SA.wagons + SHAPES.SB.wagons; // 50
const booking = new Map<string, string>();
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const contracts = await seedTenantContracts(
STAMP,
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
);
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
await forceWindowOpen(scheduleId, 60);
let isoSeed = 25_000;
for (const [i, suffix] of ORDER.entries()) {
const shape = SHAPES[suffix];
booking.set(
suffix,
await bookContainersReady({
contractId: contracts.get(suffix)!,
runStamp: STAMP,
isoSeed,
twenty: shape.twenty,
forty: shape.forty,
scheduledDate: BOOKING_DAY,
}),
);
isoSeed += shape.twenty + shape.forty;
await setPriority(booking.get(suffix)!, i + 1);
}
}, 1_800_000);
it("SA and SB take 50 wagons; SC is offered the last 3", async () => {
expect(containerWagons(SHAPES.SC.twenty, 0), "SC needs 10 wagons").toBe(SHAPES.SC.wagons);
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const suffix of ["SA", "SB"] as const) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
await expectNoPartialOffer(booking.get(suffix)!, suffix);
}
const offer = await pollPartialOffer(booking.get("SC")!);
expect(Number(offer.offered_wagons), "SC offered the 3-wagon gap").toBe(GAP);
});
it("SC ignores the offer for the whole window — it EXPIRES whole", async () => {
await extendPayWindow(scheduleId, [booking.get("SA")!, booking.get("SB")!]);
await payViaGateway(booking.get("SA")!);
await pollAllocations(booking.get("SA")!, SHAPES.SA.wagons);
await payViaGateway(booking.get("SB")!);
await pollAllocations(booking.get("SB")!, SHAPES.SB.wagons);
// The offer dies with the booking's pay deadline; the tick settles it.
await forceOfferLapse(booking.get("SC")!);
// "Whole" is the load-bearing word: an ignored PARTIAL must not leave the
// booking silently reduced to the 3 wagons it was offered — the customer
// still owns all 20 containers and can rebook them intact.
const sc = await bookingRow(booking.get("SC")!);
expect(sc.is_split, "SC was never split").not.toBe(true);
expect(await containerCount(booking.get("SC")!), "SC's 20 containers intact").toBe(
SHAPES.SC.twenty,
);
});
it("the train departs NOT FULL at 50/53 — the 3 offered wagons went unsold", async () => {
await endPaymentPhase(scheduleId);
await pollCycleConcluded(scheduleId);
expect(await allocatedWagons(scheduleId), "50 wagons allocated").toBe(RIDING);
expect(
(await scheduleRow(scheduleId)).booking_window_status,
"window not FULL at 50/53",
).not.toBe("FULL");
expect(GAP, "3 wagons wasted").toBe(3);
});
});
// ───────────────────────────────────────────────────────────────────────────
// S7 — government preempts the lowest-priority commercial reservation
// ───────────────────────────────────────────────────────────────────────────
describe("g1 s7: a government booking preempts commercial", () => {
const DEPARTURE = departureAt(56);
const BOOKING_DAY = eatDayStr(DEPARTURE);
/** GA outranks GB, so GB is the one preemption must take. */
const SHAPES = {
GA: { forty: 25, wagons: 25 },
GB: { forty: 27, wagons: 27 },
} as const;
const ORDER = ["GA", "GB"] as const;
/** More than the single free slot: the government booking cannot fit as-is. */
const GOV_WAGONS = 15;
const booking = new Map<string, string>();
let scheduleId: string;
let govBookingId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await releaseGovernmentBookings();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const contracts = await seedTenantContracts(
STAMP,
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
);
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
await forceWindowOpen(scheduleId, 60);
let isoSeed = 26_000;
for (const [i, suffix] of ORDER.entries()) {
booking.set(
suffix,
await bookContainersReady({
contractId: contracts.get(suffix)!,
runStamp: STAMP,
isoSeed,
forty: SHAPES[suffix].forty,
scheduledDate: BOOKING_DAY,
}),
);
isoSeed += SHAPES[suffix].forty;
await setPriority(booking.get(suffix)!, i + 1);
}
}, 1_800_000);
it("GA pays and GB holds a reservation — 52 of 53 slots committed", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const suffix of ORDER) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
}
await extendPayWindow(scheduleId, [booking.get("GA")!]);
await payViaGateway(booking.get("GA")!);
await pollAllocations(booking.get("GA")!, SHAPES.GA.wagons);
// GB stays UNPAID on purpose — it is the reservation the government
// booking must displace — but its hold is widened so it does not lapse on
// its own while the government booking is being created.
await extendPayWindow(scheduleId, [booking.get("GB")!]);
expect((await bookingRow(booking.get("GB")!)).status, "GB still reserved").toMatch(
/SELECTED_FOR_BATCH|AWAITING_PAYMENT/,
);
expect(SHAPES.GA.wagons + SHAPES.GB.wagons, "52 of 53 committed").toBe(G1_WAGONS - 1);
});
it("a government booking is created and expedited — PAID without paying", async () => {
govBookingId = await createGovernmentBooking({ forty: GOV_WAGONS });
// Idempotent: create() already expedites, the endpoint is the retry path.
await governmentExpedite(govBookingId);
const gov = await bookingRow(govBookingId);
expect(gov.status, "PAID after expedite").toBe("PAID");
expect(gov.is_government, "flagged government").toBe(true);
});
it("it carries the +50 000 bonus, far above any commercial score", async () => {
const govScore = Number((await bookingRow(govBookingId)).priority_score);
expect(govScore, "government bonus applied").toBeGreaterThanOrEqual(50_000);
expect(
Number((await bookingRow(booking.get("GA")!)).priority_score),
"top commercial still far below government",
).toBeLessThan(govScore);
});
it("the fill displaces GB — the lower-priority reservation — and GA is untouched", async () => {
// Staff pin, then run the batch: fillSchedule's pool is keyed on
// `booking.train_schedule_id`, and the customer-facing pin is unavailable
// because the window closed at doc review.
await pinToSchedule(govBookingId, scheduleId);
await triggerBatchRun(scheduleId);
await pollBookingStatus(booking.get("GB")!, "EXPIRED", 30);
const gb = await bookingRow(booking.get("GB")!);
expect(gb.scheduling_status, "GB back to ELIGIBLE").toBe("ELIGIBLE");
expect(gb.payment_deadline, "GB pay window cleared").toBeNull();
expect(await livePayableInvoices(booking.get("GB")!), "GB invoice closed out").toBe(0);
const ga = await bookingRow(booking.get("GA")!);
expect(ga.status, "GA survives untouched").toBe("PAID");
expect(ga.train_schedule_id, "GA still on this train").toBe(scheduleId);
});
it("the government booking rides on the freed wagons — allocated, never invoiced", async () => {
await pollAllocations(govBookingId, GOV_WAGONS);
const gov = await bookingRow(govBookingId);
expect(gov.scheduling_status, "government SCHEDULED").toBe("SCHEDULED");
expect(await livePayableInvoices(govBookingId), "government rides unpaid").toBe(0);
expect(await linkedBookings(scheduleId), "GA + government hold the seats").toBe(2);
expect(await allocatedWagons(scheduleId), "25 commercial + 15 government").toBe(
SHAPES.GA.wagons + GOV_WAGONS,
);
});
});
// ───────────────────────────────────────────────────────────────────────────
// S8 — commercial priority tiers order the batch
// ───────────────────────────────────────────────────────────────────────────
describe("g1 s8: priority tiers order the batch", () => {
const DEPARTURE = departureAt(57);
const BOOKING_DAY = eatDayStr(DEPARTURE);
/** Three equal bookings — only the TIER differs, so ordering is the only
* thing that can decide who is left with the remainder. */
const SHAPES = {
PA: { forty: 20, wagons: 20, tier: "USD payer" },
PB: { forty: 20, wagons: 20, tier: "customs service" },
PC: { forty: 20, wagons: 20, tier: "plain" },
} as const;
const ORDER = ["PA", "PB", "PC"] as const;
/** 60 wagons of demand for 53 slots → the third gets a 13-wagon offer. */
const GAP = G1_WAGONS - SHAPES.PA.wagons - SHAPES.PB.wagons; // 13
const booking = new Map<string, string>();
let scheduleId: string;
let usdConfigId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
// The USD tier. The CUSTOMS bands (7 / 15 points) ship with the corridor
// fixture, so only the currency band has to be added — and it is dropped in
// afterAll, because an active band re-ranks every later file's pool.
usdConfigId = await createPriorityConfig({
type: "CURRENCY",
label: `IT USD payer ${STAMP.slice(-5)}`,
currency: "USD",
minWagonCount: 1,
maxWagonCount: 53,
scorePoints: 35,
});
const contracts = await seedTenantContracts(STAMP, [
{ suffix: "PA", freight: "CONTAINER" as const, currency: "USD" as const },
{
suffix: "PB",
freight: "CONTAINER" as const,
customs: true,
serviceTypeCode: "RAIL_CUSTOMS",
},
{ suffix: "PC", freight: "CONTAINER" as const },
]);
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
await forceWindowOpen(scheduleId, 60);
let isoSeed = 27_000;
for (const suffix of ORDER) {
booking.set(
suffix,
await bookContainersReady({
contractId: contracts.get(suffix)!,
runStamp: STAMP,
isoSeed,
forty: SHAPES[suffix].forty,
scheduledDate: BOOKING_DAY,
customs: suffix === "PB",
}),
);
isoSeed += SHAPES[suffix].forty;
}
}, 1_800_000);
afterAll(async () => {
await dropPriorityConfig(usdConfigId);
await closeDb();
});
it("the engine scores USD above customs above plain — no manual priority set", async () => {
// Deliberately no setPriority anywhere in this file: the point is that the
// rule engine's own bands produce the order. Pinning the scores by hand
// would test setPriority, not the tiers.
const scores = new Map<string, number>();
for (const suffix of ORDER) {
scores.set(suffix, Number((await bookingRow(booking.get(suffix)!)).priority_score));
}
expect(scores.get("PA")!, "USD tier outranks the customs tier").toBeGreaterThan(
scores.get("PB")!,
);
expect(scores.get("PB")!, "customs tier outranks plain").toBeGreaterThan(scores.get("PC")!);
});
it("the two top tiers board whole; the lowest is offered the 13-wagon remainder", async () => {
expect(GAP, "13-wagon remainder after the top two").toBe(13);
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const suffix of ["PA", "PB"] as const) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
await expectNoPartialOffer(booking.get(suffix)!, suffix);
}
const offer = await pollPartialOffer(booking.get("PC")!);
expect(Number(offer.offered_wagons), "PC offered the exact remainder").toBe(GAP);
});
it("all three settle — PC ships 13 of its 20 wagons and the train is FULL", async () => {
await extendPayWindow(scheduleId, [...booking.values()]);
await payViaGateway(booking.get("PA")!);
await pollAllocations(booking.get("PA")!, SHAPES.PA.wagons);
await payViaGateway(booking.get("PB")!);
await pollAllocations(booking.get("PB")!, SHAPES.PB.wagons);
await payViaGateway(booking.get("PC")!);
await pollAllocations(booking.get("PC")!, GAP);
expect((await bookingRow(booking.get("PC")!)).is_split, "PC is the split one").toBe(true);
expect(await containerCount(booking.get("PC")!), "PC shrank to 13 containers").toBe(GAP);
await endPaymentPhase(scheduleId);
await pollWindow(
scheduleId,
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
"FULL + DONE",
);
expect(await allocatedWagons(scheduleId), "53 wagons allocated").toBe(G1_WAGONS);
});
});

View File

@@ -0,0 +1,572 @@
/**
* GROUP 2 · S9S12 — when WEIGHT binds before slots.
*
* Group 1 kept every non-slot axis slack so wagon arithmetic was the only thing
* under test. This group inverts it: the locomotives pull 3 500 T base (two
* 1 750 T units — pull weight ADDS UP across a set) and the cargo is heavy
* enough that the pull limit runs out before the slots do.
*
* The arithmetic follows from `grossWagonWeightTons` = tare + cargo. The weight
* axis is GROSS: a locomotive hauls the wagon as well as what is in it. NW5
* tare 22.4 T, two 20ft per wagon:
*
* heavy (28 T VGM): 2 × 28 + 22.4 = 78.4 T per wagon
* light (12 T VGM): 2 × 12 + 22.4 = 46.4 T per wagon
*
* S9 35 wagons × 78.4 = 2 744 T fits; 10 more would be 3 528 T > 3 500 T,
* so the next booking is cut down on WEIGHT and the window closes FULL
* with 19 slots still empty — the verdict names pull, not slots.
* S10 that same 3 528 T is admitted WHOLE by the pair carrying a 90 T
* tolerance (cap 3 590 T). Tolerance buys a whole booking, nothing else.
* S11 with 756 T of base room left, the split offer is sized from BASE room
* only — it may never reach into the tolerance.
* S12 light cargo: every slot fills at ~72% of the pull limit. SLOTS bind.
*
* WHY LOCOMOTIVE PAIRS AND NOT THE BUILT TRAINS
*
* `remainingBudget` replaces the whole limit set with
* `{wagons: physicalWagons, weightTons: Infinity, lengthMeters: Infinity}` the
* moment a schedule has a built train — so on a built consist the batch is
* blind to pull weight and only slots bind. The locomotive-pair path keeps the
* real limits, which is where this group's axis actually lives. The last
* describe pins the built-train hole itself: the batch reserves a load the
* locomotives cannot pull, and only the allocator notices — after payment.
*
* Fixture: seed-g2-weight.sql (LOCO-G2-A/B 1 750 T + 0 tolerance,
* LOCO-G2-C/D 1 750 T + 45 T each, TRN-G2-BASE for the built-train case).
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, gateway, sleep } from "./client";
import {
allocatedGrossTons,
allocatedWagons,
bookContainersReady,
bookingRow,
closeBookingWindow,
completeDocReview,
createBuiltTrainSchedule,
createSchedule,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
expectNoPartialOffer,
extendPayWindow,
forceWindowOpen,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollCycleConcluded,
pollPartialOffer,
pollWindow,
releaseUnpaidHolds,
resetCorridorDay,
scheduleRow,
seedTenantContracts,
setPriority,
wagonAllocationCount,
} from "./flows";
const STAMP = String(Date.now());
/** NW5 tare and capacity — the fixture's whole premise. */
const NW5_TARE = 22.4;
const NW5_CAPACITY = 70;
const HEAVY_VGM = 28;
const LIGHT_VGM = 12;
/** Two 20ft ride one NW5. */
const grossPerWagon = (vgm: number) => 2 * vgm + NW5_TARE;
/** Base pull of the 1 750 T + 1 750 T pair. */
const BASE_TONS = 3500;
/** LOCO-G2-C/D: 45 T + 45 T. Weight tolerance adds up too. */
const TOLERANCE_TONS = 90;
const BASE_PAIR: [string, string] = ["LOCO-G2-A", "LOCO-G2-B"];
const TOL_PAIR: [string, string] = ["LOCO-G2-C", "LOCO-G2-D"];
/** floor(760 m / 13.966 m) — the slot count a 760 m pair derives. */
const SLOTS = 54;
/**
* How the engine sizes a partial when weight is the binding axis: it walks
* candidate wagon counts and keeps the one carrying the most cargo, measuring
* each wagon at its FULL capacity rather than at the booking's real density.
* With 756 T of room that peaks at 8 wagons (8 × 70 = 560 T of nominal cargo)
* rather than 9 (756 9 × 22.4 = 554.4 T), even though this cargo only weighs
* 56 T per wagon. Conservative, and never dependent on the tolerance.
*/
function offerWagonsFor(roomTons: number, bookingWagons: number, freeSlots: number): number {
let best = 0;
let bestCargo = 0;
for (let w = 1; w <= Math.min(freeSlots, bookingWagons - 1); w += 1) {
const cargo = Math.min(w * NW5_CAPACITY, roomTons - w * NW5_TARE);
if (cargo > bestCargo) {
bestCargo = cargo;
best = w;
}
}
return best;
}
// ───────────────────────────────────────────────────────────────────────────
// S9 — weight binds before slots
// ───────────────────────────────────────────────────────────────────────────
describe("g2 s9: weight cuts a booking down while slots sit empty", () => {
const DEPARTURE = departureAt(58);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const SHAPES = {
WA: { twenty: 40, wagons: 20 },
WB: { twenty: 30, wagons: 15 },
WC: { twenty: 20, wagons: 10 },
} as const;
const ORDER = ["WA", "WB", "WC"] as const;
const BOARDED = SHAPES.WA.wagons + SHAPES.WB.wagons; // 35
const FREE_SLOTS = SLOTS - BOARDED; // 19
/** 3 500 2 744 = 756 T of pull left, against 19 free slots. */
const BASE_ROOM = BASE_TONS - BOARDED * grossPerWagon(HEAVY_VGM); // 756
const EXPECTED_OFFER = offerWagonsFor(BASE_ROOM, SHAPES.WC.wagons, FREE_SLOTS); // 8
const booking = new Map<string, string>();
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const contracts = await seedTenantContracts(
STAMP,
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
);
scheduleId = (await createSchedule({ departure: DEPARTURE, locoPair: BASE_PAIR })).id;
await forceWindowOpen(scheduleId, 60);
let isoSeed = 30_000;
for (const [i, suffix] of ORDER.entries()) {
booking.set(
suffix,
await bookContainersReady({
contractId: contracts.get(suffix)!,
runStamp: STAMP,
isoSeed,
twenty: SHAPES[suffix].twenty,
scheduledDate: BOOKING_DAY,
vgmTons: HEAVY_VGM,
}),
);
isoSeed += SHAPES[suffix].twenty;
await setPriority(booking.get(suffix)!, i + 1);
}
}, 1_800_000);
it("the heavy-wagon arithmetic is what the scenario assumes", async () => {
expect(grossPerWagon(HEAVY_VGM), "2 × 28 T + 22.4 T tare").toBe(78.4);
expect(BOARDED * grossPerWagon(HEAVY_VGM), "35 wagons fit the 3 500 T base").toBe(2744);
expect(
(BOARDED + SHAPES.WC.wagons) * grossPerWagon(HEAVY_VGM),
"WC's 10 more would breach the base",
).toBeGreaterThan(BASE_TONS);
expect(Number((await scheduleRow(scheduleId)).max_wagons), "54 slots").toBe(SLOTS);
expect(FREE_SLOTS, "19 slots would still be free").toBe(19);
});
it("WA and WB board whole; WC is cut down by WEIGHT, not by slots", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const suffix of ["WA", "WB"] as const) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
await expectNoPartialOffer(booking.get(suffix)!, suffix);
}
// 19 slots are free and WC needs 10 — on the slot axis it fits easily. What
// it gets instead is an offer sized by the 756 T of pull left.
const offered = Number((await pollPartialOffer(booking.get("WC")!)).offered_wagons);
expect(offered, "sized by remaining pull, not by slots").toBe(EXPECTED_OFFER);
expect(offered, "less than the 10 wagons WC asked for").toBeLessThan(SHAPES.WC.wagons);
expect(offered, "and far less than the free slots").toBeLessThan(FREE_SLOTS);
expect(
offered * grossPerWagon(HEAVY_VGM),
"the offered part fits the base room",
).toBeLessThanOrEqual(BASE_ROOM);
});
it("the verdict names WEIGHT: 35 of 54 slots with the pull limit spent", async () => {
await extendPayWindow(scheduleId, [booking.get("WA")!, booking.get("WB")!]);
for (const suffix of ["WA", "WB"] as const) {
await payViaGateway(booking.get(suffix)!);
await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons);
}
const tons = await allocatedGrossTons(scheduleId);
expect(tons, "2 744 T of the 3 500 T base used").toBeCloseTo(2744, 0);
expect(
tons + SHAPES.WC.wagons * grossPerWagon(HEAVY_VGM),
"WC whole would not fit on weight",
).toBeGreaterThan(BASE_TONS);
expect(await allocatedWagons(scheduleId), "35 of 54 slots used").toBe(BOARDED);
// And the verdict itself: the window reads FULL with 19 slots standing
// empty, because `isExhausted` ran out of PULL, not of wagons. On the built
// trains of Group 1 the same board would still be selling space.
expect(
(await scheduleRow(scheduleId)).booking_window_status,
"FULL — declared on weight while 19 slots are free",
).toBe("FULL");
});
});
// ───────────────────────────────────────────────────────────────────────────
// S10 — tolerance admits a WHOLE booking over the base
// ───────────────────────────────────────────────────────────────────────────
describe("g2 s10: the overage tolerance admits the last booking whole", () => {
const DEPARTURE = departureAt(59);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const SHAPES = {
TA: { twenty: 40, wagons: 20 },
TB: { twenty: 30, wagons: 15 },
TC: { twenty: 20, wagons: 10 },
} as const;
const ORDER = ["TA", "TB", "TC"] as const;
const ALL_WAGONS = 45;
const ALL_TONS = ALL_WAGONS * grossPerWagon(HEAVY_VGM); // 3528
const booking = new Map<string, string>();
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const contracts = await seedTenantContracts(
STAMP,
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
);
scheduleId = (await createSchedule({ departure: DEPARTURE, locoPair: TOL_PAIR })).id;
await forceWindowOpen(scheduleId, 60);
let isoSeed = 31_000;
for (const [i, suffix] of ORDER.entries()) {
booking.set(
suffix,
await bookContainersReady({
contractId: contracts.get(suffix)!,
runStamp: STAMP,
isoSeed,
twenty: SHAPES[suffix].twenty,
scheduledDate: BOOKING_DAY,
vgmTons: HEAVY_VGM,
}),
);
isoSeed += SHAPES[suffix].twenty;
await setPriority(booking.get(suffix)!, i + 1);
}
}, 1_800_000);
it("3 528 T breaks the 3 500 T base but sits inside the 3 590 T cap", () => {
expect(ALL_TONS, "45 heavy wagons").toBeCloseTo(3528, 6);
expect(ALL_TONS, "over base").toBeGreaterThan(BASE_TONS);
expect(ALL_TONS, "within base + tolerance").toBeLessThanOrEqual(BASE_TONS + TOLERANCE_TONS);
});
it("TC — the booking S9 could not fit — is admitted WHOLE, not split", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const suffix of ORDER) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
// The scenario's whole claim: the tolerance is spent admitting a WHOLE
// booking. A split offer here would be the bug.
await expectNoPartialOffer(booking.get(suffix)!, suffix);
}
});
it("the train rides over base, inside tolerance, at 45 of 54 slots", async () => {
await extendPayWindow(scheduleId, [...booking.values()]);
for (const suffix of ORDER) {
await payViaGateway(booking.get(suffix)!);
await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons);
}
await endPaymentPhase(scheduleId);
await pollCycleConcluded(scheduleId);
expect(await allocatedWagons(scheduleId), "45 of 54 slots").toBe(ALL_WAGONS);
const tons = await allocatedGrossTons(scheduleId);
expect(tons, "3 528 T aboard").toBeCloseTo(ALL_TONS, 0);
expect(tons, "over the 3 500 T base").toBeGreaterThan(BASE_TONS);
expect(tons, "inside the 3 590 T cap").toBeLessThanOrEqual(BASE_TONS + TOLERANCE_TONS);
});
});
// ───────────────────────────────────────────────────────────────────────────
// S11 — a split may never touch the tolerance
// ───────────────────────────────────────────────────────────────────────────
describe("g2 s11: a split is sized against base weight only", () => {
const DEPARTURE = departureAt(60);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const SHAPES = {
XA: { twenty: 40, wagons: 20 },
XB: { twenty: 30, wagons: 15 },
/** Wants 15 wagons; base room pays for a fraction of them. */
XD: { twenty: 30, wagons: 15 },
} as const;
const ORDER = ["XA", "XB", "XD"] as const;
const USED_TONS = 35 * grossPerWagon(HEAVY_VGM); // 2744
const BASE_ROOM = BASE_TONS - USED_TONS; // 756
const TOLERANCE_ROOM = BASE_ROOM + TOLERANCE_TONS; // 846
const FREE_SLOTS = SLOTS - 35; // 19
const EXPECTED_OFFER = offerWagonsFor(BASE_ROOM, SHAPES.XD.wagons, FREE_SLOTS); // 8
/** What the tolerance would have bought if the sizer were allowed to spend it. */
const OFFER_IF_TOLERANCE_SPENT = offerWagonsFor(TOLERANCE_ROOM, SHAPES.XD.wagons, FREE_SLOTS);
const booking = new Map<string, string>();
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const contracts = await seedTenantContracts(
STAMP,
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
);
// Deliberately the TOLERANCE pair: the 90 T is present and must go unspent.
scheduleId = (await createSchedule({ departure: DEPARTURE, locoPair: TOL_PAIR })).id;
await forceWindowOpen(scheduleId, 60);
let isoSeed = 32_000;
for (const [i, suffix] of ORDER.entries()) {
booking.set(
suffix,
await bookContainersReady({
contractId: contracts.get(suffix)!,
runStamp: STAMP,
isoSeed,
twenty: SHAPES[suffix].twenty,
scheduledDate: BOOKING_DAY,
vgmTons: HEAVY_VGM,
}),
);
isoSeed += SHAPES[suffix].twenty;
await setPriority(booking.get(suffix)!, i + 1);
}
}, 1_800_000);
it("base room and tolerance room would buy different offers", () => {
expect(BASE_ROOM, "756 T of base room after 2 744 T").toBe(756);
expect(
OFFER_IF_TOLERANCE_SPENT,
"spending the 90 T would buy a bigger offer — so this is a real distinction",
).toBeGreaterThan(EXPECTED_OFFER);
});
it("XD's offer is sized from base room — the tolerance stays unspent", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const suffix of ["XA", "XB"] as const) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
}
const offered = Number((await pollPartialOffer(booking.get("XD")!)).offered_wagons);
expect(offered, "split sized from BASE room only").toBe(EXPECTED_OFFER);
expect(
offered * grossPerWagon(HEAVY_VGM),
"the offered part never reaches past the base room",
).toBeLessThanOrEqual(BASE_ROOM);
});
it("the train closes on weight with 19 slots free and its tolerance unused", async () => {
await extendPayWindow(scheduleId, [booking.get("XA")!, booking.get("XB")!]);
for (const suffix of ["XA", "XB"] as const) {
await payViaGateway(booking.get(suffix)!);
await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons);
}
expect(
await allocatedGrossTons(scheduleId),
"still inside base — no tolerance spent",
).toBeLessThanOrEqual(BASE_TONS);
expect(await allocatedWagons(scheduleId), "35 of 54 slots").toBe(35);
expect(
(await scheduleRow(scheduleId)).booking_window_status,
"FULL on pull weight, not on slots",
).toBe("FULL");
});
});
// ───────────────────────────────────────────────────────────────────────────
// S12 — light cargo, slots bind
// ───────────────────────────────────────────────────────────────────────────
describe("g2 s12: with light cargo the slots bind first", () => {
const DEPARTURE = departureAt(61);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const SHAPES = {
LA: { twenty: 40, wagons: 20 },
LB: { twenty: 40, wagons: 20 },
LC: { twenty: 28, wagons: 14 },
} as const;
const ORDER = ["LA", "LB", "LC"] as const;
const FULL_TONS = SLOTS * grossPerWagon(LIGHT_VGM); // 2505.6
const booking = new Map<string, string>();
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const contracts = await seedTenantContracts(
STAMP,
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
);
scheduleId = (await createSchedule({ departure: DEPARTURE, locoPair: BASE_PAIR })).id;
await forceWindowOpen(scheduleId, 60);
let isoSeed = 33_000;
for (const suffix of ORDER) {
booking.set(
suffix,
await bookContainersReady({
contractId: contracts.get(suffix)!,
runStamp: STAMP,
isoSeed,
twenty: SHAPES[suffix].twenty,
scheduledDate: BOOKING_DAY,
vgmTons: LIGHT_VGM,
}),
);
isoSeed += SHAPES[suffix].twenty;
}
}, 1_800_000);
it("a full consist of light wagons weighs only ~72% of the pull limit", () => {
expect(grossPerWagon(LIGHT_VGM), "2 × 12 T + 22.4 T tare").toBe(46.4);
expect(FULL_TONS, "54 × 46.4 T").toBeCloseTo(2505.6, 1);
expect(FULL_TONS / BASE_TONS, "~72% of base").toBeCloseTo(0.72, 1);
expect(
ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0),
"the three fill every slot",
).toBe(SLOTS);
});
it("all three board and fill the train on SLOTS, with weight to spare", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const suffix of ORDER) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
await expectNoPartialOffer(booking.get(suffix)!, suffix);
}
await extendPayWindow(scheduleId, [...booking.values()]);
for (const suffix of ORDER) {
await payViaGateway(booking.get(suffix)!);
await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons);
}
});
it("the verdict names SLOTS — every slot filled with ~1 000 T of pull unused", async () => {
await endPaymentPhase(scheduleId);
await pollWindow(scheduleId, (s) => s.booking_window_status === "FULL", "FULL");
expect(await allocatedWagons(scheduleId), "54 of 54 slots").toBe(SLOTS);
for (const suffix of ORDER) {
expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} rides`).toBe("PAID");
}
const tons = await allocatedGrossTons(scheduleId);
expect(tons, "2 505 T aboard").toBeCloseTo(FULL_TONS, 0);
// The opposite of S9 on identical locomotives: the train is full because it
// ran out of WAGONS, not pull.
expect(BASE_TONS - tons, "nearly 1 000 T of pull unused").toBeGreaterThan(900);
});
});
// ───────────────────────────────────────────────────────────────────────────
// FINDING — on a BUILT train the batch never sees the pull limit
// ───────────────────────────────────────────────────────────────────────────
describe("g2 finding: a built train's batch ignores the locomotive pull limit", () => {
const DEPARTURE = departureAt(62);
const BOOKING_DAY = eatDayStr(DEPARTURE);
/** 45 wagons of heavy cargo — the exact load S10's tolerance admitted. */
const TWENTY = 90;
const WAGONS = 45;
/** What the ALLOCATOR weighs: cargo, plus the tare of the WHOLE 53-wagon consist. */
const ALLOCATOR_TONS = TWENTY * HEAVY_VGM + 53 * NW5_TARE; // 3707.2
let bookingId: string;
let scheduleId: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const contracts = await seedTenantContracts(STAMP, [
{ suffix: "BW", freight: "CONTAINER" as const },
]);
scheduleId = (
await createBuiltTrainSchedule({
departure: DEPARTURE,
trainCode: "TRN-G2-BASE",
})
).id;
await forceWindowOpen(scheduleId, 60);
bookingId = await bookContainersReady({
contractId: contracts.get("BW")!,
runStamp: STAMP,
isoSeed: 34_000,
twenty: TWENTY,
scheduledDate: BOOKING_DAY,
vgmTons: HEAVY_VGM,
});
}, 1_800_000);
afterAll(closeDb);
it("the load is beyond what these locomotives can pull, tolerance included", () => {
expect(WAGONS * grossPerWagon(HEAVY_VGM), "3 528 T on the booking's own wagons").toBeCloseTo(
3528,
6,
);
expect(ALLOCATOR_TONS, "3 707.2 T once the whole consist's tare is charged").toBeCloseTo(
3707.2,
1,
);
expect(ALLOCATOR_TONS, "over the 3 500 T base with no tolerance on this pair").toBeGreaterThan(
BASE_TONS,
);
});
it("the batch reserves it anyway — weight is Infinity in a built train's budget", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
// `remainingBudget` swaps the locomotive limits for
// {wagons: physicalWagons, weightTons: Infinity, lengthMeters: Infinity}
// as soon as a schedule has a built train, so nothing weighs this booking
// until the wagons are handed out.
await pollBookingStatus(bookingId, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
await expectNoPartialOffer(bookingId, "BW");
});
it("the customer pays, and only THEN does allocation refuse on weight", async () => {
await extendPayWindow(scheduleId, [bookingId]);
await payViaGateway(bookingId);
// Allocation retries on every settle tick and keeps failing with
// "Train set locomotives cannot pull the gross weight … limit 3500T incl.
// tolerance". The customer is PAID with no wagons — the state this whole
// scenario group exists to surface.
await sleep(60_000);
expect(await wagonAllocationCount(bookingId), "paid, and not a single wagon").toBe(0);
expect((await bookingRow(bookingId)).status, "money taken").toBe("PAID");
expect(await allocatedWagons(scheduleId), "the train stays empty").toBe(0);
}, 300_000);
});

View File

@@ -0,0 +1,72 @@
/**
* Runs once before any spec: wait for both APIs, then seed.
*
* Seeds are the Cypress suite's fixtures, reused verbatim (they are idempotent
* `insert … where not exists`), plus one of our own for the second tenant:
* seed-users.sql → seed-company.sql (order matters; company needs the users)
* seed-import-corridor.sql (yards, locos, wagons, rates, distances)
* seed-bulk-items.sql (PER_ITEM break-bulk cargo types)
* seed-g1-train.sql (the 53-wagon BUILT container train)
* seed-g2-weight.sql (the two 3 500 T weight-bound trains)
* seed-government.sql (the kind='government' company)
* seed-company-b.sql (user2@gmail.com's company — this suite)
* seed-customs-service-type.sql (a service type that bundles customs)
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { Client } from "pg";
const API = process.env.IT_API_URL ?? "http://localhost:3111";
const PAYMENT_API = process.env.IT_PAYMENT_URL ?? "http://localhost:3113";
const GATEWAY = process.env.IT_GATEWAY_URL ?? "http://localhost:4600";
const DB_URL =
process.env.IT_DB_URL ?? "postgres://edr_e2e:edr_e2e@localhost:5543/edr_freight_e2e";
const CYPRESS_FIXTURES = join(process.cwd(), "..", "e2e", "freight", "cypress", "fixtures");
const OWN_FIXTURES = join(process.cwd(), "sql");
const SEEDS: Array<[dir: string, file: string]> = [
[CYPRESS_FIXTURES, "seed-users.sql"],
[CYPRESS_FIXTURES, "seed-company.sql"],
[CYPRESS_FIXTURES, "seed-import-corridor.sql"],
[CYPRESS_FIXTURES, "seed-bulk-items.sql"],
[CYPRESS_FIXTURES, "seed-g1-train.sql"],
[CYPRESS_FIXTURES, "seed-g2-weight.sql"],
[CYPRESS_FIXTURES, "seed-government.sql"],
[OWN_FIXTURES, "seed-company-b.sql"],
[OWN_FIXTURES, "seed-customs-service-type.sql"],
];
async function waitFor(label: string, url: string, attempts = 60): Promise<void> {
for (let i = 0; i < attempts; i++) {
try {
const res = await fetch(url);
if (res.ok) return;
} catch {
/* not up yet */
}
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error(`${label} never became healthy at ${url}`);
}
export async function setup(): Promise<void> {
await Promise.all([
waitFor("freight-api", `${API}/api/health`),
waitFor("payment-api", `${PAYMENT_API}/health`),
waitFor("gateway-mock", `${GATEWAY}/__control/health`),
]);
const client = new Client({ connectionString: DB_URL });
await client.connect();
try {
for (const [dir, file] of SEEDS) {
await client.query(readFileSync(join(dir, file), "utf8"));
console.log(`it: seeded ${file}`);
}
} finally {
await client.end();
}
await fetch(`${GATEWAY}/__control/reset`, { method: "POST" });
}

View File

@@ -0,0 +1,249 @@
/**
* What happens when the bank misbehaves. Each test forces the gateway mock
* into a failure mode and asserts the platform's answer — the point being that
* NO failure may ever settle an invoice that was not paid, and no failure may
* lose a payment that was.
*
* Covered: provider down at initiate, hard decline, forged signature, replayed
* callback, silent settlement found only by the reconciliation sweep, and the
* unverifiable answer that must stop freight from expiring a paying customer.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { closeDb, db, gateway, payment, poll, sleep } from "./client";
import {
createImportSchedule,
currentInvoice,
departureAt,
ensureCorridorRoute,
releaseUnpaidHolds,
forceWindowOpen,
gatewayIntent,
invoiceForBooking,
payInvoice,
prepareBooking,
resetCorridorDay,
runBatch,
type ReadyBooking,
} from "./flows";
const DEPARTURE = departureAt(6);
const STAMP = String(Date.now());
/** Four independent bookings so one test's terminal state can't poison another. */
const CASES = ["FAIL1", "FAIL2", "FAIL3", "FAIL4"] as const;
type CaseName = (typeof CASES)[number];
describe("payment failure and recovery", () => {
const bookings = new Map<CaseName, ReadyBooking>();
const invoices = new Map<CaseName, string>();
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const schedule = await createImportSchedule({ departure: DEPARTURE });
await forceWindowOpen(schedule.id, 45);
let isoSeed = 100;
for (const suffix of CASES) {
bookings.set(
suffix,
await prepareBooking({
suffix,
departure: DEPARTURE,
runStamp: STAMP,
isoSeed,
twenty: 2,
}),
);
isoSeed += 2;
}
await runBatch(schedule.id);
for (const suffix of CASES) {
invoices.set(suffix, (await invoiceForBooking(bookings.get(suffix)!.bookingId)).id);
}
}, 600_000);
afterAll(closeDb);
it("leaves the invoice payable when the provider is unreachable", async () => {
const invoiceId = invoices.get("FAIL1")!;
await gateway.mode("cbe-birr", "fail");
const res = await payInvoice(invoiceId, { method: "CBE_BIRR" });
expect(res.status).toBeGreaterThanOrEqual(400);
// No phantom settlement, and the customer can retry.
const invoice = await currentInvoice(invoiceId);
expect(invoice.status).not.toBe("PAID");
expect(invoice.paid_at).toBeNull();
await gateway.mode("cbe-birr", "ok");
const retry = await payInvoice(invoiceId, { method: "CBE_BIRR" });
expect(retry.status, JSON.stringify(retry.body)).toBeLessThanOrEqual(201);
});
it("keeps the invoice open on a declined payment", async () => {
const { bookingId } = bookings.get("FAIL2")!;
const invoiceId = invoices.get("FAIL2")!;
await gateway.mode("cbe-birr", "ok");
expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
const intent = await gatewayIntent(bookingId);
await gateway.webhook({ merchantOrderId: intent.merchant_order_id, status: "FAILED" });
const failed = await poll<{ status: string }>(
"intent FAILED",
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
[intent.id],
(row) => row?.status === "FAILED",
{ attempts: 20, intervalMs: 1000 },
);
expect(failed.status).toBe("FAILED");
const invoice = await currentInvoice(invoiceId);
expect(invoice.status).not.toBe("PAID");
});
it("ignores a forged signature — event recorded, money untouched", async () => {
const { bookingId } = bookings.get("FAIL3")!;
const invoiceId = invoices.get("FAIL3")!;
expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
const intent = await gatewayIntent(bookingId);
const res = await gateway.webhook({
merchantOrderId: intent.merchant_order_id,
signature: "bad",
});
// Providers must still get a 2xx (Waafi times out at 5s and never retries).
expect(res.body.delivered).toBe(200);
const event = await poll<{ signature_valid: boolean; processing_error: string | null }>(
"forged webhook recorded",
`SELECT signature_valid, processing_error FROM edr_payment.payment_webhook_event
WHERE merchant_order_id = $1 ORDER BY received_at DESC LIMIT 1`,
[intent.merchant_order_id],
(row) => !!row,
{ attempts: 15, intervalMs: 1000 },
);
expect(event.signature_valid).toBe(false);
expect(event.processing_error).toBe("signature-invalid");
await sleep(3000);
const after = await db<{ status: string }>(
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
[intent.id],
);
expect(after[0].status).not.toBe("SUCCEEDED");
expect((await currentInvoice(invoiceId)).status).not.toBe("PAID");
});
it("settles from the reconciliation sweep alone, with no callback at all", async () => {
const { bookingId } = bookings.get("FAIL4")!;
const invoiceId = invoices.get("FAIL4")!;
expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
const intent = await gatewayIntent(bookingId);
// The customer pays at the bank, but the callback is lost in the network.
await gateway.settle(intent.merchant_order_id);
// RECONCILE_STALE_AFTER_MS=5s, sweep every 30s — one sweep is enough.
const settled = await poll<{ status: string }>(
"intent settled by sweep",
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
[intent.id],
(row) => row?.status === "SUCCEEDED",
{ attempts: 30, intervalMs: 3000 },
);
expect(settled.status).toBe("SUCCEEDED");
const invoice = await poll<{ status: string }>(
"invoice PAID via sweep",
`SELECT status FROM freight.invoices WHERE id = $1`,
[invoiceId],
(row) => row?.status === "PAID",
{ attempts: 30, intervalMs: 2000 },
);
expect(invoice.status).toBe("PAID");
// No webhook was ever delivered for this one.
const [{ n }] = await db<{ n: string }>(
`SELECT count(*)::text AS n FROM edr_payment.payment_webhook_event
WHERE merchant_order_id = $1`,
[intent.merchant_order_id],
);
expect(Number(n)).toBe(0);
});
it("reports `unverifiable` when the gateway cannot answer, so freight must not expire the hold", async () => {
// FAIL1 still has a live (unsettled) intent — FAIL2's is terminal, and a
// reference with nothing to verify legitimately answers "not paid".
const { bookingId } = bookings.get("FAIL1")!;
await gateway.mode("cbe-birr", "timeout");
const res = await payment("post", "/payments/reconcile", {
service: "FREIGHT",
referenceType: "SHIPMENT",
referenceId: bookingId,
});
await gateway.mode("cbe-birr", "ok");
expect([200, 201]).toContain(res.status);
const body = res.body?.data ?? res.body;
expect(body.paid).toBe(false);
// An unknown answer must never read as "definitely unpaid" — that is what
// stops the batch engine from expiring a customer who actually paid.
expect(body.unverifiable).toBe(true);
});
it("captures late: a settlement after the intent expired still pays the invoice", async () => {
const { bookingId } = bookings.get("FAIL3")!;
const invoiceId = invoices.get("FAIL3")!;
const intent = await gatewayIntent(bookingId);
// Retire the intent the way an expiry sweep would, then let the money land.
await db(
`UPDATE edr_payment.payment_intent
SET status = 'EXPIRED', expires_at = now() - interval '1 minute'
WHERE id = $1`,
[intent.id],
);
await gateway.webhook({
merchantOrderId: intent.merchant_order_id,
eventId: `LATE-${intent.merchant_order_id}`,
});
const captured = await poll<{ status: string }>(
"late capture flips the intent",
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
[intent.id],
(row) => row?.status === "SUCCEEDED",
{ attempts: 20, intervalMs: 1000 },
);
expect(captured.status).toBe("SUCCEEDED");
const invoice = await poll<{ status: string }>(
"invoice settled by late capture",
`SELECT status FROM freight.invoices WHERE id = $1`,
[invoiceId],
(row) => row?.status === "PAID",
{ attempts: 30, intervalMs: 2000 },
);
expect(invoice.status).toBe("PAID");
});
it("retries delivery until the consumer is back", async () => {
// Deliberately not covered here: it needs the freight container stopped
// mid-test, which would break every other file sharing this stack.
// `node integration/scripts/it.mjs logs` + a manual `docker compose stop
// freight-api-e2e` reproduces it; the relay's backoff is unit-testable.
// ponytail: outbox retry asserted only via attempts>0 below; add a
// dedicated single-file stack if this ever regresses.
const rows = await db<{ status: string; attempts: number }>(
`SELECT status, attempts FROM edr_payment.notification_outbox ORDER BY created_at DESC LIMIT 20`,
);
expect(rows.length).toBeGreaterThan(0);
expect(rows.every((r) => r.status !== "FAILED")).toBe(true);
});
});

Some files were not shown because too many files have changed in this diff Show More