diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 73e28205e..0e7949d7e 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/migrations/3150000000000-CreateUserTradeAccess.ts b/apps/edr-freight-api/src/migrations/3150000000000-CreateUserTradeAccess.ts new file mode 100644 index 000000000..9e7cba924 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3150000000000-CreateUserTradeAccess.ts @@ -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 { + 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 { + await queryRunner.dropTable('freight.user_trade_access', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/3170000000000-YardFacilityOriginDestination.ts b/apps/edr-freight-api/src/migrations/3170000000000-YardFacilityOriginDestination.ts new file mode 100644 index 000000000..fb361b702 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3170000000000-YardFacilityOriginDestination.ts @@ -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 { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3180000000000-PortYardFacilityRecords.ts b/apps/edr-freight-api/src/migrations/3180000000000-PortYardFacilityRecords.ts new file mode 100644 index 000000000..f07252959 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3180000000000-PortYardFacilityRecords.ts @@ -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 { + 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 { + // Data seed — the inserted rows are indistinguishable from operator edits + // afterwards, so reversing would risk deleting real configuration. + } +} diff --git a/apps/edr-freight-api/src/migrations/3190000000000-AddCargoTypeTonsPerWagonMap.ts b/apps/edr-freight-api/src/migrations/3190000000000-AddCargoTypeTonsPerWagonMap.ts new file mode 100644 index 000000000..04fb6c1aa --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3190000000000-AddCargoTypeTonsPerWagonMap.ts @@ -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 { + await queryRunner.query( + `ALTER TABLE "freight"."cargo_types" ADD COLUMN IF NOT EXISTS "tons_per_wagon_map" jsonb`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."cargo_types" DROP COLUMN IF EXISTS "tons_per_wagon_map"`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3200000000000-AddScheduleWindowRuleCustom.ts b/apps/edr-freight-api/src/migrations/3200000000000-AddScheduleWindowRuleCustom.ts new file mode 100644 index 000000000..821c67135 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3200000000000-AddScheduleWindowRuleCustom.ts @@ -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 { + 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 { + await queryRunner.query( + `ALTER TABLE "freight"."train_schedules" DROP COLUMN IF EXISTS "window_rule_custom"`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3210000000000-EmptyContainerReturnedBy.ts b/apps/edr-freight-api/src/migrations/3210000000000-EmptyContainerReturnedBy.ts new file mode 100644 index 000000000..b6f23b19e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3210000000000-EmptyContainerReturnedBy.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class EmptyContainerReturnedBy3210000000000 implements MigrationInterface { + name = 'EmptyContainerReturnedBy3210000000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(` + ALTER TABLE freight.empty_container_returns + DROP COLUMN IF EXISTS returned_by + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3220000000000-EmptyContainerReturnStatusHistory.ts b/apps/edr-freight-api/src/migrations/3220000000000-EmptyContainerReturnStatusHistory.ts new file mode 100644 index 000000000..da9267713 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3220000000000-EmptyContainerReturnStatusHistory.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class EmptyContainerReturnStatusHistory3220000000000 implements MigrationInterface { + name = 'EmptyContainerReturnStatusHistory3220000000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(` + ALTER TABLE freight.empty_container_returns + DROP COLUMN IF EXISTS status_history + `); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/dto/list-users-query.dto.ts b/apps/edr-freight-api/src/modules/auth/dto/list-users-query.dto.ts new file mode 100644 index 000000000..2325568e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/dto/list-users-query.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index 10dbd0b37..ff8f803b9 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/auth/list-users.controller.ts b/apps/edr-freight-api/src/modules/auth/list-users.controller.ts new file mode 100644 index 000000000..e7fbfd771 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/list-users.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/list-users.service.ts b/apps/edr-freight-api/src/modules/auth/list-users.service.ts new file mode 100644 index 000000000..cf7e22be2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/list-users.service.ts @@ -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, + ) {} + + findAll(query: ListUsersQueryDto): Promise> { + 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); + } +} diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index d49cc304e..49384922e 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -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 { + 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, diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index b9f0a74c0..49772316a 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -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") diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 771156fd3..a67e6f6ba 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -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], diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 24046aa5c..bd2355df5 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -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 }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index b44c1c5e0..9bbe6bed5 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index 06268342b..2c60b8771 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -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 { - 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 => ({ diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 149875e6e..181c47b2d 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -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. diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 7b1f34f36..837d0d801 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 38d7d2ac6..2063bb01d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 78004f024..35f84e419 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -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 { 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 { .getMany(); } - /** Open unpaid holds (wagons reserved, pay window running) for a company. */ - countUnpaidHoldsForCompany(companyId: string): Promise { - 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 { return this.repository diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index b8f73dfc8..12c56ac13 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -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 { - 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 { 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, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts index a793cc558..4c51d85a6 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 8da28e400..7e0a23e3f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -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 { - 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); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 8c004ade0..ec61fdd4a 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -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'); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 658acf39d..0e60462a4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 49ef3a29f..a7fddd816 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -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 { 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, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 5c0b5e29e..6e170f126 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -748,6 +748,7 @@ export class ContractsService { filter: FilterContractDto, forceCompanyId?: string, forceCompanyProfileId?: string, + tradeDirections?: string[], ): Promise { 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, diff --git a/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts index 1cf4c72e5..b3ec979b4 100644 --- a/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts +++ b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts index 727aee322..2538a204b 100644 --- a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts +++ b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts @@ -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; + }>; } diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts index bbc1228a5..bd40e2ab1 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -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 } }); } diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts index be7964a3d..e3e711d19 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts @@ -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]; } } diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts index ab914deff..005dad08a 100644 --- a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts index d4e5c2782..7c60a91a6 100644 --- a/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts @@ -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[]; diff --git a/apps/edr-freight-api/src/modules/overview/overview.controller.ts b/apps/edr-freight-api/src/modules/overview/overview.controller.ts index e63cc5d01..661350f49 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.controller.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.controller.ts @@ -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 { - return this.overviewService.getDashboard(query.range ?? '30d'); + async getDashboard( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + 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 { - return this.overviewService.getBookingsTab(query.range ?? '30d'); + async getBookingsTab( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + 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 { - return this.overviewService.getContractsTab(query.range ?? '30d'); + async getContractsTab( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + 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 { - return this.overviewService.getBillingTab(query.range ?? '30d'); + async getBillingTab( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + 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 { - return this.overviewService.getOperationsTab(); + getOperationsTab( + @Query() query: OverviewQueryDto, + ): Promise { + 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 { - return this.overviewService.getCustomersTab(query.range ?? '30d'); + async getCustomersTab( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.overviewService.getCustomersTab( + query.range ?? '30d', + allowed ?? undefined, + ); } @Get('staff') diff --git a/apps/edr-freight-api/src/modules/overview/overview.module.ts b/apps/edr-freight-api/src/modules/overview/overview.module.ts index 43fcdac0f..a20f75ed2 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.module.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.module.ts @@ -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], diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index 4e56f415f..c61320d8c 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -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, @InjectRepository(Train) private readonly trainRepository: Repository, + @InjectRepository(TrainSchedule) + private readonly trainScheduleRepository: Repository, @InjectRepository(Wagon) private readonly wagonRepository: Repository, @InjectRepository(Container) @@ -93,10 +106,12 @@ export class OverviewRepository { private readonly userRepository: Repository, ) { } - async getBookingKpis(): Promise { + async getBookingKpis(dirs?: string[]): Promise { + 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>(); 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>(); 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> { + async getStatusCounts(dirs?: string[]): Promise> { + 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 { + async getRecentBookings( + limit: number, + dirs?: string[], + ): Promise { + 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, 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 { + async getContractKpis(dirs?: string[]): Promise { + 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>(); 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> { + async getContractStatusCounts( + dirs?: string[], + ): Promise> { + 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 { + async getRecentContracts( + limit: number, + dirs?: string[], + ): Promise { + 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<{ diff --git a/apps/edr-freight-api/src/modules/overview/overview.service.ts b/apps/edr-freight-api/src/modules/overview/overview.service.ts index 8a7b71b69..399bf4f72 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.service.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.service.ts @@ -40,7 +40,10 @@ export class OverviewService { return { bookingsByPipeline, bookingsByStatus }; } - async getDashboard(range: OverviewRangeQuery = '30d'): Promise { + async getDashboard( + range: OverviewRangeQuery = '30d', + dirs?: string[], + ): Promise { 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 { + async getBookingsTab( + range: OverviewRangeQuery = '30d', + dirs?: string[], + ): Promise { 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 { 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 { + async getBillingTab( + range: OverviewRangeQuery = '30d', + dirs?: string[], + ): Promise { 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 { + async getOperationsTab( + range: OverviewRangeQuery = '30d', + ): Promise { + 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 { + async getCustomersTab( + range: OverviewRangeQuery = '30d', + dirs?: string[], + ): Promise { 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 { diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index 94c1268f6..b7dfa3c7e 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 2ae62d4f3..3602657de 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -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, }); } diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 961aa32bd..220250d93 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 669c7c27b..99c4aca46 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -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") diff --git a/apps/edr-freight-api/src/modules/reports/dto/report-query.dto.ts b/apps/edr-freight-api/src/modules/reports/dto/report-query.dto.ts new file mode 100644 index 000000000..82b1a76ef --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/dto/report-query.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/reports/dto/report-result.dto.ts b/apps/edr-freight-api/src/modules/reports/dto/report-result.dto.ts new file mode 100644 index 000000000..cc1c215af --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/dto/report-result.dto.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/reports/report-queries.ts b/apps/edr-freight-api/src/modules/reports/report-queries.ts new file mode 100644 index 000000000..9e4a6f617 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report-queries.ts @@ -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[]; +} + +type ReportQuery = (ds: DataSource, f: ReportFilters) => Promise; + +// 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[], 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) => 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) => + 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) => 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) => + ['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) => 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) => 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) => 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) => 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) => 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 = { + '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, +}; diff --git a/apps/edr-freight-api/src/modules/reports/reports.controller.ts b/apps/edr-freight-api/src/modules/reports/reports.controller.ts new file mode 100644 index 000000000..bc2671680 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/reports.controller.ts @@ -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 { + const allowed = await this.userTradeAccessService.resolveAllowedDirections(user); + return this.reportsService.run(key, query, allowed); + } +} diff --git a/apps/edr-freight-api/src/modules/reports/reports.module.ts b/apps/edr-freight-api/src/modules/reports/reports.module.ts new file mode 100644 index 000000000..a7fe792a5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/reports.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/reports/reports.repository.ts b/apps/edr-freight-api/src/modules/reports/reports.repository.ts new file mode 100644 index 000000000..65f154b22 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/reports.repository.ts @@ -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 { + return REPORT_QUERIES[key](this.dataSource, filters); + } +} diff --git a/apps/edr-freight-api/src/modules/reports/reports.service.ts b/apps/edr-freight-api/src/modules/reports/reports.service.ts new file mode 100644 index 000000000..04e6e9a60 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/reports.service.ts @@ -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 { + 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); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index 6d9cb82a2..eefc560e8 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -44,6 +44,19 @@ export class CreateCargoTypeDto { @IsObject() itemsPerWagonMap?: Record | null; + @ApiPropertyOptional({ + description: + 'PER_TON cargo only: the most tons of this cargo one wagon may carry, keyed by ' + + 'wagon-type id (e.g. { "": 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 | null; + @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts index 295e9e72b..94dbd2fb8 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index b096613bf..e685f3a2d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -60,6 +60,17 @@ export class CargoType extends BaseEntity { @Column({ name: 'items_per_wagon_map', type: 'jsonb', nullable: true }) itemsPerWagonMap?: Record | 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 | null; + @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts index 5712eed03..9c69f0135 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 691992c54..2e1fd8090 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 34287a023..1f25b0823 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -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 | null; + }): Promise | 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 = {}; + 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 { 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 diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts index 4b69c328f..9602125bb 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts @@ -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[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> { + if (yardIds.length === 0) return new Map(); + const rows: Array = 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): Promise { + 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; + } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts index 8affab199..0ec9fe282 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.duplicate-label.spec.ts @@ -16,6 +16,10 @@ const service = (): YardsService => update: async (_id: string, d: Partial) => 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', () => { diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts index 7dd29ded1..995a901ab 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts @@ -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> { - return this.repository.findPaged(query); + async findAll(query: ListYardsQueryDto): Promise> { + 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 { + async findById(id: string): Promise { 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): Partial | null { + const flags: Partial = {}; + 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 { 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). */ diff --git a/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts index a1f245e23..6d7898b53 100644 --- a/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts +++ b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts @@ -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: diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts index 836888dab..d346457a0 100644 --- a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts +++ b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index 0581f6000..5730120e9 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 77a3f8e55..966e051d3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -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 { // 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(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 { + 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 { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts new file mode 100644 index 000000000..0a8cc99fe --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts @@ -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> = []; + const savedItems: Array> = []; + 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) => v), + save: jest.fn(async (v: Record) => { + 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) => v), + save: jest.fn(async (v: Record) => { + 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; + }).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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 87e756ba6..8668b4f75 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -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 { + 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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index b806de5ca..84d029e4f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -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. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 9dfea0781..e8716eb99 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index bea01af0a..2769c212b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -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)); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index ead3a4e8b..ed476a6e1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -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( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 867da564b..63feb6e8c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -152,8 +152,98 @@ export function bulkItemWagonsRequired( type ItemFitCargoType = { wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null; itemsPerWagonMap?: Record | null; + tonsPerWagonMap?: Record | 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[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[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[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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 7b0f37b66..742bd2837 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -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") diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 35503d432..56f2cff00 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 406f51988..455dc3d9c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -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) => ({ ...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', () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 9c60c9821..44c50e185 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -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(source: T): Partial { + return Object.fromEntries( + Object.entries(source).filter(([, v]) => v !== undefined), + ) as Partial; +} + /** * 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, '&') @@ -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 + ? `ON BOARD — WAGON NOT RECORDED` + + 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 ` + ${esc(booking.reference)} — ${esc(leg)} + ${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)} + ${esc(booking.company?.name)} + ${esc(containerNumbers)} + - + - + `; + }) + .join('') + : ''; const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length; const totalWeight = wagons.reduce( (sum, wagon) => @@ -3034,7 +3219,7 @@ export class TrainSchedulingService { - Export Marshalling Document + ${esc(opts?.title ?? 'Export Marshalling Document')}