Merge branch 'dev' into fixes

This commit is contained in:
Nathnael
2026-08-04 09:00:52 +00:00
188 changed files with 12946 additions and 1758 deletions

View File

@@ -89,6 +89,8 @@ 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";
import { FuelModule } from "./modules/fuel/fuel.module";
@@ -206,6 +208,8 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
RoutesModule,
WarehousesModule,
OverviewModule,
ReportsModule,
UserTradeAccessModule,
VehiclesModule,
DriversModule,
FuelModule,

View File

@@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
/**
* Per-backoffice-user trade-direction scope (import / export / intercity).
* A user with no row (or all three directions) is unrestricted. Admins
* (super_admin / organization_admin) bypass the scope entirely.
*/
export class CreateUserTradeAccess3150000000000 implements MigrationInterface {
name = 'CreateUserTradeAccess3150000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'user_trade_access',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
// IAM user id (iam.users) — no FK, iam schema is externally owned.
{ name: 'user_id', type: 'uuid', isUnique: true },
// Comma-separated subset of IMPORT,EXPORT,DOMESTIC (simple-array).
{ name: 'directions', type: 'text', default: "''" },
{ name: 'updated_by_id', type: 'uuid', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex(
'freight.user_trade_access',
new TableIndex({
name: 'idx_user_trade_access_user_id',
columnNames: ['user_id'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.user_trade_access', true);
}
}

View File

@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Handling a freight type is not the same as handling it in both directions. A
* facility can be equipped to load containers onto a train but have no yard
* space to receive and stage inbound ones, so the origin and destination sides
* are stated independently per freight type.
*
* Backfilled from `handles_container` / `handles_bulk` so every existing row
* keeps its current behaviour: a facility that handles a type today handles it
* on both sides until someone narrows it in the backoffice. New rows default
* false — an unconfigured facility offers nothing rather than silently
* offering everything.
*/
export class YardFacilityOriginDestination3170000000000 implements MigrationInterface {
name = 'YardFacilityOriginDestination3170000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.yard_facilities
ADD COLUMN IF NOT EXISTS has_container_facility_origin boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS has_bulk_facility_origin boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS has_container_facility_destination boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS has_bulk_facility_destination boolean NOT NULL DEFAULT false
`);
await queryRunner.query(`
UPDATE freight.yard_facilities
SET has_container_facility_origin = handles_container,
has_container_facility_destination = handles_container,
has_bulk_facility_origin = handles_bulk,
has_bulk_facility_destination = handles_bulk
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.yard_facilities
DROP COLUMN IF EXISTS has_container_facility_origin,
DROP COLUMN IF EXISTS has_bulk_facility_origin,
DROP COLUMN IF EXISTS has_container_facility_destination,
DROP COLUMN IF EXISTS has_bulk_facility_destination
`);
}
}

View File

@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 3170 backfilled the per-side facility flags onto EXISTING yard_facilities
* rows, but the Djibouti port yards (Negad, the Doraleh terminals) are flagged
* `has_facility` without ever getting a facility record — the seeder only
* covers the inland intercity facilities. With the contract route picker now
* gating on the per-side flags, those yards report false on every side and
* vanish: import contracts lose all origin options, exports all destinations.
*
* Give every facility-flagged yard that has no live record one with both
* freight types open on both sides — exactly the offerability these yards had
* before the gate existed. Ops can narrow a port from the backoffice yards
* page, which now edits these flags.
*/
export class PortYardFacilityRecords3180000000000 implements MigrationInterface {
name = 'PortYardFacilityRecords3180000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO freight.yard_facilities
(yard_id, has_warehouse, handles_container, handles_bulk,
has_container_facility_origin, has_bulk_facility_origin,
has_container_facility_destination, has_bulk_facility_destination)
SELECT y.id, false, true, true, true, true, true, true
FROM freight.yards y
WHERE y.deleted_at IS NULL
AND y.has_facility = true
AND NOT EXISTS (
SELECT 1 FROM freight.yard_facilities f
WHERE f.yard_id = y.id AND f.deleted_at IS NULL
)
`);
}
public async down(): Promise<void> {
// Data seed — the inserted rows are indistinguishable from operator edits
// afterwards, so reversing would risk deleting real configuration.
}
}

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,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class EmptyContainerReturnedBy3210000000000 implements MigrationInterface {
name = 'EmptyContainerReturnedBy3210000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.empty_container_returns
ADD COLUMN IF NOT EXISTS returned_by varchar(20) NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.empty_container_returns
DROP COLUMN IF EXISTS returned_by
`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class EmptyContainerReturnStatusHistory3220000000000 implements MigrationInterface {
name = 'EmptyContainerReturnStatusHistory3220000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.empty_container_returns
ADD COLUMN IF NOT EXISTS status_history jsonb NOT NULL DEFAULT '[]'::jsonb
`);
await queryRunner.query(`
UPDATE freight.empty_container_returns
SET status_history = jsonb_build_array(
jsonb_build_object('status', status, 'changedAt', created_at, 'performedBy', performed_by)
)
WHERE status_history = '[]'::jsonb
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.empty_container_returns
DROP COLUMN IF EXISTS status_history
`);
}
}

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

@@ -59,6 +59,30 @@ export class BackofficeService {
];
}
/**
* IAM user ids of current employees (any org) holding ANY of the given
* permission keys — used by the notification recipients resolver's
* `permissionKeys` selector for department/role-scoped targeting.
*/
async getEmployeeUserIdsByPermission(
permissionKeys: string[],
): Promise<string[]> {
if (!permissionKeys.length) return [];
const rows: { userId: string | null }[] = await this.employeeRepository
.createQueryBuilder("employee")
.innerJoin("employee.employeePositions", "employeePosition")
.innerJoin("employeePosition.position", "position")
.innerJoin("position.positionPermission", "positionPermission")
.innerJoin("positionPermission.permission", "permission")
.where("employee.isCurrent = :isCurrent", { isCurrent: true })
.andWhere("permission.key IN (:...permissionKeys)", { permissionKeys })
.select("DISTINCT employee.user_id", "userId")
.getRawMany();
return rows
.map((r) => r.userId)
.filter((id): id is string => Boolean(id));
}
async createOrganizationUser(
organizationId: string,
dto: CreateOrganizationUserDto,

View File

@@ -9,7 +9,11 @@ import {
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
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 { BillingService } from "./billing.service";
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
@@ -18,14 +22,26 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
@BookingView()
@ApiBearerAuth()
export class BillingController {
constructor(private readonly billingService: BillingService) {}
constructor(
private readonly billingService: BillingService,
private readonly userTradeAccessService: UserTradeAccessService,
) {}
@Get("invoices")
@ApiOperation({
summary: "List invoices (paginated, filterable by company/status/search)",
})
findAll(@Query() query: FilterInvoiceDto) {
return this.billingService.findAllPaginated(query);
async findAll(
@Query() query: FilterInvoiceDto,
@CurrentUser() user: TCurrentUser,
) {
// Per-user trade-direction scope, applied via each invoice's source booking.
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
return this.billingService.findAllPaginated({
...query,
tradeDirections: allowed ?? undefined,
});
}
@Get("invoices/:id")

View File

@@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { BillingController } from "./billing.controller";
import { PortalBillingController } from "./portal-billing.controller";
import { PaymentController } from "./payment.controller";
import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module";
import { BillingService } from "./billing.service";
import { DocumentsModule } from "./documents/documents.module";
import { Invoice } from "./entities/invoice.entity";
@@ -19,6 +20,7 @@ import { CompaniesModule } from "../companies/companies.module";
forwardRef(() => PaymentModule),
CompaniesModule,
DocumentsModule,
UserTradeAccessModule,
],
controllers: [BillingController, PortalBillingController, PaymentController],
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],

View File

@@ -11,6 +11,7 @@ import { EventEmitter2 } from "@nestjs/event-emitter";
import { DataSource, EntityManager, In } from "typeorm";
import { CompaniesService } from "../companies/companies.service";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
import {
@@ -167,6 +168,8 @@ export class BillingService {
search?: string;
page?: number;
pageSize?: number;
/** Per-user trade-direction scope, applied via the source booking. */
tradeDirections?: string[];
} = {},
): Promise<{ items: Invoice[]; total: number }> {
const page = filter.page && filter.page > 0 ? filter.page : 1;
@@ -196,6 +199,14 @@ export class BillingService {
);
}
if (filter.tradeDirections) {
applyBookingRefDirectionScope(
qb,
"invoice.source_id",
filter.tradeDirections,
);
}
const [items, total] = await qb.getManyAndCount();
return { items, total };
}

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

@@ -22,6 +22,7 @@ import {
IYardsRepository,
YARDS_REPOSITORY,
} from "../rule-engine/interfaces/yards.repository.interface";
import { YardFacilitiesService } from "../rule-engine/services/yard-facilities.service";
import {
BookingReferenceCargoTypeChildDto,
BookingReferenceCargoTypeGroupDto,
@@ -170,11 +171,18 @@ export class BookingReferenceDataService {
private readonly shippingLinesRepository: IShippingLinesRepository,
@Inject(CARGO_TYPES_REPOSITORY)
private readonly cargoTypesRepository: ICargoTypesRepository,
private readonly yardFacilitiesService: YardFacilitiesService,
) { }
async getReferenceData(): Promise<BookingReferenceDataDto> {
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
await Promise.all([
const [
yards,
containerTypes,
serviceTypes,
shippingLines,
cargoTypes,
facilityYards,
] = await Promise.all([
this.yardsRepository.findAll({
where: { isActive: true },
order: { displayOrder: "ASC", code: "ASC" },
@@ -195,17 +203,30 @@ export class BookingReferenceDataService {
where: { isActive: true },
order: { displayOrder: "ASC", code: "ASC" },
}),
this.yardFacilitiesService.listFacilityYards(),
]);
// Every active yard is still listed; a yard with no facility record simply
// reports no capability, so the forms drop it from the pickers themselves.
const facilityByYardId = new Map(facilityYards.map((f) => [f.yardId, f]));
return {
yard: yards.map(
(y): BookingReferenceYardDto => ({
yard: yards.map((y): BookingReferenceYardDto => {
const facility = facilityByYardId.get(y.id);
return {
id: y.id,
name: y.label,
code: y.code,
country: y.country,
}),
),
hasContainerFacilityOrigin:
facility?.hasContainerFacilityOrigin ?? false,
hasBulkFacilityOrigin: facility?.hasBulkFacilityOrigin ?? false,
hasContainerFacilityDestination:
facility?.hasContainerFacilityDestination ?? false,
hasBulkFacilityDestination:
facility?.hasBulkFacilityDestination ?? false,
};
}),
containers: groupContainersBySize(containerTypes),
service: serviceTypes.map(
(s): BookingReferenceServiceDto => ({

View File

@@ -927,13 +927,6 @@ export class BookingTransitionService {
"OPERATION_CHANGES_REQUESTED",
]);
// A company sitting on another unpaid hold commits nothing new — this is
// the moment export capacity locks, so the lock applies here too.
// Government bookings allocate without paying and are exempt.
if (!booking.isGovernment) {
await this.bookingsService.assertNoUnpaidHold(booking.companyId);
}
// A bare initiated instance (clearance-first flow) carries no cargo or
// price — it must go through the contract completion endpoint, which
// persists cargo, prices, invoices and only then lands here itself.

View File

@@ -43,6 +43,8 @@ import {
RoAmendmentDto,
} from '../contracts/dto/phased-clearance.dto';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { scopedDirections } from '../user-trade-access/trade-scope.util';
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
import { BookingsService } from './bookings.service';
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
import { CreateBookingDto } from './dto/create-booking.dto';
@@ -150,6 +152,7 @@ export class BookingsController {
private readonly containerReceiptService: ContainerReceiptService,
private readonly firstMileService: FirstMileService,
private readonly lastMileService: LastMileService,
private readonly userTradeAccessService: UserTradeAccessService,
) {}
@Post()
@@ -217,7 +220,16 @@ export class BookingsController {
// Staff (backoffice) see every booking. Customers (portal) are always
// force-scoped to their own company, regardless of any companyId they pass.
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
return this.bookingsService.findAll(filter);
// Per-user trade-direction scope (import/export/intercity checkboxes).
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
const dirs = scopedDirections(allowed, filter.tradeDirection);
return this.bookingsService.findAll(
filter,
undefined,
undefined,
dirs ?? undefined,
);
}
// Global Logistics has clearance:view but NOT bookings:view — it is scoped
// to the customs document-clearance queue only and never sees the general

View File

@@ -1,4 +1,5 @@
import { Module, forwardRef } from "@nestjs/common";
import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module";
import { ConfigService } from "@nestjs/config";
import { TypeOrmModule } from "@nestjs/typeorm";
import { ExchangeModule, ExchangeOptions } from "@edr/api-common";
@@ -80,6 +81,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
MinioModule,
VehiclesModule,
CompaniesModule,
UserTradeAccessModule,
// CustomersModule,
RuleEngineModule,
FileUploadSettingsModule,

View File

@@ -17,6 +17,7 @@ import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import {
BookingDocumentReview,
@@ -64,6 +65,8 @@ export interface BookingListFilterOptions {
freightType?: string;
bookingType?: string;
tradeDirection?: string;
/** Per-user trade-direction scope — `[]` matches nothing. */
tradeDirections?: string[];
paymentCurrency?: string;
paymentStatus?: string;
excludePaymentStatus?: string;
@@ -1000,6 +1003,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
tradeDirection: options.tradeDirection,
});
}
if (options.tradeDirections) {
applyDirectionScope(qb, 'booking.trade_direction', options.tradeDirections);
}
if (options.paymentCurrency) {
qb.andWhere('booking.payment_currency = :paymentCurrency', {
paymentCurrency: options.paymentCurrency,
@@ -1390,16 +1396,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
.getMany();
}
/** Open unpaid holds (wagons reserved, pay window running) for a company. */
countUnpaidHoldsForCompany(companyId: string): Promise<number> {
return this.repository.count({
where: {
companyId,
status: In(['SELECTED_FOR_BATCH', 'AWAITING_PAYMENT']),
},
});
}
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
return this.repository

View File

@@ -28,6 +28,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
@@ -239,6 +240,10 @@ export class BookingsService {
*/
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
// The sheet attests that EDR has taken custody. For export that happens at
// cargo receipt (GRN), so the GRN is required even when wagons are already
// allocated — an allocation is a plan, not possession.
await assertExportReceivedWithGrn(this.dataSource, booking);
let wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo",
COALESCE(wt.code, wt.name) AS "wagonType",
@@ -291,7 +296,10 @@ export class BookingsService {
LEFT JOIN freight.containers c
ON c.id = inv.container_id AND c.deleted_at IS NULL
WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL
AND COALESCE(NULLIF(TRIM(inv.grn_number), ''), '') <> ''
AND COALESCE(
NULLIF(TRIM(inv.grn_number), ''),
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
) IS NOT NULL
ORDER BY inv.created_at`,
[bookingId],
)
@@ -909,24 +917,6 @@ export class BookingsService {
return result.booking;
}
/**
* A company with an open unpaid hold (SELECTED_FOR_BATCH — wagons reserved,
* pay window running) may not take more capacity until it pays or the hold
* dies: otherwise one customer can lock a train's wagons over and over
* without ever paying. EXPIRED / CANCELLED holds free the lock.
*/
async assertNoUnpaidHold(companyId?: string | null): Promise<void> {
if (!companyId) return;
const holds =
await this.bookingsRepository.countUnpaidHoldsForCompany(companyId);
if (holds > 0) {
throw new ConflictException(
'You already have a booking waiting for payment. Pay it or cancel it ' +
'before making a new booking.',
);
}
}
/** Create a new freight booking. */
async create(
dto: CreateBookingDto,
@@ -989,10 +979,6 @@ export class BookingsService {
companyId = company.id;
}
// Government bookings allocate without paying, so the unpaid-hold lock
// only applies to commercial companies.
if (!isGovernment) await this.assertNoUnpaidHold(companyId);
if (dto.trainScheduleId) {
// Staff manual pin: the schedule must be OPEN and on the same route.
const schedule = await this.dataSource
@@ -1619,6 +1605,7 @@ export class BookingsService {
filter: FilterBookingDto,
forceCompanyId?: string,
forceCompanyProfileId?: string,
tradeDirections?: string[],
): Promise<PaginatedBookings> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
@@ -1638,6 +1625,7 @@ export class BookingsService {
// ANDs both, so cross-company access is impossible.
companyId: forceCompanyId ?? filter.companyId,
companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
tradeDirections,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,

View File

@@ -13,6 +13,18 @@ export class BookingReferenceYardDto {
@ApiProperty({ example: 'Ethiopia' })
country!: string;
@ApiProperty({ description: 'Can load containers onto a train here.' })
hasContainerFacilityOrigin!: boolean;
@ApiProperty({ description: 'Can load bulk cargo onto a train here.' })
hasBulkFacilityOrigin!: boolean;
@ApiProperty({ description: 'Can receive containers off a train here.' })
hasContainerFacilityDestination!: boolean;
@ApiProperty({ description: 'Can receive bulk cargo off a train here.' })
hasBulkFacilityDestination!: boolean;
}
export class BookingReferenceContainerTypeDto {

View File

@@ -109,23 +109,6 @@ export class ContractBookingService {
private readonly bookingTransitionService: BookingTransitionService,
) {}
/**
* Mirrors BookingsService.assertNoUnpaidHold for the contract booking paths:
* a company sitting on an unpaid hold (SELECTED_FOR_BATCH) books nothing new
* until it pays or the hold dies.
*/
private async assertNoUnpaidHold(companyId?: string | null): Promise<void> {
if (!companyId) return;
const holds =
await this.bookingsRepository.countUnpaidHoldsForCompany(companyId);
if (holds > 0) {
throw new ConflictException(
'You already have a booking waiting for payment. Pay it or cancel it ' +
'before making a new booking.',
);
}
}
async createUnderContract(
contractId: string,
dto: CreateBookingUnderContractDto,
@@ -186,8 +169,6 @@ export class ContractBookingService {
// remainder; the customer cannot start any other booking on the contract.
// If the remainder splits again the same rule repeats until the cap is
// exhausted and the contract completes.
await this.assertNoUnpaidHold(contract.companyId);
if (contract.contractKind === 'ONE_TIME') {
if (await this.hasSplitBooking(contractId)) {
await this.assertExactRemainder(contract, dto);
@@ -475,7 +456,6 @@ export class ContractBookingService {
);
}
}
await this.assertNoUnpaidHold(contract.companyId);
const route = await this.resolveRoute(contract, dto.contractRouteId);

View File

@@ -59,6 +59,8 @@ import { GlOperationsService } from './gl-operations.service';
import { BookingRequestService } from './booking-request.service';
import { SignaturesService } from '../signatures/signatures.service';
import { BookingsService } from '../bookings/bookings.service';
import { scopedDirections } from '../user-trade-access/trade-scope.util';
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
import { CreateContractDto } from './dto/create-contract.dto';
import { UpdateContractDto } from './dto/update-contract.dto';
import { FilterContractDto } from './dto/filter-contract.dto';
@@ -108,6 +110,7 @@ export class ContractsController {
private readonly glOperationsService: GlOperationsService,
private readonly bookingRequestService: BookingRequestService,
private readonly signaturesService: SignaturesService,
private readonly userTradeAccessService: UserTradeAccessService,
private readonly bookingClearanceService: BookingClearanceService,
private readonly bookingsService: BookingsService,
) {}
@@ -210,7 +213,16 @@ export class ContractsController {
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
) {
return this.contractsService.findAll(filter);
// Per-user trade-direction scope (import/export/intercity checkboxes).
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
const dirs = scopedDirections(allowed, filter.tradeDirection);
return this.contractsService.findAll(
filter,
undefined,
undefined,
dirs ?? undefined,
);
}
const userId = user?.id;
if (!userId) throw new UnauthorizedException('Authentication required');

View File

@@ -1,4 +1,5 @@
import { Module, forwardRef } from '@nestjs/common';
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
@@ -89,6 +90,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
NotificationsModule,
NotificationInboxModule,
CompaniesModule,
UserTradeAccessModule,
// Provides the admin-editable contract document templates consumed by
// ContractDocumentViewModelBuilder when rendering contract PDFs.
ContractTemplatesModule,

View File

@@ -5,6 +5,7 @@ import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm'
import { Booking } from '../bookings/entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
import { Contract } from './entities/contract.entity';
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
@@ -38,6 +39,8 @@ export interface ContractListFilterOptions {
serviceTypeId?: string;
freightType?: string;
tradeDirection?: string;
/** Per-user trade-direction scope — `[]` matches nothing. */
tradeDirections?: string[];
paymentCurrency?: string;
customsClearingEnabled?: boolean;
/** true → only contracts with at least one uploaded clearance document. */
@@ -436,6 +439,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
tradeDirection: options.tradeDirection,
});
}
if (options.tradeDirections) {
applyDirectionScope(qb, 'contract.trade_direction', options.tradeDirections);
}
if (options.paymentCurrency) {
qb.andWhere('contract.payment_currency = :paymentCurrency', {
paymentCurrency: options.paymentCurrency,

View File

@@ -748,6 +748,7 @@ export class ContractsService {
filter: FilterContractDto,
forceCompanyId?: string,
forceCompanyProfileId?: string,
tradeDirections?: string[],
): Promise<PaginatedContracts> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
@@ -763,6 +764,7 @@ export class ContractsService {
serviceTypeId: filter.serviceTypeId,
freightType: filter.freightType,
tradeDirection: filter.tradeDirection,
tradeDirections,
paymentCurrency: filter.paymentCurrency,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,

View File

@@ -169,6 +169,11 @@ export class CreateEmptyContainerReturnDto {
@IsOptional()
@IsString()
performedBy?: string;
@ApiPropertyOptional({ enum: ['EDR', 'CUSTOMER'] })
@IsOptional()
@IsIn(['EDR', 'CUSTOMER'])
returnedBy?: 'EDR' | 'CUSTOMER';
}
export class UpdateEmptyContainerReturnStatusDto extends ImportOperationActionDto {

View File

@@ -53,4 +53,14 @@ export class EmptyContainerReturn extends BaseEntity {
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
performedBy?: string | null;
@Column({ name: 'returned_by', type: 'varchar', length: 20, nullable: true })
returnedBy?: 'EDR' | 'CUSTOMER' | null;
@Column({ name: 'status_history', type: 'jsonb', default: () => "'[]'" })
statusHistory!: Array<{
status: EmptyContainerReturnStatus;
changedAt: string;
performedBy: string | null;
}>;
}

View File

@@ -149,18 +149,23 @@ export class ImportOperationsService {
}
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date();
return this.emptyReturns.save(
this.emptyReturns.create({
containerNumber: dto.containerNumber,
bookingId: dto.bookingId ?? null,
customerId: dto.customerId ?? null,
returnDate: dto.returnDate ? new Date(dto.returnDate) : new Date(),
returnDate,
facility: dto.facility ?? null,
yard: dto.yard ?? null,
zone: dto.zone ?? null,
condition: dto.condition ?? null,
handoverNote: dto.handoverNote ?? null,
performedBy: dto.performedBy ?? null,
returnedBy: dto.returnedBy ?? null,
statusHistory: [
{ status: 'RETURNED', changedAt: returnDate.toISOString(), performedBy: dto.performedBy ?? null },
],
}),
);
}
@@ -175,6 +180,10 @@ export class ImportOperationsService {
wagonAllocationReference: dto.wagonAllocationReference ?? row.wagonAllocationReference ?? null,
handoverNote: dto.handoverNote ?? row.handoverNote ?? null,
performedBy: dto.performedBy ?? row.performedBy ?? null,
statusHistory: [
...(row.statusHistory ?? []),
{ status: dto.status, changedAt: new Date().toISOString(), performedBy: dto.performedBy ?? row.performedBy ?? null },
],
});
return this.emptyReturns.findOneOrFail({ where: { id } });
}

View File

@@ -13,9 +13,8 @@ import { ExternalProfileRepository } from "../companies/external-profile.reposit
* - `companyId` → all portal users linked to the company (external_profiles).
* - `companyProfileId` → resolved to its company, then to that company's users.
* - `organizationId` → all current employees of the org (backoffice staff).
*
* NOTE: permission-scoped staff targeting is intentionally unsupported — freight
* has no "users-by-permission" lookup. Target explicit userIds or an org instead.
* - `permissionKeys` → current employees (any org) holding any of these
* permission keys (e.g. department/role-scoped targeting).
*/
@Injectable()
export class NotificationRecipientsService {
@@ -82,6 +81,20 @@ export class NotificationRecipientsService {
}
}
if (recipients.permissionKeys?.length) {
try {
for (const uid of await this.backoffice.getEmployeeUserIdsByPermission(
recipients.permissionKeys,
)) {
ids.add(uid);
}
} catch (err) {
this.logger.warn(
`Failed to resolve permissionKeys recipients: ${(err as Error).message}`,
);
}
}
return [...ids];
}
}

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

@@ -5,6 +5,8 @@ import {
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 { OverviewQueryDto } from './dto/overview-query.dto';
@@ -18,59 +20,105 @@ import {
OverviewStaffTabDto,
} from './dto/overview-tab-response.dto';
import { OverviewService } from './overview.service';
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
@ApiTags('Overview')
@ApiBearerAuth()
@Controller('overview')
export class OverviewController {
constructor(private readonly overviewService: OverviewService) {}
constructor(
private readonly overviewService: OverviewService,
private readonly userTradeAccessService: UserTradeAccessService,
) {}
@Get()
@BookingView()
@ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' })
@ApiOkResponse({ type: OverviewResponseDto })
getDashboard(@Query() query: OverviewQueryDto): Promise<OverviewResponseDto> {
return this.overviewService.getDashboard(query.range ?? '30d');
async getDashboard(
@Query() query: OverviewQueryDto,
@CurrentUser() user: TCurrentUser,
): Promise<OverviewResponseDto> {
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
return this.overviewService.getDashboard(
query.range ?? '30d',
allowed ?? undefined,
);
}
@Get('bookings')
@BookingView()
@ApiOperation({ summary: 'Bookings tab metrics and charts' })
@ApiOkResponse({ type: OverviewBookingsTabDto })
getBookingsTab(@Query() query: OverviewQueryDto): Promise<OverviewBookingsTabDto> {
return this.overviewService.getBookingsTab(query.range ?? '30d');
async getBookingsTab(
@Query() query: OverviewQueryDto,
@CurrentUser() user: TCurrentUser,
): Promise<OverviewBookingsTabDto> {
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
return this.overviewService.getBookingsTab(
query.range ?? '30d',
allowed ?? undefined,
);
}
@Get('contracts')
@BookingView()
@ApiOperation({ summary: 'Contracts tab metrics and charts' })
@ApiOkResponse({ type: OverviewContractsTabDto })
getContractsTab(@Query() query: OverviewQueryDto): Promise<OverviewContractsTabDto> {
return this.overviewService.getContractsTab(query.range ?? '30d');
async getContractsTab(
@Query() query: OverviewQueryDto,
@CurrentUser() user: TCurrentUser,
): Promise<OverviewContractsTabDto> {
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
return this.overviewService.getContractsTab(
query.range ?? '30d',
allowed ?? undefined,
);
}
@Get('billing')
@BookingView()
@ApiOperation({ summary: 'Billing tab metrics and charts' })
@ApiOkResponse({ type: OverviewBillingTabDto })
getBillingTab(@Query() query: OverviewQueryDto): Promise<OverviewBillingTabDto> {
return this.overviewService.getBillingTab(query.range ?? '30d');
async getBillingTab(
@Query() query: OverviewQueryDto,
@CurrentUser() user: TCurrentUser,
): Promise<OverviewBillingTabDto> {
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
return this.overviewService.getBillingTab(
query.range ?? '30d',
allowed ?? undefined,
);
}
@Get('operations')
@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')
@BookingView()
@ApiOperation({ summary: 'Customers tab metrics and charts' })
@ApiOkResponse({ type: OverviewCustomersTabDto })
getCustomersTab(@Query() query: OverviewQueryDto): Promise<OverviewCustomersTabDto> {
return this.overviewService.getCustomersTab(query.range ?? '30d');
async getCustomersTab(
@Query() query: OverviewQueryDto,
@CurrentUser() user: TCurrentUser,
): Promise<OverviewCustomersTabDto> {
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
return this.overviewService.getCustomersTab(
query.range ?? '30d',
allowed ?? undefined,
);
}
@Get('staff')

View File

@@ -9,8 +9,10 @@ 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";
import { OverviewController } from "./overview.controller";
import { OverviewRepository } from "./overview.repository";
import { OverviewService } from "./overview.service";
@@ -22,6 +24,7 @@ import { OverviewService } from "./overview.service";
PaymentEntity,
Company,
Train,
TrainSchedule,
Wagon,
Container,
Cargo,
@@ -29,6 +32,7 @@ import { OverviewService } from "./overview.service";
Employee,
User,
]),
UserTradeAccessModule,
],
controllers: [OverviewController],
providers: [OverviewService, OverviewRepository],

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,
@@ -24,12 +29,17 @@ import {
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
} from "./overview.constants";
import { Company } from "../companies/entities/company.entity";
import {
bookingRefScopeSql,
directionScopeSql,
} from "../user-trade-access/trade-scope.util";
/** Bookings carry a contract_kind column; GENERAL = umbrella contract row, not a shipment. */
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;
@@ -49,6 +59,7 @@ export type OverviewRecentBookingRow = {
};
export type OverviewContractKpisRow = {
total: number;
totalActive: number;
needsAction: number;
inApproval: number;
@@ -79,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)
@@ -93,10 +106,12 @@ export class OverviewRepository {
private readonly userRepository: Repository<User>,
) { }
async getBookingKpis(): Promise<OverviewBookingKpisRow> {
async getBookingKpis(dirs?: string[]): Promise<OverviewBookingKpisRow> {
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",
)
@@ -118,6 +133,7 @@ export class OverviewRepository {
)
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params)
.setParameters({
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
@@ -127,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),
@@ -140,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")
@@ -172,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 {
@@ -179,6 +220,8 @@ export class OverviewRepository {
wagonsAvailable,
containersInTransit,
cargoesLoaded,
schedulesUpcoming,
dispatchedToday,
};
}
@@ -202,12 +245,13 @@ export class OverviewRepository {
};
}
async getBillingKpis(): Promise<{
async getBillingKpis(dirs?: string[]): Promise<{
revenueMtdEtb: number;
revenueMtdUsd: number;
pendingPayments: number;
successfulPaymentsMtd: number;
}> {
const scope = bookingRefScopeSql("payment.ref_id", dirs);
const revenueRow = await this.paymentRepository
.createQueryBuilder("payment")
.select(
@@ -223,6 +267,7 @@ export class OverviewRepository {
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
)
.andWhere(scope.sql, scope.params)
.getRawOne<Record<string, string>>();
const pendingPayments = await this.paymentRepository
@@ -230,6 +275,7 @@ export class OverviewRepository {
.where("payment.status IN (:...statuses)", {
statuses: ["action-required", "processing"],
})
.andWhere(scope.sql, scope.params)
.getCount();
return {
@@ -261,13 +307,16 @@ export class OverviewRepository {
async getBookingTrend(
days: number,
dirs?: string[],
): Promise<{ date: string; count: number }[]> {
const scope = directionScopeSql("booking.trade_direction", dirs);
const rows = await this.bookingRepository
.createQueryBuilder("booking")
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params)
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy("booking.created_at::date")
.orderBy("booking.created_at::date", "ASC")
@@ -279,13 +328,15 @@ export class OverviewRepository {
}));
}
async getStatusCounts(): Promise<Record<string, number>> {
async getStatusCounts(dirs?: string[]): Promise<Record<string, number>> {
const scope = directionScopeSql("booking.trade_direction", dirs);
const rows = await this.bookingRepository
.createQueryBuilder("booking")
.select("booking.status", "status")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params)
.groupBy("booking.status")
.getRawMany<{ status: string; count: string }>();
@@ -296,7 +347,9 @@ export class OverviewRepository {
async getPaymentTrend(
days: number,
dirs?: string[],
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository
.createQueryBuilder("payment")
.select(
@@ -316,6 +369,7 @@ export class OverviewRepository {
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
{ days },
)
.andWhere(scope.sql, scope.params)
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC")
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>();
@@ -327,7 +381,11 @@ export class OverviewRepository {
}));
}
async getRecentBookings(limit: number): Promise<OverviewRecentBookingRow[]> {
async getRecentBookings(
limit: number,
dirs?: string[],
): Promise<OverviewRecentBookingRow[]> {
const scope = directionScopeSql("booking.trade_direction", dirs);
const rows = await this.bookingRepository
.createQueryBuilder("booking")
.leftJoin("booking.company", "company")
@@ -341,6 +399,7 @@ export class OverviewRepository {
.addSelect("booking.created_at", "createdAt")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params)
.orderBy("booking.created_at", "DESC")
.limit(limit)
.getRawMany<{
@@ -366,9 +425,10 @@ export class OverviewRepository {
}));
}
async getBookingsByFreightType(): Promise<
{ label: string; count: number }[]
> {
async getBookingsByFreightType(
dirs?: string[],
): Promise<{ label: string; count: number }[]> {
const scope = directionScopeSql("booking.trade_direction", dirs);
const rows = await this.bookingRepository
.createQueryBuilder("booking")
.select("booking.freight_type", "label")
@@ -376,6 +436,7 @@ export class OverviewRepository {
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere("booking.status != 'DRAFT'")
.andWhere(scope.sql, scope.params)
.groupBy("booking.freight_type")
.orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
@@ -386,7 +447,10 @@ export class OverviewRepository {
}));
}
async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> {
async getBookingsByCurrency(
dirs?: string[],
): Promise<{ label: string; count: number }[]> {
const scope = directionScopeSql("booking.trade_direction", dirs);
const rows = await this.bookingRepository
.createQueryBuilder("booking")
.select("booking.payment_currency", "label")
@@ -394,6 +458,7 @@ export class OverviewRepository {
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere("booking.status != 'DRAFT'")
.andWhere(scope.sql, scope.params)
.groupBy("booking.payment_currency")
.orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
@@ -404,11 +469,15 @@ export class OverviewRepository {
}));
}
async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> {
async getPaymentsByStatus(
dirs?: string[],
): Promise<{ status: string; count: number }[]> {
const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository
.createQueryBuilder("payment")
.select("payment.status", "status")
.addSelect("COUNT(*)::int", "count")
.where(scope.sql, scope.params)
.groupBy("payment.status")
.orderBy("count", "DESC")
.getRawMany<{ status: string; count: string }>();
@@ -419,9 +488,12 @@ export class OverviewRepository {
}));
}
async getPaymentsByMethod(): Promise<
async getPaymentsByMethod(
dirs?: string[],
): Promise<
{ method: string; count: number; amountEtb: number; amountUsd: number }[]
> {
const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository
.createQueryBuilder("payment")
.select("payment.method", "method")
@@ -434,6 +506,7 @@ export class OverviewRepository {
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
"amountUsd",
)
.where(scope.sql, scope.params)
.groupBy("payment.method")
.orderBy("count", "DESC")
.getRawMany<{
@@ -451,9 +524,10 @@ export class OverviewRepository {
}));
}
async getRevenueByCurrency(): Promise<
{ currency: string; amount: number }[]
> {
async getRevenueByCurrency(
dirs?: string[],
): Promise<{ currency: string; amount: number }[]> {
const scope = bookingRefScopeSql("payment.ref_id", dirs);
const rows = await this.paymentRepository
.createQueryBuilder("payment")
.select("payment.currency", "currency")
@@ -462,6 +536,7 @@ export class OverviewRepository {
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
)
.andWhere(scope.sql, scope.params)
.groupBy("payment.currency")
.getRawMany<{ currency: string; amount: string }>();
@@ -495,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,
@@ -556,7 +766,9 @@ export class OverviewRepository {
async getTopCustomersByBookings(
limit: number,
dirs?: string[],
): Promise<{ label: string; count: number }[]> {
const scope = directionScopeSql("booking.trade_direction", dirs);
const rows = await this.bookingRepository
.createQueryBuilder("booking")
.leftJoin("booking.company", "company")
@@ -564,6 +776,7 @@ export class OverviewRepository {
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere("booking.status != 'DRAFT'")
.andWhere(scope.sql, scope.params)
.groupBy("company.name")
.orderBy("count", "DESC")
.limit(limit)
@@ -632,10 +845,12 @@ export class OverviewRepository {
// ── Contracts (overview Contract tab) ──────────────────────────────────────
async getContractKpis(): Promise<OverviewContractKpisRow> {
async getContractKpis(dirs?: string[]): Promise<OverviewContractKpisRow> {
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",
)
@@ -656,6 +871,7 @@ export class OverviewRepository {
"createdToday",
)
.where("contract.deleted_at IS NULL")
.andWhere(scope.sql, scope.params)
.setParameters({
closedStatuses: [...OVERVIEW_CONTRACT_CLOSED_STATUSES],
needsActionStatuses: [...OVERVIEW_CONTRACT_NEEDS_ACTION_STATUSES],
@@ -665,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),
@@ -673,24 +890,33 @@ export class OverviewRepository {
};
}
async getContractStatusCounts(): Promise<Record<string, number>> {
async getContractStatusCounts(
dirs?: string[],
): Promise<Record<string, number>> {
const scope = directionScopeSql("contract.trade_direction", dirs);
const rows = await this.contractRepository
.createQueryBuilder("contract")
.select("contract.status", "status")
.addSelect("COUNT(*)::int", "count")
.where("contract.deleted_at IS NULL")
.andWhere(scope.sql, scope.params)
.groupBy("contract.status")
.getRawMany<{ status: string; count: string }>();
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
}
async getContractTrend(days: number): Promise<{ date: string; count: number }[]> {
async getContractTrend(
days: number,
dirs?: string[],
): Promise<{ date: string; count: number }[]> {
const scope = directionScopeSql("contract.trade_direction", dirs);
const rows = await this.contractRepository
.createQueryBuilder("contract")
.select(`to_char(contract.created_at::date, 'YYYY-MM-DD')`, "date")
.addSelect("COUNT(*)::int", "count")
.where("contract.deleted_at IS NULL")
.andWhere(scope.sql, scope.params)
.andWhere(`contract.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy("contract.created_at::date")
.orderBy("contract.created_at::date", "ASC")
@@ -699,13 +925,17 @@ export class OverviewRepository {
return rows.map((row) => ({ date: row.date, count: Number(row.count) }));
}
async getContractsByKind(): Promise<{ label: string; count: number }[]> {
async getContractsByKind(
dirs?: string[],
): Promise<{ label: string; count: number }[]> {
const scope = directionScopeSql("contract.trade_direction", dirs);
const rows = await this.contractRepository
.createQueryBuilder("contract")
.select("contract.contract_kind", "label")
.addSelect("COUNT(*)::int", "count")
.where("contract.deleted_at IS NULL")
.andWhere("contract.status != 'DRAFT'")
.andWhere(scope.sql, scope.params)
.groupBy("contract.contract_kind")
.orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
@@ -713,13 +943,17 @@ export class OverviewRepository {
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
}
async getContractsByFreightType(): Promise<{ label: string; count: number }[]> {
async getContractsByFreightType(
dirs?: string[],
): Promise<{ label: string; count: number }[]> {
const scope = directionScopeSql("contract.trade_direction", dirs);
const rows = await this.contractRepository
.createQueryBuilder("contract")
.select("contract.freight_type", "label")
.addSelect("COUNT(*)::int", "count")
.where("contract.deleted_at IS NULL")
.andWhere("contract.status != 'DRAFT'")
.andWhere(scope.sql, scope.params)
.groupBy("contract.freight_type")
.orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
@@ -727,7 +961,11 @@ export class OverviewRepository {
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
}
async getRecentContracts(limit: number): Promise<OverviewRecentContractRow[]> {
async getRecentContracts(
limit: number,
dirs?: string[],
): Promise<OverviewRecentContractRow[]> {
const scope = directionScopeSql("contract.trade_direction", dirs);
const rows = await this.contractRepository
.createQueryBuilder("contract")
.leftJoin("contract.company", "company")
@@ -741,6 +979,7 @@ export class OverviewRepository {
.addSelect("contract.contract_valid_until", "validUntil")
.addSelect("contract.created_at", "createdAt")
.where("contract.deleted_at IS NULL")
.andWhere(scope.sql, scope.params)
.orderBy("contract.created_at", "DESC")
.limit(limit)
.getRawMany<{

View File

@@ -40,7 +40,10 @@ export class OverviewService {
return { bookingsByPipeline, bookingsByStatus };
}
async getDashboard(range: OverviewRangeQuery = '30d'): Promise<OverviewResponseDto> {
async getDashboard(
range: OverviewRangeQuery = '30d',
dirs?: string[],
): Promise<OverviewResponseDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [
@@ -55,16 +58,16 @@ export class OverviewService {
paymentTrend,
recentBookings,
] = await Promise.all([
this.overviewRepository.getBookingKpis(),
this.overviewRepository.getContractKpis(),
this.overviewRepository.getBookingKpis(dirs),
this.overviewRepository.getContractKpis(dirs),
this.overviewRepository.getOperationsKpis(),
this.overviewRepository.getCustomerKpis(),
this.overviewRepository.getBillingKpis(),
this.overviewRepository.getBillingKpis(dirs),
this.overviewRepository.getStaffKpis(),
this.overviewRepository.getBookingTrend(days),
this.overviewRepository.getStatusCounts(),
this.overviewRepository.getPaymentTrend(days),
this.overviewRepository.getRecentBookings(8),
this.overviewRepository.getBookingTrend(days, dirs),
this.overviewRepository.getStatusCounts(dirs),
this.overviewRepository.getPaymentTrend(days, dirs),
this.overviewRepository.getRecentBookings(8, dirs),
]);
const { bookingsByPipeline, bookingsByStatus } =
@@ -91,7 +94,10 @@ export class OverviewService {
};
}
async getBookingsTab(range: OverviewRangeQuery = '30d'): Promise<OverviewBookingsTabDto> {
async getBookingsTab(
range: OverviewRangeQuery = '30d',
dirs?: string[],
): Promise<OverviewBookingsTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [
@@ -102,12 +108,12 @@ export class OverviewService {
bookingsByCurrency,
recentBookings,
] = await Promise.all([
this.overviewRepository.getBookingKpis(),
this.overviewRepository.getBookingTrend(days),
this.overviewRepository.getStatusCounts(),
this.overviewRepository.getBookingsByFreightType(),
this.overviewRepository.getBookingsByCurrency(),
this.overviewRepository.getRecentBookings(8),
this.overviewRepository.getBookingKpis(dirs),
this.overviewRepository.getBookingTrend(days, dirs),
this.overviewRepository.getStatusCounts(dirs),
this.overviewRepository.getBookingsByFreightType(dirs),
this.overviewRepository.getBookingsByCurrency(dirs),
this.overviewRepository.getRecentBookings(8, dirs),
]);
const { bookingsByPipeline, bookingsByStatus } =
@@ -130,6 +136,7 @@ export class OverviewService {
async getContractsTab(
range: OverviewRangeQuery = '30d',
dirs?: string[],
): Promise<OverviewContractsTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
@@ -141,12 +148,12 @@ export class OverviewService {
contractsByFreightType,
recentContracts,
] = await Promise.all([
this.overviewRepository.getContractKpis(),
this.overviewRepository.getContractTrend(days),
this.overviewRepository.getContractStatusCounts(),
this.overviewRepository.getContractsByKind(),
this.overviewRepository.getContractsByFreightType(),
this.overviewRepository.getRecentContracts(8),
this.overviewRepository.getContractKpis(dirs),
this.overviewRepository.getContractTrend(days, dirs),
this.overviewRepository.getContractStatusCounts(dirs),
this.overviewRepository.getContractsByKind(dirs),
this.overviewRepository.getContractsByFreightType(dirs),
this.overviewRepository.getRecentContracts(8, dirs),
]);
const contractsByStatus = Object.entries(statusCounts)
@@ -170,16 +177,19 @@ export class OverviewService {
};
}
async getBillingTab(range: OverviewRangeQuery = '30d'): Promise<OverviewBillingTabDto> {
async getBillingTab(
range: OverviewRangeQuery = '30d',
dirs?: string[],
): Promise<OverviewBillingTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [kpis, paymentTrend, paymentsByStatus, paymentsByMethod, revenueByCurrency] =
await Promise.all([
this.overviewRepository.getBillingKpis(),
this.overviewRepository.getPaymentTrend(days),
this.overviewRepository.getPaymentsByStatus(),
this.overviewRepository.getPaymentsByMethod(),
this.overviewRepository.getRevenueByCurrency(),
this.overviewRepository.getBillingKpis(dirs),
this.overviewRepository.getPaymentTrend(days, dirs),
this.overviewRepository.getPaymentsByStatus(dirs),
this.overviewRepository.getPaymentsByMethod(dirs),
this.overviewRepository.getRevenueByCurrency(dirs),
]);
return {
@@ -192,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(),
@@ -209,6 +235,12 @@ export class OverviewService {
return {
kpis,
departureTrend,
scheduleStatusBreakdown,
wagonsByType,
wagonsByYard,
containersBySize,
cargoTonnageByType,
trainStatusBreakdown,
wagonStatusBreakdown,
containerStatusBreakdown,
@@ -217,7 +249,10 @@ export class OverviewService {
};
}
async getCustomersTab(range: OverviewRangeQuery = '30d'): Promise<OverviewCustomersTabDto> {
async getCustomersTab(
range: OverviewRangeQuery = '30d',
dirs?: string[],
): Promise<OverviewCustomersTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [kpis, customerGrowthTrend, customersByType, topCustomersByBookings] =
@@ -225,7 +260,7 @@ export class OverviewService {
this.overviewRepository.getCustomerKpis(),
this.overviewRepository.getCustomerGrowthTrend(days),
this.overviewRepository.getCustomersByType(),
this.overviewRepository.getTopCustomersByBookings(8),
this.overviewRepository.getTopCustomersByBookings(8, dirs),
]);
return {

View File

@@ -16,10 +16,10 @@ import {
BillQueryRequestDto,
BillQueryResponseDto,
} from "./internal-payment.dto";
import { Public } from "@edr/api-common";
import { PaymentService } from "./payment.service";
import { BillingService } from "../billing/billing.service";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { Public } from "@edr/api-common";
/**
* Consumer side of the payment microservice's outbox relay. Only the payment service may

View File

@@ -15,8 +15,10 @@ import {
ApiProduces,
} from "@nestjs/swagger";
import { Response } from "express";
import { Public } from "@edr/api-common";
import { CurrentUser, Public } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff, BookingView } from "../../common/booking-guards";
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { PaymentService } from "./payment.service";
import { IntentStatusDto } from "./payments.dto";
@@ -24,7 +26,10 @@ import { IntentStatusDto } from "./payments.dto";
@ApiTags("Payment")
@Controller("payments")
export class PaymentController {
constructor(private readonly paymentService: PaymentService) { }
constructor(
private readonly paymentService: PaymentService,
private readonly userTradeAccessService: UserTradeAccessService,
) { }
// Customer-detail payments tab — same one-of rule as the bookings tab.
@Get("by-company/:companyId/customer-view")
@@ -52,18 +57,23 @@ export class PaymentController {
@ApiQuery({ name: "page", required: false })
@ApiQuery({ name: "pageSize", required: false })
async getAll(
@CurrentUser() user: TCurrentUser,
@Query("search") search?: string,
@Query("status") status?: string,
@Query("method") method?: string,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
) {
// Per-user trade-direction scope, applied via the booking in ref_id.
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
return this.paymentService.getAll({
search,
status,
method,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 10,
tradeDirections: allowed ?? undefined,
});
}

View File

@@ -13,6 +13,7 @@ import {
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { BillingModule } from "../billing/billing.module";
import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module";
// import { FirstMileModule } from "../first-mile/first-mile.module";
// import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
@@ -64,6 +65,7 @@ function rabbitMQImport(): DynamicModule[] {
timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000,
}),
ConfigModule,
UserTradeAccessModule,
forwardRef(() => BillingModule),
// forwardRef(() => TrainSchedulingModule),
// FirstMileModule,

View File

@@ -7,6 +7,7 @@ import {
Logger,
NotFoundException,
} from "@nestjs/common";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
import { PaymentEntity } from "./entities/payment.entity";
import { PaymentRepository } from "./payment.repository";
import { PaymentClientService } from "./payment-client.service";
@@ -110,6 +111,8 @@ export class PaymentService {
method?: string;
page?: number;
pageSize?: number;
/** Per-user trade-direction scope, applied via the booking in ref_id. */
tradeDirections?: string[];
}) {
const { search, status, method, page = 1, pageSize = 10 } = filters;
const skip = (page - 1) * pageSize;
@@ -128,6 +131,9 @@ export class PaymentService {
if (method) {
qb.andWhere("payment.method = :method", { method });
}
if (filters.tradeDirections) {
applyBookingRefDirectionScope(qb, "payment.ref_id", filters.tradeDirections);
}
const [items, total] = await qb
.orderBy("payment.createdAt", "DESC")

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

@@ -26,6 +26,38 @@ export class CreateYardDto {
@IsBoolean()
hasFacility?: boolean;
@ApiPropertyOptional({
default: false,
description: 'Facility can load containers onto a train (contract origin side)',
})
@IsOptional()
@IsBoolean()
hasContainerFacilityOrigin?: boolean;
@ApiPropertyOptional({
default: false,
description: 'Facility can load bulk onto a train (contract origin side)',
})
@IsOptional()
@IsBoolean()
hasBulkFacilityOrigin?: boolean;
@ApiPropertyOptional({
default: false,
description: 'Facility can receive containers off a train (contract destination side)',
})
@IsOptional()
@IsBoolean()
hasContainerFacilityDestination?: boolean;
@ApiPropertyOptional({
default: false,
description: 'Facility can receive bulk off a train (contract destination side)',
})
@IsOptional()
@IsBoolean()
hasBulkFacilityDestination?: boolean;
@ApiPropertyOptional({ default: 1, description: 'UI display sort order' })
@IsOptional()
@IsInt()

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

@@ -37,6 +37,27 @@ export class YardFacility extends BaseEntity {
@Column({ name: 'handles_bulk', type: 'boolean', default: true })
handlesBulk!: boolean;
/**
* Per-side capability. Loading a type onto a train and receiving it off one
* need different ground: a facility can be equipped to send containers but
* have no space to stage arriving ones. `handles_container` / `handles_bulk`
* stay the coarse "is this type handled here at all" switch; these four say
* on which side. A yard is offered as a contract origin for a freight type
* when it handles the type AND the matching `_origin` flag is set, and as a
* destination on the same rule with `_destination`.
*/
@Column({ name: 'has_container_facility_origin', type: 'boolean', default: false })
hasContainerFacilityOrigin!: boolean;
@Column({ name: 'has_bulk_facility_origin', type: 'boolean', default: false })
hasBulkFacilityOrigin!: boolean;
@Column({ name: 'has_container_facility_destination', type: 'boolean', default: false })
hasContainerFacilityDestination!: boolean;
@Column({ name: 'has_bulk_facility_destination', type: 'boolean', default: false })
hasBulkFacilityDestination!: boolean;
@Column({ name: 'equipment_notes', type: 'text', nullable: true })
equipmentNotes?: string | null;

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

@@ -13,6 +13,30 @@ export interface YardFacilityInfo {
/** Containers need a reach stacker/gantry — not every facility has one. */
handlesContainer: boolean;
handlesBulk: boolean;
/**
* The same capability split by side of the trip — loading onto a train and
* receiving off one need different ground. Always false where the coarse
* `handles*` flag for that type is false.
*/
hasContainerFacilityOrigin: boolean;
hasBulkFacilityOrigin: boolean;
hasContainerFacilityDestination: boolean;
hasBulkFacilityDestination: boolean;
}
/** Which side of the trip a yard is being considered for. */
export type YardSide = 'ORIGIN' | 'DESTINATION';
/**
* The four per-side capability flags as stored — NOT gated on the coarse
* `handles*` switches. The yards config page edits the stored values; gating
* is applied only when the flows resolve capability (see `toInfo`).
*/
export interface YardSideFlags {
hasContainerFacilityOrigin: boolean;
hasBulkFacilityOrigin: boolean;
hasContainerFacilityDestination: boolean;
hasBulkFacilityDestination: boolean;
}
/**
@@ -38,7 +62,11 @@ export class YardFacilitiesService {
y.has_facility AS "hasFacility",
f.has_warehouse AS "hasWarehouse",
f.handles_container AS "handlesContainer",
f.handles_bulk AS "handlesBulk"
f.handles_bulk AS "handlesBulk",
f.has_container_facility_origin AS "hasContainerFacilityOrigin",
f.has_bulk_facility_origin AS "hasBulkFacilityOrigin",
f.has_container_facility_destination AS "hasContainerFacilityDestination",
f.has_bulk_facility_destination AS "hasBulkFacilityDestination"
FROM freight.yards y
LEFT JOIN freight.yard_facilities f
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true`;
@@ -51,17 +79,33 @@ export class YardFacilitiesService {
hasWarehouse: boolean | null;
handlesContainer: boolean | null;
handlesBulk: boolean | null;
hasContainerFacilityOrigin: boolean | null;
hasBulkFacilityOrigin: boolean | null;
hasContainerFacilityDestination: boolean | null;
hasBulkFacilityDestination: boolean | null;
}): YardFacilityInfo {
// No facility record means no capability, whatever the flag says.
const hasFacility = Boolean(row.hasFacility);
const handlesContainer = hasFacility && Boolean(row.handlesContainer);
const handlesBulk = hasFacility && Boolean(row.handlesBulk);
return {
yardId: row.yardId,
yardCode: row.yardCode,
yardLabel: row.yardLabel,
hasFacility,
hasWarehouse: hasFacility && Boolean(row.hasWarehouse),
handlesContainer: hasFacility && Boolean(row.handlesContainer),
handlesBulk: hasFacility && Boolean(row.handlesBulk),
handlesContainer,
handlesBulk,
// Gated on the coarse flag so the two can't contradict each other: a
// per-side flag left set on a type the facility no longer handles at all
// never resurrects that type.
hasContainerFacilityOrigin:
handlesContainer && Boolean(row.hasContainerFacilityOrigin),
hasBulkFacilityOrigin: handlesBulk && Boolean(row.hasBulkFacilityOrigin),
hasContainerFacilityDestination:
handlesContainer && Boolean(row.hasContainerFacilityDestination),
hasBulkFacilityDestination:
handlesBulk && Boolean(row.hasBulkFacilityDestination),
};
}
@@ -84,6 +128,61 @@ export class YardFacilitiesService {
return rows.map((r: Parameters<typeof this.toInfo>[0]) => this.toInfo(r));
}
/** Stored per-side flags for a set of yards, keyed by yard id. Yards with no facility record are absent. */
async sideFlagsForYards(yardIds: string[]): Promise<Map<string, YardSideFlags>> {
if (yardIds.length === 0) return new Map();
const rows: Array<YardSideFlags & { yardId: string }> = await this.dataSource.query(
`SELECT yard_id AS "yardId",
has_container_facility_origin AS "hasContainerFacilityOrigin",
has_bulk_facility_origin AS "hasBulkFacilityOrigin",
has_container_facility_destination AS "hasContainerFacilityDestination",
has_bulk_facility_destination AS "hasBulkFacilityDestination"
FROM freight.yard_facilities
WHERE deleted_at IS NULL AND yard_id = ANY($1)`,
[yardIds],
);
return new Map(
rows.map((r) => [
r.yardId,
{
hasContainerFacilityOrigin: r.hasContainerFacilityOrigin,
hasBulkFacilityOrigin: r.hasBulkFacilityOrigin,
hasContainerFacilityDestination: r.hasContainerFacilityDestination,
hasBulkFacilityDestination: r.hasBulkFacilityDestination,
},
]),
);
}
/**
* Write per-side flags from the yards config form, creating the facility
* record if the yard doesn't have one yet (backoffice-created yards don't).
* Flags left undefined keep their stored value; on first insert they default
* false — an unconfigured facility offers nothing.
*/
async upsertSideFlags(yardId: string, flags: Partial<YardSideFlags>): Promise<void> {
await this.dataSource.query(
`INSERT INTO freight.yard_facilities
(yard_id, has_container_facility_origin, has_bulk_facility_origin,
has_container_facility_destination, has_bulk_facility_destination)
VALUES ($1, COALESCE($2, false), COALESCE($3, false), COALESCE($4, false), COALESCE($5, false))
ON CONFLICT (yard_id) WHERE deleted_at IS NULL
DO UPDATE SET
has_container_facility_origin = COALESCE($2, yard_facilities.has_container_facility_origin),
has_bulk_facility_origin = COALESCE($3, yard_facilities.has_bulk_facility_origin),
has_container_facility_destination = COALESCE($4, yard_facilities.has_container_facility_destination),
has_bulk_facility_destination = COALESCE($5, yard_facilities.has_bulk_facility_destination),
updated_at = now()`,
[
yardId,
flags.hasContainerFacilityOrigin ?? null,
flags.hasBulkFacilityOrigin ?? null,
flags.hasContainerFacilityDestination ?? null,
flags.hasBulkFacilityDestination ?? null,
],
);
}
/**
* Can this facility lift this cargo? Keeps the freight-type rule in one place
* so callers can't get it subtly wrong.
@@ -97,4 +196,26 @@ export class YardFacilitiesService {
? facility.handlesContainer
: facility.handlesBulk;
}
/**
* Can this facility take this cargo on this side of the trip? The rule behind
* the contract's origin/destination yard pickers — keep it here so the API
* and the forms can't drift apart on what is offerable.
*/
canHandleFreightOnSide(
facility: YardFacilityInfo | null,
freightType: string | null | undefined,
side: YardSide,
): boolean {
if (!facility?.hasFacility) return false;
const isContainer = String(freightType).toUpperCase() === 'CONTAINER';
if (side === 'ORIGIN') {
return isContainer
? facility.hasContainerFacilityOrigin
: facility.hasBulkFacilityOrigin;
}
return isContainer
? facility.hasContainerFacilityDestination
: facility.hasBulkFacilityDestination;
}
}

View File

@@ -16,6 +16,10 @@ const service = (): YardsService =>
update: async (_id: string, d: Partial<Yard>) => d as Yard,
} as never,
{ resolveCreateOrder: async () => 1 } as never,
{
sideFlagsForYards: async () => new Map(),
upsertSideFlags: async () => undefined,
} as never,
);
describe('duplicate yard labels are rejected', () => {

View File

@@ -8,6 +8,24 @@ import { UpdateYardDto } from '../dto/update-yard.dto';
import { Yard } from '../entities/yard.entity';
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
import { DisplayOrderService } from './display-order.service';
import { YardFacilitiesService, YardSideFlags } from './yard-facilities.service';
/** Yard rows the config page lists/edits carry the stored per-side facility flags. */
export type YardWithSideFlags = Yard & YardSideFlags;
const SIDE_FLAG_KEYS = [
'hasContainerFacilityOrigin',
'hasBulkFacilityOrigin',
'hasContainerFacilityDestination',
'hasBulkFacilityDestination',
] as const;
const NO_FLAGS: YardSideFlags = {
hasContainerFacilityOrigin: false,
hasBulkFacilityOrigin: false,
hasContainerFacilityDestination: false,
hasBulkFacilityDestination: false,
};
@Injectable()
export class YardsService {
@@ -15,18 +33,34 @@ export class YardsService {
@Inject(YARDS_REPOSITORY)
private readonly repository: IYardsRepository,
private readonly displayOrder: DisplayOrderService,
private readonly facilities: YardFacilitiesService,
) {}
/** List yards — standard paginated envelope with server-side search. */
async findAll(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>> {
return this.repository.findPaged(query);
async findAll(query: ListYardsQueryDto): Promise<PaginatedResponse<YardWithSideFlags>> {
const page = await this.repository.findPaged(query);
const flags = await this.facilities.sideFlagsForYards(page.items.map((y) => y.id));
return {
...page,
items: page.items.map((y) => ({ ...y, ...NO_FLAGS, ...flags.get(y.id) })),
};
}
/** Get a yard by ID. */
async findById(id: string): Promise<Yard> {
async findById(id: string): Promise<YardWithSideFlags> {
const entity = await this.repository.findById(id);
if (!entity) throw new NotFoundException(`Yard ${id} not found`);
return entity;
const flags = await this.facilities.sideFlagsForYards([id]);
return { ...entity, ...NO_FLAGS, ...flags.get(id) };
}
/** The per-side facility flags present in the dto, or null when none were sent. */
private pickSideFlags(dto: Partial<CreateYardDto>): Partial<YardSideFlags> | null {
const flags: Partial<YardSideFlags> = {};
for (const key of SIDE_FLAG_KEYS) {
if (dto[key] !== undefined) flags[key] = dto[key];
}
return Object.keys(flags).length > 0 ? flags : null;
}
/** Create a yard. */
@@ -43,7 +77,7 @@ export class YardsService {
insertAfterId: dto.insertAfterId,
});
return this.repository.create({
const yard = await this.repository.create({
code,
label: dto.label,
country: dto.country,
@@ -51,15 +85,28 @@ export class YardsService {
hasFacility: dto.hasFacility ?? false,
displayOrder,
});
const flags = this.pickSideFlags(dto);
if (flags) await this.facilities.upsertSideFlags(yard.id, flags);
return this.findById(yard.id);
}
/** Update a yard. */
async update(id: string, dto: UpdateYardDto): Promise<Yard> {
await this.findById(id);
if (dto.label !== undefined) await this.assertLabelAvailable(dto.label, id);
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Yard ${id} not found`);
return updated;
// Per-side flags live on yard_facilities, not the yards row — split them out.
const flags = this.pickSideFlags(dto);
const yardDto = { ...dto };
for (const key of SIDE_FLAG_KEYS) delete yardDto[key];
if (Object.keys(yardDto).length > 0) {
const updated = await this.repository.update(id, yardDto);
if (!updated) throw new NotFoundException(`Yard ${id} not found`);
}
if (flags) await this.facilities.upsertSideFlags(id, flags);
return this.findById(id);
}
/** No two active yards may share a label (case/whitespace-insensitive). */

View File

@@ -7,12 +7,14 @@ export class SaveSignatureDto {
@MinLength(1)
signerDisplayName!: string;
@ApiProperty({
description: 'PNG signature image as base64 (with or without data URL prefix)',
@ApiPropertyOptional({
description:
'PNG signature image as base64 (with or without data URL prefix). Omit to keep the existing saved signature (stamp-only update).',
})
@IsOptional()
@IsString()
@MinLength(20)
signatureImageBase64!: string;
signatureImageBase64?: string;
@ApiPropertyOptional({
description:

View File

@@ -12,7 +12,8 @@ import { SavedSignatureDto } from './dto/save-signature.dto';
export interface UpsertSignatureInput {
userId: string;
signerDisplayName: string;
signatureImageBase64: string;
/** Optional; omitted = keep the existing saved signature (stamp-only update). */
signatureImageBase64?: string;
/** Optional company stamp/seal; omitted = keep the existing saved stamp. */
stampImageBase64?: string;
}
@@ -46,12 +47,14 @@ export class SignaturesService {
const previousFileId = existing?.signatureFileId ?? null;
const previousStampFileId = existing?.stampFileId ?? null;
const fileRecord = await this.filesService.upload({
resourceId: input.userId,
resource: 'saved_signatures',
code: 'signature',
file: this.toUploadFile('signature', input.userId, input.signatureImageBase64),
});
const fileRecord = input.signatureImageBase64
? await this.filesService.upload({
resourceId: input.userId,
resource: 'saved_signatures',
code: 'signature',
file: this.toUploadFile('signature', input.userId, input.signatureImageBase64),
})
: null;
const stampRecord = input.stampImageBase64
? await this.filesService.upload({
@@ -65,13 +68,13 @@ export class SignaturesService {
const saved = await this.signaturesRepository.upsert({
userId: input.userId,
signerDisplayName: input.signerDisplayName,
signatureFileId: fileRecord.id,
// Omitted stamp keeps whatever was saved before.
// Omitted image keeps whatever was saved before.
...(fileRecord ? { signatureFileId: fileRecord.id } : {}),
...(stampRecord ? { stampFileId: stampRecord.id } : {}),
});
const staleIds = [
previousFileId !== fileRecord.id ? previousFileId : null,
fileRecord && previousFileId !== fileRecord.id ? previousFileId : null,
stampRecord && previousStampFileId !== stampRecord.id
? previousStampFileId
: null,

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

@@ -73,6 +73,7 @@ import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsRequired,
bulkTonsPerWagon,
bookingGrossWeightTons,
deriveTrainCapacityFromLocomotive,
sizePartialOfferWagons,
@@ -152,6 +153,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;
@@ -1448,6 +1457,7 @@ export class BookingBatchService implements OnModuleInit {
*/
async getBatchBoard(
query: BatchBoardQueryDto = {},
allowedDirections?: string[],
): Promise<BatchBoardListResponse> {
// Board cards are heavy (per-schedule booking summaries), so the default
// page is smaller than the toolkit-wide 20.
@@ -1455,6 +1465,11 @@ export class BookingBatchService implements OnModuleInit {
defaultPageSize: 12,
});
// The board is IMPORT-only — a user scoped away from IMPORT sees nothing.
if (allowedDirections && !allowedDirections.includes("IMPORT")) {
return { items: [], meta: buildPaginationMeta(0, page, pageSize) };
}
// Status filter: any subset of the lifecycle. Omitted = all statuses, so
// arrived / cancelled / dispatched schedules stay visible as history.
const allowedStatuses = new Set<string>(BATCH_BOARD_STATUSES);
@@ -3027,6 +3042,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.
@@ -3045,6 +3061,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
@@ -3531,6 +3645,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
@@ -3859,12 +3983,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",
@@ -4106,8 +4244,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).
@@ -4175,6 +4320,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(
@@ -4182,8 +4334,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

@@ -0,0 +1,145 @@
import { BookingJourneyService } from './booking-journey.service';
/**
* autoPlaceOnFreedWagons: intercity cargo boards the wagons freed by earlier
* unloads. Exercised directly with a stubbed EntityManager — the surrounding
* loadBooking flow is integration-tested through the running app.
*/
describe('BookingJourneyService.autoPlaceOnFreedWagons', () => {
const service = new BookingJourneyService(
{} as never, // dataSource
{} as never, // yardFacilities
{} as never, // facilityHandling
{ emit: jest.fn() } as never, // events
);
const schedule = { id: 'sched-1', trainSetId: 'ts-1' };
const booking = {
id: 'booking-1',
reference: 'BK-1',
cargoTotalWeightVgm: 50,
freightType: 'CONTAINER',
};
const makeManager = (slots: unknown[], existingAllocs: unknown[] = []) => {
const savedAllocs: Array<Record<string, unknown>> = [];
const savedItems: Array<Record<string, unknown>> = [];
const allocQb = {
innerJoinAndSelect: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue(existingAllocs),
};
const slotQb = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue(slots),
};
let allocId = 0;
const manager = {
getRepository: jest.fn((entity: { name?: string }) => {
const name = entity?.name;
if (name === 'WagonBookingAllocation') {
return {
createQueryBuilder: jest.fn(() => allocQb),
create: jest.fn((v: Record<string, unknown>) => v),
save: jest.fn(async (v: Record<string, unknown>) => {
const row = { ...v, id: `alloc-${++allocId}` };
savedAllocs.push(row);
return row;
}),
update: jest.fn(),
};
}
if (name === 'TrainSetWagon') {
return { createQueryBuilder: jest.fn(() => slotQb) };
}
if (name === 'BookingContainer') {
return {
find: jest.fn().mockResolvedValue([
{
id: 'line-1',
containerNumber: 'LINE-001',
containerTypeId: 'ct-20',
units: [{ containerNumber: 'UNIT-001' }, { containerNumber: 'UNIT-002' }],
},
]),
};
}
if (name === 'WagonAllocationContainerItem') {
return {
create: jest.fn((v: Record<string, unknown>) => v),
save: jest.fn(async (v: Record<string, unknown>) => {
savedItems.push(v);
return v;
}),
};
}
throw new Error(`Unexpected repository ${name}`);
}),
};
return { manager, savedAllocs, savedItems };
};
const call = (manager: unknown) =>
(service as never as {
autoPlaceOnFreedWagons: (m: unknown, s: unknown, b: unknown) => Promise<void>;
}).autoPlaceOnFreedWagons(manager, schedule, booking);
it('places the booking on freed slots in consist order, with container items', async () => {
const slots = [
// Active cargo still riding — NOT freed.
{ id: 'slot-1', sequenceNo: 1, capacityTons: 60, allocations: [{ status: 'LOADED' }] },
// Freed by an earlier unload.
{ id: 'slot-2', sequenceNo: 2, capacityTons: 60, allocations: [{ status: 'DEPARTED' }] },
{ id: 'slot-3', sequenceNo: 3, capacityTons: 60, allocations: [] },
];
const { manager, savedAllocs, savedItems } = makeManager(slots);
await call(manager);
// 50 t fits on the first freed slot alone.
expect(savedAllocs).toHaveLength(1);
expect(savedAllocs[0]).toMatchObject({
trainSetWagonId: 'slot-2',
bookingId: 'booking-1',
allocatedWeightTons: 50,
status: 'LOADED',
});
// One item per physical unit, on the first allocation.
expect(savedItems.map((i) => i.containerNumber)).toEqual(['UNIT-001', 'UNIT-002']);
expect(savedItems.every((i) => i.wagonBookingAllocationId === 'alloc-1')).toBe(true);
});
it('spills over onto the next freed slot when one is not enough', async () => {
const slots = [
{ id: 'slot-2', sequenceNo: 2, capacityTons: 30, allocations: [{ status: 'DEPARTED' }] },
{ id: 'slot-3', sequenceNo: 3, capacityTons: 30, allocations: [] },
];
const { manager, savedAllocs } = makeManager(slots);
await call(manager);
expect(savedAllocs.map((a) => [a.trainSetWagonId, a.allocatedWeightTons])).toEqual([
['slot-2', 30],
['slot-3', 20],
]);
});
it('does nothing when the booking already has allocations', async () => {
const { manager, savedAllocs } = makeManager([], [{ id: 'existing' }]);
await call(manager);
expect(savedAllocs).toHaveLength(0);
});
it('loads without allocation when no wagon is free', async () => {
const slots = [
{ id: 'slot-1', sequenceNo: 1, capacityTons: 60, allocations: [{ status: 'LOADED' }] },
];
const { manager, savedAllocs } = makeManager(slots);
await expect(call(manager)).resolves.toBeUndefined();
expect(savedAllocs).toHaveLength(0);
});
});

View File

@@ -13,6 +13,8 @@ import { Freight } from '@edr/types';
import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service';
import { FacilityHandlingService } from './facility-handling.service';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { Yard } from '../rule-engine/entities/yard.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
@@ -82,6 +84,11 @@ export class BookingJourneyService {
loadedAt: now,
loadedByUserId: userId ?? null,
} as never);
// Intercity cargo rides the wagons freed by earlier unloads along the
// corridor — place it before the status flip so it boards with a wagon.
if (booking.tradeDirection === 'DOMESTIC') {
await this.autoPlaceOnFreedWagons(manager, schedule, booking);
}
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED');
// Keep the schedule↔booking link's tracking flag in sync — the dispatch
// readiness warnings and workspace badges read loading_status, not loadedAt.
@@ -327,19 +334,48 @@ export class BookingJourneyService {
RETURNING b.id, b.trade_direction`,
[schedule.id, schedule.destinationStationId, now],
);
if (rows.length === 0) return [];
// The facility took the cargo off the train at the final yard — raise its
// GRN, same as the per-booking unloadBooking() path does. Only when that
// yard also has a warehouse (or has no facility at all, e.g. Kality) does
// WarehouseInventoryService additionally get to allocate a warehouse/yard/
// zone row: a pure facility yard (Dire Dawa, Modjo, Sebeta, Adama) is
// fully represented by the facility event alone — there is nothing there
// for warehouse_inventory's NOT NULL warehouse/yard/zone to point at.
const facility = await this.yardFacilities.facilityForYard(schedule.destinationStationId);
const bookings = await manager
.getRepository(Booking)
.find({ where: { id: In(rows.map((r) => r.id)) }, relations: ['company'] });
const bookingById = new Map(bookings.map((b) => [b.id, b]));
for (const row of rows) {
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
if (row.trade_direction === 'DOMESTIC') {
this.events.emit('booking.completed', { bookingId: row.id });
}
const booking = bookingById.get(row.id);
if (booking) {
await this.facilityHandling.recordHandling(manager, {
booking,
yardId: schedule.destinationStationId,
trainScheduleId: schedule.id,
eventType: 'UNLOAD',
occurredAt: now,
});
}
// Same event the per-booking unloadBooking() path emits — WarehouseInventoryService
// listens for this to auto-create the warehouse_inventory row (import/intercity only,
// it filters EXPORT itself). The bulk SQL update above skipped this entirely, so
// bookings caught by this fallback never left "awaiting unload".
this.events.emit('booking.unloadedAtYard', {
bookingId: row.id,
tradeDirection: row.trade_direction,
});
if (row.trade_direction !== 'EXPORT' && (!facility?.hasFacility || facility.hasWarehouse)) {
this.events.emit('booking.unloadedAtYard', {
bookingId: row.id,
tradeDirection: row.trade_direction,
});
}
}
return rows.map((r) => r.id);
}
@@ -435,6 +471,100 @@ export class BookingJourneyService {
}
}
/**
* INTERCITY ONLY. Intercity cargo does not get its own wagons — it rides the
* slots freed by cargo already unloaded along the corridor (e.g. import
* containers uncoupled at Dire Dawa). Staff pinning is a pre-dispatch tool,
* so a DOMESTIC booking loaded mid-corridor is auto-placed here: greedy over
* on-train slots (not DEPARTED) with no active cargo (every allocation
* DEPARTED, or none), in consist order, by capacity. Container numbers are
* copied onto the first allocation so the marshalling document and its
* 40ft/20ft tally stay truthful. When nothing is free the load proceeds
* unallocated — the marshalling document then lists the booking as on board
* without a recorded wagon.
* ponytail: remainder over free capacity is dumped on the last used slot
* (paper overload beats missing cargo); upgrade path is a capacity guard in
* the intercity accept step.
*/
private async autoPlaceOnFreedWagons(
manager: EntityManager,
schedule: TrainSchedule,
booking: Booking,
): Promise<void> {
const existing = await this.allocationsForBooking(manager, schedule.id, booking.id);
if (existing.length) return;
const slots = await manager
.getRepository(TrainSetWagon)
.createQueryBuilder('slot')
.leftJoinAndSelect('slot.allocations', 'alloc')
.innerJoin(
TrainSchedule,
'schedule',
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
{ scheduleId: schedule.id },
)
.where(`slot.status != 'DEPARTED'`)
.orderBy('slot.sequence_no', 'ASC')
.getMany();
const freed = slots.filter((slot) =>
(slot.allocations ?? []).every((a) => a.status === 'DEPARTED'),
);
if (!freed.length) {
this.logger.warn(
`No freed wagon for intercity booking ${booking.reference} on schedule ${schedule.id} — loading without wagon allocation`,
);
return;
}
let remaining = Number(booking.cargoTotalWeightVgm) || 0;
const allocRepo = manager.getRepository(WagonBookingAllocation);
const created: WagonBookingAllocation[] = [];
for (const slot of freed) {
const capacity = Number(slot.capacityTons) || remaining || 1;
const take = Math.min(remaining || capacity, capacity);
created.push(
await allocRepo.save(
allocRepo.create({
trainSetWagonId: slot.id,
bookingId: booking.id,
allocatedWeightTons: take,
loadType: booking.freightType ?? null,
status: 'LOADED',
}),
),
);
remaining = Math.max(0, remaining - take);
if (remaining <= 0) break;
}
if (remaining > 0 && created.length) {
await allocRepo.update(created[created.length - 1].id, {
allocatedWeightTons: () => `allocated_weight_tons + ${remaining}`,
} as never);
}
// Container numbers onto the first allocation, from the booking's container
// lines (per physical unit when recorded, else per line).
const lines = await manager
.getRepository(BookingContainer)
.find({ where: { bookingId: booking.id }, relations: { units: true } });
const itemRepo = manager.getRepository(WagonAllocationContainerItem);
const first = created[0];
for (const line of lines) {
const units = line.units?.length ? line.units : [null];
for (const unit of units) {
await itemRepo.save(
itemRepo.create({
wagonBookingAllocationId: first.id,
bookingContainerId: line.id,
containerNumber: unit?.containerNumber ?? line.containerNumber ?? null,
containerTypeId: line.containerTypeId ?? null,
}),
);
}
}
}
private async setAllocationStatuses(
manager: EntityManager,
scheduleId: string,

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

@@ -1,6 +1,7 @@
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
import {
@@ -66,6 +67,7 @@ export class TrainSchedulingController {
private readonly intercityService: IntercityService,
private readonly bookingJourneyService: BookingJourneyService,
private readonly billingService: BillingService,
private readonly userTradeAccessService: UserTradeAccessService,
) { }
@Get("my-booking-windows")
@@ -130,8 +132,14 @@ export class TrainSchedulingController {
summary:
"Batch monitoring board: paginated import schedules (all statuses) with bookings grouped by state",
})
getBatchBoard(@Query() query: BatchBoardQueryDto) {
return this.bookingBatchService.getBatchBoard(query);
async getBatchBoard(
@Query() query: BatchBoardQueryDto,
@CurrentUser() user: AuthUserPayload,
) {
// Batch board is IMPORT-only — a user without IMPORT access sees nothing.
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
return this.bookingBatchService.getBatchBoard(query, allowed ?? undefined);
}
@Get("batch-board/:scheduleId")
@@ -703,6 +711,20 @@ export class TrainSchedulingController {
return res.send(buffer);
}
@Get("schedules/:id/intercity/marshalling/document")
@TrainSchedulingView()
@ApiOperation({ summary: "Download current on-board intercity marshalling (Marshalling 2) PDF" })
async intercityMarshallingDocument(
@Param("id", ParseUUIDPipe) id: string,
@Res() res: Response,
) {
const { filename, buffer } = await this.trainSchedulingService.intercityMarshallingDocument(id);
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.setHeader("Content-Length", buffer.length);
return res.send(buffer);
}
// ---- batch / booking-window staff actions ----
@Post("schedules/:id/run-batch")
@@ -834,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({
@@ -868,15 +916,31 @@ export class TrainSchedulingController {
@Get("container/schedules")
@TrainSchedulingView()
@ApiOperation({ summary: "List container train schedules (paginated)" })
getContainerTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) {
return this.trainSchedulingService.getContainerTrainSchedules(query);
async getContainerTrainSchedules(
@Query() query: ListTrainSchedulesQueryDto,
@CurrentUser() user: AuthUserPayload,
) {
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
return this.trainSchedulingService.getContainerTrainSchedules(
query,
allowed ?? undefined,
);
}
@Get("bulk/schedules")
@TrainSchedulingView()
@ApiOperation({ summary: "List bulk train schedules (paginated)" })
getBulkTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) {
return this.trainSchedulingService.getContainerTrainSchedules(query);
async getBulkTrainSchedules(
@Query() query: ListTrainSchedulesQueryDto,
@CurrentUser() user: AuthUserPayload,
) {
const allowed =
await this.userTradeAccessService.resolveAllowedDirections(user);
return this.trainSchedulingService.getContainerTrainSchedules(
query,
allowed ?? undefined,
);
}
@Get("container/schedules/:id")

View File

@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
import { BillingModule } from '../billing/billing.module';
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
import { BookingsModule } from '../bookings/bookings.module';
import { Container } from '../container-management/entities/container.entity';
import { LocomotivesModule } from '../locomotives/locomotives.module';
@@ -63,6 +64,7 @@ import { ContractsModule } from '../contracts/contracts.module';
]),
forwardRef(() => BookingsModule),
BillingModule,
UserTradeAccessModule,
NotificationsModule,
NotificationInboxModule,
LocomotivesModule,

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';
@@ -1121,6 +1166,132 @@ describe('TrainSchedulingService', () => {
expect(html).not.toContain('empty)');
expect(html).not.toContain('EMPTY');
});
// ---- intercity marshalling (Marshalling 2): the current on-board view ----
const onBoardView = (schedule: unknown) =>
(service as never as {
intercityOnBoardView: (s: unknown) => { wagons: unknown[]; unassignedBookings: unknown[] };
}).intercityOnBoardView(schedule);
const buildWithOpts = (schedule: unknown, opts: unknown) =>
(service as never as {
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
}).buildExportLoadListHtml(schedule, opts);
const allocWith = (over: Record<string, unknown>) => ({ ...loadedAllocation, ...over });
it('drops DEPARTED wagon slots and DEPARTED allocations from the on-board view', () => {
const schedule = {
trainSet: {
wagons: [
{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' },
{ ...makeWagon(2, 'W-002', [allocWith({ status: 'LOADED' })]), status: 'DEPARTED' },
{
...makeWagon(3, 'W-003', [
allocWith({ status: 'LOADED', bookingId: 'booking-3' }),
allocWith({ status: 'DEPARTED', bookingId: 'booking-4' }),
]),
status: 'RESERVED',
},
],
},
scheduleBookings: [],
};
const { wagons } = onBoardView(schedule);
const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map(
(w) => w.physicalWagon.wagonNumber,
);
expect(numbers).toEqual(['W-001', 'W-003']);
const w3 = (wagons as Array<{ physicalWagon: { wagonNumber: string }; allocations: Array<{ bookingId: string }> }>).find(
(w) => w.physicalWagon.wagonNumber === 'W-003',
);
expect(w3?.allocations.map((a) => a.bookingId)).toEqual(['booking-3']);
});
it('keeps an attached wagon whose cargo all departed, as an EMPTY row', () => {
const schedule = {
id: 'schedule-1',
trainNumber: '8302',
direction: 'EXPORT',
trainSet: {
wagons: [
{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' },
{ ...makeWagon(2, 'W-002', [allocWith({ status: 'DEPARTED' })]), status: 'RESERVED' },
],
},
scheduleBookings: [],
};
const { wagons, unassignedBookings } = onBoardView(schedule);
const html = buildWithOpts(schedule, { wagons, unassignedBookings });
expect(html).toContain('W-002');
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1);
expect(html).toContain('2 (1 empty)');
});
it('hides a leg slot (boardYardId set) until it has confirmed LOADED cargo', () => {
const legWagonEmpty = { ...makeWagon(2, 'W-LEG', [allocWith({ status: 'RESERVED' })]), status: 'RESERVED', boardYardId: 'yard-mid' };
const legWagonLoaded = { ...makeWagon(3, 'W-LEG2', [allocWith({ status: 'LOADED' })]), status: 'RESERVED', boardYardId: 'yard-mid' };
const schedule = {
trainSet: { wagons: [legWagonEmpty, legWagonLoaded] },
scheduleBookings: [],
};
const { wagons } = onBoardView(schedule);
const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map(
(w) => w.physicalWagon.wagonNumber,
);
expect(numbers).toEqual(['W-LEG2']);
});
it('lists an IN_TRANSIT booking with no wagon allocation in the unassigned section', () => {
const rider = {
id: 'booking-9',
reference: 'BK-2026-000009',
status: 'IN_TRANSIT',
company: { name: 'Rider Co' },
cargoType: { cargoTypeName: 'Cement', code: 'CEM' },
originYard: { label: 'Adama' },
destinationYard: { label: 'Dire Dawa' },
bookingContainers: [{ containerNumber: 'RIDE-001' }],
};
const done = { id: 'booking-8', reference: 'BK-2026-000008', status: 'COMPLETED' };
const schedule = {
id: 'schedule-1',
trainNumber: '8302',
direction: 'EXPORT',
trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' }] },
scheduleBookings: [{ bookingId: rider.id, booking: rider }, { bookingId: done.id, booking: done }],
};
const { wagons, unassignedBookings } = onBoardView(schedule);
expect((unassignedBookings as Array<{ id: string }>).map((b) => b.id)).toEqual(['booking-9']);
const html = buildWithOpts(schedule, {
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
positionLabel: 'After Dire Dawa',
wagons,
unassignedBookings,
});
expect(html).toContain('ON BOARD — WAGON NOT RECORDED');
expect(html).toContain('BK-2026-000009');
expect(html).toContain('RIDE-001');
expect(html).not.toContain('BK-2026-000008');
expect(html).toContain('Intercity Marshalling Document / Load List (Marshalling 2)');
expect(html).toContain('After Dire Dawa');
});
it('rejects the intercity marshalling document for a train that has not been dispatched', async () => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
id: 'schedule-1',
status: 'SCHEDULED',
});
await expect(
service.intercityMarshallingDocument('schedule-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('moveWagonLoad — staff rearrange', () => {

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
@@ -2928,6 +3010,78 @@ export class TrainSchedulingService {
};
}
/**
* The train's composition as it stands right now — the source for the
* intercity marshalling ("Marshalling 2") document printed after mid-corridor
* station work. A wagon slot is on the train iff it has not DEPARTED and
* either rides the whole corridor (no boardYardId) or has confirmed LOADED
* cargo. Kept wagons carry only their LOADED allocations (DEPARTED =
* unloaded, PLANNED/RESERVED = not on board yet).
* ponytail: boardYardId presence is the "boarded yet?" heuristic; upgrade
* path is comparing the board yard against the latest checkpoint sequence.
*/
private intercityOnBoardView(schedule: TrainSchedule): {
wagons: TrainSetWagon[];
unassignedBookings: Booking[];
} {
const wagons = (schedule.trainSet?.wagons ?? [])
.filter((wagon) => {
if (wagon.status === 'DEPARTED') return false;
const hasLoaded = (wagon.allocations ?? []).some((a) => a.status === 'LOADED');
return wagon.boardYardId == null || hasLoaded;
})
.map((wagon) => ({
...wagon,
allocations: (wagon.allocations ?? []).filter((a) => a.status === 'LOADED'),
})) as TrainSetWagon[];
const onBoardBookingIds = new Set(
wagons.flatMap((wagon) => (wagon.allocations ?? []).map((a) => a.bookingId)),
);
// IN_TRANSIT bookings with no kept allocation: intercity riders accepted
// after dispatch (never wagon-pinned) and loads whose allocation was never
// confirmed LOADED. They are physically on the train, so they get a row.
const unassignedBookings = (schedule.scheduleBookings ?? [])
.map((link) => link.booking)
.filter((booking): booking is Booking => Boolean(booking))
.filter((booking) => booking.status === 'IN_TRANSIT' && !onBoardBookingIds.has(booking.id));
return { wagons, unassignedBookings };
}
async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status !== 'DISPATCHED' && schedule.status !== 'ARRIVED') {
throw new BadRequestException(
'Intercity marshalling document applies only to dispatched or arrived trains',
);
}
const checkpoints = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
const last = checkpoints[checkpoints.length - 1];
const positionLabel = last
? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}`
: `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`;
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
const html = this.buildExportLoadListHtml(schedule, {
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
positionLabel,
wagons,
unassignedBookings,
});
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
const reference = schedule.trainNumber ?? schedule.id;
return {
filename: `intercity-marshalling-${this.safeDocumentName(reference)}.pdf`,
buffer,
};
}
/**
* A container item's size in feet, for the marshalling document's 40ft/20ft
* tally. Two independent sources, since only one is populated depending on
@@ -2954,7 +3108,15 @@ export class TrainSchedulingService {
return null;
}
private buildExportLoadListHtml(schedule: TrainSchedule): string {
private buildExportLoadListHtml(
schedule: TrainSchedule,
opts?: {
title?: string;
positionLabel?: string;
wagons?: TrainSetWagon[];
unassignedBookings?: Booking[];
},
): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
@@ -2967,7 +3129,7 @@ export class TrainSchedulingService {
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
// The document is checked against the physical train, so it has to run in
// consist order — the relation comes back unordered.
const wagons = [...(schedule.trainSet?.wagons ?? [])].sort(
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort(
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
);
const rows = wagons
@@ -3011,6 +3173,29 @@ export class TrainSchedulingService {
});
})
.join('');
// Intercity riders accepted after dispatch have no wagon slot recorded —
// they are still physically on the train, so they get rows of their own.
const unassigned = opts?.unassignedBookings ?? [];
const unassignedRows = unassigned.length
? `<tr class="empty"><td colspan="11">ON BOARD — WAGON NOT RECORDED</td></tr>` +
unassigned
.map((booking) => {
const containerNumbers = (booking.bookingContainers ?? [])
.map((container) => container.containerNumber)
.filter(Boolean)
.join(', ');
const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'}${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`;
return `<tr>
<td colspan="6">${esc(booking.reference)}${esc(leg)}</td>
<td>${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)}</td>
<td>${esc(booking.company?.name)}</td>
<td>${esc(containerNumbers)}</td>
<td>-</td>
<td>-</td>
</tr>`;
})
.join('')
: '';
const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length;
const totalWeight = wagons.reduce(
(sum, wagon) =>
@@ -3034,7 +3219,7 @@ export class TrainSchedulingService {
<html>
<head>
<meta charset="utf-8" />
<title>Export Marshalling Document</title>
<title>${esc(opts?.title ?? 'Export Marshalling Document')}</title>
<style>
@page { size: A4 landscape; margin: 10mm; }
* { box-sizing: border-box; }
@@ -3063,7 +3248,7 @@ export class TrainSchedulingService {
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Export Marshalling Document / Load List</h1>
<h1>${esc(opts?.title ?? 'Export Marshalling Document / Load List')}</h1>
</div>
<div class="meta">
Train / Schedule
@@ -3088,6 +3273,7 @@ export class TrainSchedulingService {
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
${opts?.positionLabel ? `<div class="tile"><span>Current position</span><strong>${esc(opts.positionLabel)}</strong></div>` : ''}
</div>
<table>
@@ -3108,6 +3294,7 @@ export class TrainSchedulingService {
</thead>
<tbody>
${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'}
${unassignedRows}
</tbody>
</table>
@@ -3899,13 +4086,25 @@ export class TrainSchedulingService {
return Object.assign(detail, { warehouseAutomation });
}
async getContainerTrainSchedules(query: ListTrainSchedulesQueryDto = {}) {
async getContainerTrainSchedules(
query: ListTrainSchedulesQueryDto = {},
allowedDirections?: string[],
) {
const { page, pageSize, skip, take } = normalizePagination(query);
// Per-user trade-direction scope: schedules carry a `direction` column.
if (allowedDirections && allowedDirections.length === 0) {
return {
items: [],
meta: buildPaginationMeta(0, page, pageSize),
};
}
// Exact-match filters (enum/id semantics). Freight type is derived from
// the bookings aboard — no column to match — so it rides on `id` as an
// EXISTS fragment instead.
const base: FindOptionsWhere<TrainSchedule> = {};
if (allowedDirections) base.direction = In(allowedDirections) as never;
if (query.status) base.status = query.status;
if (query.originStationId) base.originStationId = query.originStationId;
if (query.destinationStationId) base.destinationStationId = query.destinationStationId;
@@ -7335,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,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayUnique, IsIn } from 'class-validator';
import { Freight } from '@edr/types';
export class UpsertUserTradeAccessDto {
@ApiProperty({
description:
'Trade directions the user may see. All three (or no config row) = unrestricted; empty array = sees nothing.',
isArray: true,
enum: ['IMPORT', 'EXPORT', 'DOMESTIC'],
example: ['IMPORT', 'DOMESTIC'],
})
@ArrayUnique()
@IsIn(['IMPORT', 'EXPORT', 'DOMESTIC'], { each: true })
directions!: Freight.ScheduleTradeDirection[];
}

View File

@@ -0,0 +1,27 @@
import { BaseEntity } from '@edr/api-common';
import { Freight } from '@edr/types';
import { Column, Entity, Index } from 'typeorm';
/**
* Which trade directions (IMPORT / EXPORT / DOMESTIC=Intercity) a backoffice
* user may see. No row, or all three directions, means unrestricted.
*/
@Entity({ schema: 'freight', name: 'user_trade_access' })
export class UserTradeAccess extends BaseEntity {
/** IAM user id (iam.users) — no FK, iam schema is externally owned. */
@Index()
@Column({ name: 'user_id', type: 'uuid', unique: true })
userId!: string;
@Column({ name: 'directions', type: 'text', default: '' })
directionsRaw!: string;
@Column({ name: 'updated_by_id', type: 'uuid', nullable: true })
updatedById!: string | null;
get directions(): Freight.ScheduleTradeDirection[] {
return this.directionsRaw
? (this.directionsRaw.split(',') as Freight.ScheduleTradeDirection[])
: [];
}
}

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

@@ -0,0 +1,100 @@
import { Freight } from '@edr/types';
import { Brackets, SelectQueryBuilder, WhereExpressionBuilder } from 'typeorm';
/**
* Resolve the effective direction list for a query.
*
* @param allowed the user's scope — null = unrestricted
* @param requested an explicit ?tradeDirection=… filter, if any
* @returns directions to filter by, `null` = no filter, `[]` = show nothing
*/
export function scopedDirections(
allowed: Freight.ScheduleTradeDirection[] | null,
requested?: string | null,
): string[] | null {
if (!allowed) return requested ? [requested] : null;
if (!requested) return [...allowed];
return allowed.includes(requested as Freight.ScheduleTradeDirection)
? [requested]
: [];
}
/**
* Apply a direction scope to a query builder column.
* `dirs = null` → untouched; `dirs = []` → matches nothing.
*/
export function applyDirectionScope<T extends WhereExpressionBuilder>(
qb: T,
column: string,
dirs: string[] | null,
): T {
if (dirs === null) return qb;
if (dirs.length === 0) {
qb.andWhere('1 = 0');
return qb;
}
// Unique param name so multiple scopes can coexist on one query.
const param = `scopeDirs_${column.replace(/\W/g, '_')}`;
qb.andWhere(`${column} IN (:...${param})`, { [param]: dirs });
return qb;
}
/**
* SQL-fragment form of {@link applyDirectionScope} for fluent query chains:
* `.andWhere(f.sql, f.params)`. `dirs = null/undefined` → TRUE (no-op).
*/
export function directionScopeSql(
column: string,
dirs: string[] | null | undefined,
): { sql: string; params: Record<string, unknown> } {
if (!dirs) return { sql: 'TRUE', params: {} };
if (dirs.length === 0) return { sql: 'FALSE', params: {} };
const param = `scopeDirs_${column.replace(/\W/g, '_')}`;
return { sql: `${column} IN (:...${param})`, params: { [param]: dirs } };
}
/**
* SQL-fragment form of {@link applyBookingRefDirectionScope}: hides rows whose
* varchar ref column points at a booking outside the scope; rows that do not
* point at a booking stay visible (they carry no direction to scope by).
*/
export function bookingRefScopeSql(
refColumn: string,
dirs: string[] | null | undefined,
): { sql: string; params: Record<string, unknown> } {
if (!dirs) return { sql: 'TRUE', params: {} };
const param = `scopeRefDirs_${refColumn.replace(/\W/g, '_')}`;
const disallowed = dirs.length
? `b.trade_direction NOT IN (:...${param})`
: 'TRUE';
return {
sql: `NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id::text = ${refColumn} AND ${disallowed})`,
params: dirs.length ? { [param]: dirs } : {},
};
}
/**
* Scope rows whose direction lives on a related booking referenced by a
* varchar id column (invoices.source_id, payments.ref_id). Rows that do not
* point at a booking stay visible — they carry no direction to scope by.
*/
export function applyBookingRefDirectionScope<T>(
qb: SelectQueryBuilder<T & object>,
refColumn: string,
dirs: string[] | null,
): SelectQueryBuilder<T & object> {
if (dirs === null) return qb;
const param = `scopeRefDirs_${refColumn.replace(/\W/g, '_')}`;
const disallowed = dirs.length
? `b.trade_direction NOT IN (:...${param})`
: 'TRUE';
qb.andWhere(
new Brackets((w) => {
w.where(
`NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id::text = ${refColumn} AND ${disallowed})`,
);
}),
);
if (dirs.length) qb.setParameter(param, dirs);
return qb;
}

View File

@@ -0,0 +1,67 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Param,
ParseUUIDPipe,
Put,
} from '@nestjs/common';
import { ApiBearerAuth, 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 { StaffReference } from '../../common/booking-guards';
import { isFreightApprovalAdmin } from '../../common/freight-permission.util';
import { UpsertUserTradeAccessDto } from './dto/upsert-user-trade-access.dto';
import { UserTradeAccessService } from './user-trade-access.service';
@ApiTags('user-trade-access')
@Controller('user-trade-access')
@StaffReference()
@ApiBearerAuth()
export class UserTradeAccessController {
constructor(private readonly service: UserTradeAccessService) {}
@Get()
@ApiOperation({ summary: 'List every configured user trade-direction scope' })
list(@CurrentUser() user: TCurrentUser) {
this.assertAdmin(user);
return this.service.listConfigs();
}
@Get('me')
@ApiOperation({ summary: "Current user's effective trade-direction scope" })
async me(@CurrentUser() user: TCurrentUser) {
const allowed = await this.service.resolveAllowedDirections(user);
return {
restricted: allowed !== null,
directions: allowed ?? ['IMPORT', 'EXPORT', 'DOMESTIC'],
};
}
@Put(':userId')
@ApiOperation({
summary: 'Set the trade directions a backoffice user may see',
})
upsert(
@Param('userId', ParseUUIDPipe) userId: string,
@Body() dto: UpsertUserTradeAccessDto,
@CurrentUser() user: TCurrentUser,
) {
this.assertAdmin(user);
return this.service.upsert(
userId,
dto.directions,
(user as { id?: string } | null)?.id ?? null,
);
}
private assertAdmin(user: TCurrentUser) {
if (!isFreightApprovalAdmin(user)) {
throw new ForbiddenException(
'Only super or organization admins can manage trade-direction access',
);
}
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserTradeAccess } from './entities/user-trade-access.entity';
import { UserTradeAccessController } from './user-trade-access.controller';
import { UserTradeAccessRepository } from './user-trade-access.repository';
import { UserTradeAccessService } from './user-trade-access.service';
@Module({
imports: [TypeOrmModule.forFeature([UserTradeAccess])],
controllers: [UserTradeAccessController],
providers: [UserTradeAccessService, UserTradeAccessRepository],
exports: [UserTradeAccessService],
})
export class UserTradeAccessModule {}

View File

@@ -0,0 +1,23 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserTradeAccess } from './entities/user-trade-access.entity';
@Injectable()
export class UserTradeAccessRepository extends BaseRepository<UserTradeAccess> {
constructor(
@InjectRepository(UserTradeAccess) repository: Repository<UserTradeAccess>,
) {
super(repository);
}
findByUserId(userId: string): Promise<UserTradeAccess | null> {
return this.repository.findOne({ where: { userId } });
}
findAllConfigs(): Promise<UserTradeAccess[]> {
return this.repository.find({ order: { updatedAt: 'DESC' } });
}
}

View File

@@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import { Freight } from '@edr/types';
import { isFreightApprovalAdmin } from '../../common/freight-permission.util';
import { UserTradeAccess } from './entities/user-trade-access.entity';
import { UserTradeAccessRepository } from './user-trade-access.repository';
const ALL: Freight.ScheduleTradeDirection[] = ['IMPORT', 'EXPORT', 'DOMESTIC'];
/** Loose current-user shape: JWT payloads and TCurrentUser both fit. */
export type ScopeUser =
| ({ id?: string; sub?: string; roles?: { key?: string }[] } & object)
| null
| undefined;
export type UserTradeAccessView = {
userId: string;
directions: Freight.ScheduleTradeDirection[];
updatedAt: Date;
};
@Injectable()
export class UserTradeAccessService {
constructor(private readonly repository: UserTradeAccessRepository) {}
async listConfigs(): Promise<UserTradeAccessView[]> {
const rows = await this.repository.findAllConfigs();
return rows.map((r) => this.toView(r));
}
async upsert(
userId: string,
directions: Freight.ScheduleTradeDirection[],
actorId?: string | null,
): Promise<UserTradeAccessView> {
// Normalize to canonical order so "all three" compares reliably.
const normalized = ALL.filter((d) => directions.includes(d));
const existing = await this.repository.findByUserId(userId);
const saved = existing
? await this.repository.update(existing.id, {
directionsRaw: normalized.join(','),
updatedById: actorId ?? null,
})
: await this.repository.create({
userId,
directionsRaw: normalized.join(','),
updatedById: actorId ?? null,
});
return this.toView(saved as UserTradeAccess);
}
/**
* Effective scope for the current user.
* `null` = unrestricted (no config, all three directions, admin, or no user
* on the request — routes without auth cannot be scoped).
*/
async resolveAllowedDirections(
user: ScopeUser,
): Promise<Freight.ScheduleTradeDirection[] | null> {
const userId = user?.id ?? user?.sub;
if (!userId) return null;
if (isFreightApprovalAdmin(user)) return null;
const row = await this.repository.findByUserId(userId);
if (!row) return null;
const dirs = row.directions;
if (dirs.length >= ALL.length) return null;
return dirs;
}
private toView(row: UserTradeAccess): UserTradeAccessView {
return {
userId: row.userId,
directions: row.directions,
updatedAt: row.updatedAt,
};
}
}

View File

@@ -27,12 +27,20 @@ async function main() {
const dataSource = app.get(DataSource);
const inventory = app.get(WarehouseInventoryService);
// Skip bookings destined for a pure facility yard (has a facility but no
// warehouse, e.g. Dire Dawa) — those are fully represented by their
// facility_handling_events UNLOAD record, not a warehouse_inventory row.
// Same gate as BookingJourneyService.autoArriveAtFinalYard.
const bookings: { id: string; tradeDirection: string }[] = await dataSource.query(
`SELECT b.id, b.trade_direction AS "tradeDirection"
FROM freight.bookings b
JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.yard_facilities yf
ON yf.yard_id = dy.id AND yf.deleted_at IS NULL AND yf.is_active = true
WHERE b.deleted_at IS NULL
AND b.trade_direction IN ('IMPORT', 'DOMESTIC')
AND b.status IN ('ARRIVED', 'COMPLETED')
AND NOT (dy.has_facility = true AND COALESCE(yf.has_warehouse, false) = false)
AND NOT EXISTS (
SELECT 1 FROM freight.warehouse_inventory wi
WHERE wi.booking_id = b.id AND wi.deleted_at IS NULL

View File

@@ -59,14 +59,23 @@ export class YardFacilitiesSeeder {
[yard.id],
);
// Every facility works both sides of the trip today, so the per-side
// flags mirror the freight-type flags. Narrow an individual yard here
// when a real one turns out to load a type but not receive it.
await this.dataSource.query(
`INSERT INTO freight.yard_facilities
(yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes)
VALUES ($1, $2, $3, true, $4)
(yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes,
has_container_facility_origin, has_container_facility_destination,
has_bulk_facility_origin, has_bulk_facility_destination)
VALUES ($1, $2, $3, true, $4, $3, $3, true, true)
ON CONFLICT (yard_id) WHERE deleted_at IS NULL
DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse,
handles_container = EXCLUDED.handles_container,
handles_bulk = EXCLUDED.handles_bulk,
has_container_facility_origin = EXCLUDED.has_container_facility_origin,
has_container_facility_destination = EXCLUDED.has_container_facility_destination,
has_bulk_facility_origin = EXCLUDED.has_bulk_facility_origin,
has_bulk_facility_destination = EXCLUDED.has_bulk_facility_destination,
updated_at = NOW()`,
[yard.id, hasWarehouse, handlesContainer, `${facility} load/unload facility`],
);

View File

@@ -55,6 +55,7 @@
"@tanstack/react-table": "^8.21.3",
"@tinymce/tinymce-react": "^6.3.0",
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
"@types/three": "^0.185.3",
"@vis.gl/react-google-maps": "^1.8.3",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.1",
@@ -103,6 +104,7 @@
"sonner": "^2.0.7",
"stream-browserify": "^3.0.0",
"tailwind-merge": "^3.6.0",
"three": "^0.185.1",
"tinymce": "^8.6.0",
"xlsx": "^0.18.5",
"zod": "^3.25.76",

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";
@@ -111,6 +114,7 @@ import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetail
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import TradeAccessPage from "./pages/configuration/TradeAccessPage";
import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage";
import FirstMilePage from "./pages/operations/FirstMilePage";
import LastMilePage from "./pages/operations/LastMilePage";
@@ -153,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",
@@ -425,12 +435,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Container />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Dispatch Queue",
href: "/dashboard/dispatch-queue",
icon: <Send />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory?direction=IMPORT",
@@ -585,6 +589,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/configuration/train-scheduling-rules",
permission: FREIGHT_PERMS.trainScheduling.rulesManage,
},
{
label: "Trade access",
href: "/dashboard/configuration/trade-access",
permission: FREIGHT_PERMS.admin,
},
],
},
{
@@ -817,6 +826,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"
@@ -1549,6 +1560,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="configuration/trade-access"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<TradeAccessPage />
</RequirePermission>
}
/>
{/* <Route
path="configuration/contract-validity-periods"
element={

View File

@@ -1,13 +1,11 @@
import { useMemo, useState } from "react";
import { useQueries, useQuery } from "@tanstack/react-query";
import { Badge, Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core";
import { Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core";
import { Coins, Truck } from "lucide-react";
import { api } from "@/services/api";
import { warehouseService } from "@/services/warehouse.service";
import { lastMileService } from "@/services/last-mile.service";
import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal";
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
import { groupByBooking, TruckRows } from "@/pages/warehouses/ImportTrucksPage";
import { SectionCard } from "./SectionCard";
import { MetricTile } from "./MetricTile";
@@ -15,43 +13,14 @@ import { MetricTile } from "./MetricTile";
const money = (amount: number, currency: string) =>
`${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
const fmt = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleString() : "—");
function inspectionLabel(status: string | null | undefined): { text: string; color: string } {
if (!status) return { text: "Pending", color: "gray" };
if (status === "PASSED") return { text: "Passed", color: "edr-green" };
if (status === "FAILED") return { text: "Failed", color: "red" };
return { text: status, color: "gray" };
}
interface TruckRow {
key: string;
plate: string;
driver: string | null;
truckType: string | null;
containers: string[];
warehouseArrived: string | null;
warehouseDeparted: string | null;
destinationArrived: string | null;
returned: string | null;
detentionOpen: boolean;
detentionDays: number | null;
detentionAmount: number | null;
hasDetentionRule: boolean;
inspection: { text: string; color: string };
}
/**
* Every truck tied to a booking's last mile — EDR-dispatched or customer
* self-haul (a booking only ever uses one), each with its own warehouse-gate
* and destination-detention clocks, plus the booking's cargo-side cost totals
* (storage/demurrage/double handling — billed per row internally, always
* shown here as one booking-level total). Detention stays EDR-only; customer
* self-haul rows show "—" since EDR only bills detention on its own fleet.
* Cargo costs (booking-level totals) plus the same truck-import block the
* Unloaded Queue's "Import trucks" view uses — Truck Arrival/Leaving, Exit
* paper, Handover, Inspect, Detention times, Warehouse gate times — reused
* as-is so this tab never drifts from that queue's behavior.
*/
export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
const [feeModalOpen, setFeeModalOpen] = useState(false);
const [detentionModalOpen, setDetentionModalOpen] = useState(false);
const inventoryQuery = useQuery(
api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }),
@@ -59,47 +28,27 @@ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
const inventoryItems = inventoryQuery.data ?? [];
const latestInventory = inventoryItems[0] ?? null;
const edrTrucksQuery = useQuery({
queryKey: ["booking-edr-trucks", bookingId],
queryFn: () => warehouseService.getLastMileTrucks(bookingId),
});
const edrTrucks = edrTrucksQuery.data ?? [];
const customerTrucksQuery = useQuery({
queryKey: ["booking-customer-trucks", bookingId],
queryFn: () => warehouseService.getCustomerTrucks(bookingId),
enabled: edrTrucksQuery.isSuccess && edrTrucks.length === 0,
});
const customerTrucks = customerTrucksQuery.data ?? [];
const mode: "EDR" | "CUSTOMER" | "NONE" =
edrTrucks.length > 0 ? "EDR" : customerTrucks.length > 0 ? "CUSTOMER" : "NONE";
const containerItemsQuery = useQuery({
queryKey: ["booking-container-items-for-trucks", bookingId],
queryFn: () => warehouseService.getContainerItems(bookingId),
});
const inspectionByContainer = new Map(
(containerItemsQuery.data ?? []).map((c) => [c.containerNumber, c.inspectionStatus]),
// Same query key as the Unloaded Queue page — shares its cache instead of
// refetching the whole queue when it's already loaded elsewhere.
const unloadedQuery = useQuery(api.warehouses.importUnloadedQueue.queryOptions({}));
const bookingRows = useMemo(
() => (unloadedQuery.data ?? []).filter((row) => row.bookingId === bookingId),
[unloadedQuery.data, bookingId],
);
const lastMileId = edrTrucks[0]?.lastMileId ?? null;
const detentionPreviewQuery = useQuery({
queryKey: ["truck-detention-preview-for-trucks-tab", lastMileId],
queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data),
enabled: Boolean(lastMileId),
});
const detentionPreview = detentionPreviewQuery.data;
const detentionByVehicle = new Map(
(detentionPreview?.groups ?? []).map((g) => [g.vehicleId ?? "", g]),
);
const lastMileRecordQuery = useQuery({
queryKey: ["last-mile-record-for-trucks-tab", lastMileId],
queryFn: () => lastMileService.getById(lastMileId as string).then((r) => r.data),
enabled: Boolean(lastMileId),
});
const group = useMemo(() => {
const [existing] = groupByBooking(bookingRows);
return (
existing ?? {
bookingId,
bookingReference: bookingId,
customerName: null,
trainSchedule: null,
status: "NONE",
arrivalTime: null,
rows: [],
}
);
}, [bookingRows, bookingId]);
// Booking-level cost strip: same per-row fee preview the accrual dashboard
// and FeePreviewModal already use, summed across every inventory row on
@@ -114,61 +63,7 @@ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
const sumByType = (type: string) =>
allFees.filter((f) => f.ruleType === type).reduce((sum, f) => sum + Number(f.amount || 0), 0);
const rows: TruckRow[] = useMemo(() => {
if (mode === "EDR") {
return edrTrucks.map((t) => {
const g = detentionByVehicle.get(t.vehicleId);
return {
key: t.vehicleId,
plate: [t.truckPlateNumber, t.trailerPlateNumber].filter(Boolean).join(" + ") || "—",
driver: t.driverName,
truckType: t.truckType,
containers: t.containerNumber ? [t.containerNumber] : [],
warehouseArrived: t.arrivedAt,
warehouseDeparted: t.departedAt,
destinationArrived: g?.startDate ?? null,
returned: g?.endIsOpen ? null : g?.endDate ?? null,
detentionOpen: Boolean(g?.endIsOpen),
detentionDays: g?.chargeableDays ?? null,
detentionAmount: g?.amount ?? null,
hasDetentionRule: Boolean(g?.ruleId),
inspection: inspectionLabel(t.containerNumber ? inspectionByContainer.get(t.containerNumber) : undefined),
};
});
}
if (mode === "CUSTOMER") {
return customerTrucks.map((t) => {
const containers = (t.containers ?? []).map((c) => c.containerNumber);
const statuses = new Set(containers.map((cn) => inspectionByContainer.get(cn) ?? null));
const inspection =
containers.length === 0
? inspectionLabel(undefined)
: statuses.size > 1
? { text: "Mixed", color: "yellow" }
: inspectionLabel([...statuses][0]);
return {
key: t.id,
plate: t.plateNumber,
driver: t.driverName,
truckType: t.truckType,
containers,
warehouseArrived: t.arrivedAt ?? null,
warehouseDeparted: t.departedAt ?? null,
destinationArrived: null,
returned: null,
detentionOpen: false,
detentionDays: null,
detentionAmount: null,
hasDetentionRule: false,
inspection,
};
});
}
return [];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, edrTrucks, customerTrucks, inspectionByContainer, detentionByVehicle]);
if (inventoryQuery.isLoading || edrTrucksQuery.isLoading) {
if (inventoryQuery.isLoading || unloadedQuery.isLoading) {
return (
<Center py={60}>
<Group gap={10}>
@@ -201,87 +96,14 @@ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
</SimpleGrid>
</SectionCard>
<SectionCard
icon={Truck}
title="Trucks"
subtitle={
mode === "EDR" ? "EDR Last Mile" : mode === "CUSTOMER" ? "Customer Self-Haul" : undefined
}
accent="grape"
extra={
mode === "EDR" && (
<Button size="xs" variant="light" onClick={() => setDetentionModalOpen(true)}>
Detention times
</Button>
)
}
>
{rows.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="md">
No trucks assigned to this booking's last mile yet.
</Text>
) : (
<Table.ScrollContainer minWidth={1000}>
<Table verticalSpacing="xs" fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Plate</Table.Th>
<Table.Th>Driver</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Container(s)</Table.Th>
<Table.Th>Wh. arrived</Table.Th>
<Table.Th>Wh. departed</Table.Th>
<Table.Th>Dest. arrived</Table.Th>
<Table.Th>Returned</Table.Th>
<Table.Th>Detention</Table.Th>
<Table.Th>Inspection</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r) => (
<Table.Tr key={r.key}>
<Table.Td>{r.plate}</Table.Td>
<Table.Td>{r.driver ?? "—"}</Table.Td>
<Table.Td>{r.truckType ?? "—"}</Table.Td>
<Table.Td>{r.containers.length ? r.containers.join(", ") : "—"}</Table.Td>
<Table.Td>{fmt(r.warehouseArrived)}</Table.Td>
<Table.Td>{fmt(r.warehouseDeparted)}</Table.Td>
<Table.Td>{fmt(r.destinationArrived)}</Table.Td>
<Table.Td>
{r.detentionOpen ? (
<Badge size="xs" color="orange" variant="light">
still out
</Badge>
) : (
fmt(r.returned)
)}
</Table.Td>
<Table.Td>
{mode !== "EDR" || r.detentionDays == null ? (
"—"
) : (
<>
{r.detentionDays}d · {money(r.detentionAmount ?? 0, detentionPreview?.currency ?? "USD")}
{!r.hasDetentionRule && (
<Text span size="xs" c="red">
{" "}
· no rule
</Text>
)}
</>
)}
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={r.inspection.color}>
{r.inspection.text}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<SectionCard icon={Truck} title="Trucks" accent="grape">
<Table.ScrollContainer minWidth={1100}>
<Table verticalSpacing="xs" fz="xs">
<Table.Tbody>
<TruckRows group={group} />
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</SectionCard>
<FeePreviewModal
@@ -289,13 +111,6 @@ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
onClose={() => setFeeModalOpen(false)}
inventoryId={latestInventory?.id ?? null}
/>
{mode === "EDR" && (
<TruckDetentionModal
opened={detentionModalOpen}
onClose={() => setDetentionModalOpen(false)}
record={lastMileRecordQuery.data ?? null}
/>
)}
</Stack>
);
}

View File

@@ -172,11 +172,11 @@ function emptyUnit(): UnitDraft {
function emptyLine(size: string): ContainerLineDraft {
return {
containerSize: size,
quantity: "1",
quantity: "0",
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: [emptyUnit()],
units: [],
};
}

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

@@ -1,5 +1,5 @@
import { useState } from "react";
import { FileSignature, Loader2 } from "lucide-react";
import { FileSignature, Loader2, Stamp } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast";
@@ -27,9 +27,9 @@ import {
} from "@edr/ui-common";
/**
* Lets the signed-in user view and update the reusable signature stored on
* their profile. The same signature is offered for approval when signing a
* booking contract.
* Lets the signed-in user view and update the reusable signature and company
* stamp stored on their profile — managed independently of each other. Both
* are offered when signing a booking contract.
*/
export function MySignatureCard() {
const { user } = useAuth();
@@ -38,42 +38,63 @@ export function MySignatureCard() {
);
const saveMutation = useMutation(api.signatures.save.mutationOptions());
const [open, setOpen] = useState(false);
const [signatureOpen, setSignatureOpen] = useState(false);
const [stampOpen, setStampOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
const [stampData, setStampData] = useState<string | null>(null);
const defaultName =
user?.name?.en || user?.username || user?.email || "";
const savedName = saved?.signerDisplayName ?? defaultName;
const openDialog = () => {
setSignerName(saved?.signerDisplayName ?? defaultName);
const openSignatureDialog = () => {
setSignerName(savedName);
setSignatureData(null);
setStampData(saved?.stampImageUrl ?? null);
setOpen(true);
setSignatureOpen(true);
};
const save = () => {
const saveSignature = () => {
if (!signatureData || !signerName.trim()) return;
saveMutation.mutate(
{
signerDisplayName: signerName.trim(),
signatureImageBase64: signatureData,
// Only send the stamp when it changed — omitted keeps the saved one.
...(stampData && stampData !== saved?.stampImageUrl
? { stampImageBase64: stampData }
: {}),
// Stamp untouched — it is managed by its own dialog.
},
{
onSuccess: () => {
toast.success("Signature saved");
setOpen(false);
setSignatureOpen(false);
},
onError: () => toast.error("Failed to save signature"),
},
);
};
const openStampDialog = () => {
setStampData(saved?.stampImageUrl ?? null);
setStampOpen(true);
};
const saveStamp = () => {
if (!stampData) return;
saveMutation.mutate(
{
signerDisplayName: savedName || defaultName,
// Signature untouched — stamp-only update.
stampImageBase64: stampData,
},
{
onSuccess: () => {
toast.success("Stamp saved");
setStampOpen(false);
},
onError: () => toast.error("Failed to save stamp"),
},
);
};
return (
<Card>
<CardHeader>
@@ -85,47 +106,64 @@ export function MySignatureCard() {
This signature can be reused to sign booking contracts.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-6">
{isLoading ? (
<div className="flex h-36 items-center justify-center">
<Loader2 className="size-6 animate-spin text-primary" />
</div>
) : saved?.signatureImageUrl ? (
<div className="space-y-2">
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.signatureImageUrl}
alt="My saved signature"
className="mx-auto h-36 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">
Saved as {saved.signerDisplayName}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground">
You have not saved a signature yet.
</p>
)}
{saved?.stampImageUrl && (
<div className="space-y-2">
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.stampImageUrl}
alt="My saved company stamp"
className="mx-auto h-24 w-full object-contain"
/>
<>
<div className="space-y-2">
{saved?.signatureImageUrl ? (
<>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.signatureImageUrl}
alt="My saved signature"
className="mx-auto h-36 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">
Saved as {saved.signerDisplayName}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
You have not saved a signature yet.
</p>
)}
<Button variant="outline" size="sm" onClick={openSignatureDialog}>
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
</Button>
</div>
<p className="text-xs text-muted-foreground">Company stamp</p>
</div>
<div className="space-y-2">
{saved?.stampImageUrl ? (
<>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.stampImageUrl}
alt="My saved company stamp"
className="mx-auto h-24 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">Company stamp</p>
</>
) : (
<p className="text-sm text-muted-foreground">
You have not uploaded a company stamp yet.
</p>
)}
<Button variant="outline" size="sm" onClick={openStampDialog}>
<Stamp className="size-4" />
{saved?.stampImageUrl ? "Update stamp" : "Upload stamp"}
</Button>
</div>
</>
)}
<Button variant="outline" size="sm" onClick={openDialog}>
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
</Button>
</CardContent>
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={signatureOpen} onOpenChange={setSignatureOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Save your signature</DialogTitle>
@@ -145,21 +183,16 @@ export function MySignatureCard() {
/>
</div>
<ContractSignaturePad onChange={setSignatureData} />
<StampUpload
value={stampData}
onChange={setStampData}
description="Stored on your profile and prefilled when you sign contracts."
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
<Button variant="outline" onClick={() => setSignatureOpen(false)}>
Cancel
</Button>
<Button
disabled={
saveMutation.isPending || !signatureData || !signerName.trim()
}
onClick={save}
onClick={saveSignature}
>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
@@ -170,6 +203,39 @@ export function MySignatureCard() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={stampOpen} onOpenChange={setStampOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Company stamp</DialogTitle>
<DialogDescription>
Upload your official company stamp or seal as an image. It is
stored on your profile and applied next to your signature on
contracts.
</DialogDescription>
</DialogHeader>
<StampUpload
value={stampData}
onChange={setStampData}
description="Stored on your profile and prefilled when you sign contracts."
/>
<DialogFooter>
<Button variant="outline" onClick={() => setStampOpen(false)}>
Cancel
</Button>
<Button
disabled={saveMutation.isPending || !stampData}
onClick={saveStamp}
>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Save stamp"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

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

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