mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into fixes
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-backoffice-user trade-direction scope (import / export / intercity).
|
||||
* A user with no row (or all three directions) is unrestricted. Admins
|
||||
* (super_admin / organization_admin) bypass the scope entirely.
|
||||
*/
|
||||
export class CreateUserTradeAccess3150000000000 implements MigrationInterface {
|
||||
name = 'CreateUserTradeAccess3150000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'user_trade_access',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||
// IAM user id (iam.users) — no FK, iam schema is externally owned.
|
||||
{ name: 'user_id', type: 'uuid', isUnique: true },
|
||||
// Comma-separated subset of IMPORT,EXPORT,DOMESTIC (simple-array).
|
||||
{ name: 'directions', type: 'text', default: "''" },
|
||||
{ name: 'updated_by_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'freight.user_trade_access',
|
||||
new TableIndex({
|
||||
name: 'idx_user_trade_access_user_id',
|
||||
columnNames: ['user_id'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.user_trade_access', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Handling a freight type is not the same as handling it in both directions. A
|
||||
* facility can be equipped to load containers onto a train but have no yard
|
||||
* space to receive and stage inbound ones, so the origin and destination sides
|
||||
* are stated independently per freight type.
|
||||
*
|
||||
* Backfilled from `handles_container` / `handles_bulk` so every existing row
|
||||
* keeps its current behaviour: a facility that handles a type today handles it
|
||||
* on both sides until someone narrows it in the backoffice. New rows default
|
||||
* false — an unconfigured facility offers nothing rather than silently
|
||||
* offering everything.
|
||||
*/
|
||||
export class YardFacilityOriginDestination3170000000000 implements MigrationInterface {
|
||||
name = 'YardFacilityOriginDestination3170000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.yard_facilities
|
||||
ADD COLUMN IF NOT EXISTS has_container_facility_origin boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS has_bulk_facility_origin boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS has_container_facility_destination boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS has_bulk_facility_destination boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.yard_facilities
|
||||
SET has_container_facility_origin = handles_container,
|
||||
has_container_facility_destination = handles_container,
|
||||
has_bulk_facility_origin = handles_bulk,
|
||||
has_bulk_facility_destination = handles_bulk
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.yard_facilities
|
||||
DROP COLUMN IF EXISTS has_container_facility_origin,
|
||||
DROP COLUMN IF EXISTS has_bulk_facility_origin,
|
||||
DROP COLUMN IF EXISTS has_container_facility_destination,
|
||||
DROP COLUMN IF EXISTS has_bulk_facility_destination
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 3170 backfilled the per-side facility flags onto EXISTING yard_facilities
|
||||
* rows, but the Djibouti port yards (Negad, the Doraleh terminals) are flagged
|
||||
* `has_facility` without ever getting a facility record — the seeder only
|
||||
* covers the inland intercity facilities. With the contract route picker now
|
||||
* gating on the per-side flags, those yards report false on every side and
|
||||
* vanish: import contracts lose all origin options, exports all destinations.
|
||||
*
|
||||
* Give every facility-flagged yard that has no live record one with both
|
||||
* freight types open on both sides — exactly the offerability these yards had
|
||||
* before the gate existed. Ops can narrow a port from the backoffice yards
|
||||
* page, which now edits these flags.
|
||||
*/
|
||||
export class PortYardFacilityRecords3180000000000 implements MigrationInterface {
|
||||
name = 'PortYardFacilityRecords3180000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.yard_facilities
|
||||
(yard_id, has_warehouse, handles_container, handles_bulk,
|
||||
has_container_facility_origin, has_bulk_facility_origin,
|
||||
has_container_facility_destination, has_bulk_facility_destination)
|
||||
SELECT y.id, false, true, true, true, true, true, true
|
||||
FROM freight.yards y
|
||||
WHERE y.deleted_at IS NULL
|
||||
AND y.has_facility = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.yard_facilities f
|
||||
WHERE f.yard_id = y.id AND f.deleted_at IS NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// Data seed — the inserted rows are indistinguishable from operator edits
|
||||
// afterwards, so reversing would risk deleting real configuration.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* PER_TON (bulk) cargo can have a loading limit BELOW the wagon's rated
|
||||
* capacity: sugar rides 50T on a 70T wagon (density/stowage/policy), so 200T
|
||||
* needs 4 wagons, not the 3 that raw capacity implies. Stored as a jsonb map
|
||||
* { [wagonTypeId]: maxTons } on cargo_types — the PER_TON mirror of
|
||||
* items_per_wagon_map. Unset (or no key) means the wagon's full rated capacity,
|
||||
* so existing cargo types keep their current behaviour with no backfill.
|
||||
*/
|
||||
export class AddCargoTypeTonsPerWagonMap3190000000000 implements MigrationInterface {
|
||||
name = 'AddCargoTypeTonsPerWagonMap3190000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."cargo_types" ADD COLUMN IF NOT EXISTS "tons_per_wagon_map" jsonb`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."cargo_types" DROP COLUMN IF EXISTS "tons_per_wagon_map"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Marks a train schedule whose booking-window rule was configured by staff at
|
||||
* creation rather than inherited from the live global rules.
|
||||
*
|
||||
* Without this flag `restampPendingWindows` — which re-derives EVERY still
|
||||
* PRE_WINDOW schedule from the current global config after a global-rules edit —
|
||||
* would silently overwrite those hand-picked settings, which is precisely what
|
||||
* the per-schedule configuration exists to prevent.
|
||||
*
|
||||
* Defaults false, so every existing schedule keeps following the global rules.
|
||||
*/
|
||||
export class AddScheduleWindowRuleCustom3200000000000 implements MigrationInterface {
|
||||
name = 'AddScheduleWindowRuleCustom3200000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."train_schedules" ADD COLUMN IF NOT EXISTS "window_rule_custom" boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "freight"."train_schedules" DROP COLUMN IF EXISTS "window_rule_custom"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class EmptyContainerReturnedBy3210000000000 implements MigrationInterface {
|
||||
name = 'EmptyContainerReturnedBy3210000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.empty_container_returns
|
||||
ADD COLUMN IF NOT EXISTS returned_by varchar(20) NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.empty_container_returns
|
||||
DROP COLUMN IF EXISTS returned_by
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class EmptyContainerReturnStatusHistory3220000000000 implements MigrationInterface {
|
||||
name = 'EmptyContainerReturnStatusHistory3220000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.empty_container_returns
|
||||
ADD COLUMN IF NOT EXISTS status_history jsonb NOT NULL DEFAULT '[]'::jsonb
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.empty_container_returns
|
||||
SET status_history = jsonb_build_array(
|
||||
jsonb_build_object('status', status, 'changedAt', created_at, 'performedBy', performed_by)
|
||||
)
|
||||
WHERE status_history = '[]'::jsonb
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.empty_container_returns
|
||||
DROP COLUMN IF EXISTS status_history
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
69
apps/edr-freight-api/src/modules/auth/list-users.service.ts
Normal file
69
apps/edr-freight-api/src/modules/auth/list-users.service.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ListUsersQueryDto } from './dto/list-users-query.dto';
|
||||
import { paginateQuery } from '../../common/utils/pagination.util';
|
||||
|
||||
/**
|
||||
* Read-only listing of `iam.users` for backoffice pickers.
|
||||
*
|
||||
* Exists because `@tria-plc/iamapi-common@1.0.0`'s `GET /users/filter` pairs a
|
||||
* `@QueryParams()` pagination DTO with a plain `@Query()` DTO that does not
|
||||
* declare `skip`/`take`/`orderBy`; the global whitelist pipe then 400s on the
|
||||
* very params the route's own paginator reads. Drop this once IAM ships a fix.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ListUsersService {
|
||||
constructor(
|
||||
@InjectRepository(User) private readonly users: Repository<User>,
|
||||
) {}
|
||||
|
||||
findAll(query: ListUsersQueryDto): Promise<PaginatedResponse<User>> {
|
||||
const sortBy = query.sortBy ?? 'username';
|
||||
const qb = this.users
|
||||
.createQueryBuilder('user')
|
||||
// Explicit select: never widen this to `user` — the entity's lazy
|
||||
// relations include credentials and sessions.
|
||||
.select([
|
||||
'user.id',
|
||||
'user.name',
|
||||
'user.username',
|
||||
'user.email',
|
||||
'user.phoneNumber',
|
||||
'user.userType',
|
||||
'user.status',
|
||||
'user.isActive',
|
||||
'user.createdAt',
|
||||
])
|
||||
.orderBy(`user.${sortBy}`, query.sortOrder ?? 'ASC');
|
||||
|
||||
if (query.userType) {
|
||||
qb.andWhere('user.userType = :userType', { userType: query.userType });
|
||||
}
|
||||
if (query.userStatus) {
|
||||
qb.andWhere('user.status = :userStatus', { userStatus: query.userStatus });
|
||||
}
|
||||
if (query.isActive !== undefined) {
|
||||
qb.andWhere('user.isActive = :isActive', { isActive: query.isActive });
|
||||
}
|
||||
if (query.search) {
|
||||
// `name` is localized jsonb ({ en, am, … }), not a string — match its
|
||||
// values rather than casting the whole object to text.
|
||||
qb.andWhere(
|
||||
`(user.username ILIKE :search
|
||||
OR user.email ILIKE :search
|
||||
OR user.phone_number ILIKE :search
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM jsonb_each_text(user.name) AS n(k, v)
|
||||
WHERE n.v ILIKE :search
|
||||
))`,
|
||||
{ search: `%${query.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
return paginateQuery(qb, query);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,30 @@ export class BackofficeService {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* IAM user ids of current employees (any org) holding ANY of the given
|
||||
* permission keys — used by the notification recipients resolver's
|
||||
* `permissionKeys` selector for department/role-scoped targeting.
|
||||
*/
|
||||
async getEmployeeUserIdsByPermission(
|
||||
permissionKeys: string[],
|
||||
): Promise<string[]> {
|
||||
if (!permissionKeys.length) return [];
|
||||
const rows: { userId: string | null }[] = await this.employeeRepository
|
||||
.createQueryBuilder("employee")
|
||||
.innerJoin("employee.employeePositions", "employeePosition")
|
||||
.innerJoin("employeePosition.position", "position")
|
||||
.innerJoin("position.positionPermission", "positionPermission")
|
||||
.innerJoin("positionPermission.permission", "permission")
|
||||
.where("employee.isCurrent = :isCurrent", { isCurrent: true })
|
||||
.andWhere("permission.key IN (:...permissionKeys)", { permissionKeys })
|
||||
.select("DISTINCT employee.user_id", "userId")
|
||||
.getRawMany();
|
||||
return rows
|
||||
.map((r) => r.userId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
async createOrganizationUser(
|
||||
organizationId: string,
|
||||
dto: CreateOrganizationUserDto,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
IYardsRepository,
|
||||
YARDS_REPOSITORY,
|
||||
} from "../rule-engine/interfaces/yards.repository.interface";
|
||||
import { YardFacilitiesService } from "../rule-engine/services/yard-facilities.service";
|
||||
import {
|
||||
BookingReferenceCargoTypeChildDto,
|
||||
BookingReferenceCargoTypeGroupDto,
|
||||
@@ -170,11 +171,18 @@ export class BookingReferenceDataService {
|
||||
private readonly shippingLinesRepository: IShippingLinesRepository,
|
||||
@Inject(CARGO_TYPES_REPOSITORY)
|
||||
private readonly cargoTypesRepository: ICargoTypesRepository,
|
||||
private readonly yardFacilitiesService: YardFacilitiesService,
|
||||
) { }
|
||||
|
||||
async getReferenceData(): Promise<BookingReferenceDataDto> {
|
||||
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
|
||||
await Promise.all([
|
||||
const [
|
||||
yards,
|
||||
containerTypes,
|
||||
serviceTypes,
|
||||
shippingLines,
|
||||
cargoTypes,
|
||||
facilityYards,
|
||||
] = await Promise.all([
|
||||
this.yardsRepository.findAll({
|
||||
where: { isActive: true },
|
||||
order: { displayOrder: "ASC", code: "ASC" },
|
||||
@@ -195,17 +203,30 @@ export class BookingReferenceDataService {
|
||||
where: { isActive: true },
|
||||
order: { displayOrder: "ASC", code: "ASC" },
|
||||
}),
|
||||
this.yardFacilitiesService.listFacilityYards(),
|
||||
]);
|
||||
|
||||
// Every active yard is still listed; a yard with no facility record simply
|
||||
// reports no capability, so the forms drop it from the pickers themselves.
|
||||
const facilityByYardId = new Map(facilityYards.map((f) => [f.yardId, f]));
|
||||
|
||||
return {
|
||||
yard: yards.map((y): BookingReferenceYardDto => {
|
||||
const facility = facilityByYardId.get(y.id);
|
||||
return {
|
||||
yard: yards.map(
|
||||
(y): BookingReferenceYardDto => ({
|
||||
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 => ({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import {
|
||||
BookingDocumentReview,
|
||||
@@ -64,6 +65,8 @@ export interface BookingListFilterOptions {
|
||||
freightType?: string;
|
||||
bookingType?: string;
|
||||
tradeDirection?: string;
|
||||
/** Per-user trade-direction scope — `[]` matches nothing. */
|
||||
tradeDirections?: string[];
|
||||
paymentCurrency?: string;
|
||||
paymentStatus?: string;
|
||||
excludePaymentStatus?: string;
|
||||
@@ -1000,6 +1003,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
tradeDirection: options.tradeDirection,
|
||||
});
|
||||
}
|
||||
if (options.tradeDirections) {
|
||||
applyDirectionScope(qb, 'booking.trade_direction', options.tradeDirections);
|
||||
}
|
||||
if (options.paymentCurrency) {
|
||||
qb.andWhere('booking.payment_currency = :paymentCurrency', {
|
||||
paymentCurrency: options.paymentCurrency,
|
||||
@@ -1390,16 +1396,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Open unpaid holds (wagons reserved, pay window running) for a company. */
|
||||
countUnpaidHoldsForCompany(companyId: string): Promise<number> {
|
||||
return this.repository.count({
|
||||
where: {
|
||||
companyId,
|
||||
status: In(['SELECTED_FOR_BATCH', 'AWAITING_PAYMENT']),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
|
||||
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
|
||||
@@ -28,6 +28,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
@@ -239,6 +240,10 @@ export class BookingsService {
|
||||
*/
|
||||
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
// The sheet attests that EDR has taken custody. For export that happens at
|
||||
// cargo receipt (GRN), so the GRN is required even when wagons are already
|
||||
// allocated — an allocation is a plan, not possession.
|
||||
await assertExportReceivedWithGrn(this.dataSource, booking);
|
||||
let wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query(
|
||||
`SELECT tsw.sequence_no AS "sequenceNo",
|
||||
COALESCE(wt.code, wt.name) AS "wagonType",
|
||||
@@ -291,7 +296,10 @@ export class BookingsService {
|
||||
LEFT JOIN freight.containers c
|
||||
ON c.id = inv.container_id AND c.deleted_at IS NULL
|
||||
WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL
|
||||
AND COALESCE(NULLIF(TRIM(inv.grn_number), ''), '') <> ''
|
||||
AND COALESCE(
|
||||
NULLIF(TRIM(inv.grn_number), ''),
|
||||
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
|
||||
) IS NOT NULL
|
||||
ORDER BY inv.created_at`,
|
||||
[bookingId],
|
||||
)
|
||||
@@ -909,24 +917,6 @@ export class BookingsService {
|
||||
return result.booking;
|
||||
}
|
||||
|
||||
/**
|
||||
* A company with an open unpaid hold (SELECTED_FOR_BATCH — wagons reserved,
|
||||
* pay window running) may not take more capacity until it pays or the hold
|
||||
* dies: otherwise one customer can lock a train's wagons over and over
|
||||
* without ever paying. EXPIRED / CANCELLED holds free the lock.
|
||||
*/
|
||||
async assertNoUnpaidHold(companyId?: string | null): Promise<void> {
|
||||
if (!companyId) return;
|
||||
const holds =
|
||||
await this.bookingsRepository.countUnpaidHoldsForCompany(companyId);
|
||||
if (holds > 0) {
|
||||
throw new ConflictException(
|
||||
'You already have a booking waiting for payment. Pay it or cancel it ' +
|
||||
'before making a new booking.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a new freight booking. */
|
||||
async create(
|
||||
dto: CreateBookingDto,
|
||||
@@ -989,10 +979,6 @@ export class BookingsService {
|
||||
companyId = company.id;
|
||||
}
|
||||
|
||||
// Government bookings allocate without paying, so the unpaid-hold lock
|
||||
// only applies to commercial companies.
|
||||
if (!isGovernment) await this.assertNoUnpaidHold(companyId);
|
||||
|
||||
if (dto.trainScheduleId) {
|
||||
// Staff manual pin: the schedule must be OPEN and on the same route.
|
||||
const schedule = await this.dataSource
|
||||
@@ -1619,6 +1605,7 @@ export class BookingsService {
|
||||
filter: FilterBookingDto,
|
||||
forceCompanyId?: string,
|
||||
forceCompanyProfileId?: string,
|
||||
tradeDirections?: string[],
|
||||
): Promise<PaginatedBookings> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
@@ -1638,6 +1625,7 @@ export class BookingsService {
|
||||
// ANDs both, so cross-company access is impossible.
|
||||
companyId: forceCompanyId ?? filter.companyId,
|
||||
companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
|
||||
tradeDirections,
|
||||
contractType: filter.contractType,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -109,23 +109,6 @@ export class ContractBookingService {
|
||||
private readonly bookingTransitionService: BookingTransitionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Mirrors BookingsService.assertNoUnpaidHold for the contract booking paths:
|
||||
* a company sitting on an unpaid hold (SELECTED_FOR_BATCH) books nothing new
|
||||
* until it pays or the hold dies.
|
||||
*/
|
||||
private async assertNoUnpaidHold(companyId?: string | null): Promise<void> {
|
||||
if (!companyId) return;
|
||||
const holds =
|
||||
await this.bookingsRepository.countUnpaidHoldsForCompany(companyId);
|
||||
if (holds > 0) {
|
||||
throw new ConflictException(
|
||||
'You already have a booking waiting for payment. Pay it or cancel it ' +
|
||||
'before making a new booking.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async createUnderContract(
|
||||
contractId: string,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
@@ -186,8 +169,6 @@ export class ContractBookingService {
|
||||
// remainder; the customer cannot start any other booking on the contract.
|
||||
// If the remainder splits again the same rule repeats until the cap is
|
||||
// exhausted and the contract completes.
|
||||
await this.assertNoUnpaidHold(contract.companyId);
|
||||
|
||||
if (contract.contractKind === 'ONE_TIME') {
|
||||
if (await this.hasSplitBooking(contractId)) {
|
||||
await this.assertExactRemainder(contract, dto);
|
||||
@@ -475,7 +456,6 @@ export class ContractBookingService {
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.assertNoUnpaidHold(contract.companyId);
|
||||
|
||||
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm'
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
|
||||
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||
@@ -38,6 +39,8 @@ export interface ContractListFilterOptions {
|
||||
serviceTypeId?: string;
|
||||
freightType?: string;
|
||||
tradeDirection?: string;
|
||||
/** Per-user trade-direction scope — `[]` matches nothing. */
|
||||
tradeDirections?: string[];
|
||||
paymentCurrency?: string;
|
||||
customsClearingEnabled?: boolean;
|
||||
/** true → only contracts with at least one uploaded clearance document. */
|
||||
@@ -436,6 +439,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
tradeDirection: options.tradeDirection,
|
||||
});
|
||||
}
|
||||
if (options.tradeDirections) {
|
||||
applyDirectionScope(qb, 'contract.trade_direction', options.tradeDirections);
|
||||
}
|
||||
if (options.paymentCurrency) {
|
||||
qb.andWhere('contract.payment_currency = :paymentCurrency', {
|
||||
paymentCurrency: options.paymentCurrency,
|
||||
|
||||
@@ -748,6 +748,7 @@ export class ContractsService {
|
||||
filter: FilterContractDto,
|
||||
forceCompanyId?: string,
|
||||
forceCompanyProfileId?: string,
|
||||
tradeDirections?: string[],
|
||||
): Promise<PaginatedContracts> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
@@ -763,6 +764,7 @@ export class ContractsService {
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
freightType: filter.freightType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
tradeDirections,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
createdFrom: filter.createdFrom,
|
||||
createdTo: filter.createdTo,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class OverviewBookingKpisDto {
|
||||
@ApiProperty() total!: number;
|
||||
@ApiProperty() totalActive!: number;
|
||||
@ApiProperty() needsAction!: number;
|
||||
@ApiProperty() urgent!: number;
|
||||
@@ -9,6 +10,7 @@ export class OverviewBookingKpisDto {
|
||||
}
|
||||
|
||||
export class OverviewContractKpisDto {
|
||||
@ApiProperty() total!: number;
|
||||
@ApiProperty() totalActive!: number;
|
||||
@ApiProperty() needsAction!: number;
|
||||
@ApiProperty() inApproval!: number;
|
||||
@@ -21,6 +23,8 @@ export class OverviewOperationsKpisDto {
|
||||
@ApiProperty() wagonsAvailable!: number;
|
||||
@ApiProperty() containersInTransit!: number;
|
||||
@ApiProperty() cargoesLoaded!: number;
|
||||
@ApiProperty() schedulesUpcoming!: number;
|
||||
@ApiProperty() dispatchedToday!: number;
|
||||
}
|
||||
|
||||
export class OverviewCustomerKpisDto {
|
||||
|
||||
@@ -104,10 +104,40 @@ export class OverviewBillingTabDto {
|
||||
generatedAt!: string;
|
||||
}
|
||||
|
||||
export class OverviewDirectionTrendPointDto {
|
||||
@ApiProperty({ example: '2026-08-01' }) date!: string;
|
||||
@ApiProperty() importCount!: number;
|
||||
@ApiProperty() exportCount!: number;
|
||||
@ApiProperty() domesticCount!: number;
|
||||
}
|
||||
|
||||
export class OverviewTonnagePointDto {
|
||||
@ApiProperty() label!: string;
|
||||
@ApiProperty() tons!: number;
|
||||
}
|
||||
|
||||
export class OverviewOperationsTabDto {
|
||||
@ApiProperty({ type: OverviewOperationsKpisDto })
|
||||
kpis!: OverviewOperationsKpisDto;
|
||||
|
||||
@ApiProperty({ type: [OverviewDirectionTrendPointDto] })
|
||||
departureTrend!: OverviewDirectionTrendPointDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
scheduleStatusBreakdown!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||
wagonsByType!: OverviewLabelCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||
wagonsByYard!: OverviewLabelCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||
containersBySize!: OverviewLabelCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewTonnagePointDto] })
|
||||
cargoTonnageByType!: OverviewTonnagePointDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
trainStatusBreakdown!: OverviewStatusCountDto[];
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { BookingView } from '../../common/booking-guards';
|
||||
import { OverviewQueryDto } from './dto/overview-query.dto';
|
||||
@@ -18,59 +20,105 @@ import {
|
||||
OverviewStaffTabDto,
|
||||
} from './dto/overview-tab-response.dto';
|
||||
import { OverviewService } from './overview.service';
|
||||
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
||||
|
||||
@ApiTags('Overview')
|
||||
@ApiBearerAuth()
|
||||
@Controller('overview')
|
||||
export class OverviewController {
|
||||
constructor(private readonly overviewService: OverviewService) {}
|
||||
constructor(
|
||||
private readonly overviewService: OverviewService,
|
||||
private readonly userTradeAccessService: UserTradeAccessService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' })
|
||||
@ApiOkResponse({ type: OverviewResponseDto })
|
||||
getDashboard(@Query() query: OverviewQueryDto): Promise<OverviewResponseDto> {
|
||||
return this.overviewService.getDashboard(query.range ?? '30d');
|
||||
async getDashboard(
|
||||
@Query() query: OverviewQueryDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
): Promise<OverviewResponseDto> {
|
||||
const allowed =
|
||||
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
return this.overviewService.getDashboard(
|
||||
query.range ?? '30d',
|
||||
allowed ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('bookings')
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: 'Bookings tab metrics and charts' })
|
||||
@ApiOkResponse({ type: OverviewBookingsTabDto })
|
||||
getBookingsTab(@Query() query: OverviewQueryDto): Promise<OverviewBookingsTabDto> {
|
||||
return this.overviewService.getBookingsTab(query.range ?? '30d');
|
||||
async getBookingsTab(
|
||||
@Query() query: OverviewQueryDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
): Promise<OverviewBookingsTabDto> {
|
||||
const allowed =
|
||||
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
return this.overviewService.getBookingsTab(
|
||||
query.range ?? '30d',
|
||||
allowed ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('contracts')
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: 'Contracts tab metrics and charts' })
|
||||
@ApiOkResponse({ type: OverviewContractsTabDto })
|
||||
getContractsTab(@Query() query: OverviewQueryDto): Promise<OverviewContractsTabDto> {
|
||||
return this.overviewService.getContractsTab(query.range ?? '30d');
|
||||
async getContractsTab(
|
||||
@Query() query: OverviewQueryDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
): Promise<OverviewContractsTabDto> {
|
||||
const allowed =
|
||||
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
return this.overviewService.getContractsTab(
|
||||
query.range ?? '30d',
|
||||
allowed ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('billing')
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: 'Billing tab metrics and charts' })
|
||||
@ApiOkResponse({ type: OverviewBillingTabDto })
|
||||
getBillingTab(@Query() query: OverviewQueryDto): Promise<OverviewBillingTabDto> {
|
||||
return this.overviewService.getBillingTab(query.range ?? '30d');
|
||||
async getBillingTab(
|
||||
@Query() query: OverviewQueryDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
): Promise<OverviewBillingTabDto> {
|
||||
const allowed =
|
||||
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
return this.overviewService.getBillingTab(
|
||||
query.range ?? '30d',
|
||||
allowed ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('operations')
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: 'Operations tab metrics and charts' })
|
||||
@ApiOkResponse({ type: OverviewOperationsTabDto })
|
||||
getOperationsTab(): Promise<OverviewOperationsTabDto> {
|
||||
return this.overviewService.getOperationsTab();
|
||||
getOperationsTab(
|
||||
@Query() query: OverviewQueryDto,
|
||||
): Promise<OverviewOperationsTabDto> {
|
||||
return this.overviewService.getOperationsTab(query.range ?? '30d');
|
||||
}
|
||||
|
||||
@Get('customers')
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: 'Customers tab metrics and charts' })
|
||||
@ApiOkResponse({ type: OverviewCustomersTabDto })
|
||||
getCustomersTab(@Query() query: OverviewQueryDto): Promise<OverviewCustomersTabDto> {
|
||||
return this.overviewService.getCustomersTab(query.range ?? '30d');
|
||||
async getCustomersTab(
|
||||
@Query() query: OverviewQueryDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
): Promise<OverviewCustomersTabDto> {
|
||||
const allowed =
|
||||
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
return this.overviewService.getCustomersTab(
|
||||
query.range ?? '30d',
|
||||
allowed ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('staff')
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -11,7 +11,12 @@ import { Cargo } from "../cargoes/entities/cargoes.entity";
|
||||
import { Container } from "../container-management/entities/container.entity";
|
||||
import { Contract } from "../contracts/entities/contract.entity";
|
||||
import { PaymentEntity } from "../payment/entities/payment.entity";
|
||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||
import { ContainerType } from "../rule-engine/entities/container-type.entity";
|
||||
import { Yard } from "../rule-engine/entities/yard.entity";
|
||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||
import { Train } from "../trains/entities/train.entity";
|
||||
import { WagonType } from "../wagon-types/entities/wagon-type.entity";
|
||||
import { Wagon } from "../wagons/entities/wagon.entity";
|
||||
import {
|
||||
OVERVIEW_CLOSED_STATUSES,
|
||||
@@ -24,12 +29,17 @@ import {
|
||||
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
|
||||
} from "./overview.constants";
|
||||
import { Company } from "../companies/entities/company.entity";
|
||||
import {
|
||||
bookingRefScopeSql,
|
||||
directionScopeSql,
|
||||
} from "../user-trade-access/trade-scope.util";
|
||||
|
||||
/** Bookings carry a contract_kind column; GENERAL = umbrella contract row, not a shipment. */
|
||||
const EXCLUDE_GENERAL_CONTRACT_BOOKINGS =
|
||||
"(booking.contract_kind IS NULL OR booking.contract_kind <> 'GENERAL')";
|
||||
|
||||
export type OverviewBookingKpisRow = {
|
||||
total: number;
|
||||
totalActive: number;
|
||||
needsAction: number;
|
||||
urgent: number;
|
||||
@@ -49,6 +59,7 @@ export type OverviewRecentBookingRow = {
|
||||
};
|
||||
|
||||
export type OverviewContractKpisRow = {
|
||||
total: number;
|
||||
totalActive: number;
|
||||
needsAction: number;
|
||||
inApproval: number;
|
||||
@@ -79,6 +90,8 @@ export class OverviewRepository {
|
||||
private readonly companyRepository: Repository<Company>,
|
||||
@InjectRepository(Train)
|
||||
private readonly trainRepository: Repository<Train>,
|
||||
@InjectRepository(TrainSchedule)
|
||||
private readonly trainScheduleRepository: Repository<TrainSchedule>,
|
||||
@InjectRepository(Wagon)
|
||||
private readonly wagonRepository: Repository<Wagon>,
|
||||
@InjectRepository(Container)
|
||||
@@ -93,10 +106,12 @@ export class OverviewRepository {
|
||||
private readonly userRepository: Repository<User>,
|
||||
) { }
|
||||
|
||||
async getBookingKpis(): Promise<OverviewBookingKpisRow> {
|
||||
async getBookingKpis(dirs?: string[]): Promise<OverviewBookingKpisRow> {
|
||||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||
const row = await this.bookingRepository
|
||||
.createQueryBuilder("booking")
|
||||
.select(
|
||||
.select("COUNT(*)::int", "total")
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`,
|
||||
"totalActive",
|
||||
)
|
||||
@@ -118,6 +133,7 @@ export class OverviewRepository {
|
||||
)
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.setParameters({
|
||||
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
|
||||
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
|
||||
@@ -127,6 +143,7 @@ export class OverviewRepository {
|
||||
.getRawOne<Record<string, string>>();
|
||||
|
||||
return {
|
||||
total: Number(row?.total ?? 0),
|
||||
totalActive: Number(row?.totalActive ?? 0),
|
||||
needsAction: Number(row?.needsAction ?? 0),
|
||||
urgent: Number(row?.urgent ?? 0),
|
||||
@@ -140,9 +157,17 @@ export class OverviewRepository {
|
||||
wagonsAvailable: number;
|
||||
containersInTransit: number;
|
||||
cargoesLoaded: number;
|
||||
schedulesUpcoming: number;
|
||||
dispatchedToday: number;
|
||||
}> {
|
||||
const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] =
|
||||
await Promise.all([
|
||||
const [
|
||||
trainsActive,
|
||||
wagonsAvailable,
|
||||
containersInTransit,
|
||||
cargoesLoaded,
|
||||
schedulesUpcoming,
|
||||
dispatchedToday,
|
||||
] = await Promise.all([
|
||||
this.trainRepository
|
||||
.createQueryBuilder("train")
|
||||
.where("train.deleted_at IS NULL")
|
||||
@@ -172,6 +197,22 @@ export class OverviewRepository {
|
||||
statuses: ["LOADED", "IN_TRANSIT"],
|
||||
})
|
||||
.getCount(),
|
||||
this.trainScheduleRepository
|
||||
.createQueryBuilder("schedule")
|
||||
.where("schedule.deleted_at IS NULL")
|
||||
.andWhere("schedule.status = :status", {
|
||||
status: Freight.TrainScheduleStatus.Scheduled,
|
||||
})
|
||||
.andWhere("schedule.scheduled_departure_date >= CURRENT_DATE")
|
||||
.getCount(),
|
||||
this.trainScheduleRepository
|
||||
.createQueryBuilder("schedule")
|
||||
.where("schedule.deleted_at IS NULL")
|
||||
.andWhere("schedule.status = :status", {
|
||||
status: Freight.TrainScheduleStatus.Dispatched,
|
||||
})
|
||||
.andWhere("schedule.scheduled_departure_date::date = CURRENT_DATE")
|
||||
.getCount(),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -179,6 +220,8 @@ export class OverviewRepository {
|
||||
wagonsAvailable,
|
||||
containersInTransit,
|
||||
cargoesLoaded,
|
||||
schedulesUpcoming,
|
||||
dispatchedToday,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -202,12 +245,13 @@ export class OverviewRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async getBillingKpis(): Promise<{
|
||||
async getBillingKpis(dirs?: string[]): Promise<{
|
||||
revenueMtdEtb: number;
|
||||
revenueMtdUsd: number;
|
||||
pendingPayments: number;
|
||||
successfulPaymentsMtd: number;
|
||||
}> {
|
||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||
const revenueRow = await this.paymentRepository
|
||||
.createQueryBuilder("payment")
|
||||
.select(
|
||||
@@ -223,6 +267,7 @@ export class OverviewRepository {
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
||||
)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.getRawOne<Record<string, string>>();
|
||||
|
||||
const pendingPayments = await this.paymentRepository
|
||||
@@ -230,6 +275,7 @@ export class OverviewRepository {
|
||||
.where("payment.status IN (:...statuses)", {
|
||||
statuses: ["action-required", "processing"],
|
||||
})
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.getCount();
|
||||
|
||||
return {
|
||||
@@ -261,13 +307,16 @@ export class OverviewRepository {
|
||||
|
||||
async getBookingTrend(
|
||||
days: number,
|
||||
dirs?: string[],
|
||||
): Promise<{ date: string; count: number }[]> {
|
||||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder("booking")
|
||||
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy("booking.created_at::date")
|
||||
.orderBy("booking.created_at::date", "ASC")
|
||||
@@ -279,13 +328,15 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getStatusCounts(): Promise<Record<string, number>> {
|
||||
async getStatusCounts(dirs?: string[]): Promise<Record<string, number>> {
|
||||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder("booking")
|
||||
.select("booking.status", "status")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy("booking.status")
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
|
||||
@@ -296,7 +347,9 @@ export class OverviewRepository {
|
||||
|
||||
async getPaymentTrend(
|
||||
days: number,
|
||||
dirs?: string[],
|
||||
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
|
||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder("payment")
|
||||
.select(
|
||||
@@ -316,6 +369,7 @@ export class OverviewRepository {
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||
{ days },
|
||||
)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
|
||||
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC")
|
||||
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>();
|
||||
@@ -327,7 +381,11 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getRecentBookings(limit: number): Promise<OverviewRecentBookingRow[]> {
|
||||
async getRecentBookings(
|
||||
limit: number,
|
||||
dirs?: string[],
|
||||
): Promise<OverviewRecentBookingRow[]> {
|
||||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder("booking")
|
||||
.leftJoin("booking.company", "company")
|
||||
@@ -341,6 +399,7 @@ export class OverviewRepository {
|
||||
.addSelect("booking.created_at", "createdAt")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.orderBy("booking.created_at", "DESC")
|
||||
.limit(limit)
|
||||
.getRawMany<{
|
||||
@@ -366,9 +425,10 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getBookingsByFreightType(): Promise<
|
||||
{ label: string; count: number }[]
|
||||
> {
|
||||
async getBookingsByFreightType(
|
||||
dirs?: string[],
|
||||
): Promise<{ label: string; count: number }[]> {
|
||||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder("booking")
|
||||
.select("booking.freight_type", "label")
|
||||
@@ -376,6 +436,7 @@ export class OverviewRepository {
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere("booking.status != 'DRAFT'")
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy("booking.freight_type")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
@@ -386,7 +447,10 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> {
|
||||
async getBookingsByCurrency(
|
||||
dirs?: string[],
|
||||
): Promise<{ label: string; count: number }[]> {
|
||||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder("booking")
|
||||
.select("booking.payment_currency", "label")
|
||||
@@ -394,6 +458,7 @@ export class OverviewRepository {
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
||||
.andWhere("booking.status != 'DRAFT'")
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy("booking.payment_currency")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
@@ -404,11 +469,15 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> {
|
||||
async getPaymentsByStatus(
|
||||
dirs?: string[],
|
||||
): Promise<{ status: string; count: number }[]> {
|
||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder("payment")
|
||||
.select("payment.status", "status")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where(scope.sql, scope.params)
|
||||
.groupBy("payment.status")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
@@ -419,9 +488,12 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getPaymentsByMethod(): Promise<
|
||||
async getPaymentsByMethod(
|
||||
dirs?: string[],
|
||||
): Promise<
|
||||
{ method: string; count: number; amountEtb: number; amountUsd: number }[]
|
||||
> {
|
||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder("payment")
|
||||
.select("payment.method", "method")
|
||||
@@ -434,6 +506,7 @@ export class OverviewRepository {
|
||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
|
||||
"amountUsd",
|
||||
)
|
||||
.where(scope.sql, scope.params)
|
||||
.groupBy("payment.method")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{
|
||||
@@ -451,9 +524,10 @@ export class OverviewRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
async getRevenueByCurrency(): Promise<
|
||||
{ currency: string; amount: number }[]
|
||||
> {
|
||||
async getRevenueByCurrency(
|
||||
dirs?: string[],
|
||||
): Promise<{ currency: string; amount: number }[]> {
|
||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||
const rows = await this.paymentRepository
|
||||
.createQueryBuilder("payment")
|
||||
.select("payment.currency", "currency")
|
||||
@@ -462,6 +536,7 @@ export class OverviewRepository {
|
||||
.andWhere(
|
||||
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
||||
)
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy("payment.currency")
|
||||
.getRawMany<{ currency: string; amount: string }>();
|
||||
|
||||
@@ -495,6 +570,141 @@ export class OverviewRepository {
|
||||
return this.statusBreakdown(this.cargoRepository, "cargo");
|
||||
}
|
||||
|
||||
async getScheduleStatusBreakdown(): Promise<
|
||||
{ status: string; count: number }[]
|
||||
> {
|
||||
return this.statusBreakdown(this.trainScheduleRepository, "schedule");
|
||||
}
|
||||
|
||||
/** Scheduled departures per day over the range, split by trade direction. */
|
||||
async getDepartureTrend(days: number): Promise<
|
||||
{
|
||||
date: string;
|
||||
importCount: number;
|
||||
exportCount: number;
|
||||
domesticCount: number;
|
||||
}[]
|
||||
> {
|
||||
const rows = await this.trainScheduleRepository
|
||||
.createQueryBuilder("schedule")
|
||||
.select(
|
||||
`to_char(schedule.scheduled_departure_date::date, 'YYYY-MM-DD')`,
|
||||
"date",
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE schedule.direction = 'IMPORT')::int`,
|
||||
"importCount",
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE schedule.direction = 'EXPORT')::int`,
|
||||
"exportCount",
|
||||
)
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE schedule.direction NOT IN ('IMPORT', 'EXPORT') OR schedule.direction IS NULL)::int`,
|
||||
"domesticCount",
|
||||
)
|
||||
.where("schedule.deleted_at IS NULL")
|
||||
.andWhere("schedule.status != :draft", {
|
||||
draft: Freight.TrainScheduleStatus.Draft,
|
||||
})
|
||||
.andWhere(
|
||||
`schedule.scheduled_departure_date >= CURRENT_DATE - :days::int + 1`,
|
||||
{ days },
|
||||
)
|
||||
.andWhere(
|
||||
`schedule.scheduled_departure_date < CURRENT_DATE + :ahead::int`,
|
||||
{ ahead: 8 },
|
||||
)
|
||||
.groupBy("schedule.scheduled_departure_date::date")
|
||||
.orderBy("schedule.scheduled_departure_date::date", "ASC")
|
||||
.getRawMany<{
|
||||
date: string;
|
||||
importCount: string;
|
||||
exportCount: string;
|
||||
domesticCount: string;
|
||||
}>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
date: row.date,
|
||||
importCount: Number(row.importCount),
|
||||
exportCount: Number(row.exportCount),
|
||||
domesticCount: Number(row.domesticCount),
|
||||
}));
|
||||
}
|
||||
|
||||
async getWagonsByType(): Promise<{ label: string; count: number }[]> {
|
||||
const rows = await this.wagonRepository
|
||||
.createQueryBuilder("wagon")
|
||||
.leftJoin(WagonType, "wagon_type", "wagon_type.id = wagon.wagon_type_id")
|
||||
.select(`COALESCE(wagon_type.name, 'Unknown')`, "label")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("wagon.deleted_at IS NULL")
|
||||
.groupBy("wagon_type.name")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
||||
}
|
||||
|
||||
async getWagonsByYard(limit: number): Promise<
|
||||
{ label: string; count: number }[]
|
||||
> {
|
||||
const rows = await this.wagonRepository
|
||||
.createQueryBuilder("wagon")
|
||||
.innerJoin(Yard, "yard", "yard.id = wagon.current_yard_id")
|
||||
.select("yard.label", "label")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("wagon.deleted_at IS NULL")
|
||||
.groupBy("yard.label")
|
||||
.orderBy("count", "DESC")
|
||||
.limit(limit)
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
||||
}
|
||||
|
||||
async getContainersBySize(): Promise<{ label: string; count: number }[]> {
|
||||
const rows = await this.containerRepository
|
||||
.createQueryBuilder("container")
|
||||
.leftJoin(
|
||||
ContainerType,
|
||||
"container_type",
|
||||
"container_type.id = container.container_type_id",
|
||||
)
|
||||
.select(
|
||||
`COALESCE(container_type.size_ft::text || ' ft', container_type.code, 'Unknown')`,
|
||||
"label",
|
||||
)
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("container.deleted_at IS NULL")
|
||||
.groupBy("container_type.size_ft")
|
||||
.addGroupBy("container_type.code")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
|
||||
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
||||
}
|
||||
|
||||
/** Total cargo weight (tons) grouped by cargo type, heaviest first. */
|
||||
async getCargoTonnageByType(limit: number): Promise<
|
||||
{ label: string; tons: number }[]
|
||||
> {
|
||||
const rows = await this.cargoRepository
|
||||
.createQueryBuilder("cargo")
|
||||
.leftJoin(CargoType, "cargo_type", "cargo_type.id = cargo.cargo_type_id")
|
||||
.select(`COALESCE(cargo_type.cargo_type_name, 'Other')`, "label")
|
||||
.addSelect(`ROUND(COALESCE(SUM(cargo.weight), 0) / 1000, 1)`, "tons")
|
||||
.where("cargo.deleted_at IS NULL")
|
||||
.groupBy("cargo_type.cargo_type_name")
|
||||
.orderBy("tons", "DESC")
|
||||
.limit(limit)
|
||||
.getRawMany<{ label: string; tons: string }>();
|
||||
|
||||
return rows
|
||||
.map((row) => ({ label: row.label, tons: Number(row.tons) }))
|
||||
.filter((row) => row.tons > 0);
|
||||
}
|
||||
|
||||
private async statusBreakdown(
|
||||
repository: Repository<ObjectLiteral>,
|
||||
alias: string,
|
||||
@@ -556,7 +766,9 @@ export class OverviewRepository {
|
||||
|
||||
async getTopCustomersByBookings(
|
||||
limit: number,
|
||||
dirs?: string[],
|
||||
): Promise<{ label: string; count: number }[]> {
|
||||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||
const rows = await this.bookingRepository
|
||||
.createQueryBuilder("booking")
|
||||
.leftJoin("booking.company", "company")
|
||||
@@ -564,6 +776,7 @@ export class OverviewRepository {
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("booking.deleted_at IS NULL")
|
||||
.andWhere("booking.status != 'DRAFT'")
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy("company.name")
|
||||
.orderBy("count", "DESC")
|
||||
.limit(limit)
|
||||
@@ -632,10 +845,12 @@ export class OverviewRepository {
|
||||
|
||||
// ── Contracts (overview Contract tab) ──────────────────────────────────────
|
||||
|
||||
async getContractKpis(): Promise<OverviewContractKpisRow> {
|
||||
async getContractKpis(dirs?: string[]): Promise<OverviewContractKpisRow> {
|
||||
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||||
const row = await this.contractRepository
|
||||
.createQueryBuilder("contract")
|
||||
.select(
|
||||
.select("COUNT(*)::int", "total")
|
||||
.addSelect(
|
||||
`COUNT(*) FILTER (WHERE contract.status NOT IN (:...closedStatuses) AND contract.status != 'DRAFT')::int`,
|
||||
"totalActive",
|
||||
)
|
||||
@@ -656,6 +871,7 @@ export class OverviewRepository {
|
||||
"createdToday",
|
||||
)
|
||||
.where("contract.deleted_at IS NULL")
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.setParameters({
|
||||
closedStatuses: [...OVERVIEW_CONTRACT_CLOSED_STATUSES],
|
||||
needsActionStatuses: [...OVERVIEW_CONTRACT_NEEDS_ACTION_STATUSES],
|
||||
@@ -665,6 +881,7 @@ export class OverviewRepository {
|
||||
.getRawOne<Record<string, string>>();
|
||||
|
||||
return {
|
||||
total: Number(row?.total ?? 0),
|
||||
totalActive: Number(row?.totalActive ?? 0),
|
||||
needsAction: Number(row?.needsAction ?? 0),
|
||||
inApproval: Number(row?.inApproval ?? 0),
|
||||
@@ -673,24 +890,33 @@ export class OverviewRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async getContractStatusCounts(): Promise<Record<string, number>> {
|
||||
async getContractStatusCounts(
|
||||
dirs?: string[],
|
||||
): Promise<Record<string, number>> {
|
||||
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||||
const rows = await this.contractRepository
|
||||
.createQueryBuilder("contract")
|
||||
.select("contract.status", "status")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("contract.deleted_at IS NULL")
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy("contract.status")
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
|
||||
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
|
||||
}
|
||||
|
||||
async getContractTrend(days: number): Promise<{ date: string; count: number }[]> {
|
||||
async getContractTrend(
|
||||
days: number,
|
||||
dirs?: string[],
|
||||
): Promise<{ date: string; count: number }[]> {
|
||||
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||||
const rows = await this.contractRepository
|
||||
.createQueryBuilder("contract")
|
||||
.select(`to_char(contract.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("contract.deleted_at IS NULL")
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.andWhere(`contract.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||
.groupBy("contract.created_at::date")
|
||||
.orderBy("contract.created_at::date", "ASC")
|
||||
@@ -699,13 +925,17 @@ export class OverviewRepository {
|
||||
return rows.map((row) => ({ date: row.date, count: Number(row.count) }));
|
||||
}
|
||||
|
||||
async getContractsByKind(): Promise<{ label: string; count: number }[]> {
|
||||
async getContractsByKind(
|
||||
dirs?: string[],
|
||||
): Promise<{ label: string; count: number }[]> {
|
||||
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||||
const rows = await this.contractRepository
|
||||
.createQueryBuilder("contract")
|
||||
.select("contract.contract_kind", "label")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("contract.deleted_at IS NULL")
|
||||
.andWhere("contract.status != 'DRAFT'")
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy("contract.contract_kind")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
@@ -713,13 +943,17 @@ export class OverviewRepository {
|
||||
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
||||
}
|
||||
|
||||
async getContractsByFreightType(): Promise<{ label: string; count: number }[]> {
|
||||
async getContractsByFreightType(
|
||||
dirs?: string[],
|
||||
): Promise<{ label: string; count: number }[]> {
|
||||
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||||
const rows = await this.contractRepository
|
||||
.createQueryBuilder("contract")
|
||||
.select("contract.freight_type", "label")
|
||||
.addSelect("COUNT(*)::int", "count")
|
||||
.where("contract.deleted_at IS NULL")
|
||||
.andWhere("contract.status != 'DRAFT'")
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.groupBy("contract.freight_type")
|
||||
.orderBy("count", "DESC")
|
||||
.getRawMany<{ label: string; count: string }>();
|
||||
@@ -727,7 +961,11 @@ export class OverviewRepository {
|
||||
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
||||
}
|
||||
|
||||
async getRecentContracts(limit: number): Promise<OverviewRecentContractRow[]> {
|
||||
async getRecentContracts(
|
||||
limit: number,
|
||||
dirs?: string[],
|
||||
): Promise<OverviewRecentContractRow[]> {
|
||||
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||||
const rows = await this.contractRepository
|
||||
.createQueryBuilder("contract")
|
||||
.leftJoin("contract.company", "company")
|
||||
@@ -741,6 +979,7 @@ export class OverviewRepository {
|
||||
.addSelect("contract.contract_valid_until", "validUntil")
|
||||
.addSelect("contract.created_at", "createdAt")
|
||||
.where("contract.deleted_at IS NULL")
|
||||
.andWhere(scope.sql, scope.params)
|
||||
.orderBy("contract.created_at", "DESC")
|
||||
.limit(limit)
|
||||
.getRawMany<{
|
||||
|
||||
@@ -40,7 +40,10 @@ export class OverviewService {
|
||||
return { bookingsByPipeline, bookingsByStatus };
|
||||
}
|
||||
|
||||
async getDashboard(range: OverviewRangeQuery = '30d'): Promise<OverviewResponseDto> {
|
||||
async getDashboard(
|
||||
range: OverviewRangeQuery = '30d',
|
||||
dirs?: string[],
|
||||
): Promise<OverviewResponseDto> {
|
||||
const days = OVERVIEW_RANGE_DAYS[range];
|
||||
|
||||
const [
|
||||
@@ -55,16 +58,16 @@ export class OverviewService {
|
||||
paymentTrend,
|
||||
recentBookings,
|
||||
] = await Promise.all([
|
||||
this.overviewRepository.getBookingKpis(),
|
||||
this.overviewRepository.getContractKpis(),
|
||||
this.overviewRepository.getBookingKpis(dirs),
|
||||
this.overviewRepository.getContractKpis(dirs),
|
||||
this.overviewRepository.getOperationsKpis(),
|
||||
this.overviewRepository.getCustomerKpis(),
|
||||
this.overviewRepository.getBillingKpis(),
|
||||
this.overviewRepository.getBillingKpis(dirs),
|
||||
this.overviewRepository.getStaffKpis(),
|
||||
this.overviewRepository.getBookingTrend(days),
|
||||
this.overviewRepository.getStatusCounts(),
|
||||
this.overviewRepository.getPaymentTrend(days),
|
||||
this.overviewRepository.getRecentBookings(8),
|
||||
this.overviewRepository.getBookingTrend(days, dirs),
|
||||
this.overviewRepository.getStatusCounts(dirs),
|
||||
this.overviewRepository.getPaymentTrend(days, dirs),
|
||||
this.overviewRepository.getRecentBookings(8, dirs),
|
||||
]);
|
||||
|
||||
const { bookingsByPipeline, bookingsByStatus } =
|
||||
@@ -91,7 +94,10 @@ export class OverviewService {
|
||||
};
|
||||
}
|
||||
|
||||
async getBookingsTab(range: OverviewRangeQuery = '30d'): Promise<OverviewBookingsTabDto> {
|
||||
async getBookingsTab(
|
||||
range: OverviewRangeQuery = '30d',
|
||||
dirs?: string[],
|
||||
): Promise<OverviewBookingsTabDto> {
|
||||
const days = OVERVIEW_RANGE_DAYS[range];
|
||||
|
||||
const [
|
||||
@@ -102,12 +108,12 @@ export class OverviewService {
|
||||
bookingsByCurrency,
|
||||
recentBookings,
|
||||
] = await Promise.all([
|
||||
this.overviewRepository.getBookingKpis(),
|
||||
this.overviewRepository.getBookingTrend(days),
|
||||
this.overviewRepository.getStatusCounts(),
|
||||
this.overviewRepository.getBookingsByFreightType(),
|
||||
this.overviewRepository.getBookingsByCurrency(),
|
||||
this.overviewRepository.getRecentBookings(8),
|
||||
this.overviewRepository.getBookingKpis(dirs),
|
||||
this.overviewRepository.getBookingTrend(days, dirs),
|
||||
this.overviewRepository.getStatusCounts(dirs),
|
||||
this.overviewRepository.getBookingsByFreightType(dirs),
|
||||
this.overviewRepository.getBookingsByCurrency(dirs),
|
||||
this.overviewRepository.getRecentBookings(8, dirs),
|
||||
]);
|
||||
|
||||
const { bookingsByPipeline, bookingsByStatus } =
|
||||
@@ -130,6 +136,7 @@ export class OverviewService {
|
||||
|
||||
async getContractsTab(
|
||||
range: OverviewRangeQuery = '30d',
|
||||
dirs?: string[],
|
||||
): Promise<OverviewContractsTabDto> {
|
||||
const days = OVERVIEW_RANGE_DAYS[range];
|
||||
|
||||
@@ -141,12 +148,12 @@ export class OverviewService {
|
||||
contractsByFreightType,
|
||||
recentContracts,
|
||||
] = await Promise.all([
|
||||
this.overviewRepository.getContractKpis(),
|
||||
this.overviewRepository.getContractTrend(days),
|
||||
this.overviewRepository.getContractStatusCounts(),
|
||||
this.overviewRepository.getContractsByKind(),
|
||||
this.overviewRepository.getContractsByFreightType(),
|
||||
this.overviewRepository.getRecentContracts(8),
|
||||
this.overviewRepository.getContractKpis(dirs),
|
||||
this.overviewRepository.getContractTrend(days, dirs),
|
||||
this.overviewRepository.getContractStatusCounts(dirs),
|
||||
this.overviewRepository.getContractsByKind(dirs),
|
||||
this.overviewRepository.getContractsByFreightType(dirs),
|
||||
this.overviewRepository.getRecentContracts(8, dirs),
|
||||
]);
|
||||
|
||||
const contractsByStatus = Object.entries(statusCounts)
|
||||
@@ -170,16 +177,19 @@ export class OverviewService {
|
||||
};
|
||||
}
|
||||
|
||||
async getBillingTab(range: OverviewRangeQuery = '30d'): Promise<OverviewBillingTabDto> {
|
||||
async getBillingTab(
|
||||
range: OverviewRangeQuery = '30d',
|
||||
dirs?: string[],
|
||||
): Promise<OverviewBillingTabDto> {
|
||||
const days = OVERVIEW_RANGE_DAYS[range];
|
||||
|
||||
const [kpis, paymentTrend, paymentsByStatus, paymentsByMethod, revenueByCurrency] =
|
||||
await Promise.all([
|
||||
this.overviewRepository.getBillingKpis(),
|
||||
this.overviewRepository.getPaymentTrend(days),
|
||||
this.overviewRepository.getPaymentsByStatus(),
|
||||
this.overviewRepository.getPaymentsByMethod(),
|
||||
this.overviewRepository.getRevenueByCurrency(),
|
||||
this.overviewRepository.getBillingKpis(dirs),
|
||||
this.overviewRepository.getPaymentTrend(days, dirs),
|
||||
this.overviewRepository.getPaymentsByStatus(dirs),
|
||||
this.overviewRepository.getPaymentsByMethod(dirs),
|
||||
this.overviewRepository.getRevenueByCurrency(dirs),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -192,15 +202,31 @@ export class OverviewService {
|
||||
};
|
||||
}
|
||||
|
||||
async getOperationsTab(): Promise<OverviewOperationsTabDto> {
|
||||
async getOperationsTab(
|
||||
range: OverviewRangeQuery = '30d',
|
||||
): Promise<OverviewOperationsTabDto> {
|
||||
const days = OVERVIEW_RANGE_DAYS[range];
|
||||
|
||||
const [
|
||||
kpis,
|
||||
departureTrend,
|
||||
scheduleStatusBreakdown,
|
||||
wagonsByType,
|
||||
wagonsByYard,
|
||||
containersBySize,
|
||||
cargoTonnageByType,
|
||||
trainStatusBreakdown,
|
||||
wagonStatusBreakdown,
|
||||
containerStatusBreakdown,
|
||||
cargoStatusBreakdown,
|
||||
] = await Promise.all([
|
||||
this.overviewRepository.getOperationsKpis(),
|
||||
this.overviewRepository.getDepartureTrend(days),
|
||||
this.overviewRepository.getScheduleStatusBreakdown(),
|
||||
this.overviewRepository.getWagonsByType(),
|
||||
this.overviewRepository.getWagonsByYard(8),
|
||||
this.overviewRepository.getContainersBySize(),
|
||||
this.overviewRepository.getCargoTonnageByType(8),
|
||||
this.overviewRepository.getTrainStatusBreakdown(),
|
||||
this.overviewRepository.getWagonStatusBreakdown(),
|
||||
this.overviewRepository.getContainerStatusBreakdown(),
|
||||
@@ -209,6 +235,12 @@ export class OverviewService {
|
||||
|
||||
return {
|
||||
kpis,
|
||||
departureTrend,
|
||||
scheduleStatusBreakdown,
|
||||
wagonsByType,
|
||||
wagonsByYard,
|
||||
containersBySize,
|
||||
cargoTonnageByType,
|
||||
trainStatusBreakdown,
|
||||
wagonStatusBreakdown,
|
||||
containerStatusBreakdown,
|
||||
@@ -217,7 +249,10 @@ export class OverviewService {
|
||||
};
|
||||
}
|
||||
|
||||
async getCustomersTab(range: OverviewRangeQuery = '30d'): Promise<OverviewCustomersTabDto> {
|
||||
async getCustomersTab(
|
||||
range: OverviewRangeQuery = '30d',
|
||||
dirs?: string[],
|
||||
): Promise<OverviewCustomersTabDto> {
|
||||
const days = OVERVIEW_RANGE_DAYS[range];
|
||||
|
||||
const [kpis, customerGrowthTrend, customersByType, topCustomersByBookings] =
|
||||
@@ -225,7 +260,7 @@ export class OverviewService {
|
||||
this.overviewRepository.getCustomerKpis(),
|
||||
this.overviewRepository.getCustomerGrowthTrend(days),
|
||||
this.overviewRepository.getCustomersByType(),
|
||||
this.overviewRepository.getTopCustomersByBookings(8),
|
||||
this.overviewRepository.getTopCustomersByBookings(8, dirs),
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class ReportQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Inclusive start date (YYYY-MM-DD). Default: 30 days ago.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dateFrom?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Inclusive end date (YYYY-MM-DD). Default: today.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dateTo?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['day', 'week', 'month'], default: 'day' })
|
||||
@IsOptional()
|
||||
@IsIn(['day', 'week', 'month'])
|
||||
granularity?: 'day' | 'week' | 'month';
|
||||
|
||||
@ApiPropertyOptional({ description: 'Comma-separated company UUIDs' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyIds?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Comma-separated route UUIDs' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
routeIds?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Comma-separated yard UUIDs (matches origin or destination)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
yardIds?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Comma-separated cargo type UUIDs' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cargoTypeIds?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Comma-separated status values (report-specific)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
statuses?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Trade direction filter' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
direction?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['CONTAINER', 'BULK'] })
|
||||
@IsOptional()
|
||||
@IsIn(['CONTAINER', 'BULK'])
|
||||
freightType?: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ReportKpiDto {
|
||||
@ApiProperty()
|
||||
label!: string;
|
||||
|
||||
@ApiProperty()
|
||||
value!: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export class ReportResultDto {
|
||||
@ApiProperty({ type: [ReportKpiDto] })
|
||||
kpis!: ReportKpiDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: 'array',
|
||||
items: { type: 'object', additionalProperties: true },
|
||||
description: 'Report rows; columns vary per report key',
|
||||
})
|
||||
rows!: Record<string, unknown>[];
|
||||
}
|
||||
669
apps/edr-freight-api/src/modules/reports/report-queries.ts
Normal file
669
apps/edr-freight-api/src/modules/reports/report-queries.ts
Normal file
@@ -0,0 +1,669 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
export interface ReportFilters {
|
||||
/** ISO timestamp, inclusive lower bound. null = no lower bound (all time). */
|
||||
dateFrom: string | null;
|
||||
/** ISO timestamp, exclusive upper bound. null = no upper bound. */
|
||||
dateTo: string | null;
|
||||
granularity: 'day' | 'week' | 'month';
|
||||
companyIds: string[] | null;
|
||||
routeIds: string[] | null;
|
||||
yardIds: string[] | null;
|
||||
cargoTypeIds: string[] | null;
|
||||
statuses: string[] | null;
|
||||
/** Trade-scope-resolved directions. null = unrestricted, [] = show nothing. */
|
||||
directions: string[] | null;
|
||||
freightType: string | null;
|
||||
}
|
||||
|
||||
export interface ReportKpi {
|
||||
label: string;
|
||||
value: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface ReportResult {
|
||||
kpis: ReportKpi[];
|
||||
rows: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
type ReportQuery = (ds: DataSource, f: ReportFilters) => Promise<ReportResult>;
|
||||
|
||||
// For PER_ITEM bulk bookings cargo_total_weight_vgm holds an item COUNT, and
|
||||
// the real tonnage lives in bulk_total_weight_tons — hence the COALESCE order.
|
||||
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||
// adjusted_total_amount silently overrides total_amount when set.
|
||||
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
||||
// GENERAL contract_kind rows are umbrella contracts, not shipments; counting
|
||||
// them double-counts every child booking (same guard as overview.repository).
|
||||
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
|
||||
const DEAD_STATUSES = "'DRAFT','CANCELLED','REJECTED','EXPIRED'";
|
||||
|
||||
const num = (v: unknown): number => (v === null || v === undefined ? 0 : Number(v));
|
||||
const sum = (rows: Record<string, unknown>[], col: string): number =>
|
||||
rows.reduce((acc, r) => acc + num(r[col]), 0);
|
||||
|
||||
/**
|
||||
* Shared WHERE for booking-based reports (alias `b`).
|
||||
* Params occupy $1..$8 in this fixed order; report SQL continues at $9.
|
||||
*/
|
||||
function bookingWhere(f: ReportFilters): { where: string; params: unknown[] } {
|
||||
return {
|
||||
where: `
|
||||
b.deleted_at IS NULL
|
||||
AND ${NOT_UMBRELLA}
|
||||
AND ($1::timestamptz IS NULL OR b.created_at >= $1)
|
||||
AND ($2::timestamptz IS NULL OR b.created_at < $2)
|
||||
AND ($3::uuid[] IS NULL OR b.company_id = ANY($3))
|
||||
AND ($4::uuid[] IS NULL OR b.cargo_type_id = ANY($4))
|
||||
AND ($5::text[] IS NULL OR b.trade_direction = ANY($5))
|
||||
AND ($6::text IS NULL OR b.freight_type = $6)
|
||||
AND (CASE WHEN $7::text[] IS NULL
|
||||
THEN b.status NOT IN (${DEAD_STATUSES})
|
||||
ELSE b.status = ANY($7) END)
|
||||
AND ($8::uuid[] IS NULL OR b.origin_yard_id = ANY($8) OR b.destination_yard_id = ANY($8))`,
|
||||
params: [
|
||||
f.dateFrom,
|
||||
f.dateTo,
|
||||
f.companyIds,
|
||||
f.cargoTypeIds,
|
||||
f.directions,
|
||||
f.freightType,
|
||||
f.statuses,
|
||||
f.yardIds,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Direction scope for rows that reference a booking through a varchar id
|
||||
* column (invoices.source_id, payments.ref_id). Rows not pointing at a
|
||||
* booking stay visible — they carry no direction to scope by.
|
||||
* (Positional-param port of trade-scope.util's bookingRefScopeSql.)
|
||||
*/
|
||||
const refDirScope = (refColumn: string, param: string): string => `
|
||||
(${param}::text[] IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM freight.bookings sb
|
||||
WHERE sb.id::text = ${refColumn} AND NOT (sb.trade_direction = ANY(${param}))))`;
|
||||
|
||||
const bookingsTrend: ReportQuery = async (ds, f) => {
|
||||
const { where, params } = bookingWhere(f);
|
||||
const rows = await ds.query(
|
||||
`SELECT to_char(date_trunc($9, b.created_at), 'YYYY-MM-DD') AS period,
|
||||
COUNT(*)::int AS bookings,
|
||||
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
|
||||
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
|
||||
FROM freight.bookings b
|
||||
WHERE ${where}
|
||||
GROUP BY 1 ORDER BY 1`,
|
||||
[...params, f.granularity],
|
||||
);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Bookings', value: sum(rows, 'bookings') },
|
||||
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
|
||||
{ label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const revenueByCustomer: ReportQuery = async (ds, f) => {
|
||||
const { where, params } = bookingWhere(f);
|
||||
const rows = await ds.query(
|
||||
`SELECT c.name AS customer,
|
||||
COUNT(*)::int AS bookings,
|
||||
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
|
||||
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
|
||||
FROM freight.bookings b
|
||||
JOIN freight.companies c ON c.id = b.company_id
|
||||
WHERE ${where}
|
||||
GROUP BY c.name ORDER BY revenue DESC LIMIT 100`,
|
||||
params,
|
||||
);
|
||||
const total = sum(rows, 'revenue');
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Customers', value: rows.length },
|
||||
{ label: 'Revenue', value: total, unit: 'ETB' },
|
||||
{
|
||||
label: 'Top customer share',
|
||||
value: total > 0 ? Math.round((num(rows[0]?.revenue) / total) * 100) : 0,
|
||||
unit: '%',
|
||||
},
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const revenueByLane: ReportQuery = async (ds, f) => {
|
||||
const { where, params } = bookingWhere(f);
|
||||
const rows = await ds.query(
|
||||
`SELECT o.label AS origin, d.label AS destination,
|
||||
COUNT(*)::int AS bookings,
|
||||
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
|
||||
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
|
||||
FROM freight.bookings b
|
||||
JOIN freight.yards o ON o.id = b.origin_yard_id
|
||||
JOIN freight.yards d ON d.id = b.destination_yard_id
|
||||
WHERE ${where}
|
||||
GROUP BY 1, 2 ORDER BY revenue DESC LIMIT 100`,
|
||||
params,
|
||||
);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Lanes', value: rows.length },
|
||||
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
|
||||
{ label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const contractUtilization: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT ct.reference, c.name AS customer, ct.status, ct.contract_kind AS kind,
|
||||
to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from,
|
||||
to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until,
|
||||
cap.committed::float8 AS committed,
|
||||
booked.tons::float8 AS booked_tons,
|
||||
booked.cnt AS bookings,
|
||||
CASE WHEN cap.committed > 0
|
||||
THEN ROUND(booked.tons / cap.committed * 100)::float8 END AS utilization_pct
|
||||
FROM freight.contracts ct
|
||||
LEFT JOIN freight.companies c ON c.id = ct.company_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(s.quantity_cap), 0) AS committed
|
||||
FROM freight.contract_cargo_scope s
|
||||
WHERE s.contract_id = ct.id AND s.deleted_at IS NULL) cap ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(${TONS}), 0) AS tons, COUNT(*)::int AS cnt
|
||||
FROM freight.bookings b
|
||||
WHERE b.contract_id = ct.id AND b.deleted_at IS NULL
|
||||
AND b.status NOT IN (${DEAD_STATUSES})) booked ON true
|
||||
WHERE ct.deleted_at IS NULL
|
||||
AND ct.status NOT IN ('DRAFT')
|
||||
AND ct.contract_valid_from < COALESCE($2::timestamptz, 'infinity')
|
||||
AND (ct.contract_valid_until IS NULL
|
||||
OR ct.contract_valid_until >= COALESCE($1::timestamptz, '-infinity'))
|
||||
AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3))
|
||||
AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4))
|
||||
AND ($5::text[] IS NULL OR ct.status = ANY($5))
|
||||
ORDER BY utilization_pct DESC NULLS LAST LIMIT 200`,
|
||||
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses],
|
||||
);
|
||||
const capped = rows.filter((r: Record<string, unknown>) => num(r.committed) > 0);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Contracts', value: rows.length },
|
||||
{
|
||||
label: 'Avg utilization',
|
||||
value: capped.length
|
||||
? Math.round(sum(capped, 'utilization_pct') / capped.length)
|
||||
: 0,
|
||||
unit: '%',
|
||||
},
|
||||
{ label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
// ponytail: 60-min departure grace is a constant; make it a query param if ops
|
||||
// ever wants a configurable threshold.
|
||||
const trainOnTime: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT o.label AS origin, d.label AS destination,
|
||||
COUNT(*)::int AS trips,
|
||||
COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL)::int AS departed,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_departure_at - ts.scheduled_departure_date)) / 60)
|
||||
FILTER (WHERE ts.actual_departure_at IS NOT NULL))::float8 AS avg_dep_delay_min,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.scheduled_arrival_date)) / 60)
|
||||
FILTER (WHERE ts.actual_arrival_at IS NOT NULL
|
||||
AND ts.scheduled_arrival_date IS NOT NULL))::float8 AS avg_arr_delay_min,
|
||||
ROUND(100.0 * COUNT(*) FILTER (WHERE ts.actual_departure_at
|
||||
<= ts.scheduled_departure_date + interval '60 minutes')
|
||||
/ NULLIF(COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL), 0))::float8 AS on_time_pct
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards o ON o.id = ts.origin_station_id
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DISPATCHED', 'ARRIVED')
|
||||
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
||||
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
||||
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
|
||||
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
|
||||
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
||||
GROUP BY 1, 2 ORDER BY trips DESC`,
|
||||
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
|
||||
);
|
||||
const departed = sum(rows, 'departed');
|
||||
const weighted = rows.reduce(
|
||||
(acc: number, r: Record<string, unknown>) =>
|
||||
acc + (num(r.on_time_pct) * num(r.departed)) / 100,
|
||||
0,
|
||||
);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Trips', value: sum(rows, 'trips') },
|
||||
{
|
||||
label: 'On-time departures',
|
||||
value: departed > 0 ? Math.round((weighted / departed) * 100) : 0,
|
||||
unit: '%',
|
||||
},
|
||||
{
|
||||
label: 'Avg departure delay',
|
||||
value: rows.length ? Math.round(sum(rows, 'avg_dep_delay_min') / rows.length) : 0,
|
||||
unit: 'min',
|
||||
},
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const scheduleFillRate: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT ts.train_number, ts.reference,
|
||||
to_char(ts.scheduled_departure_date, 'YYYY-MM-DD') AS departure,
|
||||
o.label AS origin, d.label AS destination, ts.direction, ts.status,
|
||||
ts.max_wagons, tset.wagon_count,
|
||||
ROUND(w.cap_tons)::float8 AS capacity_tons,
|
||||
ROUND(w.booked_tons)::float8 AS booked_tons,
|
||||
CASE WHEN w.cap_tons > 0
|
||||
THEN ROUND(w.booked_tons / w.cap_tons * 100)::float8 END AS fill_pct
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards o ON o.id = ts.origin_station_id
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id
|
||||
LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(tw.capacity_tons), 0) AS cap_tons,
|
||||
COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons
|
||||
FROM freight.train_set_wagons tw
|
||||
WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status <> 'CANCELLED'
|
||||
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
||||
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
||||
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
|
||||
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
|
||||
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
||||
ORDER BY ts.scheduled_departure_date DESC LIMIT 200`,
|
||||
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
|
||||
);
|
||||
const withCap = rows.filter((r: Record<string, unknown>) => num(r.capacity_tons) > 0);
|
||||
const capTons = sum(withCap, 'capacity_tons');
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Schedules', value: rows.length },
|
||||
{
|
||||
label: 'Avg fill rate',
|
||||
value: capTons > 0 ? Math.round((sum(withCap, 'booked_tons') / capTons) * 100) : 0,
|
||||
unit: '%',
|
||||
},
|
||||
{ label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const tripsPerRoute: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT o.label AS origin, d.label AS destination, ts.direction,
|
||||
COUNT(*)::int AS trips,
|
||||
ROUND(COALESCE(SUM(w.booked_tons), 0))::float8 AS tons_hauled,
|
||||
ROUND(COALESCE(AVG(w.booked_tons), 0))::float8 AS avg_tons_per_trip
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards o ON o.id = ts.origin_station_id
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons
|
||||
FROM freight.train_set_wagons tw
|
||||
WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DISPATCHED', 'ARRIVED')
|
||||
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
||||
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
||||
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
|
||||
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
|
||||
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
||||
GROUP BY 1, 2, 3 ORDER BY trips DESC`,
|
||||
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
|
||||
);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Trips', value: sum(rows, 'trips') },
|
||||
{ label: 'Routes served', value: rows.length },
|
||||
{ label: 'Tonnage hauled', value: sum(rows, 'tons_hauled'), unit: 't' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const invoicedVsCollected: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT to_char(date_trunc($5, COALESCE(i.issued_at, i.created_at)), 'YYYY-MM-DD') AS period,
|
||||
COUNT(*)::int AS invoices,
|
||||
ROUND(SUM(i.total_amount))::float8 AS invoiced,
|
||||
ROUND(SUM(i.paid_amount))::float8 AS collected,
|
||||
ROUND(SUM(i.balance_amount))::float8 AS outstanding
|
||||
FROM freight.invoices i
|
||||
WHERE i.deleted_at IS NULL
|
||||
AND i.status NOT IN ('DRAFT', 'CANCELLED')
|
||||
AND ($1::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) >= $1)
|
||||
AND ($2::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) < $2)
|
||||
AND ($3::uuid[] IS NULL OR i.company_id = ANY($3))
|
||||
AND ${refDirScope('i.source_id', '$4')}
|
||||
GROUP BY 1 ORDER BY 1`,
|
||||
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.granularity],
|
||||
);
|
||||
const invoiced = sum(rows, 'invoiced');
|
||||
const collected = sum(rows, 'collected');
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Invoiced', value: invoiced, unit: 'ETB' },
|
||||
{ label: 'Collected', value: collected, unit: 'ETB' },
|
||||
{
|
||||
label: 'Collection rate',
|
||||
value: invoiced > 0 ? Math.round((collected / invoiced) * 100) : 0,
|
||||
unit: '%',
|
||||
},
|
||||
{ label: 'Outstanding', value: sum(rows, 'outstanding'), unit: 'ETB' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
// Aging is an as-of snapshot: dateTo is the as-of moment (default now),
|
||||
// dateFrom is ignored.
|
||||
const agingReceivables: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT c.name AS customer,
|
||||
COUNT(*)::int AS invoices,
|
||||
ROUND(SUM(i.balance_amount))::float8 AS outstanding,
|
||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at >= COALESCE($1::timestamptz, now())), 0))::float8 AS current,
|
||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now())
|
||||
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '30 days'), 0))::float8 AS overdue_0_30,
|
||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '30 days'
|
||||
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '60 days'), 0))::float8 AS overdue_31_60,
|
||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '60 days'
|
||||
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_61_90,
|
||||
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_90_plus
|
||||
FROM freight.invoices i
|
||||
JOIN freight.companies c ON c.id = i.company_id
|
||||
WHERE i.deleted_at IS NULL
|
||||
AND i.status IN ('ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE')
|
||||
AND i.balance_amount > 0
|
||||
AND ($1::timestamptz IS NULL OR i.created_at < $1)
|
||||
AND ($2::uuid[] IS NULL OR i.company_id = ANY($2))
|
||||
AND ${refDirScope('i.source_id', '$3')}
|
||||
GROUP BY 1 ORDER BY outstanding DESC LIMIT 200`,
|
||||
[f.dateTo, f.companyIds, f.directions],
|
||||
);
|
||||
const outstanding = sum(rows, 'outstanding');
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Outstanding', value: outstanding, unit: 'ETB' },
|
||||
{ label: 'Overdue', value: outstanding - sum(rows, 'current'), unit: 'ETB' },
|
||||
{ label: 'Customers with balance', value: rows.length },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const revenueByPaymentMethod: ReportQuery = async (ds, f) => {
|
||||
// payments.status values are lowercase-hyphenated ('success'), unlike every
|
||||
// other status enum in the schema. No deleted_at on this table.
|
||||
const rows = await ds.query(
|
||||
`SELECT p.method::text AS method,
|
||||
COUNT(*)::int AS payments,
|
||||
ROUND(SUM(p.amount))::float8 AS amount
|
||||
FROM freight.payments p
|
||||
WHERE p.status = 'success'
|
||||
AND ($1::timestamptz IS NULL OR p.created_at >= $1)
|
||||
AND ($2::timestamptz IS NULL OR p.created_at < $2)
|
||||
AND ${refDirScope('p.ref_id', '$3')}
|
||||
GROUP BY 1 ORDER BY amount DESC`,
|
||||
[f.dateFrom, f.dateTo, f.directions],
|
||||
);
|
||||
const total = sum(rows, 'amount');
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Collected', value: total, unit: 'ETB' },
|
||||
{ label: 'Payments', value: sum(rows, 'payments') },
|
||||
{
|
||||
label: 'Top method share',
|
||||
value: total > 0 ? Math.round((num(rows[0]?.amount) / total) * 100) : 0,
|
||||
unit: '%',
|
||||
},
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Record-level list exports. Same engine, raw rows instead of aggregates.
|
||||
// ponytail: flat LIMIT 5000 per list — stream/paginate the export if a table
|
||||
// ever outgrows that.
|
||||
const LIST_LIMIT = 5000;
|
||||
|
||||
const bookingsList: ReportQuery = async (ds, f) => {
|
||||
const { where, params } = bookingWhere(f);
|
||||
const rows = await ds.query(
|
||||
`SELECT b.reference,
|
||||
to_char(b.created_at, 'YYYY-MM-DD') AS created,
|
||||
c.name AS customer, b.status, b.freight_type,
|
||||
b.trade_direction AS direction,
|
||||
o.label AS origin, d.label AS destination,
|
||||
COALESCE(cty.cargo_type_name, b.cargo_free_text) AS cargo,
|
||||
ROUND(${TONS})::float8 AS tons,
|
||||
ROUND(${REVENUE})::float8 AS amount,
|
||||
b.payment_status, b.scheduling_status
|
||||
FROM freight.bookings b
|
||||
JOIN freight.companies c ON c.id = b.company_id
|
||||
JOIN freight.yards o ON o.id = b.origin_yard_id
|
||||
JOIN freight.yards d ON d.id = b.destination_yard_id
|
||||
LEFT JOIN freight.cargo_types cty ON cty.id = b.cargo_type_id
|
||||
WHERE ${where}
|
||||
ORDER BY b.created_at DESC LIMIT ${LIST_LIMIT}`,
|
||||
params,
|
||||
);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Bookings', value: rows.length },
|
||||
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
|
||||
{ label: 'Amount', value: sum(rows, 'amount'), unit: 'ETB' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const contractsList: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT ct.reference, c.name AS customer, ct.contract_kind AS kind,
|
||||
ct.status, ct.trade_direction AS direction, ct.freight_type,
|
||||
to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from,
|
||||
to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until,
|
||||
to_char(ct.created_at, 'YYYY-MM-DD') AS created
|
||||
FROM freight.contracts ct
|
||||
LEFT JOIN freight.companies c ON c.id = ct.company_id
|
||||
WHERE ct.deleted_at IS NULL
|
||||
AND ($1::timestamptz IS NULL OR ct.created_at >= $1)
|
||||
AND ($2::timestamptz IS NULL OR ct.created_at < $2)
|
||||
AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3))
|
||||
AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4))
|
||||
AND ($5::text[] IS NULL OR ct.status = ANY($5))
|
||||
ORDER BY ct.created_at DESC LIMIT ${LIST_LIMIT}`,
|
||||
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses],
|
||||
);
|
||||
const active = rows.filter((r: Record<string, unknown>) =>
|
||||
['CONTRACT_ACTIVE', 'ACTIVE_SHIPMENT_IN_PROGRESS'].includes(String(r.status)),
|
||||
).length;
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Contracts', value: rows.length },
|
||||
{ label: 'Active', value: active },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const schedulesList: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT ts.train_number, ts.reference, ts.direction, ts.status,
|
||||
o.label AS origin, d.label AS destination,
|
||||
to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI') AS scheduled_departure,
|
||||
to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI') AS actual_departure,
|
||||
to_char(ts.scheduled_arrival_date, 'YYYY-MM-DD HH24:MI') AS scheduled_arrival,
|
||||
to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI') AS actual_arrival,
|
||||
ts.max_wagons, tset.wagon_count
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.yards o ON o.id = ts.origin_station_id
|
||||
JOIN freight.yards d ON d.id = ts.destination_station_id
|
||||
LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
||||
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
||||
AND ($3::text[] IS NULL OR ts.direction = ANY($3))
|
||||
AND ($4::text[] IS NULL OR ts.status = ANY($4))
|
||||
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
||||
ORDER BY ts.scheduled_departure_date DESC LIMIT ${LIST_LIMIT}`,
|
||||
[f.dateFrom, f.dateTo, f.directions, f.statuses, f.yardIds],
|
||||
);
|
||||
const count = (s: string) =>
|
||||
rows.filter((r: Record<string, unknown>) => r.status === s).length;
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Schedules', value: rows.length },
|
||||
{ label: 'Dispatched', value: count('DISPATCHED') },
|
||||
{ label: 'Arrived', value: count('ARRIVED') },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const fleetWagons: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT w.wagon_number, wt.name AS type,
|
||||
wt.capacity_tons::float8 AS capacity_tons,
|
||||
w.status, y.label AS current_yard
|
||||
FROM freight.wagons w
|
||||
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
||||
LEFT JOIN freight.yards y ON y.id = w.current_yard_id
|
||||
WHERE w.deleted_at IS NULL
|
||||
AND ($1::text[] IS NULL OR w.status = ANY($1))
|
||||
AND ($2::uuid[] IS NULL OR w.current_yard_id = ANY($2))
|
||||
ORDER BY w.wagon_number LIMIT ${LIST_LIMIT}`,
|
||||
[f.statuses, f.yardIds],
|
||||
);
|
||||
const count = (s: string) =>
|
||||
rows.filter((r: Record<string, unknown>) => r.status === s).length;
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Wagons', value: rows.length },
|
||||
{ label: 'Available', value: count('AVAILABLE') },
|
||||
{ label: 'Assigned', value: count('ASSIGNED') },
|
||||
{ label: 'Maintenance', value: count('MAINTENANCE') },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const fleetLocomotives: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT l.code, l.name, l.locomotive_type,
|
||||
l.max_pull_weight_tons::float8 AS max_pull_tons,
|
||||
l.status, y.label AS current_yard
|
||||
FROM freight.locomotives l
|
||||
LEFT JOIN freight.yards y ON y.id = l.current_yard_id
|
||||
WHERE l.deleted_at IS NULL
|
||||
AND ($1::text[] IS NULL OR l.status = ANY($1))
|
||||
AND ($2::uuid[] IS NULL OR l.current_yard_id = ANY($2))
|
||||
ORDER BY l.code LIMIT ${LIST_LIMIT}`,
|
||||
[f.statuses, f.yardIds],
|
||||
);
|
||||
const available = rows.filter(
|
||||
(r: Record<string, unknown>) => r.status === 'AVAILABLE',
|
||||
).length;
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Locomotives', value: rows.length },
|
||||
{ label: 'Available', value: available },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const customersList: ReportQuery = async (ds, f) => {
|
||||
const rows = await ds.query(
|
||||
`SELECT c.name, c.type, c.kind, c.status, c.tin,
|
||||
to_char(c.approved_at, 'YYYY-MM-DD') AS approved,
|
||||
to_char(c.created_at, 'YYYY-MM-DD') AS created
|
||||
FROM freight.companies c
|
||||
WHERE c.deleted_at IS NULL
|
||||
AND ($1::timestamptz IS NULL OR c.created_at >= $1)
|
||||
AND ($2::timestamptz IS NULL OR c.created_at < $2)
|
||||
AND ($3::text[] IS NULL OR c.status = ANY($3))
|
||||
ORDER BY c.created_at DESC LIMIT ${LIST_LIMIT}`,
|
||||
[f.dateFrom, f.dateTo, f.statuses],
|
||||
);
|
||||
const active = rows.filter(
|
||||
(r: Record<string, unknown>) => r.status === 'active',
|
||||
).length;
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Customers', value: rows.length },
|
||||
{ label: 'Active', value: active },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
const paymentsList: ReportQuery = async (ds, f) => {
|
||||
// No deleted_at on freight.payments; statuses are lowercase-hyphenated.
|
||||
const rows = await ds.query(
|
||||
`SELECT to_char(p.created_at, 'YYYY-MM-DD HH24:MI') AS created,
|
||||
p.method::text AS method, p.status::text AS status,
|
||||
p.currency::text AS currency,
|
||||
ROUND(p.amount)::float8 AS amount,
|
||||
p.transaction_id, p.merchant_order_id,
|
||||
to_char(p.paid_at, 'YYYY-MM-DD') AS paid
|
||||
FROM freight.payments p
|
||||
WHERE ($1::timestamptz IS NULL OR p.created_at >= $1)
|
||||
AND ($2::timestamptz IS NULL OR p.created_at < $2)
|
||||
AND ($3::text[] IS NULL OR p.status::text = ANY($3))
|
||||
AND ${refDirScope('p.ref_id', '$4')}
|
||||
ORDER BY p.created_at DESC LIMIT ${LIST_LIMIT}`,
|
||||
[f.dateFrom, f.dateTo, f.statuses, f.directions],
|
||||
);
|
||||
const success = rows.filter(
|
||||
(r: Record<string, unknown>) => r.status === 'success',
|
||||
);
|
||||
return {
|
||||
kpis: [
|
||||
{ label: 'Payments', value: rows.length },
|
||||
{ label: 'Successful', value: success.length },
|
||||
{ label: 'Collected', value: sum(success, 'amount'), unit: 'ETB' },
|
||||
],
|
||||
rows,
|
||||
};
|
||||
};
|
||||
|
||||
export const REPORT_QUERIES: Record<string, ReportQuery> = {
|
||||
'bookings-list': bookingsList,
|
||||
'contracts-list': contractsList,
|
||||
'schedules-list': schedulesList,
|
||||
'fleet-wagons': fleetWagons,
|
||||
'fleet-locomotives': fleetLocomotives,
|
||||
'customers-list': customersList,
|
||||
'payments-list': paymentsList,
|
||||
'bookings-trend': bookingsTrend,
|
||||
'revenue-by-customer': revenueByCustomer,
|
||||
'revenue-by-lane': revenueByLane,
|
||||
'contract-utilization': contractUtilization,
|
||||
'train-on-time': trainOnTime,
|
||||
'schedule-fill-rate': scheduleFillRate,
|
||||
'trips-per-route': tripsPerRoute,
|
||||
'invoiced-vs-collected': invoicedVsCollected,
|
||||
'aging-receivables': agingReceivables,
|
||||
'revenue-by-payment-method': revenueByPaymentMethod,
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { BookingView } from '../../common/booking-guards';
|
||||
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
||||
import { ReportQueryDto } from './dto/report-query.dto';
|
||||
import { ReportResultDto } from './dto/report-result.dto';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
@ApiTags('Reports')
|
||||
@ApiBearerAuth()
|
||||
@Controller('reports')
|
||||
export class ReportsController {
|
||||
constructor(
|
||||
private readonly reportsService: ReportsService,
|
||||
private readonly userTradeAccessService: UserTradeAccessService,
|
||||
) {}
|
||||
|
||||
@Get(':key')
|
||||
@BookingView()
|
||||
@ApiOperation({ summary: 'Run a canned report by key with optional filters' })
|
||||
@ApiOkResponse({ type: ReportResultDto })
|
||||
async run(
|
||||
@Param('key') key: string,
|
||||
@Query() query: ReportQueryDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
): Promise<ReportResultDto> {
|
||||
const allowed = await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
return this.reportsService.run(key, query, allowed);
|
||||
}
|
||||
}
|
||||
13
apps/edr-freight-api/src/modules/reports/reports.module.ts
Normal file
13
apps/edr-freight-api/src/modules/reports/reports.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||
import { ReportsController } from './reports.controller';
|
||||
import { ReportsRepository } from './reports.repository';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
@Module({
|
||||
imports: [UserTradeAccessModule],
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService, ReportsRepository],
|
||||
})
|
||||
export class ReportsModule {}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { REPORT_QUERIES, ReportFilters, ReportResult } from './report-queries';
|
||||
|
||||
@Injectable()
|
||||
export class ReportsRepository {
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
run(key: keyof typeof REPORT_QUERIES, filters: ReportFilters): Promise<ReportResult> {
|
||||
return REPORT_QUERIES[key](this.dataSource, filters);
|
||||
}
|
||||
}
|
||||
46
apps/edr-freight-api/src/modules/reports/reports.service.ts
Normal file
46
apps/edr-freight-api/src/modules/reports/reports.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { scopedDirections } from '../user-trade-access/trade-scope.util';
|
||||
import { ReportQueryDto } from './dto/report-query.dto';
|
||||
import { REPORT_QUERIES, ReportFilters, ReportResult } from './report-queries';
|
||||
import { ReportsRepository } from './reports.repository';
|
||||
import type { Freight } from '@edr/types';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const list = (csv?: string): string[] | null => {
|
||||
const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? [];
|
||||
return items.length ? items : null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
constructor(private readonly repository: ReportsRepository) {}
|
||||
|
||||
run(
|
||||
key: string,
|
||||
dto: ReportQueryDto,
|
||||
allowedDirections: Freight.ScheduleTradeDirection[] | null,
|
||||
): Promise<ReportResult> {
|
||||
if (!(key in REPORT_QUERIES)) {
|
||||
throw new NotFoundException(`Unknown report: ${key}`);
|
||||
}
|
||||
// No default range: absent dates mean all time, so exports cover everything.
|
||||
const to = dto.dateTo ? new Date(dto.dateTo) : null;
|
||||
const from = dto.dateFrom ? new Date(dto.dateFrom) : null;
|
||||
const filters: ReportFilters = {
|
||||
dateFrom: from ? from.toISOString() : null,
|
||||
// dateTo is inclusive in the API; queries treat the bound as exclusive.
|
||||
dateTo: to ? new Date(to.getTime() + DAY_MS).toISOString() : null,
|
||||
granularity: dto.granularity ?? 'day',
|
||||
companyIds: list(dto.companyIds),
|
||||
routeIds: list(dto.routeIds),
|
||||
yardIds: list(dto.yardIds),
|
||||
cargoTypeIds: list(dto.cargoTypeIds),
|
||||
statuses: list(dto.statuses),
|
||||
directions: scopedDirections(allowedDirections, dto.direction),
|
||||
freightType: dto.freightType ?? null,
|
||||
};
|
||||
return this.repository.run(key, filters);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,19 @@ export class CreateCargoTypeDto {
|
||||
@IsObject()
|
||||
itemsPerWagonMap?: Record<string, number> | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'PER_TON cargo only: the most tons of this cargo one wagon may carry, keyed by ' +
|
||||
'wagon-type id (e.g. { "<nw5-id>": 50 } loads sugar 50T on a 70T wagon, so 200T ' +
|
||||
'takes 4 wagons). Optional — omit a wagon type to use its full rated capacity. ' +
|
||||
'Rejected when it exceeds that wagon type\'s rated capacity.',
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'number', minimum: 0.001 },
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
tonsPerWagonMap?: Record<string, number> | null;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -60,6 +60,17 @@ export class CargoType extends BaseEntity {
|
||||
@Column({ name: 'items_per_wagon_map', type: 'jsonb', nullable: true })
|
||||
itemsPerWagonMap?: Record<string, number> | null;
|
||||
|
||||
/**
|
||||
* PER_TON (bulk) only: the most tons of THIS cargo that may ride one wagon of
|
||||
* each allowed type, keyed by wagon-type id (e.g. sugar → { NW5: 50 } on a
|
||||
* 70T wagon). Caps both the wagon count and how much each wagon is loaded, so
|
||||
* 200T of sugar takes 4 wagons at 50T rather than 3 at 70T. A missing key (or
|
||||
* a null map) means the wagon's full rated capacity — unlike itemsPerWagonMap
|
||||
* this is optional, so cargo without a loading limit is unaffected.
|
||||
*/
|
||||
@Column({ name: 'tons_per_wagon_map', type: 'jsonb', nullable: true })
|
||||
tonsPerWagonMap?: Record<string, number> | null;
|
||||
|
||||
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
|
||||
requiresDirectorApproval!: boolean;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
||||
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
@@ -13,6 +14,7 @@ import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
||||
import { CargoType } from '../entities/cargo-type.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
|
||||
import {
|
||||
CARGO_TYPES_REPOSITORY,
|
||||
ICargoTypesRepository,
|
||||
@@ -27,6 +29,7 @@ export class CargoTypesService {
|
||||
private readonly repository: ICargoTypesRepository,
|
||||
@Inject(RATES_REPOSITORY)
|
||||
private readonly ratesRepository: IRatesRepository,
|
||||
private readonly wagonTypesRepository: WagonTypesRepository,
|
||||
private readonly displayOrder: DisplayOrderService,
|
||||
) {}
|
||||
|
||||
@@ -75,6 +78,63 @@ export class CargoTypesService {
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* PER_TON (bulk) cargo may cap how many tons ride one wagon, BELOW that
|
||||
* wagon's rated capacity: sugar at 50T on a 70T wagon means 200T takes 4
|
||||
* wagons, not 3. Unlike the PER_ITEM fit this is optional — an absent key
|
||||
* means the full rated capacity, so existing cargo types are unaffected.
|
||||
*
|
||||
* A cap ABOVE the rated capacity is rejected: nobody loads 90T on a 70T
|
||||
* wagon, so it is a typo, and silently clamping it would leave the config
|
||||
* screen showing a number the trains never honour. (Allocation clamps too, via
|
||||
* `bulkTonsPerWagon`, for caps left stale by a later wagon-type edit — this
|
||||
* check cannot see those, since the cargo type is never re-saved.)
|
||||
*
|
||||
* Returns the map trimmed to the allowed wagon types, or null when the cargo
|
||||
* is not PER_TON / nothing is capped.
|
||||
*/
|
||||
private async resolveTonsPerWagonMap(input: {
|
||||
unitOfMeasure?: CargoUnitOfMeasure | null;
|
||||
wagonTypeIds: string[];
|
||||
tonsPerWagonMap?: Record<string, number> | null;
|
||||
}): Promise<Record<string, number> | null> {
|
||||
if (input.unitOfMeasure !== CargoUnitOfMeasure.PerTon || !input.wagonTypeIds.length) {
|
||||
return null;
|
||||
}
|
||||
const capped = input.wagonTypeIds.filter(
|
||||
(id) => input.tonsPerWagonMap?.[id] !== undefined && input.tonsPerWagonMap[id] !== null,
|
||||
);
|
||||
if (!capped.length) return null;
|
||||
|
||||
const wagonTypes = await this.wagonTypesRepository.findAll({
|
||||
where: { id: In(capped) },
|
||||
});
|
||||
const capacityById = new Map(
|
||||
wagonTypes.map((wt) => [wt.id, Number(wt.capacityTons) || 0]),
|
||||
);
|
||||
|
||||
const map: Record<string, number> = {};
|
||||
for (const wagonTypeId of capped) {
|
||||
const tons = Number(input.tonsPerWagonMap?.[wagonTypeId]);
|
||||
if (!Number.isFinite(tons) || tons <= 0) {
|
||||
throw new BadRequestException(
|
||||
`tonsPerWagonMap for wagon type ${wagonTypeId} must be a number greater than 0`,
|
||||
);
|
||||
}
|
||||
const capacity = capacityById.get(wagonTypeId);
|
||||
if (capacity === undefined) {
|
||||
throw new BadRequestException(`Wagon type ${wagonTypeId} not found`);
|
||||
}
|
||||
if (capacity > 0 && tons > capacity) {
|
||||
throw new BadRequestException(
|
||||
`Max tons per wagon (${tons}T) exceeds wagon type ${wagonTypeId} rated capacity ${capacity}T`,
|
||||
);
|
||||
}
|
||||
map[wagonTypeId] = tons;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Create a new cargo type. */
|
||||
async create(dto: CreateCargoTypeDto): Promise<CargoType> {
|
||||
const code = generateCode(dto.cargoTypeName);
|
||||
@@ -90,6 +150,12 @@ export class CargoTypesService {
|
||||
insertAfterId: dto.insertAfterId,
|
||||
});
|
||||
|
||||
const tonsPerWagonMap = await this.resolveTonsPerWagonMap({
|
||||
unitOfMeasure: dto.unitOfMeasure ?? null,
|
||||
wagonTypeIds: dto.wagonTypeIds ?? [],
|
||||
tonsPerWagonMap: dto.tonsPerWagonMap,
|
||||
});
|
||||
|
||||
return this.repository.create({
|
||||
code,
|
||||
cargoTypeName: dto.cargoTypeName,
|
||||
@@ -104,6 +170,7 @@ export class CargoTypesService {
|
||||
wagonTypeIds: dto.wagonTypeIds ?? [],
|
||||
itemsPerWagonMap: dto.itemsPerWagonMap,
|
||||
}),
|
||||
tonsPerWagonMap,
|
||||
displayOrder,
|
||||
});
|
||||
}
|
||||
@@ -116,12 +183,32 @@ export class CargoTypesService {
|
||||
const parent = await this.repository.findById(dto.parentGroupId);
|
||||
if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`);
|
||||
}
|
||||
const { wagonTypeIds, itemsPerWagonMap, insertAfterId: _insertAfterId, ...columns } = dto;
|
||||
const {
|
||||
wagonTypeIds,
|
||||
itemsPerWagonMap,
|
||||
tonsPerWagonMap,
|
||||
insertAfterId: _insertAfterId,
|
||||
...columns
|
||||
} = dto;
|
||||
// Re-validate the fit map whenever anything it depends on moves — a partial
|
||||
// update merges with the stored values so e.g. adding a wagon type without
|
||||
// its fit still 400s. Untouched fields leave the stored map alone.
|
||||
const touchesItemsFit =
|
||||
wagonTypeIds !== undefined || itemsPerWagonMap !== undefined || dto.unitOfMeasure !== undefined;
|
||||
// Same merge rule for the tonnage cap: re-resolve whenever the uom, the
|
||||
// allowed wagon types, or the caps themselves move, so a wagon type added
|
||||
// without a cap keeps its full rated capacity and a uom flip drops stale caps.
|
||||
const touchesTonsCap =
|
||||
wagonTypeIds !== undefined || tonsPerWagonMap !== undefined || dto.unitOfMeasure !== undefined;
|
||||
const resolvedTonsPerWagonMap = touchesTonsCap
|
||||
? await this.resolveTonsPerWagonMap({
|
||||
unitOfMeasure:
|
||||
dto.unitOfMeasure !== undefined ? dto.unitOfMeasure : existing.unitOfMeasure,
|
||||
wagonTypeIds: wagonTypeIds ?? (existing.wagonTypes ?? []).map((wt) => wt.id),
|
||||
tonsPerWagonMap:
|
||||
tonsPerWagonMap !== undefined ? tonsPerWagonMap : existing.tonsPerWagonMap,
|
||||
})
|
||||
: undefined;
|
||||
const updated = await this.repository.update(id, {
|
||||
...columns,
|
||||
...(wagonTypeIds
|
||||
@@ -138,6 +225,7 @@ export class CargoTypesService {
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
...(touchesTonsCap ? { tonsPerWagonMap: resolvedTonsPerWagonMap } : {}),
|
||||
});
|
||||
if (!updated) throw new NotFoundException(`Cargo type ${id} not found`);
|
||||
// A uom flip renames how existing rates bill (PER_TON ↔ PER_ITEM name the
|
||||
|
||||
@@ -13,6 +13,30 @@ export interface YardFacilityInfo {
|
||||
/** Containers need a reach stacker/gantry — not every facility has one. */
|
||||
handlesContainer: boolean;
|
||||
handlesBulk: boolean;
|
||||
/**
|
||||
* The same capability split by side of the trip — loading onto a train and
|
||||
* receiving off one need different ground. Always false where the coarse
|
||||
* `handles*` flag for that type is false.
|
||||
*/
|
||||
hasContainerFacilityOrigin: boolean;
|
||||
hasBulkFacilityOrigin: boolean;
|
||||
hasContainerFacilityDestination: boolean;
|
||||
hasBulkFacilityDestination: boolean;
|
||||
}
|
||||
|
||||
/** Which side of the trip a yard is being considered for. */
|
||||
export type YardSide = 'ORIGIN' | 'DESTINATION';
|
||||
|
||||
/**
|
||||
* The four per-side capability flags as stored — NOT gated on the coarse
|
||||
* `handles*` switches. The yards config page edits the stored values; gating
|
||||
* is applied only when the flows resolve capability (see `toInfo`).
|
||||
*/
|
||||
export interface YardSideFlags {
|
||||
hasContainerFacilityOrigin: boolean;
|
||||
hasBulkFacilityOrigin: boolean;
|
||||
hasContainerFacilityDestination: boolean;
|
||||
hasBulkFacilityDestination: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,7 +62,11 @@ export class YardFacilitiesService {
|
||||
y.has_facility AS "hasFacility",
|
||||
f.has_warehouse AS "hasWarehouse",
|
||||
f.handles_container AS "handlesContainer",
|
||||
f.handles_bulk AS "handlesBulk"
|
||||
f.handles_bulk AS "handlesBulk",
|
||||
f.has_container_facility_origin AS "hasContainerFacilityOrigin",
|
||||
f.has_bulk_facility_origin AS "hasBulkFacilityOrigin",
|
||||
f.has_container_facility_destination AS "hasContainerFacilityDestination",
|
||||
f.has_bulk_facility_destination AS "hasBulkFacilityDestination"
|
||||
FROM freight.yards y
|
||||
LEFT JOIN freight.yard_facilities f
|
||||
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true`;
|
||||
@@ -51,17 +79,33 @@ export class YardFacilitiesService {
|
||||
hasWarehouse: boolean | null;
|
||||
handlesContainer: boolean | null;
|
||||
handlesBulk: boolean | null;
|
||||
hasContainerFacilityOrigin: boolean | null;
|
||||
hasBulkFacilityOrigin: boolean | null;
|
||||
hasContainerFacilityDestination: boolean | null;
|
||||
hasBulkFacilityDestination: boolean | null;
|
||||
}): YardFacilityInfo {
|
||||
// No facility record means no capability, whatever the flag says.
|
||||
const hasFacility = Boolean(row.hasFacility);
|
||||
const handlesContainer = hasFacility && Boolean(row.handlesContainer);
|
||||
const handlesBulk = hasFacility && Boolean(row.handlesBulk);
|
||||
return {
|
||||
yardId: row.yardId,
|
||||
yardCode: row.yardCode,
|
||||
yardLabel: row.yardLabel,
|
||||
hasFacility,
|
||||
hasWarehouse: hasFacility && Boolean(row.hasWarehouse),
|
||||
handlesContainer: hasFacility && Boolean(row.handlesContainer),
|
||||
handlesBulk: hasFacility && Boolean(row.handlesBulk),
|
||||
handlesContainer,
|
||||
handlesBulk,
|
||||
// Gated on the coarse flag so the two can't contradict each other: a
|
||||
// per-side flag left set on a type the facility no longer handles at all
|
||||
// never resurrects that type.
|
||||
hasContainerFacilityOrigin:
|
||||
handlesContainer && Boolean(row.hasContainerFacilityOrigin),
|
||||
hasBulkFacilityOrigin: handlesBulk && Boolean(row.hasBulkFacilityOrigin),
|
||||
hasContainerFacilityDestination:
|
||||
handlesContainer && Boolean(row.hasContainerFacilityDestination),
|
||||
hasBulkFacilityDestination:
|
||||
handlesBulk && Boolean(row.hasBulkFacilityDestination),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,6 +128,61 @@ export class YardFacilitiesService {
|
||||
return rows.map((r: Parameters<typeof this.toInfo>[0]) => this.toInfo(r));
|
||||
}
|
||||
|
||||
/** Stored per-side flags for a set of yards, keyed by yard id. Yards with no facility record are absent. */
|
||||
async sideFlagsForYards(yardIds: string[]): Promise<Map<string, YardSideFlags>> {
|
||||
if (yardIds.length === 0) return new Map();
|
||||
const rows: Array<YardSideFlags & { yardId: string }> = await this.dataSource.query(
|
||||
`SELECT yard_id AS "yardId",
|
||||
has_container_facility_origin AS "hasContainerFacilityOrigin",
|
||||
has_bulk_facility_origin AS "hasBulkFacilityOrigin",
|
||||
has_container_facility_destination AS "hasContainerFacilityDestination",
|
||||
has_bulk_facility_destination AS "hasBulkFacilityDestination"
|
||||
FROM freight.yard_facilities
|
||||
WHERE deleted_at IS NULL AND yard_id = ANY($1)`,
|
||||
[yardIds],
|
||||
);
|
||||
return new Map(
|
||||
rows.map((r) => [
|
||||
r.yardId,
|
||||
{
|
||||
hasContainerFacilityOrigin: r.hasContainerFacilityOrigin,
|
||||
hasBulkFacilityOrigin: r.hasBulkFacilityOrigin,
|
||||
hasContainerFacilityDestination: r.hasContainerFacilityDestination,
|
||||
hasBulkFacilityDestination: r.hasBulkFacilityDestination,
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write per-side flags from the yards config form, creating the facility
|
||||
* record if the yard doesn't have one yet (backoffice-created yards don't).
|
||||
* Flags left undefined keep their stored value; on first insert they default
|
||||
* false — an unconfigured facility offers nothing.
|
||||
*/
|
||||
async upsertSideFlags(yardId: string, flags: Partial<YardSideFlags>): Promise<void> {
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO freight.yard_facilities
|
||||
(yard_id, has_container_facility_origin, has_bulk_facility_origin,
|
||||
has_container_facility_destination, has_bulk_facility_destination)
|
||||
VALUES ($1, COALESCE($2, false), COALESCE($3, false), COALESCE($4, false), COALESCE($5, false))
|
||||
ON CONFLICT (yard_id) WHERE deleted_at IS NULL
|
||||
DO UPDATE SET
|
||||
has_container_facility_origin = COALESCE($2, yard_facilities.has_container_facility_origin),
|
||||
has_bulk_facility_origin = COALESCE($3, yard_facilities.has_bulk_facility_origin),
|
||||
has_container_facility_destination = COALESCE($4, yard_facilities.has_container_facility_destination),
|
||||
has_bulk_facility_destination = COALESCE($5, yard_facilities.has_bulk_facility_destination),
|
||||
updated_at = now()`,
|
||||
[
|
||||
yardId,
|
||||
flags.hasContainerFacilityOrigin ?? null,
|
||||
flags.hasBulkFacilityOrigin ?? null,
|
||||
flags.hasContainerFacilityDestination ?? null,
|
||||
flags.hasBulkFacilityDestination ?? null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this facility lift this cargo? Keeps the freight-type rule in one place
|
||||
* so callers can't get it subtly wrong.
|
||||
@@ -97,4 +196,26 @@ export class YardFacilitiesService {
|
||||
? facility.handlesContainer
|
||||
: facility.handlesBulk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this facility take this cargo on this side of the trip? The rule behind
|
||||
* the contract's origin/destination yard pickers — keep it here so the API
|
||||
* and the forms can't drift apart on what is offerable.
|
||||
*/
|
||||
canHandleFreightOnSide(
|
||||
facility: YardFacilityInfo | null,
|
||||
freightType: string | null | undefined,
|
||||
side: YardSide,
|
||||
): boolean {
|
||||
if (!facility?.hasFacility) return false;
|
||||
const isContainer = String(freightType).toUpperCase() === 'CONTAINER';
|
||||
if (side === 'ORIGIN') {
|
||||
return isContainer
|
||||
? facility.hasContainerFacilityOrigin
|
||||
: facility.hasBulkFacilityOrigin;
|
||||
}
|
||||
return isContainer
|
||||
? facility.hasContainerFacilityDestination
|
||||
: facility.hasBulkFacilityDestination;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ const service = (): YardsService =>
|
||||
update: async (_id: string, d: Partial<Yard>) => d as Yard,
|
||||
} as never,
|
||||
{ resolveCreateOrder: async () => 1 } as never,
|
||||
{
|
||||
sideFlagsForYards: async () => new Map(),
|
||||
upsertSideFlags: async () => undefined,
|
||||
} as never,
|
||||
);
|
||||
|
||||
describe('duplicate yard labels are rejected', () => {
|
||||
|
||||
@@ -8,6 +8,24 @@ import { UpdateYardDto } from '../dto/update-yard.dto';
|
||||
import { Yard } from '../entities/yard.entity';
|
||||
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
|
||||
import { DisplayOrderService } from './display-order.service';
|
||||
import { YardFacilitiesService, YardSideFlags } from './yard-facilities.service';
|
||||
|
||||
/** Yard rows the config page lists/edits carry the stored per-side facility flags. */
|
||||
export type YardWithSideFlags = Yard & YardSideFlags;
|
||||
|
||||
const SIDE_FLAG_KEYS = [
|
||||
'hasContainerFacilityOrigin',
|
||||
'hasBulkFacilityOrigin',
|
||||
'hasContainerFacilityDestination',
|
||||
'hasBulkFacilityDestination',
|
||||
] as const;
|
||||
|
||||
const NO_FLAGS: YardSideFlags = {
|
||||
hasContainerFacilityOrigin: false,
|
||||
hasBulkFacilityOrigin: false,
|
||||
hasContainerFacilityDestination: false,
|
||||
hasBulkFacilityDestination: false,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class YardsService {
|
||||
@@ -15,18 +33,34 @@ export class YardsService {
|
||||
@Inject(YARDS_REPOSITORY)
|
||||
private readonly repository: IYardsRepository,
|
||||
private readonly displayOrder: DisplayOrderService,
|
||||
private readonly facilities: YardFacilitiesService,
|
||||
) {}
|
||||
|
||||
/** List yards — standard paginated envelope with server-side search. */
|
||||
async findAll(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>> {
|
||||
return this.repository.findPaged(query);
|
||||
async findAll(query: ListYardsQueryDto): Promise<PaginatedResponse<YardWithSideFlags>> {
|
||||
const page = await this.repository.findPaged(query);
|
||||
const flags = await this.facilities.sideFlagsForYards(page.items.map((y) => y.id));
|
||||
return {
|
||||
...page,
|
||||
items: page.items.map((y) => ({ ...y, ...NO_FLAGS, ...flags.get(y.id) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Get a yard by ID. */
|
||||
async findById(id: string): Promise<Yard> {
|
||||
async findById(id: string): Promise<YardWithSideFlags> {
|
||||
const entity = await this.repository.findById(id);
|
||||
if (!entity) throw new NotFoundException(`Yard ${id} not found`);
|
||||
return entity;
|
||||
const flags = await this.facilities.sideFlagsForYards([id]);
|
||||
return { ...entity, ...NO_FLAGS, ...flags.get(id) };
|
||||
}
|
||||
|
||||
/** The per-side facility flags present in the dto, or null when none were sent. */
|
||||
private pickSideFlags(dto: Partial<CreateYardDto>): Partial<YardSideFlags> | null {
|
||||
const flags: Partial<YardSideFlags> = {};
|
||||
for (const key of SIDE_FLAG_KEYS) {
|
||||
if (dto[key] !== undefined) flags[key] = dto[key];
|
||||
}
|
||||
return Object.keys(flags).length > 0 ? flags : null;
|
||||
}
|
||||
|
||||
/** Create a yard. */
|
||||
@@ -43,7 +77,7 @@ export class YardsService {
|
||||
insertAfterId: dto.insertAfterId,
|
||||
});
|
||||
|
||||
return this.repository.create({
|
||||
const yard = await this.repository.create({
|
||||
code,
|
||||
label: dto.label,
|
||||
country: dto.country,
|
||||
@@ -51,15 +85,28 @@ export class YardsService {
|
||||
hasFacility: dto.hasFacility ?? false,
|
||||
displayOrder,
|
||||
});
|
||||
|
||||
const flags = this.pickSideFlags(dto);
|
||||
if (flags) await this.facilities.upsertSideFlags(yard.id, flags);
|
||||
return this.findById(yard.id);
|
||||
}
|
||||
|
||||
/** Update a yard. */
|
||||
async update(id: string, dto: UpdateYardDto): Promise<Yard> {
|
||||
await this.findById(id);
|
||||
if (dto.label !== undefined) await this.assertLabelAvailable(dto.label, id);
|
||||
const updated = await this.repository.update(id, dto);
|
||||
|
||||
// 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`);
|
||||
return updated;
|
||||
}
|
||||
if (flags) await this.facilities.upsertSideFlags(id, flags);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/** No two active yards may share a label (case/whitespace-insensitive). */
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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({
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
bulkTonsPerWagon,
|
||||
bookingGrossWeightTons,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
sizePartialOfferWagons,
|
||||
@@ -152,6 +153,14 @@ export interface ExportTrainOption {
|
||||
}>;
|
||||
}
|
||||
|
||||
/** A train a paid-unallocated booking can board (route + capacity verified). */
|
||||
export interface AllocationCandidate {
|
||||
id: string;
|
||||
reference: string | null;
|
||||
direction: string | null;
|
||||
scheduledDepartureDate: Date;
|
||||
}
|
||||
|
||||
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
||||
interface RouteDayGroup {
|
||||
originYardId: string;
|
||||
@@ -1448,6 +1457,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
*/
|
||||
async getBatchBoard(
|
||||
query: BatchBoardQueryDto = {},
|
||||
allowedDirections?: string[],
|
||||
): Promise<BatchBoardListResponse> {
|
||||
// Board cards are heavy (per-schedule booking summaries), so the default
|
||||
// page is smaller than the toolkit-wide 20.
|
||||
@@ -1455,6 +1465,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
defaultPageSize: 12,
|
||||
});
|
||||
|
||||
// The board is IMPORT-only — a user scoped away from IMPORT sees nothing.
|
||||
if (allowedDirections && !allowedDirections.includes("IMPORT")) {
|
||||
return { items: [], meta: buildPaginationMeta(0, page, pageSize) };
|
||||
}
|
||||
|
||||
// Status filter: any subset of the lifecycle. Omitted = all statuses, so
|
||||
// arrived / cancelled / dispatched schedules stay visible as history.
|
||||
const allowedStatuses = new Set<string>(BATCH_BOARD_STATUSES);
|
||||
@@ -3027,6 +3042,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
: booking.status;
|
||||
await manager.getRepository(Booking).update(bookingId, {
|
||||
trainScheduleId: newScheduleId,
|
||||
scheduledDate: schedule.scheduledDepartureDate,
|
||||
status: restoredStatus,
|
||||
// A paid booking still hunting for a wagon keeps its flag through the
|
||||
// move — it only clears when wagons are actually assigned.
|
||||
@@ -3045,6 +3061,104 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.notifyBoardChanged(newScheduleId, "booking_moved");
|
||||
}
|
||||
|
||||
/**
|
||||
* Trains a paid-but-unallocated booking can board right now: OPEN window,
|
||||
* future departure, route covers the booking's leg, and remaining corridor
|
||||
* capacity fits it. Split by the booking's own scheduled day so the UI can
|
||||
* offer one-click same-day allocation vs an explicit "another date" choice.
|
||||
*/
|
||||
async allocationCandidates(bookingId: string): Promise<{
|
||||
sameDay: AllocationCandidate[];
|
||||
otherDays: AllocationCandidate[];
|
||||
}> {
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: bookingId },
|
||||
relations: {
|
||||
bookingContainers: { containerType: true },
|
||||
// wagonTypes drives the break-bulk items-per-wagon fit — size the
|
||||
// booking exactly as the intercity accept check does.
|
||||
cargoType: { wagonTypes: true },
|
||||
},
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
],
|
||||
});
|
||||
const today = eatDay(new Date());
|
||||
const bookingDay = booking.scheduledDate ? eatDay(booking.scheduledDate) : null;
|
||||
const sameDay: AllocationCandidate[] = [];
|
||||
const otherDays: AllocationCandidate[] = [];
|
||||
for (const s of schedules) {
|
||||
if (!s.scheduledDepartureDate || eatDay(s.scheduledDepartureDate) < today) continue;
|
||||
if (s.bookingWindowStatus !== "OPEN") continue;
|
||||
if (s.id === booking.trainScheduleId) continue;
|
||||
const stops = await this.stopsForSchedule(s);
|
||||
const fromIdx = stops.indexOf(booking.originYardId);
|
||||
const toIdx = stops.indexOf(booking.destinationYardId);
|
||||
if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) continue;
|
||||
// ponytail: full capacity build per candidate is heavy; the set is small
|
||||
// (future OPEN trains on the booking's route) — precompute if it grows.
|
||||
const cap = await this.intercityCapacity(s.id);
|
||||
if (!cap) continue;
|
||||
const leg = cap.budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||
if (!cap.budget.fits(cap.needFor(booking), leg)) continue;
|
||||
const candidate: AllocationCandidate = {
|
||||
id: s.id,
|
||||
reference: s.reference ?? s.trainNumber ?? null,
|
||||
direction: s.direction ?? null,
|
||||
scheduledDepartureDate: s.scheduledDepartureDate,
|
||||
};
|
||||
(eatDay(s.scheduledDepartureDate) === bookingDay ? sameDay : otherDays).push(candidate);
|
||||
}
|
||||
const byDate = (a: AllocationCandidate, b: AllocationCandidate) =>
|
||||
new Date(a.scheduledDepartureDate).getTime() - new Date(b.scheduledDepartureDate).getTime();
|
||||
sameDay.sort(byDate);
|
||||
otherDays.sort(byDate);
|
||||
return { sameDay, otherDays };
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a PAID booking that lost (or never got) its train: re-point via
|
||||
* moveToSchedule (window/route validation + day sync), then allocate it
|
||||
* immediately — payment already landed, so no new pay window opens. The
|
||||
* customer gets an in-app notice when the new train departs on a different
|
||||
* day than their original choice.
|
||||
*/
|
||||
async allocatePaid(bookingId: string, scheduleId: string): Promise<void> {
|
||||
const before = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId } });
|
||||
if (!before) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
if (before.paymentStatus !== "PAID" && before.status !== "PAID") {
|
||||
throw new BadRequestException(
|
||||
"Booking is not paid — use the regular scheduling flow",
|
||||
);
|
||||
}
|
||||
const previousDay = before.scheduledDate ? eatDay(before.scheduledDate) : null;
|
||||
await this.moveToSchedule(bookingId, scheduleId);
|
||||
const fresh = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: bookingId },
|
||||
relations: { bookingContainers: { containerType: true }, cargoType: true },
|
||||
});
|
||||
if (!fresh) return;
|
||||
if (!(await this.holdIfWagonShort(scheduleId, fresh))) {
|
||||
await this.allocate(scheduleId, fresh, "paid");
|
||||
}
|
||||
const schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: scheduleId } });
|
||||
if (
|
||||
previousDay &&
|
||||
schedule?.scheduledDepartureDate &&
|
||||
eatDay(schedule.scheduledDepartureDate) !== previousDay
|
||||
) {
|
||||
this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One reminder per hold, shortly before its pay deadline (the window tick
|
||||
* calls this every pass; `payment_reminder_sent_at` dedups). Skips paid
|
||||
@@ -3531,6 +3645,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Paid but detached from any train (staff removed it from an allocation,
|
||||
// or a sweep caught it unpinned): money was taken, so it must board — it
|
||||
// stays paid-unallocated for staff to place via the allocate action.
|
||||
if (paid) {
|
||||
this.logger.log(
|
||||
`[BATCH] expire skipped for ${booking.reference} — payment landed ` +
|
||||
`but no train attached; left paid-unallocated for manual placement`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Reconcile-before-expire (only when a pay window was actually open):
|
||||
// no webhook arrived, so ask the gateway DIRECTLY whether the money
|
||||
// landed. A late capture found there is registered as SUCCEEDED and
|
||||
@@ -3859,12 +3983,26 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// booking can use — don't kill it for nothing.
|
||||
const overlaps = victimLeg.fromEdge < leg.toEdge && leg.fromEdge < victimLeg.toEdge;
|
||||
if (!overlaps) continue;
|
||||
const victimPaid =
|
||||
victim.paymentStatus === "PAID" || victim.status === "PAID";
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
||||
scheduleId,
|
||||
victim.id,
|
||||
manager,
|
||||
);
|
||||
if (victimPaid) {
|
||||
// Paid bookings are never expired — money was taken, so it boards.
|
||||
// Detach it so it surfaces in the paid-unallocated queue for staff
|
||||
// to re-place; the settled invoice stays untouched.
|
||||
await manager.getRepository(Booking).update(victim.id, {
|
||||
trainScheduleId: null,
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
return;
|
||||
}
|
||||
await manager.getRepository(Booking).update(victim.id, {
|
||||
status: "EXPIRED",
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
@@ -4106,8 +4244,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
const capacityTons = this.dimsFor(booking, wagonDims).capacityTons;
|
||||
const cargoTons = bookingCargoTons(booking);
|
||||
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
|
||||
// wagon), so divide by the cap where one is configured for this type.
|
||||
const tonsPerWagon = bulkTonsPerWagon(
|
||||
booking.cargoType,
|
||||
booking.cargoType?.wagonTypes?.[0]?.id,
|
||||
capacityTons,
|
||||
);
|
||||
const byWeight =
|
||||
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
|
||||
cargoTons > 0 && tonsPerWagon > 0 ? Math.ceil(cargoTons / tonsPerWagon) : 0;
|
||||
|
||||
// Break-bulk (PER_ITEM): indivisible items can need more wagons than raw
|
||||
// tonnage suggests (floor items-per-wagon loses the fractional capacity).
|
||||
@@ -4175,6 +4320,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0)
|
||||
.map((o) => {
|
||||
const wagonTypeId = o.wagonTypeId as string;
|
||||
// Each type sized on its OWN per-wagon tonnage cap, not just its rating
|
||||
// — a type capped lower swallows less per wagon.
|
||||
const tonsPerWagon = bulkTonsPerWagon(
|
||||
booking.cargoType,
|
||||
wagonTypeId,
|
||||
o.dims.capacityTons,
|
||||
);
|
||||
const wagonsIfAlone = Math.max(
|
||||
1,
|
||||
bulkItemWagonsRequired(
|
||||
@@ -4182,8 +4334,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
o.dims.capacityTons,
|
||||
bulkItemsFitFor(booking.cargoType, wagonTypeId),
|
||||
) ||
|
||||
(o.dims.capacityTons > 0
|
||||
? Math.ceil(bookingCargoTons(booking) / o.dims.capacityTons)
|
||||
(tonsPerWagon > 0
|
||||
? Math.ceil(bookingCargoTons(booking) / tonsPerWagon)
|
||||
: total),
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { BookingJourneyService } from './booking-journey.service';
|
||||
|
||||
/**
|
||||
* autoPlaceOnFreedWagons: intercity cargo boards the wagons freed by earlier
|
||||
* unloads. Exercised directly with a stubbed EntityManager — the surrounding
|
||||
* loadBooking flow is integration-tested through the running app.
|
||||
*/
|
||||
describe('BookingJourneyService.autoPlaceOnFreedWagons', () => {
|
||||
const service = new BookingJourneyService(
|
||||
{} as never, // dataSource
|
||||
{} as never, // yardFacilities
|
||||
{} as never, // facilityHandling
|
||||
{ emit: jest.fn() } as never, // events
|
||||
);
|
||||
|
||||
const schedule = { id: 'sched-1', trainSetId: 'ts-1' };
|
||||
const booking = {
|
||||
id: 'booking-1',
|
||||
reference: 'BK-1',
|
||||
cargoTotalWeightVgm: 50,
|
||||
freightType: 'CONTAINER',
|
||||
};
|
||||
|
||||
const makeManager = (slots: unknown[], existingAllocs: unknown[] = []) => {
|
||||
const savedAllocs: Array<Record<string, unknown>> = [];
|
||||
const savedItems: Array<Record<string, unknown>> = [];
|
||||
const allocQb = {
|
||||
innerJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue(existingAllocs),
|
||||
};
|
||||
const slotQb = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue(slots),
|
||||
};
|
||||
let allocId = 0;
|
||||
const manager = {
|
||||
getRepository: jest.fn((entity: { name?: string }) => {
|
||||
const name = entity?.name;
|
||||
if (name === 'WagonBookingAllocation') {
|
||||
return {
|
||||
createQueryBuilder: jest.fn(() => allocQb),
|
||||
create: jest.fn((v: Record<string, unknown>) => v),
|
||||
save: jest.fn(async (v: Record<string, unknown>) => {
|
||||
const row = { ...v, id: `alloc-${++allocId}` };
|
||||
savedAllocs.push(row);
|
||||
return row;
|
||||
}),
|
||||
update: jest.fn(),
|
||||
};
|
||||
}
|
||||
if (name === 'TrainSetWagon') {
|
||||
return { createQueryBuilder: jest.fn(() => slotQb) };
|
||||
}
|
||||
if (name === 'BookingContainer') {
|
||||
return {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'line-1',
|
||||
containerNumber: 'LINE-001',
|
||||
containerTypeId: 'ct-20',
|
||||
units: [{ containerNumber: 'UNIT-001' }, { containerNumber: 'UNIT-002' }],
|
||||
},
|
||||
]),
|
||||
};
|
||||
}
|
||||
if (name === 'WagonAllocationContainerItem') {
|
||||
return {
|
||||
create: jest.fn((v: Record<string, unknown>) => v),
|
||||
save: jest.fn(async (v: Record<string, unknown>) => {
|
||||
savedItems.push(v);
|
||||
return v;
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected repository ${name}`);
|
||||
}),
|
||||
};
|
||||
return { manager, savedAllocs, savedItems };
|
||||
};
|
||||
|
||||
const call = (manager: unknown) =>
|
||||
(service as never as {
|
||||
autoPlaceOnFreedWagons: (m: unknown, s: unknown, b: unknown) => Promise<void>;
|
||||
}).autoPlaceOnFreedWagons(manager, schedule, booking);
|
||||
|
||||
it('places the booking on freed slots in consist order, with container items', async () => {
|
||||
const slots = [
|
||||
// Active cargo still riding — NOT freed.
|
||||
{ id: 'slot-1', sequenceNo: 1, capacityTons: 60, allocations: [{ status: 'LOADED' }] },
|
||||
// Freed by an earlier unload.
|
||||
{ id: 'slot-2', sequenceNo: 2, capacityTons: 60, allocations: [{ status: 'DEPARTED' }] },
|
||||
{ id: 'slot-3', sequenceNo: 3, capacityTons: 60, allocations: [] },
|
||||
];
|
||||
const { manager, savedAllocs, savedItems } = makeManager(slots);
|
||||
|
||||
await call(manager);
|
||||
|
||||
// 50 t fits on the first freed slot alone.
|
||||
expect(savedAllocs).toHaveLength(1);
|
||||
expect(savedAllocs[0]).toMatchObject({
|
||||
trainSetWagonId: 'slot-2',
|
||||
bookingId: 'booking-1',
|
||||
allocatedWeightTons: 50,
|
||||
status: 'LOADED',
|
||||
});
|
||||
// One item per physical unit, on the first allocation.
|
||||
expect(savedItems.map((i) => i.containerNumber)).toEqual(['UNIT-001', 'UNIT-002']);
|
||||
expect(savedItems.every((i) => i.wagonBookingAllocationId === 'alloc-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('spills over onto the next freed slot when one is not enough', async () => {
|
||||
const slots = [
|
||||
{ id: 'slot-2', sequenceNo: 2, capacityTons: 30, allocations: [{ status: 'DEPARTED' }] },
|
||||
{ id: 'slot-3', sequenceNo: 3, capacityTons: 30, allocations: [] },
|
||||
];
|
||||
const { manager, savedAllocs } = makeManager(slots);
|
||||
|
||||
await call(manager);
|
||||
|
||||
expect(savedAllocs.map((a) => [a.trainSetWagonId, a.allocatedWeightTons])).toEqual([
|
||||
['slot-2', 30],
|
||||
['slot-3', 20],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does nothing when the booking already has allocations', async () => {
|
||||
const { manager, savedAllocs } = makeManager([], [{ id: 'existing' }]);
|
||||
await call(manager);
|
||||
expect(savedAllocs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('loads without allocation when no wagon is free', async () => {
|
||||
const slots = [
|
||||
{ id: 'slot-1', sequenceNo: 1, capacityTons: 60, allocations: [{ status: 'LOADED' }] },
|
||||
];
|
||||
const { manager, savedAllocs } = makeManager(slots);
|
||||
await expect(call(manager)).resolves.toBeUndefined();
|
||||
expect(savedAllocs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -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,20 +334,49 @@ 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".
|
||||
if (row.trade_direction !== 'EXPORT' && (!facility?.hasFacility || facility.hasWarehouse)) {
|
||||
this.events.emit('booking.unloadedAtYard', {
|
||||
bookingId: row.id,
|
||||
tradeDirection: row.trade_direction,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
@@ -435,6 +471,100 @@ export class BookingJourneyService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* INTERCITY ONLY. Intercity cargo does not get its own wagons — it rides the
|
||||
* slots freed by cargo already unloaded along the corridor (e.g. import
|
||||
* containers uncoupled at Dire Dawa). Staff pinning is a pre-dispatch tool,
|
||||
* so a DOMESTIC booking loaded mid-corridor is auto-placed here: greedy over
|
||||
* on-train slots (not DEPARTED) with no active cargo (every allocation
|
||||
* DEPARTED, or none), in consist order, by capacity. Container numbers are
|
||||
* copied onto the first allocation so the marshalling document and its
|
||||
* 40ft/20ft tally stay truthful. When nothing is free the load proceeds
|
||||
* unallocated — the marshalling document then lists the booking as on board
|
||||
* without a recorded wagon.
|
||||
* ponytail: remainder over free capacity is dumped on the last used slot
|
||||
* (paper overload beats missing cargo); upgrade path is a capacity guard in
|
||||
* the intercity accept step.
|
||||
*/
|
||||
private async autoPlaceOnFreedWagons(
|
||||
manager: EntityManager,
|
||||
schedule: TrainSchedule,
|
||||
booking: Booking,
|
||||
): Promise<void> {
|
||||
const existing = await this.allocationsForBooking(manager, schedule.id, booking.id);
|
||||
if (existing.length) return;
|
||||
|
||||
const slots = await manager
|
||||
.getRepository(TrainSetWagon)
|
||||
.createQueryBuilder('slot')
|
||||
.leftJoinAndSelect('slot.allocations', 'alloc')
|
||||
.innerJoin(
|
||||
TrainSchedule,
|
||||
'schedule',
|
||||
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
|
||||
{ scheduleId: schedule.id },
|
||||
)
|
||||
.where(`slot.status != 'DEPARTED'`)
|
||||
.orderBy('slot.sequence_no', 'ASC')
|
||||
.getMany();
|
||||
const freed = slots.filter((slot) =>
|
||||
(slot.allocations ?? []).every((a) => a.status === 'DEPARTED'),
|
||||
);
|
||||
if (!freed.length) {
|
||||
this.logger.warn(
|
||||
`No freed wagon for intercity booking ${booking.reference} on schedule ${schedule.id} — loading without wagon allocation`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let remaining = Number(booking.cargoTotalWeightVgm) || 0;
|
||||
const allocRepo = manager.getRepository(WagonBookingAllocation);
|
||||
const created: WagonBookingAllocation[] = [];
|
||||
for (const slot of freed) {
|
||||
const capacity = Number(slot.capacityTons) || remaining || 1;
|
||||
const take = Math.min(remaining || capacity, capacity);
|
||||
created.push(
|
||||
await allocRepo.save(
|
||||
allocRepo.create({
|
||||
trainSetWagonId: slot.id,
|
||||
bookingId: booking.id,
|
||||
allocatedWeightTons: take,
|
||||
loadType: booking.freightType ?? null,
|
||||
status: 'LOADED',
|
||||
}),
|
||||
),
|
||||
);
|
||||
remaining = Math.max(0, remaining - take);
|
||||
if (remaining <= 0) break;
|
||||
}
|
||||
if (remaining > 0 && created.length) {
|
||||
await allocRepo.update(created[created.length - 1].id, {
|
||||
allocatedWeightTons: () => `allocated_weight_tons + ${remaining}`,
|
||||
} as never);
|
||||
}
|
||||
|
||||
// Container numbers onto the first allocation, from the booking's container
|
||||
// lines (per physical unit when recorded, else per line).
|
||||
const lines = await manager
|
||||
.getRepository(BookingContainer)
|
||||
.find({ where: { bookingId: booking.id }, relations: { units: true } });
|
||||
const itemRepo = manager.getRepository(WagonAllocationContainerItem);
|
||||
const first = created[0];
|
||||
for (const line of lines) {
|
||||
const units = line.units?.length ? line.units : [null];
|
||||
for (const unit of units) {
|
||||
await itemRepo.save(
|
||||
itemRepo.create({
|
||||
wagonBookingAllocationId: first.id,
|
||||
bookingContainerId: line.id,
|
||||
containerNumber: unit?.containerNumber ?? line.containerNumber ?? null,
|
||||
containerTypeId: line.containerTypeId ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async setAllocationStatuses(
|
||||
manager: EntityManager,
|
||||
scheduleId: string,
|
||||
|
||||
@@ -275,6 +275,19 @@ export class BookingNotifierService {
|
||||
this.inApp(b, 'Booking rescheduled', msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff placed a paid booking onto a train departing on a DIFFERENT day than
|
||||
* the customer's original choice. In-app only — staff drove the change and
|
||||
* the allocation itself already notifies through the secured path.
|
||||
*/
|
||||
allocatedOtherDay(b: Booking, newDeparture: Date): void {
|
||||
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
|
||||
const msg =
|
||||
`Booking ${b.reference ?? b.id} has been allocated to a train on a different date. ` +
|
||||
`New departure date: ${when}.`;
|
||||
this.inApp(b, 'Booking allocated to another date', msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking was removed from its train during a staff reschedule (not a government
|
||||
* pre-empt). It returns to eligible — the customer must rebook or reschedule.
|
||||
|
||||
@@ -9,9 +9,107 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
Max,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* Per-schedule booking-window rule chosen AT CREATION, instead of inheriting the
|
||||
* live global rules. Mirrors {@link UpdateScheduleWindowRuleDto}, plus the
|
||||
* booking-close offset (which the post-creation override deliberately never
|
||||
* touches). Every field is optional — an omitted field falls back to the global
|
||||
* value, so staff can override just the one knob they care about.
|
||||
*/
|
||||
export class CreateScheduleWindowRuleDto {
|
||||
@ApiPropertyOptional({ example: 8, description: 'Local EAT hour the booking desk opens each day' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(23)
|
||||
windowOpenHour?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 17,
|
||||
description:
|
||||
'Local EAT hour the booking desk shuts each day. Equal to windowOpenHour = 24-hour desk',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(23)
|
||||
windowCloseHour?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 3, description: 'How long each booking cycle stays open, in hours' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.0166)
|
||||
@Max(12)
|
||||
windowDurationHours?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 30, description: 'Max staff document-review minutes after the window closes' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
docReviewMinutes?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 60, description: 'Customer payment window minutes' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
paymentWindowMinutes?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 3,
|
||||
description: 'Days before departure the IMPORT/DOMESTIC booking window starts',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
importWindowLeadDays?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 24,
|
||||
description: 'Hours before departure the single FCFS EXPORT window opens',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
exportBookingLeadHours?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 180,
|
||||
nullable: true,
|
||||
description:
|
||||
'Minutes before departure the booking window closes; 0/null = close at departure. ' +
|
||||
'Only the offset matching the schedule direction is used (import offset for ' +
|
||||
'IMPORT/DOMESTIC, export offset for EXPORT).',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
importCloseOffsetMinutes?: number | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 1440,
|
||||
nullable: true,
|
||||
description: 'Minutes before departure an EXPORT booking window closes; 0/null = at departure',
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
exportCloseOffsetMinutes?: number | null;
|
||||
}
|
||||
|
||||
export class CreateContainerTrainScheduleDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
@@ -73,4 +171,19 @@ export class CreateContainerTrainScheduleDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
reverseWagonOrder?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: CreateScheduleWindowRuleDto,
|
||||
description:
|
||||
'Configure the booking window for THIS schedule instead of inheriting the live ' +
|
||||
'global rules. Omit to use the global rules (the default). The values sent are ' +
|
||||
'frozen onto the schedule as its rule snapshot, exactly as a post-creation ' +
|
||||
'override would. Rejected for an IMPORT/DOMESTIC train that joins an existing ' +
|
||||
'route+day group — those siblings share one window timeline, so edit the group ' +
|
||||
"window instead of giving one member its own.",
|
||||
})
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => CreateScheduleWindowRuleDto)
|
||||
windowRule?: CreateScheduleWindowRuleDto;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -152,8 +152,98 @@ export function bulkItemWagonsRequired(
|
||||
type ItemFitCargoType = {
|
||||
wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null;
|
||||
itemsPerWagonMap?: Record<string, number> | null;
|
||||
tonsPerWagonMap?: Record<string, number> | null;
|
||||
} | null;
|
||||
|
||||
/**
|
||||
* Tons of THIS cargo one wagon of this type may carry: the cargo type's
|
||||
* configured loading limit when set, else the wagon's full rated capacity.
|
||||
* Sugar capped at 50T rides 50T on a 70T wagon, so 200T needs 4 wagons and each
|
||||
* is loaded to 50 — both the count and the fill follow from this one number.
|
||||
*
|
||||
* The configured cap is CLAMPED to the rated capacity rather than trusted: the
|
||||
* cargo-types service rejects a cap above capacity at save time, but a wagon
|
||||
* type edited DOWN afterwards would leave a stale cap that overloads the wagon.
|
||||
* Clamping here means no call site can ever load past the physical rating.
|
||||
*/
|
||||
export function bulkTonsPerWagon(
|
||||
cargoType: ItemFitCargoType | undefined,
|
||||
wagonTypeId: string | null | undefined,
|
||||
capacityTons: number | string | null | undefined,
|
||||
): number {
|
||||
const capacity = num(capacityTons);
|
||||
const cap = wagonTypeId ? num(cargoType?.tonsPerWagonMap?.[wagonTypeId]) : 0;
|
||||
if (!(cap > 0)) return capacity;
|
||||
return capacity > 0 ? Math.min(cap, capacity) : cap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons a PER_TON bulk booking needs on one wagon type, respecting the cargo
|
||||
* type's per-wagon loading limit: 200T of sugar capped at 50T → 4 wagons even
|
||||
* though the wagon is rated 70T. Returns 0 when there is no tonnage or no
|
||||
* usable per-wagon figure, so callers can fall back as before.
|
||||
*/
|
||||
export function bulkTonWagonsRequired(
|
||||
booking: Parameters<typeof bookingCargoTons>[0],
|
||||
cargoType: ItemFitCargoType | undefined,
|
||||
wagonTypeId: string | null | undefined,
|
||||
capacityTons: number | string | null | undefined,
|
||||
): number {
|
||||
const perWagon = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons);
|
||||
const tons = bookingCargoTons(booking);
|
||||
if (!(perWagon > 0) || !(tons > 0)) return 0;
|
||||
return Math.max(1, Math.ceil(tons / perWagon));
|
||||
}
|
||||
|
||||
/**
|
||||
* Best (fewest-wagon) PER_TON count across the cargo type's allowed wagon
|
||||
* types, each sized on its OWN loading limit — the tonnage twin of
|
||||
* {@link bulkItemWagonsForAllowedTypes}, for the call sites that have no single
|
||||
* wagon type fixed yet. Falls back to `fallbackCapacityTons` when the cargo
|
||||
* type has no usable allowed types.
|
||||
*/
|
||||
export function bulkTonWagonsForAllowedTypes(
|
||||
booking: Parameters<typeof bookingCargoTons>[0],
|
||||
cargoType: ItemFitCargoType | undefined,
|
||||
fallbackCapacityTons: number,
|
||||
): number {
|
||||
const allowed = (cargoType?.wagonTypes ?? []).filter((wt) => num(wt.capacityTons) > 0);
|
||||
if (!allowed.length) {
|
||||
return bulkTonWagonsRequired(booking, cargoType, null, fallbackCapacityTons);
|
||||
}
|
||||
let best = 0;
|
||||
for (const wagonType of allowed) {
|
||||
const wagons = bulkTonWagonsRequired(
|
||||
booking,
|
||||
cargoType,
|
||||
wagonType.id,
|
||||
wagonType.capacityTons,
|
||||
);
|
||||
if (wagons > 0 && (best === 0 || wagons < best)) best = wagons;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons a BULK booking needs, whichever way its cargo is measured: PER_ITEM
|
||||
* sizes by indivisible items, everything else by tonnage under the cargo type's
|
||||
* per-wagon loading limit. One call so no site has to remember both paths.
|
||||
*/
|
||||
export function bulkWagonsForAllowedTypes(
|
||||
booking: Parameters<typeof bookingCargoTons>[0] & {
|
||||
freightType?: string | null;
|
||||
cargoTotalWeightVgm?: number | string | null;
|
||||
bulkTotalWeightTons?: number | string | null;
|
||||
},
|
||||
cargoType: ItemFitCargoType | undefined,
|
||||
fallbackCapacityTons: number,
|
||||
): number {
|
||||
return (
|
||||
bulkItemWagonsForAllowedTypes(booking, cargoType, fallbackCapacityTons) ||
|
||||
bulkTonWagonsForAllowedTypes(booking, cargoType, fallbackCapacityTons)
|
||||
);
|
||||
}
|
||||
|
||||
/** Configured whole-items fit of one wagon type for a cargo type; null if unset. */
|
||||
export function bulkItemsFitFor(
|
||||
cargoType: ItemFitCargoType | undefined,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -805,6 +805,51 @@ describe('TrainSchedulingService', () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
describe('restampPendingWindows (hand-configured windows are exempt)', () => {
|
||||
const future = new Date(Date.now() + 30 * 24 * 3600_000);
|
||||
const update = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
update.mockClear();
|
||||
// Global rules read + the TrainSchedule repo the restamp writes through.
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
const name = (entity as { name?: string })?.name;
|
||||
if (name === 'TrainSchedulingGlobalRules') {
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
return { update };
|
||||
});
|
||||
});
|
||||
|
||||
it('re-stamps a schedule that follows the global rules', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
{
|
||||
id: 'sched-global',
|
||||
direction: 'IMPORT',
|
||||
scheduledDepartureDate: future,
|
||||
windowRuleCustom: false,
|
||||
},
|
||||
]);
|
||||
await expect(service.restampPendingWindows()).resolves.toBe(1);
|
||||
expect(update).toHaveBeenCalledWith('sched-global', expect.anything());
|
||||
});
|
||||
|
||||
it('leaves a hand-configured schedule alone', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
{
|
||||
id: 'sched-custom',
|
||||
direction: 'IMPORT',
|
||||
scheduledDepartureDate: future,
|
||||
windowRuleCustom: true,
|
||||
},
|
||||
]);
|
||||
// Staff picked these times deliberately — a global-rules edit must not
|
||||
// overwrite them, or the per-schedule configuration would be pointless.
|
||||
await expect(service.restampPendingWindows()).resolves.toBe(0);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUnassignedBookings', () => {
|
||||
const scheduleId = 'sched-unassigned-1';
|
||||
const trainSetId = 'train-set-unassigned';
|
||||
@@ -1121,6 +1166,132 @@ describe('TrainSchedulingService', () => {
|
||||
expect(html).not.toContain('empty)');
|
||||
expect(html).not.toContain('EMPTY');
|
||||
});
|
||||
|
||||
// ---- intercity marshalling (Marshalling 2): the current on-board view ----
|
||||
|
||||
const onBoardView = (schedule: unknown) =>
|
||||
(service as never as {
|
||||
intercityOnBoardView: (s: unknown) => { wagons: unknown[]; unassignedBookings: unknown[] };
|
||||
}).intercityOnBoardView(schedule);
|
||||
|
||||
const buildWithOpts = (schedule: unknown, opts: unknown) =>
|
||||
(service as never as {
|
||||
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
|
||||
}).buildExportLoadListHtml(schedule, opts);
|
||||
|
||||
const allocWith = (over: Record<string, unknown>) => ({ ...loadedAllocation, ...over });
|
||||
|
||||
it('drops DEPARTED wagon slots and DEPARTED allocations from the on-board view', () => {
|
||||
const schedule = {
|
||||
trainSet: {
|
||||
wagons: [
|
||||
{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' },
|
||||
{ ...makeWagon(2, 'W-002', [allocWith({ status: 'LOADED' })]), status: 'DEPARTED' },
|
||||
{
|
||||
...makeWagon(3, 'W-003', [
|
||||
allocWith({ status: 'LOADED', bookingId: 'booking-3' }),
|
||||
allocWith({ status: 'DEPARTED', bookingId: 'booking-4' }),
|
||||
]),
|
||||
status: 'RESERVED',
|
||||
},
|
||||
],
|
||||
},
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const { wagons } = onBoardView(schedule);
|
||||
const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map(
|
||||
(w) => w.physicalWagon.wagonNumber,
|
||||
);
|
||||
expect(numbers).toEqual(['W-001', 'W-003']);
|
||||
const w3 = (wagons as Array<{ physicalWagon: { wagonNumber: string }; allocations: Array<{ bookingId: string }> }>).find(
|
||||
(w) => w.physicalWagon.wagonNumber === 'W-003',
|
||||
);
|
||||
expect(w3?.allocations.map((a) => a.bookingId)).toEqual(['booking-3']);
|
||||
});
|
||||
|
||||
it('keeps an attached wagon whose cargo all departed, as an EMPTY row', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
direction: 'EXPORT',
|
||||
trainSet: {
|
||||
wagons: [
|
||||
{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' },
|
||||
{ ...makeWagon(2, 'W-002', [allocWith({ status: 'DEPARTED' })]), status: 'RESERVED' },
|
||||
],
|
||||
},
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const { wagons, unassignedBookings } = onBoardView(schedule);
|
||||
const html = buildWithOpts(schedule, { wagons, unassignedBookings });
|
||||
expect(html).toContain('W-002');
|
||||
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1);
|
||||
expect(html).toContain('2 (1 empty)');
|
||||
});
|
||||
|
||||
it('hides a leg slot (boardYardId set) until it has confirmed LOADED cargo', () => {
|
||||
const legWagonEmpty = { ...makeWagon(2, 'W-LEG', [allocWith({ status: 'RESERVED' })]), status: 'RESERVED', boardYardId: 'yard-mid' };
|
||||
const legWagonLoaded = { ...makeWagon(3, 'W-LEG2', [allocWith({ status: 'LOADED' })]), status: 'RESERVED', boardYardId: 'yard-mid' };
|
||||
const schedule = {
|
||||
trainSet: { wagons: [legWagonEmpty, legWagonLoaded] },
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const { wagons } = onBoardView(schedule);
|
||||
const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map(
|
||||
(w) => w.physicalWagon.wagonNumber,
|
||||
);
|
||||
expect(numbers).toEqual(['W-LEG2']);
|
||||
});
|
||||
|
||||
it('lists an IN_TRANSIT booking with no wagon allocation in the unassigned section', () => {
|
||||
const rider = {
|
||||
id: 'booking-9',
|
||||
reference: 'BK-2026-000009',
|
||||
status: 'IN_TRANSIT',
|
||||
company: { name: 'Rider Co' },
|
||||
cargoType: { cargoTypeName: 'Cement', code: 'CEM' },
|
||||
originYard: { label: 'Adama' },
|
||||
destinationYard: { label: 'Dire Dawa' },
|
||||
bookingContainers: [{ containerNumber: 'RIDE-001' }],
|
||||
};
|
||||
const done = { id: 'booking-8', reference: 'BK-2026-000008', status: 'COMPLETED' };
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
direction: 'EXPORT',
|
||||
trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' }] },
|
||||
scheduleBookings: [{ bookingId: rider.id, booking: rider }, { bookingId: done.id, booking: done }],
|
||||
};
|
||||
|
||||
const { wagons, unassignedBookings } = onBoardView(schedule);
|
||||
expect((unassignedBookings as Array<{ id: string }>).map((b) => b.id)).toEqual(['booking-9']);
|
||||
|
||||
const html = buildWithOpts(schedule, {
|
||||
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
|
||||
positionLabel: 'After Dire Dawa',
|
||||
wagons,
|
||||
unassignedBookings,
|
||||
});
|
||||
expect(html).toContain('ON BOARD — WAGON NOT RECORDED');
|
||||
expect(html).toContain('BK-2026-000009');
|
||||
expect(html).toContain('RIDE-001');
|
||||
expect(html).not.toContain('BK-2026-000008');
|
||||
expect(html).toContain('Intercity Marshalling Document / Load List (Marshalling 2)');
|
||||
expect(html).toContain('After Dire Dawa');
|
||||
});
|
||||
|
||||
it('rejects the intercity marshalling document for a train that has not been dispatched', async () => {
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
||||
id: 'schedule-1',
|
||||
status: 'SCHEDULED',
|
||||
});
|
||||
await expect(
|
||||
service.intercityMarshallingDocument('schedule-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveWagonLoad — staff rearrange', () => {
|
||||
|
||||
@@ -138,6 +138,7 @@ import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
bulkTonsPerWagon,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
combinedLocomotiveLimits,
|
||||
trainSetLocomotiveLimits,
|
||||
@@ -180,6 +181,13 @@ import {
|
||||
|
||||
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
|
||||
|
||||
/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */
|
||||
function pickDefined<T extends object>(source: T): Partial<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(source).filter(([, v]) => v !== undefined),
|
||||
) as Partial<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking-window rule fields frozen onto a train schedule at creation (and
|
||||
* refreshed by restampPendingWindows for not-yet-open schedules). The board draws
|
||||
@@ -905,6 +913,9 @@ export class TrainSchedulingService {
|
||||
windowClosesAt: cap(times.windowClosesAt, t.departure),
|
||||
...ruleFields,
|
||||
rulePaymentWindowMinutes,
|
||||
// Deliberately overridden — exempt from the global re-stamp, which would
|
||||
// otherwise revert this schedule the next time global rules are saved.
|
||||
windowRuleCustom: true,
|
||||
});
|
||||
}
|
||||
this.logger.log(
|
||||
@@ -1196,6 +1207,9 @@ export class TrainSchedulingService {
|
||||
let restamped = 0;
|
||||
for (const s of schedules) {
|
||||
if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue;
|
||||
// Hand-configured windows are not "pending the global rule" — staff picked
|
||||
// these times deliberately, so a global-rules edit must leave them alone.
|
||||
if (s.windowRuleCustom) continue;
|
||||
const times =
|
||||
s.direction === 'EXPORT'
|
||||
? computeExportWindowTimes(s.scheduledDepartureDate, cfg)
|
||||
@@ -1459,29 +1473,8 @@ export class TrainSchedulingService {
|
||||
// it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT
|
||||
// (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens
|
||||
// 24h before departure (FCFS). No schedule is ever always-open now.
|
||||
const windowCfg = await this.getWindowConfig();
|
||||
const globalCfg = await this.getWindowConfig();
|
||||
|
||||
// Staff cannot schedule inside the lead window — there must be room for a
|
||||
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT
|
||||
// days (lead 3, today 11th → first allowed departure is the 14th); EXPORT
|
||||
// lead is in hours (24h = 1 day ahead).
|
||||
const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date());
|
||||
if (departure.getTime() < earliest.getTime()) {
|
||||
const detail =
|
||||
direction === 'EXPORT'
|
||||
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
|
||||
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
|
||||
throw new BadRequestException(
|
||||
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
|
||||
`${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
|
||||
`(earliest ${earliest.toISOString()})`,
|
||||
);
|
||||
}
|
||||
// Freeze the rule this schedule is born with. A later global-rules edit
|
||||
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
|
||||
// already-open schedule keeps this snapshot, and the batch board draws its
|
||||
// windows from it rather than the live config.
|
||||
const ruleSnapshot = windowRuleSnapshot(windowCfg);
|
||||
// Route+day grouping (IMPORT/DOMESTIC only): if a schedule already exists
|
||||
// on this origin + destination + EAT departure day, this new train JOINS
|
||||
// its group and adopts the group's shared window timeline (open/close +
|
||||
@@ -1505,6 +1498,77 @@ export class TrainSchedulingService {
|
||||
route.destinationYardId,
|
||||
departure,
|
||||
);
|
||||
|
||||
// Per-schedule window rule chosen at creation. Refused for a train that
|
||||
// JOINS an existing route+day group: the group shares ONE window timeline,
|
||||
// so a joining train adopts the anchor's times verbatim and its own
|
||||
// settings would be silently discarded. Staff edit the group's window
|
||||
// instead (Booking window settings, which fans out to every sibling).
|
||||
if (dto.windowRule && groupAnchor) {
|
||||
throw new BadRequestException(
|
||||
'This train joins an existing booking group (same route and departure day), ' +
|
||||
'which shares one booking window across all its trains. Create it with the ' +
|
||||
'group settings, then use Booking window settings to change the window for ' +
|
||||
'the whole group.',
|
||||
);
|
||||
}
|
||||
|
||||
// The rule this schedule is born under: staff overrides on top of the live
|
||||
// global config, so an omitted field still follows the global value.
|
||||
const windowCfg: BookingWindowConfig = dto.windowRule
|
||||
? {
|
||||
...globalCfg,
|
||||
...pickDefined({
|
||||
windowOpenHour: dto.windowRule.windowOpenHour,
|
||||
windowCloseHour: dto.windowRule.windowCloseHour,
|
||||
windowDurationHours: dto.windowRule.windowDurationHours,
|
||||
docReviewMinutes: dto.windowRule.docReviewMinutes,
|
||||
importWindowLeadDays: dto.windowRule.importWindowLeadDays,
|
||||
exportBookingLeadHours: dto.windowRule.exportBookingLeadHours,
|
||||
}),
|
||||
// One pay-window override drives both directions (only the one
|
||||
// matching this schedule's direction is ever read).
|
||||
...(dto.windowRule.paymentWindowMinutes !== undefined
|
||||
? {
|
||||
paymentWindowMinutes: dto.windowRule.paymentWindowMinutes,
|
||||
exportPaymentWindowMinutes: dto.windowRule.paymentWindowMinutes,
|
||||
}
|
||||
: {}),
|
||||
// Close offsets are nullable-by-intent: null/0 means "close at
|
||||
// departure", which must override a non-null global, so these are
|
||||
// merged on presence rather than on definedness.
|
||||
...(dto.windowRule.importCloseOffsetMinutes !== undefined
|
||||
? { importCloseOffsetMinutes: dto.windowRule.importCloseOffsetMinutes ?? null }
|
||||
: {}),
|
||||
...(dto.windowRule.exportCloseOffsetMinutes !== undefined
|
||||
? { exportCloseOffsetMinutes: dto.windowRule.exportCloseOffsetMinutes ?? null }
|
||||
: {}),
|
||||
}
|
||||
: globalCfg;
|
||||
|
||||
// Staff cannot schedule inside the lead window — there must be room for a
|
||||
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT
|
||||
// days (lead 3, today 11th → first allowed departure is the 14th); EXPORT
|
||||
// lead is in hours (24h = 1 day ahead). Checked against the schedule's OWN
|
||||
// lead, so a custom lead is honoured rather than rejected by the global one.
|
||||
const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date());
|
||||
if (departure.getTime() < earliest.getTime()) {
|
||||
const detail =
|
||||
direction === 'EXPORT'
|
||||
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
|
||||
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
|
||||
throw new BadRequestException(
|
||||
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
|
||||
`${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
|
||||
`(earliest ${earliest.toISOString()})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Freeze the rule this schedule is born with. A later global-rules edit
|
||||
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
|
||||
// already-open schedule keeps this snapshot, and the batch board draws its
|
||||
// windows from it rather than the live config.
|
||||
const ruleSnapshot = windowRuleSnapshot(windowCfg);
|
||||
const computedTimes =
|
||||
direction === 'EXPORT'
|
||||
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
|
||||
@@ -1513,12 +1577,30 @@ export class TrainSchedulingService {
|
||||
...ruleSnapshot,
|
||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||
};
|
||||
if (
|
||||
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'These booking-window settings leave no window before departure — with the ' +
|
||||
'desk hours and close offset applied, the window would only open once the ' +
|
||||
'train has left.',
|
||||
);
|
||||
}
|
||||
const windowFields = {
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...(groupAnchor
|
||||
? this.groupWindowFieldsFrom(groupAnchor, departure)
|
||||
: computedTimes),
|
||||
// `windowRuleSnapshot` never stamps the pay window (NULL = follow the
|
||||
// live global value for the direction), so an explicit staff override is
|
||||
// persisted here — the same field the post-creation override writes.
|
||||
...(dto.windowRule?.paymentWindowMinutes !== undefined
|
||||
? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes }
|
||||
: {}),
|
||||
// Hand-configured windows opt OUT of the global re-stamp, or the next
|
||||
// global-rules edit would overwrite exactly what staff chose here.
|
||||
windowRuleCustom: dto.windowRule != null,
|
||||
};
|
||||
// A built train's own consist is the schedule's capacity: full when all
|
||||
// its wagons are allocated. Trains built without wagons yet fall back to
|
||||
@@ -2928,6 +3010,78 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The train's composition as it stands right now — the source for the
|
||||
* intercity marshalling ("Marshalling 2") document printed after mid-corridor
|
||||
* station work. A wagon slot is on the train iff it has not DEPARTED and
|
||||
* either rides the whole corridor (no boardYardId) or has confirmed LOADED
|
||||
* cargo. Kept wagons carry only their LOADED allocations (DEPARTED =
|
||||
* unloaded, PLANNED/RESERVED = not on board yet).
|
||||
* ponytail: boardYardId presence is the "boarded yet?" heuristic; upgrade
|
||||
* path is comparing the board yard against the latest checkpoint sequence.
|
||||
*/
|
||||
private intercityOnBoardView(schedule: TrainSchedule): {
|
||||
wagons: TrainSetWagon[];
|
||||
unassignedBookings: Booking[];
|
||||
} {
|
||||
const wagons = (schedule.trainSet?.wagons ?? [])
|
||||
.filter((wagon) => {
|
||||
if (wagon.status === 'DEPARTED') return false;
|
||||
const hasLoaded = (wagon.allocations ?? []).some((a) => a.status === 'LOADED');
|
||||
return wagon.boardYardId == null || hasLoaded;
|
||||
})
|
||||
.map((wagon) => ({
|
||||
...wagon,
|
||||
allocations: (wagon.allocations ?? []).filter((a) => a.status === 'LOADED'),
|
||||
})) as TrainSetWagon[];
|
||||
|
||||
const onBoardBookingIds = new Set(
|
||||
wagons.flatMap((wagon) => (wagon.allocations ?? []).map((a) => a.bookingId)),
|
||||
);
|
||||
// IN_TRANSIT bookings with no kept allocation: intercity riders accepted
|
||||
// after dispatch (never wagon-pinned) and loads whose allocation was never
|
||||
// confirmed LOADED. They are physically on the train, so they get a row.
|
||||
const unassignedBookings = (schedule.scheduleBookings ?? [])
|
||||
.map((link) => link.booking)
|
||||
.filter((booking): booking is Booking => Boolean(booking))
|
||||
.filter((booking) => booking.status === 'IN_TRANSIT' && !onBoardBookingIds.has(booking.id));
|
||||
|
||||
return { wagons, unassignedBookings };
|
||||
}
|
||||
|
||||
async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (schedule.status !== 'DISPATCHED' && schedule.status !== 'ARRIVED') {
|
||||
throw new BadRequestException(
|
||||
'Intercity marshalling document applies only to dispatched or arrived trains',
|
||||
);
|
||||
}
|
||||
|
||||
const checkpoints = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
|
||||
const last = checkpoints[checkpoints.length - 1];
|
||||
const positionLabel = last
|
||||
? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}`
|
||||
: `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`;
|
||||
|
||||
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
|
||||
const html = this.buildExportLoadListHtml(schedule, {
|
||||
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
|
||||
positionLabel,
|
||||
wagons,
|
||||
unassignedBookings,
|
||||
});
|
||||
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
||||
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
|
||||
const reference = schedule.trainNumber ?? schedule.id;
|
||||
return {
|
||||
filename: `intercity-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A container item's size in feet, for the marshalling document's 40ft/20ft
|
||||
* tally. Two independent sources, since only one is populated depending on
|
||||
@@ -2954,7 +3108,15 @@ export class TrainSchedulingService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildExportLoadListHtml(schedule: TrainSchedule): string {
|
||||
private buildExportLoadListHtml(
|
||||
schedule: TrainSchedule,
|
||||
opts?: {
|
||||
title?: string;
|
||||
positionLabel?: string;
|
||||
wagons?: TrainSetWagon[];
|
||||
unassignedBookings?: Booking[];
|
||||
},
|
||||
): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
.replace(/&/g, '&')
|
||||
@@ -2967,7 +3129,7 @@ export class TrainSchedulingService {
|
||||
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
|
||||
// The document is checked against the physical train, so it has to run in
|
||||
// consist order — the relation comes back unordered.
|
||||
const wagons = [...(schedule.trainSet?.wagons ?? [])].sort(
|
||||
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort(
|
||||
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
|
||||
);
|
||||
const rows = wagons
|
||||
@@ -3011,6 +3173,29 @@ export class TrainSchedulingService {
|
||||
});
|
||||
})
|
||||
.join('');
|
||||
// Intercity riders accepted after dispatch have no wagon slot recorded —
|
||||
// they are still physically on the train, so they get rows of their own.
|
||||
const unassigned = opts?.unassignedBookings ?? [];
|
||||
const unassignedRows = unassigned.length
|
||||
? `<tr class="empty"><td colspan="11">ON BOARD — WAGON NOT RECORDED</td></tr>` +
|
||||
unassigned
|
||||
.map((booking) => {
|
||||
const containerNumbers = (booking.bookingContainers ?? [])
|
||||
.map((container) => container.containerNumber)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'} → ${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`;
|
||||
return `<tr>
|
||||
<td colspan="6">${esc(booking.reference)} — ${esc(leg)}</td>
|
||||
<td>${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)}</td>
|
||||
<td>${esc(booking.company?.name)}</td>
|
||||
<td>${esc(containerNumbers)}</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join('')
|
||||
: '';
|
||||
const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length;
|
||||
const totalWeight = wagons.reduce(
|
||||
(sum, wagon) =>
|
||||
@@ -3034,7 +3219,7 @@ export class TrainSchedulingService {
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Export Marshalling Document</title>
|
||||
<title>${esc(opts?.title ?? 'Export Marshalling Document')}</title>
|
||||
<style>
|
||||
@page { size: A4 landscape; margin: 10mm; }
|
||||
* { box-sizing: border-box; }
|
||||
@@ -3063,7 +3248,7 @@ export class TrainSchedulingService {
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Export Marshalling Document / Load List</h1>
|
||||
<h1>${esc(opts?.title ?? 'Export Marshalling Document / Load List')}</h1>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Train / Schedule
|
||||
@@ -3088,6 +3273,7 @@ export class TrainSchedulingService {
|
||||
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
|
||||
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
|
||||
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
|
||||
${opts?.positionLabel ? `<div class="tile"><span>Current position</span><strong>${esc(opts.positionLabel)}</strong></div>` : ''}
|
||||
</div>
|
||||
|
||||
<table>
|
||||
@@ -3108,6 +3294,7 @@ export class TrainSchedulingService {
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'}
|
||||
${unassignedRows}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -3899,13 +4086,25 @@ export class TrainSchedulingService {
|
||||
return Object.assign(detail, { warehouseAutomation });
|
||||
}
|
||||
|
||||
async getContainerTrainSchedules(query: ListTrainSchedulesQueryDto = {}) {
|
||||
async getContainerTrainSchedules(
|
||||
query: ListTrainSchedulesQueryDto = {},
|
||||
allowedDirections?: string[],
|
||||
) {
|
||||
const { page, pageSize, skip, take } = normalizePagination(query);
|
||||
|
||||
// Per-user trade-direction scope: schedules carry a `direction` column.
|
||||
if (allowedDirections && allowedDirections.length === 0) {
|
||||
return {
|
||||
items: [],
|
||||
meta: buildPaginationMeta(0, page, pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
// Exact-match filters (enum/id semantics). Freight type is derived from
|
||||
// the bookings aboard — no column to match — so it rides on `id` as an
|
||||
// EXISTS fragment instead.
|
||||
const base: FindOptionsWhere<TrainSchedule> = {};
|
||||
if (allowedDirections) base.direction = In(allowedDirections) as never;
|
||||
if (query.status) base.status = query.status;
|
||||
if (query.originStationId) base.originStationId = query.originStationId;
|
||||
if (query.destinationStationId) base.destinationStationId = query.destinationStationId;
|
||||
@@ -7335,8 +7534,10 @@ export class TrainSchedulingService {
|
||||
? Math.ceil(booking.wagonsRequired)
|
||||
: 0;
|
||||
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
||||
const byWeight =
|
||||
cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0;
|
||||
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
|
||||
// wagon) — more wagons for the same cargo, so more tare to pull.
|
||||
const tonsPerWagon = bulkTonsPerWagon(booking.cargoType, wagonTypeId, dims.capacityTons);
|
||||
const byWeight = cargo > 0 && tonsPerWagon > 0 ? Math.ceil(cargo / tonsPerWagon) : 0;
|
||||
// Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw
|
||||
// tonnage suggests — their tare must be pulled too (batch dimsFor parity).
|
||||
const byItems = bulkItemWagonsRequired(
|
||||
|
||||
@@ -5,7 +5,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsForAllowedTypes,
|
||||
bulkWagonsForAllowedTypes,
|
||||
} from './train-capacity.util';
|
||||
import {
|
||||
sortBookingsForScheduling,
|
||||
@@ -122,10 +122,11 @@ const shortageFor = (
|
||||
? Math.max(
|
||||
1,
|
||||
// Break-bulk (PER_ITEM) sizes by indivisible items (items-fit map
|
||||
// respected); PER_TON falls through to tonnage over the largest
|
||||
// candidate. bookingCargoTons, not raw VGM — for PER_ITEM that
|
||||
// column is the item count, not tons.
|
||||
bulkItemWagonsForAllowedTypes(
|
||||
// respected); PER_TON divides by its per-wagon tonnage cap where one
|
||||
// is configured, else the largest candidate's rating.
|
||||
// bookingCargoTons, not raw VGM — for PER_ITEM that column is the
|
||||
// item count, not tons.
|
||||
bulkWagonsForAllowedTypes(
|
||||
booking,
|
||||
booking.cargoType,
|
||||
Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))),
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
bulkTonsPerWagon,
|
||||
bulkTonWagonsRequired,
|
||||
consistViolations,
|
||||
} from './train-capacity.util';
|
||||
|
||||
@@ -186,15 +188,29 @@ export function buildBulkWagonPlan(
|
||||
bulkItemWagonsRequired(b, capacity, bulkItemsFitFor(b.cargoType, wagonType.id)),
|
||||
);
|
||||
const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0);
|
||||
|
||||
// PER_TON cargo with a per-wagon tonnage cap (sugar 50T on a 70T wagon) can't
|
||||
// pool with uncapped tonnage either: its wagons stop at the cap, so 200T needs
|
||||
// 4 wagons and pooling it at 70T would plan 3. Capped bookings are sized on
|
||||
// their own cap; only genuinely uncapped tonnage pools at rated capacity.
|
||||
const cappedTonSlotsByBooking = bookings.map((b, i) =>
|
||||
itemSlotsByBooking[i] > 0 || bulkTonsPerWagon(b.cargoType, wagonType.id, capacity) >= capacity
|
||||
? 0
|
||||
: bulkTonWagonsRequired(b, b.cargoType, wagonType.id, capacity),
|
||||
);
|
||||
const cappedTonSlots = cappedTonSlotsByBooking.reduce((sum, n) => sum + n, 0);
|
||||
|
||||
const totalWeight = roundTons(
|
||||
bookings.reduce(
|
||||
(sum, b, i) =>
|
||||
itemSlotsByBooking[i] > 0 ? sum : sum + Number(b.cargoTotalWeightVgm ?? 0),
|
||||
itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0
|
||||
? sum
|
||||
: sum + Number(b.cargoTotalWeightVgm ?? 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0;
|
||||
const slots = Math.max(1, tonSlots + itemSlots);
|
||||
const slots = Math.max(1, tonSlots + itemSlots + cappedTonSlots);
|
||||
|
||||
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
|
||||
sequenceNo: index + 1,
|
||||
@@ -315,6 +331,7 @@ function allocateBookingsToSlots(
|
||||
// bookingCargoTons, not the raw VGM column: for break-bulk (PER_ITEM)
|
||||
// bookings that column is an item COUNT, not tons.
|
||||
remainingWeightTons: roundTons(bookingCargoTons(booking)),
|
||||
cargoType: booking.cargoType,
|
||||
}));
|
||||
|
||||
let bookingIndex = 0;
|
||||
@@ -326,8 +343,15 @@ function allocateBookingsToSlots(
|
||||
|
||||
while (wagonRemaining > 0 && bookingIndex < remaining.length) {
|
||||
const booking = remaining[bookingIndex];
|
||||
// A PER_TON loading cap (sugar 50T on a 70T wagon) binds the FILL as well
|
||||
// as the wagon count — the plan reserved a wagon per capped chunk, so
|
||||
// pouring rated capacity into it would leave the last wagon empty.
|
||||
const takeCap = Math.min(
|
||||
wagonRemaining,
|
||||
bulkTonsPerWagon(booking.cargoType, slot.wagonTypeId, slot.capacityTons),
|
||||
);
|
||||
const allocatedWeightTons = roundTons(
|
||||
Math.min(wagonRemaining, booking.remainingWeightTons),
|
||||
Math.min(takeCap, booking.remainingWeightTons),
|
||||
);
|
||||
|
||||
if (allocatedWeightTons <= 0) {
|
||||
@@ -350,6 +374,12 @@ function allocateBookingsToSlots(
|
||||
|
||||
if (booking.remainingWeightTons <= 0) {
|
||||
bookingIndex += 1;
|
||||
} else if (allocatedWeightTons >= takeCap) {
|
||||
// The cap stopped this wagon short of its rating and the booking has
|
||||
// more to load. The leftover room is NOT free: `buildBulkWagonPlan`
|
||||
// already reserved a wagon for the rest, so backfilling another booking
|
||||
// here would double-book the consist. Close the wagon.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayUnique, IsIn } from 'class-validator';
|
||||
import { Freight } from '@edr/types';
|
||||
|
||||
export class UpsertUserTradeAccessDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Trade directions the user may see. All three (or no config row) = unrestricted; empty array = sees nothing.',
|
||||
isArray: true,
|
||||
enum: ['IMPORT', 'EXPORT', 'DOMESTIC'],
|
||||
example: ['IMPORT', 'DOMESTIC'],
|
||||
})
|
||||
@ArrayUnique()
|
||||
@IsIn(['IMPORT', 'EXPORT', 'DOMESTIC'], { each: true })
|
||||
directions!: Freight.ScheduleTradeDirection[];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Freight } from '@edr/types';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Which trade directions (IMPORT / EXPORT / DOMESTIC=Intercity) a backoffice
|
||||
* user may see. No row, or all three directions, means unrestricted.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'user_trade_access' })
|
||||
export class UserTradeAccess extends BaseEntity {
|
||||
/** IAM user id (iam.users) — no FK, iam schema is externally owned. */
|
||||
@Index()
|
||||
@Column({ name: 'user_id', type: 'uuid', unique: true })
|
||||
userId!: string;
|
||||
|
||||
@Column({ name: 'directions', type: 'text', default: '' })
|
||||
directionsRaw!: string;
|
||||
|
||||
@Column({ name: 'updated_by_id', type: 'uuid', nullable: true })
|
||||
updatedById!: string | null;
|
||||
|
||||
get directions(): Freight.ScheduleTradeDirection[] {
|
||||
return this.directionsRaw
|
||||
? (this.directionsRaw.split(',') as Freight.ScheduleTradeDirection[])
|
||||
: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { applyDirectionScope, scopedDirections } from './trade-scope.util';
|
||||
|
||||
/**
|
||||
* The scope decides what a restricted user may see, so the cases that matter
|
||||
* are the ones where a wrong answer widens access: an unrestricted fallback
|
||||
* where a restriction was configured, or an out-of-scope explicit filter
|
||||
* being honoured instead of denied.
|
||||
*/
|
||||
describe('scopedDirections', () => {
|
||||
it('leaves an unrestricted user unfiltered', () => {
|
||||
expect(scopedDirections(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('honours an explicit filter for an unrestricted user', () => {
|
||||
expect(scopedDirections(null, 'EXPORT')).toEqual(['EXPORT']);
|
||||
});
|
||||
|
||||
it('falls back to the full scope when no filter is requested', () => {
|
||||
expect(scopedDirections(['EXPORT'])).toEqual(['EXPORT']);
|
||||
});
|
||||
|
||||
it('narrows to the intersection when the filter is in scope', () => {
|
||||
expect(scopedDirections(['IMPORT', 'EXPORT'], 'EXPORT')).toEqual(['EXPORT']);
|
||||
});
|
||||
|
||||
it('denies an out-of-scope filter instead of widening access', () => {
|
||||
expect(scopedDirections(['EXPORT'], 'IMPORT')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyDirectionScope', () => {
|
||||
const makeQb = () => {
|
||||
const calls: { sql: string; params?: object }[] = [];
|
||||
const qb = {
|
||||
calls,
|
||||
andWhere(sql: string, params?: object) {
|
||||
calls.push({ sql, params });
|
||||
return qb;
|
||||
},
|
||||
};
|
||||
return qb;
|
||||
};
|
||||
|
||||
it('does not touch the query when unrestricted', () => {
|
||||
const qb = makeQb();
|
||||
applyDirectionScope(qb as never, 'booking.trade_direction', null);
|
||||
expect(qb.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('matches nothing on an empty scope rather than everything', () => {
|
||||
const qb = makeQb();
|
||||
applyDirectionScope(qb as never, 'booking.trade_direction', []);
|
||||
expect(qb.calls[0].sql).toBe('1 = 0');
|
||||
});
|
||||
|
||||
it('filters to the allowed directions', () => {
|
||||
const qb = makeQb();
|
||||
applyDirectionScope(qb as never, 'booking.trade_direction', ['EXPORT']);
|
||||
expect(qb.calls[0].sql).toContain('booking.trade_direction IN');
|
||||
expect(qb.calls[0].params).toEqual({
|
||||
scopeDirs_booking_trade_direction: ['EXPORT'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Freight } from '@edr/types';
|
||||
import { Brackets, SelectQueryBuilder, WhereExpressionBuilder } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Resolve the effective direction list for a query.
|
||||
*
|
||||
* @param allowed the user's scope — null = unrestricted
|
||||
* @param requested an explicit ?tradeDirection=… filter, if any
|
||||
* @returns directions to filter by, `null` = no filter, `[]` = show nothing
|
||||
*/
|
||||
export function scopedDirections(
|
||||
allowed: Freight.ScheduleTradeDirection[] | null,
|
||||
requested?: string | null,
|
||||
): string[] | null {
|
||||
if (!allowed) return requested ? [requested] : null;
|
||||
if (!requested) return [...allowed];
|
||||
return allowed.includes(requested as Freight.ScheduleTradeDirection)
|
||||
? [requested]
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a direction scope to a query builder column.
|
||||
* `dirs = null` → untouched; `dirs = []` → matches nothing.
|
||||
*/
|
||||
export function applyDirectionScope<T extends WhereExpressionBuilder>(
|
||||
qb: T,
|
||||
column: string,
|
||||
dirs: string[] | null,
|
||||
): T {
|
||||
if (dirs === null) return qb;
|
||||
if (dirs.length === 0) {
|
||||
qb.andWhere('1 = 0');
|
||||
return qb;
|
||||
}
|
||||
// Unique param name so multiple scopes can coexist on one query.
|
||||
const param = `scopeDirs_${column.replace(/\W/g, '_')}`;
|
||||
qb.andWhere(`${column} IN (:...${param})`, { [param]: dirs });
|
||||
return qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL-fragment form of {@link applyDirectionScope} for fluent query chains:
|
||||
* `.andWhere(f.sql, f.params)`. `dirs = null/undefined` → TRUE (no-op).
|
||||
*/
|
||||
export function directionScopeSql(
|
||||
column: string,
|
||||
dirs: string[] | null | undefined,
|
||||
): { sql: string; params: Record<string, unknown> } {
|
||||
if (!dirs) return { sql: 'TRUE', params: {} };
|
||||
if (dirs.length === 0) return { sql: 'FALSE', params: {} };
|
||||
const param = `scopeDirs_${column.replace(/\W/g, '_')}`;
|
||||
return { sql: `${column} IN (:...${param})`, params: { [param]: dirs } };
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL-fragment form of {@link applyBookingRefDirectionScope}: hides rows whose
|
||||
* varchar ref column points at a booking outside the scope; rows that do not
|
||||
* point at a booking stay visible (they carry no direction to scope by).
|
||||
*/
|
||||
export function bookingRefScopeSql(
|
||||
refColumn: string,
|
||||
dirs: string[] | null | undefined,
|
||||
): { sql: string; params: Record<string, unknown> } {
|
||||
if (!dirs) return { sql: 'TRUE', params: {} };
|
||||
const param = `scopeRefDirs_${refColumn.replace(/\W/g, '_')}`;
|
||||
const disallowed = dirs.length
|
||||
? `b.trade_direction NOT IN (:...${param})`
|
||||
: 'TRUE';
|
||||
return {
|
||||
sql: `NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id::text = ${refColumn} AND ${disallowed})`,
|
||||
params: dirs.length ? { [param]: dirs } : {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope rows whose direction lives on a related booking referenced by a
|
||||
* varchar id column (invoices.source_id, payments.ref_id). Rows that do not
|
||||
* point at a booking stay visible — they carry no direction to scope by.
|
||||
*/
|
||||
export function applyBookingRefDirectionScope<T>(
|
||||
qb: SelectQueryBuilder<T & object>,
|
||||
refColumn: string,
|
||||
dirs: string[] | null,
|
||||
): SelectQueryBuilder<T & object> {
|
||||
if (dirs === null) return qb;
|
||||
const param = `scopeRefDirs_${refColumn.replace(/\W/g, '_')}`;
|
||||
const disallowed = dirs.length
|
||||
? `b.trade_direction NOT IN (:...${param})`
|
||||
: 'TRUE';
|
||||
qb.andWhere(
|
||||
new Brackets((w) => {
|
||||
w.where(
|
||||
`NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id::text = ${refColumn} AND ${disallowed})`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
if (dirs.length) qb.setParameter(param, dirs);
|
||||
return qb;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Put,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { StaffReference } from '../../common/booking-guards';
|
||||
import { isFreightApprovalAdmin } from '../../common/freight-permission.util';
|
||||
import { UpsertUserTradeAccessDto } from './dto/upsert-user-trade-access.dto';
|
||||
import { UserTradeAccessService } from './user-trade-access.service';
|
||||
|
||||
@ApiTags('user-trade-access')
|
||||
@Controller('user-trade-access')
|
||||
@StaffReference()
|
||||
@ApiBearerAuth()
|
||||
export class UserTradeAccessController {
|
||||
constructor(private readonly service: UserTradeAccessService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List every configured user trade-direction scope' })
|
||||
list(@CurrentUser() user: TCurrentUser) {
|
||||
this.assertAdmin(user);
|
||||
return this.service.listConfigs();
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ApiOperation({ summary: "Current user's effective trade-direction scope" })
|
||||
async me(@CurrentUser() user: TCurrentUser) {
|
||||
const allowed = await this.service.resolveAllowedDirections(user);
|
||||
return {
|
||||
restricted: allowed !== null,
|
||||
directions: allowed ?? ['IMPORT', 'EXPORT', 'DOMESTIC'],
|
||||
};
|
||||
}
|
||||
|
||||
@Put(':userId')
|
||||
@ApiOperation({
|
||||
summary: 'Set the trade directions a backoffice user may see',
|
||||
})
|
||||
upsert(
|
||||
@Param('userId', ParseUUIDPipe) userId: string,
|
||||
@Body() dto: UpsertUserTradeAccessDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
this.assertAdmin(user);
|
||||
return this.service.upsert(
|
||||
userId,
|
||||
dto.directions,
|
||||
(user as { id?: string } | null)?.id ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
private assertAdmin(user: TCurrentUser) {
|
||||
if (!isFreightApprovalAdmin(user)) {
|
||||
throw new ForbiddenException(
|
||||
'Only super or organization admins can manage trade-direction access',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { UserTradeAccess } from './entities/user-trade-access.entity';
|
||||
import { UserTradeAccessController } from './user-trade-access.controller';
|
||||
import { UserTradeAccessRepository } from './user-trade-access.repository';
|
||||
import { UserTradeAccessService } from './user-trade-access.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([UserTradeAccess])],
|
||||
controllers: [UserTradeAccessController],
|
||||
providers: [UserTradeAccessService, UserTradeAccessRepository],
|
||||
exports: [UserTradeAccessService],
|
||||
})
|
||||
export class UserTradeAccessModule {}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { UserTradeAccess } from './entities/user-trade-access.entity';
|
||||
|
||||
@Injectable()
|
||||
export class UserTradeAccessRepository extends BaseRepository<UserTradeAccess> {
|
||||
constructor(
|
||||
@InjectRepository(UserTradeAccess) repository: Repository<UserTradeAccess>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByUserId(userId: string): Promise<UserTradeAccess | null> {
|
||||
return this.repository.findOne({ where: { userId } });
|
||||
}
|
||||
|
||||
findAllConfigs(): Promise<UserTradeAccess[]> {
|
||||
return this.repository.find({ order: { updatedAt: 'DESC' } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Freight } from '@edr/types';
|
||||
import { isFreightApprovalAdmin } from '../../common/freight-permission.util';
|
||||
import { UserTradeAccess } from './entities/user-trade-access.entity';
|
||||
import { UserTradeAccessRepository } from './user-trade-access.repository';
|
||||
|
||||
const ALL: Freight.ScheduleTradeDirection[] = ['IMPORT', 'EXPORT', 'DOMESTIC'];
|
||||
|
||||
/** Loose current-user shape: JWT payloads and TCurrentUser both fit. */
|
||||
export type ScopeUser =
|
||||
| ({ id?: string; sub?: string; roles?: { key?: string }[] } & object)
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export type UserTradeAccessView = {
|
||||
userId: string;
|
||||
directions: Freight.ScheduleTradeDirection[];
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class UserTradeAccessService {
|
||||
constructor(private readonly repository: UserTradeAccessRepository) {}
|
||||
|
||||
async listConfigs(): Promise<UserTradeAccessView[]> {
|
||||
const rows = await this.repository.findAllConfigs();
|
||||
return rows.map((r) => this.toView(r));
|
||||
}
|
||||
|
||||
async upsert(
|
||||
userId: string,
|
||||
directions: Freight.ScheduleTradeDirection[],
|
||||
actorId?: string | null,
|
||||
): Promise<UserTradeAccessView> {
|
||||
// Normalize to canonical order so "all three" compares reliably.
|
||||
const normalized = ALL.filter((d) => directions.includes(d));
|
||||
const existing = await this.repository.findByUserId(userId);
|
||||
const saved = existing
|
||||
? await this.repository.update(existing.id, {
|
||||
directionsRaw: normalized.join(','),
|
||||
updatedById: actorId ?? null,
|
||||
})
|
||||
: await this.repository.create({
|
||||
userId,
|
||||
directionsRaw: normalized.join(','),
|
||||
updatedById: actorId ?? null,
|
||||
});
|
||||
return this.toView(saved as UserTradeAccess);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective scope for the current user.
|
||||
* `null` = unrestricted (no config, all three directions, admin, or no user
|
||||
* on the request — routes without auth cannot be scoped).
|
||||
*/
|
||||
async resolveAllowedDirections(
|
||||
user: ScopeUser,
|
||||
): Promise<Freight.ScheduleTradeDirection[] | null> {
|
||||
const userId = user?.id ?? user?.sub;
|
||||
if (!userId) return null;
|
||||
if (isFreightApprovalAdmin(user)) return null;
|
||||
const row = await this.repository.findByUserId(userId);
|
||||
if (!row) return null;
|
||||
const dirs = row.directions;
|
||||
if (dirs.length >= ALL.length) return null;
|
||||
return dirs;
|
||||
}
|
||||
|
||||
private toView(row: UserTradeAccess): UserTradeAccessView {
|
||||
return {
|
||||
userId: row.userId,
|
||||
directions: row.directions,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -27,12 +27,20 @@ async function main() {
|
||||
const dataSource = app.get(DataSource);
|
||||
const inventory = app.get(WarehouseInventoryService);
|
||||
|
||||
// Skip bookings destined for a pure facility yard (has a facility but no
|
||||
// warehouse, e.g. Dire Dawa) — those are fully represented by their
|
||||
// facility_handling_events UNLOAD record, not a warehouse_inventory row.
|
||||
// Same gate as BookingJourneyService.autoArriveAtFinalYard.
|
||||
const bookings: { id: string; tradeDirection: string }[] = await dataSource.query(
|
||||
`SELECT b.id, b.trade_direction AS "tradeDirection"
|
||||
FROM freight.bookings b
|
||||
JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.yard_facilities yf
|
||||
ON yf.yard_id = dy.id AND yf.deleted_at IS NULL AND yf.is_active = true
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND b.trade_direction IN ('IMPORT', 'DOMESTIC')
|
||||
AND b.status IN ('ARRIVED', 'COMPLETED')
|
||||
AND NOT (dy.has_facility = true AND COALESCE(yf.has_warehouse, false) = false)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.warehouse_inventory wi
|
||||
WHERE wi.booking_id = b.id AND wi.deleted_at IS NULL
|
||||
|
||||
@@ -59,14 +59,23 @@ export class YardFacilitiesSeeder {
|
||||
[yard.id],
|
||||
);
|
||||
|
||||
// Every facility works both sides of the trip today, so the per-side
|
||||
// flags mirror the freight-type flags. Narrow an individual yard here
|
||||
// when a real one turns out to load a type but not receive it.
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO freight.yard_facilities
|
||||
(yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes)
|
||||
VALUES ($1, $2, $3, true, $4)
|
||||
(yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes,
|
||||
has_container_facility_origin, has_container_facility_destination,
|
||||
has_bulk_facility_origin, has_bulk_facility_destination)
|
||||
VALUES ($1, $2, $3, true, $4, $3, $3, true, true)
|
||||
ON CONFLICT (yard_id) WHERE deleted_at IS NULL
|
||||
DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse,
|
||||
handles_container = EXCLUDED.handles_container,
|
||||
handles_bulk = EXCLUDED.handles_bulk,
|
||||
has_container_facility_origin = EXCLUDED.has_container_facility_origin,
|
||||
has_container_facility_destination = EXCLUDED.has_container_facility_destination,
|
||||
has_bulk_facility_origin = EXCLUDED.has_bulk_facility_origin,
|
||||
has_bulk_facility_destination = EXCLUDED.has_bulk_facility_destination,
|
||||
updated_at = NOW()`,
|
||||
[yard.id, hasWarehouse, handlesContainer, `${facility} load/unload facility`],
|
||||
);
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tinymce/tinymce-react": "^6.3.0",
|
||||
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
|
||||
"@types/three": "^0.185.3",
|
||||
"@vis.gl/react-google-maps": "^1.8.3",
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -103,6 +104,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"three": "^0.185.1",
|
||||
"tinymce": "^8.6.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.25.76",
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ArrowLeftRight,
|
||||
Boxes,
|
||||
Building2,
|
||||
BarChart3,
|
||||
Container,
|
||||
FileSignature,
|
||||
FileText,
|
||||
@@ -71,6 +72,8 @@ import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||
import ReportsHubPage from "./pages/reports/ReportsHubPage";
|
||||
import ReportPage from "./pages/reports/ReportPage";
|
||||
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
|
||||
import PaymentsPage from "./pages/payments/PaymentsPage";
|
||||
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||
@@ -111,6 +114,7 @@ import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetail
|
||||
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
||||
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||
import TradeAccessPage from "./pages/configuration/TradeAccessPage";
|
||||
import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage";
|
||||
import FirstMilePage from "./pages/operations/FirstMilePage";
|
||||
import LastMilePage from "./pages/operations/LastMilePage";
|
||||
@@ -153,6 +157,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <LayoutDashboard />,
|
||||
permission: FREIGHT_PERMS.overview.view,
|
||||
},
|
||||
{
|
||||
label: "Reports",
|
||||
href: "/dashboard/reports",
|
||||
icon: <BarChart3 />,
|
||||
permission: FREIGHT_PERMS.bookings.view,
|
||||
},
|
||||
{
|
||||
label: "Customers",
|
||||
href: "/dashboard/customers",
|
||||
@@ -425,12 +435,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Container />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
icon: <Send />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Terminal Inventory",
|
||||
href: "/dashboard/warehouse-inventory?direction=IMPORT",
|
||||
@@ -585,6 +589,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/configuration/train-scheduling-rules",
|
||||
permission: FREIGHT_PERMS.trainScheduling.rulesManage,
|
||||
},
|
||||
{
|
||||
label: "Trade access",
|
||||
href: "/dashboard/configuration/trade-access",
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -817,6 +826,8 @@ const App = () => {
|
||||
/>
|
||||
<Route path="/dashboard" element={<DashboardShell />}>
|
||||
<Route path="overview" element={<OverviewPage />} />
|
||||
<Route path="reports" element={<ReportsHubPage />} />
|
||||
<Route path="reports/:reportKey" element={<ReportPage />} />
|
||||
{/* Dev/testing page for the mock AI booking assistant. */}
|
||||
<Route
|
||||
path="ai-booking-mock-test"
|
||||
@@ -1549,6 +1560,14 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="configuration/trade-access"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<TradeAccessPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* <Route
|
||||
path="configuration/contract-validity-periods"
|
||||
element={
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { Badge, Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core";
|
||||
import { Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core";
|
||||
import { Coins, Truck } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
import { lastMileService } from "@/services/last-mile.service";
|
||||
import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal";
|
||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||
import { groupByBooking, TruckRows } from "@/pages/warehouses/ImportTrucksPage";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import { MetricTile } from "./MetricTile";
|
||||
@@ -15,43 +13,14 @@ import { MetricTile } from "./MetricTile";
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
|
||||
const fmt = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleString() : "—");
|
||||
|
||||
function inspectionLabel(status: string | null | undefined): { text: string; color: string } {
|
||||
if (!status) return { text: "Pending", color: "gray" };
|
||||
if (status === "PASSED") return { text: "Passed", color: "edr-green" };
|
||||
if (status === "FAILED") return { text: "Failed", color: "red" };
|
||||
return { text: status, color: "gray" };
|
||||
}
|
||||
|
||||
interface TruckRow {
|
||||
key: string;
|
||||
plate: string;
|
||||
driver: string | null;
|
||||
truckType: string | null;
|
||||
containers: string[];
|
||||
warehouseArrived: string | null;
|
||||
warehouseDeparted: string | null;
|
||||
destinationArrived: string | null;
|
||||
returned: string | null;
|
||||
detentionOpen: boolean;
|
||||
detentionDays: number | null;
|
||||
detentionAmount: number | null;
|
||||
hasDetentionRule: boolean;
|
||||
inspection: { text: string; color: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every truck tied to a booking's last mile — EDR-dispatched or customer
|
||||
* self-haul (a booking only ever uses one), each with its own warehouse-gate
|
||||
* and destination-detention clocks, plus the booking's cargo-side cost totals
|
||||
* (storage/demurrage/double handling — billed per row internally, always
|
||||
* shown here as one booking-level total). Detention stays EDR-only; customer
|
||||
* self-haul rows show "—" since EDR only bills detention on its own fleet.
|
||||
* Cargo costs (booking-level totals) plus the same truck-import block the
|
||||
* Unloaded Queue's "Import trucks" view uses — Truck Arrival/Leaving, Exit
|
||||
* paper, Handover, Inspect, Detention times, Warehouse gate times — reused
|
||||
* as-is so this tab never drifts from that queue's behavior.
|
||||
*/
|
||||
export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
|
||||
const [feeModalOpen, setFeeModalOpen] = useState(false);
|
||||
const [detentionModalOpen, setDetentionModalOpen] = useState(false);
|
||||
|
||||
const inventoryQuery = useQuery(
|
||||
api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }),
|
||||
@@ -59,47 +28,27 @@ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
|
||||
const inventoryItems = inventoryQuery.data ?? [];
|
||||
const latestInventory = inventoryItems[0] ?? null;
|
||||
|
||||
const edrTrucksQuery = useQuery({
|
||||
queryKey: ["booking-edr-trucks", bookingId],
|
||||
queryFn: () => warehouseService.getLastMileTrucks(bookingId),
|
||||
});
|
||||
const edrTrucks = edrTrucksQuery.data ?? [];
|
||||
|
||||
const customerTrucksQuery = useQuery({
|
||||
queryKey: ["booking-customer-trucks", bookingId],
|
||||
queryFn: () => warehouseService.getCustomerTrucks(bookingId),
|
||||
enabled: edrTrucksQuery.isSuccess && edrTrucks.length === 0,
|
||||
});
|
||||
const customerTrucks = customerTrucksQuery.data ?? [];
|
||||
|
||||
const mode: "EDR" | "CUSTOMER" | "NONE" =
|
||||
edrTrucks.length > 0 ? "EDR" : customerTrucks.length > 0 ? "CUSTOMER" : "NONE";
|
||||
|
||||
const containerItemsQuery = useQuery({
|
||||
queryKey: ["booking-container-items-for-trucks", bookingId],
|
||||
queryFn: () => warehouseService.getContainerItems(bookingId),
|
||||
});
|
||||
const inspectionByContainer = new Map(
|
||||
(containerItemsQuery.data ?? []).map((c) => [c.containerNumber, c.inspectionStatus]),
|
||||
// Same query key as the Unloaded Queue page — shares its cache instead of
|
||||
// refetching the whole queue when it's already loaded elsewhere.
|
||||
const unloadedQuery = useQuery(api.warehouses.importUnloadedQueue.queryOptions({}));
|
||||
const bookingRows = useMemo(
|
||||
() => (unloadedQuery.data ?? []).filter((row) => row.bookingId === bookingId),
|
||||
[unloadedQuery.data, bookingId],
|
||||
);
|
||||
|
||||
const lastMileId = edrTrucks[0]?.lastMileId ?? null;
|
||||
|
||||
const detentionPreviewQuery = useQuery({
|
||||
queryKey: ["truck-detention-preview-for-trucks-tab", lastMileId],
|
||||
queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data),
|
||||
enabled: Boolean(lastMileId),
|
||||
});
|
||||
const detentionPreview = detentionPreviewQuery.data;
|
||||
const detentionByVehicle = new Map(
|
||||
(detentionPreview?.groups ?? []).map((g) => [g.vehicleId ?? "", g]),
|
||||
const group = useMemo(() => {
|
||||
const [existing] = groupByBooking(bookingRows);
|
||||
return (
|
||||
existing ?? {
|
||||
bookingId,
|
||||
bookingReference: bookingId,
|
||||
customerName: null,
|
||||
trainSchedule: null,
|
||||
status: "NONE",
|
||||
arrivalTime: null,
|
||||
rows: [],
|
||||
}
|
||||
);
|
||||
|
||||
const lastMileRecordQuery = useQuery({
|
||||
queryKey: ["last-mile-record-for-trucks-tab", lastMileId],
|
||||
queryFn: () => lastMileService.getById(lastMileId as string).then((r) => r.data),
|
||||
enabled: Boolean(lastMileId),
|
||||
});
|
||||
}, [bookingRows, bookingId]);
|
||||
|
||||
// Booking-level cost strip: same per-row fee preview the accrual dashboard
|
||||
// and FeePreviewModal already use, summed across every inventory row on
|
||||
@@ -114,61 +63,7 @@ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
|
||||
const sumByType = (type: string) =>
|
||||
allFees.filter((f) => f.ruleType === type).reduce((sum, f) => sum + Number(f.amount || 0), 0);
|
||||
|
||||
const rows: TruckRow[] = useMemo(() => {
|
||||
if (mode === "EDR") {
|
||||
return edrTrucks.map((t) => {
|
||||
const g = detentionByVehicle.get(t.vehicleId);
|
||||
return {
|
||||
key: t.vehicleId,
|
||||
plate: [t.truckPlateNumber, t.trailerPlateNumber].filter(Boolean).join(" + ") || "—",
|
||||
driver: t.driverName,
|
||||
truckType: t.truckType,
|
||||
containers: t.containerNumber ? [t.containerNumber] : [],
|
||||
warehouseArrived: t.arrivedAt,
|
||||
warehouseDeparted: t.departedAt,
|
||||
destinationArrived: g?.startDate ?? null,
|
||||
returned: g?.endIsOpen ? null : g?.endDate ?? null,
|
||||
detentionOpen: Boolean(g?.endIsOpen),
|
||||
detentionDays: g?.chargeableDays ?? null,
|
||||
detentionAmount: g?.amount ?? null,
|
||||
hasDetentionRule: Boolean(g?.ruleId),
|
||||
inspection: inspectionLabel(t.containerNumber ? inspectionByContainer.get(t.containerNumber) : undefined),
|
||||
};
|
||||
});
|
||||
}
|
||||
if (mode === "CUSTOMER") {
|
||||
return customerTrucks.map((t) => {
|
||||
const containers = (t.containers ?? []).map((c) => c.containerNumber);
|
||||
const statuses = new Set(containers.map((cn) => inspectionByContainer.get(cn) ?? null));
|
||||
const inspection =
|
||||
containers.length === 0
|
||||
? inspectionLabel(undefined)
|
||||
: statuses.size > 1
|
||||
? { text: "Mixed", color: "yellow" }
|
||||
: inspectionLabel([...statuses][0]);
|
||||
return {
|
||||
key: t.id,
|
||||
plate: t.plateNumber,
|
||||
driver: t.driverName,
|
||||
truckType: t.truckType,
|
||||
containers,
|
||||
warehouseArrived: t.arrivedAt ?? null,
|
||||
warehouseDeparted: t.departedAt ?? null,
|
||||
destinationArrived: null,
|
||||
returned: null,
|
||||
detentionOpen: false,
|
||||
detentionDays: null,
|
||||
detentionAmount: null,
|
||||
hasDetentionRule: false,
|
||||
inspection,
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode, edrTrucks, customerTrucks, inspectionByContainer, detentionByVehicle]);
|
||||
|
||||
if (inventoryQuery.isLoading || edrTrucksQuery.isLoading) {
|
||||
if (inventoryQuery.isLoading || unloadedQuery.isLoading) {
|
||||
return (
|
||||
<Center py={60}>
|
||||
<Group gap={10}>
|
||||
@@ -201,87 +96,14 @@ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
|
||||
</SimpleGrid>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={Truck}
|
||||
title="Trucks"
|
||||
subtitle={
|
||||
mode === "EDR" ? "EDR Last Mile" : mode === "CUSTOMER" ? "Customer Self-Haul" : undefined
|
||||
}
|
||||
accent="grape"
|
||||
extra={
|
||||
mode === "EDR" && (
|
||||
<Button size="xs" variant="light" onClick={() => setDetentionModalOpen(true)}>
|
||||
Detention times
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
No trucks assigned to this booking's last mile yet.
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1000}>
|
||||
<SectionCard icon={Truck} title="Trucks" accent="grape">
|
||||
<Table.ScrollContainer minWidth={1100}>
|
||||
<Table verticalSpacing="xs" fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Plate</Table.Th>
|
||||
<Table.Th>Driver</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Container(s)</Table.Th>
|
||||
<Table.Th>Wh. arrived</Table.Th>
|
||||
<Table.Th>Wh. departed</Table.Th>
|
||||
<Table.Th>Dest. arrived</Table.Th>
|
||||
<Table.Th>Returned</Table.Th>
|
||||
<Table.Th>Detention</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r) => (
|
||||
<Table.Tr key={r.key}>
|
||||
<Table.Td>{r.plate}</Table.Td>
|
||||
<Table.Td>{r.driver ?? "—"}</Table.Td>
|
||||
<Table.Td>{r.truckType ?? "—"}</Table.Td>
|
||||
<Table.Td>{r.containers.length ? r.containers.join(", ") : "—"}</Table.Td>
|
||||
<Table.Td>{fmt(r.warehouseArrived)}</Table.Td>
|
||||
<Table.Td>{fmt(r.warehouseDeparted)}</Table.Td>
|
||||
<Table.Td>{fmt(r.destinationArrived)}</Table.Td>
|
||||
<Table.Td>
|
||||
{r.detentionOpen ? (
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
still out
|
||||
</Badge>
|
||||
) : (
|
||||
fmt(r.returned)
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{mode !== "EDR" || r.detentionDays == null ? (
|
||||
"—"
|
||||
) : (
|
||||
<>
|
||||
{r.detentionDays}d · {money(r.detentionAmount ?? 0, detentionPreview?.currency ?? "USD")}
|
||||
{!r.hasDetentionRule && (
|
||||
<Text span size="xs" c="red">
|
||||
{" "}
|
||||
· no rule
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={r.inspection.color}>
|
||||
{r.inspection.text}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
<TruckRows group={group} />
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<FeePreviewModal
|
||||
@@ -289,13 +111,6 @@ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) {
|
||||
onClose={() => setFeeModalOpen(false)}
|
||||
inventoryId={latestInventory?.id ?? null}
|
||||
/>
|
||||
{mode === "EDR" && (
|
||||
<TruckDetentionModal
|
||||
opened={detentionModalOpen}
|
||||
onClose={() => setDetentionModalOpen(false)}
|
||||
record={lastMileRecordQuery.data ?? null}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,11 +172,11 @@ function emptyUnit(): UnitDraft {
|
||||
function emptyLine(size: string): ContainerLineDraft {
|
||||
return {
|
||||
containerSize: size,
|
||||
quantity: "1",
|
||||
quantity: "0",
|
||||
hazardousQuantity: "0",
|
||||
reeferQuantity: "0",
|
||||
returnQuantity: "0",
|
||||
units: [emptyUnit()],
|
||||
units: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowRight, FileText, Train, Users } from "lucide-react";
|
||||
import { Card, Group, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
const links = [
|
||||
{
|
||||
title: "Booking requests",
|
||||
description: "Review and action incoming freight bookings",
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: FileText,
|
||||
permission: [FREIGHT_PERMS.bookings.view],
|
||||
},
|
||||
{
|
||||
title: "Train scheduling v2",
|
||||
description: "Full allocation workflow — assign, pin wagons, finalize",
|
||||
href: "/dashboard/operations/train-scheduling-v2",
|
||||
icon: Train,
|
||||
permission: [FREIGHT_PERMS.trainScheduling.view],
|
||||
},
|
||||
{
|
||||
title: "Trains",
|
||||
description: "Manage train master data and fleet status",
|
||||
href: "/dashboard/trains",
|
||||
icon: Train,
|
||||
permission: [FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.trains.view],
|
||||
},
|
||||
{
|
||||
title: "User management",
|
||||
description: "Employees, roles, and permissions",
|
||||
href: "/user-management",
|
||||
icon: Users,
|
||||
permission: [
|
||||
FREIGHT_PERMS.admin,
|
||||
FREIGHT_PERMS.staff.roles.view,
|
||||
FREIGHT_PERMS.staff.employeeRegistration.view,
|
||||
FREIGHT_PERMS.staff.roleAssignment.view,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function OverviewQuickLinks() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const visible = links.filter((link) =>
|
||||
link.permission.some((key) => hasPermission(user, key)),
|
||||
);
|
||||
if (!visible.length) return null;
|
||||
|
||||
return (
|
||||
<Stack gap="md" h="100%">
|
||||
<Text fw={600}>Quick links</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
{visible.map((link) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<Card
|
||||
key={link.href}
|
||||
p="md"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(link.href)}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group align="flex-start" gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="edr-green" size="lg" radius="md">
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm">
|
||||
{link.title}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{link.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<ArrowRight size={16} color="var(--mantine-color-gray-5)" />
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
export interface StackedBarSeries {
|
||||
/** Key into each data row holding this series' value. */
|
||||
key: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface OverviewStackedBarChartProps<T extends object> {
|
||||
title: string;
|
||||
data: T[];
|
||||
/** Fixed order + fixed color per series — colors follow the entity, not the rank. */
|
||||
series: StackedBarSeries[];
|
||||
xKey?: string;
|
||||
emptyMessage?: string;
|
||||
formatXLabel?: (value: string) => string;
|
||||
}
|
||||
|
||||
export function OverviewStackedBarChart<T extends object>({
|
||||
title,
|
||||
data,
|
||||
series,
|
||||
xKey = "date",
|
||||
emptyMessage = "No data available",
|
||||
formatXLabel,
|
||||
}: OverviewStackedBarChartProps<T>) {
|
||||
const hasData = data.some((row) =>
|
||||
series.some((s) => Number((row as Record<string, unknown>)[s.key]) > 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
|
||||
<Stack gap="sm" h="100%">
|
||||
<Text fw={600}>{title}</Text>
|
||||
{!hasData ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
{emptyMessage}
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={230}>
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" vertical={false} />
|
||||
<XAxis
|
||||
dataKey={xKey}
|
||||
tickFormatter={formatXLabel}
|
||||
tick={{ fontSize: 11 }}
|
||||
stroke="#94a3b8"
|
||||
/>
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
||||
<Tooltip labelFormatter={formatXLabel && ((v) => formatXLabel(String(v)))} />
|
||||
<Legend iconType="circle" iconSize={9} />
|
||||
{series.map((s, index) => (
|
||||
<Bar
|
||||
key={s.key}
|
||||
dataKey={s.key}
|
||||
name={s.label}
|
||||
stackId="stack"
|
||||
fill={s.color}
|
||||
stroke="#ffffff"
|
||||
strokeWidth={1}
|
||||
barSize={18}
|
||||
radius={index === series.length - 1 ? [4, 4, 0, 0] : undefined}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel";
|
||||
import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel";
|
||||
import { OverviewContractsTabPanel } from "./tabs/OverviewContractsTabPanel";
|
||||
import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel";
|
||||
import { OverviewFleetTabPanel } from "./tabs/OverviewFleetTabPanel";
|
||||
import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel";
|
||||
import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel";
|
||||
|
||||
@@ -36,7 +37,12 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
|
||||
const bookings = useOverviewBookingsTab(range, tab === "bookings");
|
||||
const contracts = useOverviewContractsTab(range, tab === "contracts");
|
||||
const billing = useOverviewBillingTab(range, tab === "billing");
|
||||
const operations = useOverviewOperationsTab(tab === "operations");
|
||||
// Fleet reuses the operations dataset — same query key, so switching between
|
||||
// the two tabs costs one fetch.
|
||||
const operations = useOverviewOperationsTab(
|
||||
range,
|
||||
tab === "operations" || tab === "fleet",
|
||||
);
|
||||
const customers = useOverviewCustomersTab(range, tab === "customers");
|
||||
const staff = useOverviewStaffTab(range, tab === "staff");
|
||||
|
||||
@@ -47,7 +53,7 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
|
||||
? contracts
|
||||
: tab === "billing"
|
||||
? billing
|
||||
: tab === "operations"
|
||||
: tab === "operations" || tab === "fleet"
|
||||
? operations
|
||||
: tab === "customers"
|
||||
? customers
|
||||
@@ -99,6 +105,9 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
|
||||
{tab === "operations" && operations.data && (
|
||||
<OverviewOperationsTabPanel data={operations.data} />
|
||||
)}
|
||||
{tab === "fleet" && operations.data && (
|
||||
<OverviewFleetTabPanel data={operations.data} />
|
||||
)}
|
||||
{tab === "customers" && customers.data && (
|
||||
<OverviewCustomersTabPanel data={customers.data} />
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,13 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
|
||||
<Stack gap="lg">
|
||||
<OverviewKpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Total bookings",
|
||||
value: data.kpis.total,
|
||||
icon: FileText,
|
||||
accent: "gold",
|
||||
hint: "All time",
|
||||
},
|
||||
{
|
||||
label: "Active bookings",
|
||||
value: data.kpis.totalActive,
|
||||
@@ -71,7 +78,7 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
|
||||
</Grid>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="By status"
|
||||
data={data.bookingsByStatus.map((item) => ({
|
||||
@@ -81,10 +88,20 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
|
||||
emptyMessage="No bookings yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewDonutChart
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="By freight type"
|
||||
data={data.bookingsByFreightType.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.count,
|
||||
}))}
|
||||
valueLabel="Bookings"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="By payment currency"
|
||||
data={data.bookingsByCurrency.map((item) => ({
|
||||
name: item.label,
|
||||
value: item.count,
|
||||
}))}
|
||||
@@ -92,14 +109,6 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<OverviewHorizontalBarChart
|
||||
title="By payment currency"
|
||||
data={data.bookingsByCurrency.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.count,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<OverviewRecentBookingsTable bookings={data.recentBookings} />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -41,6 +41,13 @@ export function OverviewContractsTabPanel({
|
||||
<Stack gap="lg">
|
||||
<OverviewKpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Total contracts",
|
||||
value: data.kpis.total,
|
||||
icon: FileSignature,
|
||||
accent: "gold",
|
||||
hint: "All time",
|
||||
},
|
||||
{
|
||||
label: "Active contracts",
|
||||
value: data.kpis.totalActive,
|
||||
@@ -94,7 +101,7 @@ export function OverviewContractsTabPanel({
|
||||
</Grid>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="By status"
|
||||
data={data.contractsByStatus.map((item) => ({
|
||||
@@ -104,7 +111,7 @@ export function OverviewContractsTabPanel({
|
||||
emptyMessage="No contracts yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="By kind"
|
||||
data={data.contractsByKind.map((item) => ({
|
||||
@@ -113,15 +120,16 @@ export function OverviewContractsTabPanel({
|
||||
}))}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<OverviewHorizontalBarChart
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="By freight type"
|
||||
data={data.contractsByFreightType.map((item) => ({
|
||||
label: item.label === "CONTAINER" ? "Container" : "Bulk",
|
||||
name: item.label === "CONTAINER" ? "Container" : "Bulk",
|
||||
value: item.count,
|
||||
}))}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<OverviewRecentContractsTable contracts={data.recentContracts} />
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Train, Truck, Wrench } from "lucide-react";
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import type { IOverviewOperationsTab, IOverviewStatusCount } from "@/types/overview";
|
||||
import { OverviewDonutChart } from "../OverviewDonutChart";
|
||||
import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart";
|
||||
import { OverviewKpiStrip } from "../OverviewKpiStrip";
|
||||
|
||||
function formatStatusLabel(status: string) {
|
||||
return status
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function sumCounts(items: IOverviewStatusCount[]) {
|
||||
return items.reduce((sum, item) => sum + item.count, 0);
|
||||
}
|
||||
|
||||
function countByStatus(items: IOverviewStatusCount[], status: string) {
|
||||
return items.find((item) => item.status === status)?.count ?? 0;
|
||||
}
|
||||
|
||||
function toDonutData(items: IOverviewStatusCount[]) {
|
||||
return items.map((item) => ({
|
||||
name: formatStatusLabel(item.status),
|
||||
value: item.count,
|
||||
}));
|
||||
}
|
||||
|
||||
interface OverviewFleetTabPanelProps {
|
||||
data: IOverviewOperationsTab;
|
||||
}
|
||||
|
||||
export function OverviewFleetTabPanel({ data }: OverviewFleetTabPanelProps) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<OverviewKpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Total trains",
|
||||
value: sumCounts(data.trainStatusBreakdown),
|
||||
icon: Train,
|
||||
accent: "gold",
|
||||
hint: "All time",
|
||||
},
|
||||
{
|
||||
label: "Active trains",
|
||||
value: data.kpis.trainsActive,
|
||||
icon: Train,
|
||||
accent: "emerald",
|
||||
},
|
||||
{
|
||||
label: "Total wagons",
|
||||
value: sumCounts(data.wagonStatusBreakdown),
|
||||
icon: Truck,
|
||||
accent: "gold",
|
||||
hint: "All time",
|
||||
},
|
||||
{
|
||||
label: "Wagons available",
|
||||
value: data.kpis.wagonsAvailable,
|
||||
icon: Truck,
|
||||
accent: "emerald",
|
||||
},
|
||||
{
|
||||
label: "In maintenance",
|
||||
value: countByStatus(data.wagonStatusBreakdown, "MAINTENANCE"),
|
||||
icon: Wrench,
|
||||
accent: "amber",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Wagon fleet by type"
|
||||
data={data.wagonsByType.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.count,
|
||||
}))}
|
||||
valueLabel="Wagons"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Wagons by yard"
|
||||
data={data.wagonsByYard.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.count,
|
||||
}))}
|
||||
valueLabel="Wagons"
|
||||
emptyMessage="No wagons assigned to yards"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewDonutChart
|
||||
title="Train status"
|
||||
data={toDonutData(data.trainStatusBreakdown)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewDonutChart
|
||||
title="Wagon status"
|
||||
data={toDonutData(data.wagonStatusBreakdown)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
import { Box, Container as ContainerIcon, Train, Truck } from "lucide-react";
|
||||
import {
|
||||
Box,
|
||||
CalendarClock,
|
||||
Container as ContainerIcon,
|
||||
Send,
|
||||
Train,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import type { IOverviewOperationsTab } from "@/types/overview";
|
||||
import { OverviewDonutChart } from "../OverviewDonutChart";
|
||||
import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart";
|
||||
import { OverviewKpiStrip } from "../OverviewKpiStrip";
|
||||
import { OverviewStackedBarChart } from "../OverviewStackedBarChart";
|
||||
|
||||
interface OverviewOperationsTabPanelProps {
|
||||
data: IOverviewOperationsTab;
|
||||
}
|
||||
/** Fixed direction colors (CVD-validated pair + violet): color follows the entity. */
|
||||
const DIRECTION_SERIES = [
|
||||
{ key: "exportCount", label: "Export", color: "#D98A0B" },
|
||||
{ key: "importCount", label: "Import", color: "#0369a1" },
|
||||
{ key: "domesticCount", label: "Domestic", color: "#7c3aed" },
|
||||
];
|
||||
|
||||
function formatStatusLabel(status: string) {
|
||||
return status
|
||||
@@ -16,6 +28,22 @@ function formatStatusLabel(status: string) {
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function formatDateLabel(date: string) {
|
||||
const parsed = new Date(`${date}T00:00:00`);
|
||||
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function toDonutData(items: { status: string; count: number }[]) {
|
||||
return items.map((item) => ({
|
||||
name: formatStatusLabel(item.status),
|
||||
value: item.count,
|
||||
}));
|
||||
}
|
||||
|
||||
interface OverviewOperationsTabPanelProps {
|
||||
data: IOverviewOperationsTab;
|
||||
}
|
||||
|
||||
export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelProps) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
@@ -27,6 +55,19 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP
|
||||
icon: Train,
|
||||
accent: "emerald",
|
||||
},
|
||||
{
|
||||
label: "Upcoming departures",
|
||||
value: data.kpis.schedulesUpcoming,
|
||||
icon: CalendarClock,
|
||||
accent: "sky",
|
||||
hint: "Scheduled, not yet departed",
|
||||
},
|
||||
{
|
||||
label: "Dispatched today",
|
||||
value: data.kpis.dispatchedToday,
|
||||
icon: Send,
|
||||
accent: "amber",
|
||||
},
|
||||
{
|
||||
label: "Wagons available",
|
||||
value: data.kpis.wagonsAvailable,
|
||||
@@ -46,40 +87,58 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP
|
||||
/>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewStackedBarChart
|
||||
title="Train departures by direction"
|
||||
data={data.departureTrend}
|
||||
series={DIRECTION_SERIES}
|
||||
formatXLabel={formatDateLabel}
|
||||
emptyMessage="No scheduled departures in this period"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Train status"
|
||||
data={data.trainStatusBreakdown.map((item) => ({
|
||||
name: formatStatusLabel(item.status),
|
||||
value: item.count,
|
||||
title="Schedule status"
|
||||
data={toDonutData(data.scheduleStatusBreakdown)}
|
||||
emptyMessage="No train schedules yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Cargo tonnage by type"
|
||||
data={data.cargoTonnageByType.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.tons,
|
||||
}))}
|
||||
valueLabel="Tons"
|
||||
emptyMessage="No cargo recorded"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewDonutChart
|
||||
title="Wagon status"
|
||||
data={data.wagonStatusBreakdown.map((item) => ({
|
||||
name: formatStatusLabel(item.status),
|
||||
title="Containers by size"
|
||||
data={data.containersBySize.map((item) => ({
|
||||
name: item.label,
|
||||
value: item.count,
|
||||
}))}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewDonutChart
|
||||
title="Container status"
|
||||
data={data.containerStatusBreakdown.map((item) => ({
|
||||
name: formatStatusLabel(item.status),
|
||||
value: item.count,
|
||||
}))}
|
||||
data={toDonutData(data.containerStatusBreakdown)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewDonutChart
|
||||
title="Cargo status"
|
||||
data={data.cargoStatusBreakdown.map((item) => ({
|
||||
name: formatStatusLabel(item.status),
|
||||
value: item.count,
|
||||
}))}
|
||||
data={toDonutData(data.cargoStatusBreakdown)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { FileSignature, Loader2 } from "lucide-react";
|
||||
import { FileSignature, Loader2, Stamp } from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
@@ -27,9 +27,9 @@ import {
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Lets the signed-in user view and update the reusable signature stored on
|
||||
* their profile. The same signature is offered for approval when signing a
|
||||
* booking contract.
|
||||
* Lets the signed-in user view and update the reusable signature and company
|
||||
* stamp stored on their profile — managed independently of each other. Both
|
||||
* are offered when signing a booking contract.
|
||||
*/
|
||||
export function MySignatureCard() {
|
||||
const { user } = useAuth();
|
||||
@@ -38,42 +38,63 @@ export function MySignatureCard() {
|
||||
);
|
||||
const saveMutation = useMutation(api.signatures.save.mutationOptions());
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [signatureOpen, setSignatureOpen] = useState(false);
|
||||
const [stampOpen, setStampOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
const [stampData, setStampData] = useState<string | null>(null);
|
||||
|
||||
const defaultName =
|
||||
user?.name?.en || user?.username || user?.email || "";
|
||||
const savedName = saved?.signerDisplayName ?? defaultName;
|
||||
|
||||
const openDialog = () => {
|
||||
setSignerName(saved?.signerDisplayName ?? defaultName);
|
||||
const openSignatureDialog = () => {
|
||||
setSignerName(savedName);
|
||||
setSignatureData(null);
|
||||
setStampData(saved?.stampImageUrl ?? null);
|
||||
setOpen(true);
|
||||
setSignatureOpen(true);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const saveSignature = () => {
|
||||
if (!signatureData || !signerName.trim()) return;
|
||||
saveMutation.mutate(
|
||||
{
|
||||
signerDisplayName: signerName.trim(),
|
||||
signatureImageBase64: signatureData,
|
||||
// Only send the stamp when it changed — omitted keeps the saved one.
|
||||
...(stampData && stampData !== saved?.stampImageUrl
|
||||
? { stampImageBase64: stampData }
|
||||
: {}),
|
||||
// Stamp untouched — it is managed by its own dialog.
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Signature saved");
|
||||
setOpen(false);
|
||||
setSignatureOpen(false);
|
||||
},
|
||||
onError: () => toast.error("Failed to save signature"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const openStampDialog = () => {
|
||||
setStampData(saved?.stampImageUrl ?? null);
|
||||
setStampOpen(true);
|
||||
};
|
||||
|
||||
const saveStamp = () => {
|
||||
if (!stampData) return;
|
||||
saveMutation.mutate(
|
||||
{
|
||||
signerDisplayName: savedName || defaultName,
|
||||
// Signature untouched — stamp-only update.
|
||||
stampImageBase64: stampData,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Stamp saved");
|
||||
setStampOpen(false);
|
||||
},
|
||||
onError: () => toast.error("Failed to save stamp"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -85,13 +106,16 @@ export function MySignatureCard() {
|
||||
This signature can be reused to sign booking contracts.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="flex h-36 items-center justify-center">
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : saved?.signatureImageUrl ? (
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{saved?.signatureImageUrl ? (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||
<img
|
||||
src={saved.signatureImageUrl}
|
||||
@@ -102,14 +126,20 @@ export function MySignatureCard() {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Saved as {saved.signerDisplayName}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You have not saved a signature yet.
|
||||
</p>
|
||||
)}
|
||||
{saved?.stampImageUrl && (
|
||||
<Button variant="outline" size="sm" onClick={openSignatureDialog}>
|
||||
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{saved?.stampImageUrl ? (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||
<img
|
||||
src={saved.stampImageUrl}
|
||||
@@ -118,14 +148,22 @@ export function MySignatureCard() {
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Company stamp</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You have not uploaded a company stamp yet.
|
||||
</p>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={openDialog}>
|
||||
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
|
||||
<Button variant="outline" size="sm" onClick={openStampDialog}>
|
||||
<Stamp className="size-4" />
|
||||
{saved?.stampImageUrl ? "Update stamp" : "Upload stamp"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={signatureOpen} onOpenChange={setSignatureOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save your signature</DialogTitle>
|
||||
@@ -145,21 +183,16 @@ export function MySignatureCard() {
|
||||
/>
|
||||
</div>
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
<StampUpload
|
||||
value={stampData}
|
||||
onChange={setStampData}
|
||||
description="Stored on your profile and prefilled when you sign contracts."
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
<Button variant="outline" onClick={() => setSignatureOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
saveMutation.isPending || !signatureData || !signerName.trim()
|
||||
}
|
||||
onClick={save}
|
||||
onClick={saveSignature}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
@@ -170,6 +203,39 @@ export function MySignatureCard() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={stampOpen} onOpenChange={setStampOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Company stamp</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload your official company stamp or seal as an image. It is
|
||||
stored on your profile and applied next to your signature on
|
||||
contracts.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<StampUpload
|
||||
value={stampData}
|
||||
onChange={setStampData}
|
||||
description="Stored on your profile and prefilled when you sign contracts."
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setStampOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saveMutation.isPending || !stampData}
|
||||
onClick={saveStamp}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Save stamp"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Info, Moon, Sun } from "lucide-react";
|
||||
|
||||
import DurationField from "@/components/trainScheduling/DurationField";
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
import type { CreateScheduleWindowRulePayload } from "@/types/trainScheduling";
|
||||
|
||||
/** Fallbacks matching the API's global-rules defaults (used if the fetch fails). */
|
||||
const DEFAULTS = {
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 17,
|
||||
windowDurationHours: 3,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
importWindowLeadDays: 3,
|
||||
exportBookingLeadHours: 24,
|
||||
};
|
||||
|
||||
/** 12-hour label for an EAT hour 0–23, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
|
||||
function hourLabel(hour: number): string {
|
||||
const period = hour < 12 ? "AM" : "PM";
|
||||
const h12 = hour % 12 === 0 ? 12 : hour % 12;
|
||||
return `${h12}:00 ${period}`;
|
||||
}
|
||||
|
||||
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({
|
||||
value: String(h),
|
||||
label: `${hourLabel(h)} · ${String(h).padStart(2, "0")}:00`,
|
||||
}));
|
||||
|
||||
export interface WindowFormState {
|
||||
windowOpenHour: number;
|
||||
windowCloseHour: number;
|
||||
windowDurationHours: number | "";
|
||||
docReviewMinutes: number | "";
|
||||
paymentWindowMinutes: number | "";
|
||||
importWindowLeadDays: number | "";
|
||||
exportBookingLeadHours: number | "";
|
||||
/** Blank = close exactly at departure. */
|
||||
closeOffsetMinutes: number | "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the create payload from form state, or returns an error message when a
|
||||
* required field was left blank. The close offset is direction-scoped: only the
|
||||
* offset matching this schedule's direction is sent, since the other is never read.
|
||||
*/
|
||||
export function buildWindowRulePayload(
|
||||
form: WindowFormState,
|
||||
isExport: boolean,
|
||||
): { payload: CreateScheduleWindowRulePayload } | { error: string } {
|
||||
const duration = Number(form.windowDurationHours);
|
||||
const doc = Number(form.docReviewMinutes);
|
||||
const pay = Number(form.paymentWindowMinutes);
|
||||
const lead = Number(form.importWindowLeadDays);
|
||||
const exportLead = Number(form.exportBookingLeadHours);
|
||||
|
||||
const leadInvalid = isExport
|
||||
? form.exportBookingLeadHours === "" || !Number.isFinite(exportLead) || exportLead < 1
|
||||
: form.importWindowLeadDays === "" || !Number.isFinite(lead);
|
||||
if (
|
||||
form.windowDurationHours === "" ||
|
||||
form.docReviewMinutes === "" ||
|
||||
form.paymentWindowMinutes === "" ||
|
||||
!Number.isFinite(duration) ||
|
||||
!Number.isFinite(doc) ||
|
||||
!Number.isFinite(pay) ||
|
||||
leadInvalid
|
||||
) {
|
||||
return { error: "Fill every booking-window field, or turn the toggle off" };
|
||||
}
|
||||
|
||||
// Blank offset = close at departure. Sent as null (not omitted) so it wins
|
||||
// over a non-null global offset.
|
||||
const offset = form.closeOffsetMinutes === "" ? null : Number(form.closeOffsetMinutes);
|
||||
|
||||
return {
|
||||
payload: {
|
||||
windowOpenHour: form.windowOpenHour,
|
||||
windowCloseHour: form.windowCloseHour,
|
||||
windowDurationHours: duration,
|
||||
docReviewMinutes: doc,
|
||||
paymentWindowMinutes: pay,
|
||||
...(isExport
|
||||
? { exportBookingLeadHours: exportLead, exportCloseOffsetMinutes: offset }
|
||||
: { importWindowLeadDays: lead, importCloseOffsetMinutes: offset }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateScheduleWindowFieldsProps {
|
||||
/** Direction of the selected route — picks lead/offset semantics. */
|
||||
isExport: boolean;
|
||||
form: WindowFormState | null;
|
||||
onChange: (next: WindowFormState) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking-window settings for a schedule being created. Prefills from the live
|
||||
* global rules (so the fields show what the schedule WOULD inherit), then lets
|
||||
* staff tune them for this one train. Mirrors BookingWindowSettingsModal, plus
|
||||
* the booking-close offset.
|
||||
*/
|
||||
export default function CreateScheduleWindowFields({
|
||||
isExport,
|
||||
form,
|
||||
onChange,
|
||||
}: CreateScheduleWindowFieldsProps) {
|
||||
const rulesQuery = useQuery({
|
||||
queryKey: ["train-scheduling", "global-rules"],
|
||||
queryFn: () => trainSchedulingService.getGlobalRules(),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
// Seed once from the global rules, so the toggle opens on the values this
|
||||
// schedule would otherwise inherit rather than on hardcoded guesses.
|
||||
const [seeded, setSeeded] = useState(false);
|
||||
useEffect(() => {
|
||||
if (seeded || form != null) return;
|
||||
const r = rulesQuery.data;
|
||||
if (!r && rulesQuery.isLoading) return;
|
||||
const num = (v: unknown, fallback: number) => {
|
||||
const n = v == null || v === "" ? NaN : Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
const offset = isExport
|
||||
? (r as { exportCloseOffsetMinutes?: number | null } | undefined)
|
||||
?.exportCloseOffsetMinutes
|
||||
: (r as { importCloseOffsetMinutes?: number | null } | undefined)
|
||||
?.importCloseOffsetMinutes;
|
||||
onChange({
|
||||
windowOpenHour: num(r?.windowOpenHour, DEFAULTS.windowOpenHour),
|
||||
windowCloseHour: num(r?.windowCloseHour, DEFAULTS.windowCloseHour),
|
||||
windowDurationHours: num(r?.windowDurationHours, DEFAULTS.windowDurationHours),
|
||||
docReviewMinutes: num(r?.docReviewMinutes, DEFAULTS.docReviewMinutes),
|
||||
paymentWindowMinutes: num(
|
||||
isExport
|
||||
? (r as { exportPaymentWindowMinutes?: number } | undefined)
|
||||
?.exportPaymentWindowMinutes
|
||||
: r?.paymentWindowMinutes,
|
||||
DEFAULTS.paymentWindowMinutes,
|
||||
),
|
||||
importWindowLeadDays: num(r?.importWindowLeadDays, DEFAULTS.importWindowLeadDays),
|
||||
exportBookingLeadHours: num(
|
||||
r?.exportBookingLeadHours,
|
||||
DEFAULTS.exportBookingLeadHours,
|
||||
),
|
||||
closeOffsetMinutes: offset == null || offset === 0 ? "" : Number(offset),
|
||||
});
|
||||
setSeeded(true);
|
||||
}, [seeded, form, rulesQuery.data, rulesQuery.isLoading, isExport, onChange]);
|
||||
|
||||
const set = (patch: Partial<WindowFormState>) => {
|
||||
if (form) onChange({ ...form, ...patch });
|
||||
};
|
||||
|
||||
const is24h = form != null && form.windowOpenHour === form.windowCloseHour;
|
||||
// Close < open is a valid OVERNIGHT desk (e.g. 08:00 → 07:00 next morning).
|
||||
const isOvernight = form != null && form.windowCloseHour < form.windowOpenHour;
|
||||
|
||||
const reopenSummary = useMemo(() => {
|
||||
if (!form) return "";
|
||||
const total = (Number(form.docReviewMinutes) || 0) + (Number(form.paymentWindowMinutes) || 0);
|
||||
const h = Math.floor(total / 60);
|
||||
const m = total % 60;
|
||||
const parts = [h ? `${h}h` : "", m ? `${m}m` : ""].filter(Boolean);
|
||||
return parts.length ? parts.join(" ") : "0m";
|
||||
}, [form]);
|
||||
|
||||
if (!form) {
|
||||
return (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{isExport ? (
|
||||
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
||||
Export schedules use a single first-come-first-served window: it opens the
|
||||
export lead time before departure — shifted to the next desk opening if that
|
||||
lands outside desk hours — and stays open until it closes. Cycle timing below
|
||||
doesn't apply.
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert variant="light" color="orange" icon={<Info size={16} />}>
|
||||
These settings apply to this train only, and can be set only for the FIRST
|
||||
train on a route and departure day. Later trains that day join its booking
|
||||
group and share the same window.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* ── Daily desk hours ─────────────────────────────────────────── */}
|
||||
<Box>
|
||||
<Group justify="space-between" align="center" mb={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
Daily desk hours (EAT)
|
||||
</Text>
|
||||
{is24h ? (
|
||||
<Badge variant="light" color="grape" leftSection={<Moon size={12} />}>
|
||||
24-hour desk
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="light" color="edr-green" leftSection={<Sun size={12} />}>
|
||||
{hourLabel(form.windowOpenHour)} – {hourLabel(form.windowCloseHour)}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label="Opens"
|
||||
data={HOUR_OPTIONS}
|
||||
value={String(form.windowOpenHour)}
|
||||
onChange={(v) => v != null && set({ windowOpenHour: Number(v) })}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
<Select
|
||||
label="Closes"
|
||||
data={HOUR_OPTIONS}
|
||||
value={String(form.windowCloseHour)}
|
||||
onChange={(v) => v != null && set({ windowCloseHour: Number(v) })}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
</Group>
|
||||
{isOvernight && !is24h ? (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
Overnight desk — opens {form.windowOpenHour}:00 and runs past midnight,
|
||||
closing {form.windowCloseHour}:00 the next morning.
|
||||
</Text>
|
||||
) : null}
|
||||
<Switch
|
||||
mt="sm"
|
||||
size="sm"
|
||||
color="grape"
|
||||
label="Run 24 hours a day (never pause overnight)"
|
||||
checked={is24h}
|
||||
onChange={(e) =>
|
||||
set({
|
||||
// On → close == open (24h desk). Off → restore a normal ~9h day.
|
||||
windowCloseHour: e.currentTarget.checked
|
||||
? form.windowOpenHour
|
||||
: Math.min(23, form.windowOpenHour + 9),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Cycle timing ─────────────────────────────────────────────── */}
|
||||
<Box>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
Cycle timing
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
<DurationField
|
||||
label="Window duration"
|
||||
description="How long each booking cycle stays open before it closes for review"
|
||||
value={form.windowDurationHours}
|
||||
nativeUnit="hours"
|
||||
onChange={(v) => set({ windowDurationHours: v })}
|
||||
min={0.0166}
|
||||
disabled={isExport}
|
||||
/>
|
||||
<Group grow align="flex-start">
|
||||
<DurationField
|
||||
label="Document review"
|
||||
description="Staff time to accept documents after the window closes"
|
||||
value={form.docReviewMinutes}
|
||||
nativeUnit="minutes"
|
||||
onChange={(v) => set({ docReviewMinutes: v })}
|
||||
min={0}
|
||||
disabled={isExport}
|
||||
/>
|
||||
<DurationField
|
||||
label="Payment window"
|
||||
description="Time a selected customer has to pay"
|
||||
value={form.paymentWindowMinutes}
|
||||
nativeUnit="minutes"
|
||||
onChange={(v) => set({ paymentWindowMinutes: v })}
|
||||
min={1}
|
||||
/>
|
||||
</Group>
|
||||
{!isExport ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Reopen gap after each cycle = document review + payment ={" "}
|
||||
<b>{reopenSummary}</b>.
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Lead time ────────────────────────────────────────────────── */}
|
||||
{isExport ? (
|
||||
<NumberInput
|
||||
label="Export booking lead (hours)"
|
||||
description="How many hours before departure the export booking window opens"
|
||||
value={form.exportBookingLeadHours}
|
||||
onChange={(v) => set({ exportBookingLeadHours: v === "" ? "" : Number(v) })}
|
||||
min={1}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal={false}
|
||||
/>
|
||||
) : (
|
||||
<NumberInput
|
||||
label="Window lead (days)"
|
||||
description="How many days before departure the booking window starts"
|
||||
value={form.importWindowLeadDays}
|
||||
onChange={(v) => set({ importWindowLeadDays: v === "" ? "" : Number(v) })}
|
||||
min={0}
|
||||
clampBehavior="none"
|
||||
allowNegative={false}
|
||||
allowDecimal={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Booking close offset ─────────────────────────────────────── */}
|
||||
<Box>
|
||||
<Text size="sm" fw={600} mb={2}>
|
||||
Booking close offset
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb={8}>
|
||||
How long before departure this schedule stops accepting bookings. e.g. a
|
||||
3-hour import offset closes a 17:00 departure's window at 14:00; a 1-day
|
||||
export offset closes a Jul-10 16:00 departure at Jul-9 16:00. Leave blank to
|
||||
close exactly at departure.
|
||||
</Text>
|
||||
<DurationField
|
||||
label={isExport ? "Export close offset" : "Import close offset"}
|
||||
description={
|
||||
isExport
|
||||
? "This export booking window closes this long before departure (blank = at departure)"
|
||||
: "This import booking window closes this long before departure (blank = at departure)"
|
||||
}
|
||||
value={form.closeOffsetMinutes}
|
||||
nativeUnit="minutes"
|
||||
onChange={(v) => set({ closeOffsetMinutes: v })}
|
||||
min={0}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user