mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 23:00:57 +00:00
Merge branch 'dev' into fixes
This commit is contained in:
@@ -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 => ({
|
||||
yard: yards.map((y): BookingReferenceYardDto => {
|
||||
const facility = facilityByYardId.get(y.id);
|
||||
return {
|
||||
id: y.id,
|
||||
name: y.label,
|
||||
code: y.code,
|
||||
country: y.country,
|
||||
}),
|
||||
),
|
||||
hasContainerFacilityOrigin:
|
||||
facility?.hasContainerFacilityOrigin ?? false,
|
||||
hasBulkFacilityOrigin: facility?.hasBulkFacilityOrigin ?? false,
|
||||
hasContainerFacilityDestination:
|
||||
facility?.hasContainerFacilityDestination ?? false,
|
||||
hasBulkFacilityDestination:
|
||||
facility?.hasBulkFacilityDestination ?? false,
|
||||
};
|
||||
}),
|
||||
containers: groupContainersBySize(containerTypes),
|
||||
service: serviceTypes.map(
|
||||
(s): BookingReferenceServiceDto => ({
|
||||
|
||||
@@ -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);
|
||||
if (!updated) throw new NotFoundException(`Yard ${id} not found`);
|
||||
return updated;
|
||||
|
||||
// Per-side flags live on yard_facilities, not the yards row — split them out.
|
||||
const flags = this.pickSideFlags(dto);
|
||||
const yardDto = { ...dto };
|
||||
for (const key of SIDE_FLAG_KEYS) delete yardDto[key];
|
||||
|
||||
if (Object.keys(yardDto).length > 0) {
|
||||
const updated = await this.repository.update(id, yardDto);
|
||||
if (!updated) throw new NotFoundException(`Yard ${id} not found`);
|
||||
}
|
||||
if (flags) await this.facilities.upsertSideFlags(id, flags);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/** No two active yards may share a label (case/whitespace-insensitive). */
|
||||
|
||||
@@ -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({
|
||||
resourceId: input.userId,
|
||||
resource: 'saved_signatures',
|
||||
code: 'signature',
|
||||
file: this.toUploadFile('signature', input.userId, input.signatureImageBase64),
|
||||
});
|
||||
const fileRecord = input.signatureImageBase64
|
||||
? await this.filesService.upload({
|
||||
resourceId: input.userId,
|
||||
resource: 'saved_signatures',
|
||||
code: 'signature',
|
||||
file: this.toUploadFile('signature', input.userId, input.signatureImageBase64),
|
||||
})
|
||||
: null;
|
||||
|
||||
const stampRecord = input.stampImageBase64
|
||||
? await this.filesService.upload({
|
||||
@@ -65,13 +68,13 @@ export class SignaturesService {
|
||||
const saved = await this.signaturesRepository.upsert({
|
||||
userId: input.userId,
|
||||
signerDisplayName: input.signerDisplayName,
|
||||
signatureFileId: fileRecord.id,
|
||||
// Omitted stamp keeps whatever was saved before.
|
||||
// Omitted image keeps whatever was saved before.
|
||||
...(fileRecord ? { signatureFileId: fileRecord.id } : {}),
|
||||
...(stampRecord ? { stampFileId: stampRecord.id } : {}),
|
||||
});
|
||||
|
||||
const staleIds = [
|
||||
previousFileId !== fileRecord.id ? previousFileId : null,
|
||||
fileRecord && previousFileId !== fileRecord.id ? previousFileId : null,
|
||||
stampRecord && previousStampFileId !== stampRecord.id
|
||||
? previousStampFileId
|
||||
: null,
|
||||
|
||||
@@ -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,19 +334,48 @@ export class BookingJourneyService {
|
||||
RETURNING b.id, b.trade_direction`,
|
||||
[schedule.id, schedule.destinationStationId, now],
|
||||
);
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
// The facility took the cargo off the train at the final yard — raise its
|
||||
// GRN, same as the per-booking unloadBooking() path does. Only when that
|
||||
// yard also has a warehouse (or has no facility at all, e.g. Kality) does
|
||||
// WarehouseInventoryService additionally get to allocate a warehouse/yard/
|
||||
// zone row: a pure facility yard (Dire Dawa, Modjo, Sebeta, Adama) is
|
||||
// fully represented by the facility event alone — there is nothing there
|
||||
// for warehouse_inventory's NOT NULL warehouse/yard/zone to point at.
|
||||
const facility = await this.yardFacilities.facilityForYard(schedule.destinationStationId);
|
||||
const bookings = await manager
|
||||
.getRepository(Booking)
|
||||
.find({ where: { id: In(rows.map((r) => r.id)) }, relations: ['company'] });
|
||||
const bookingById = new Map(bookings.map((b) => [b.id, b]));
|
||||
|
||||
for (const row of rows) {
|
||||
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
|
||||
if (row.trade_direction === 'DOMESTIC') {
|
||||
this.events.emit('booking.completed', { bookingId: row.id });
|
||||
}
|
||||
|
||||
const booking = bookingById.get(row.id);
|
||||
if (booking) {
|
||||
await this.facilityHandling.recordHandling(manager, {
|
||||
booking,
|
||||
yardId: schedule.destinationStationId,
|
||||
trainScheduleId: schedule.id,
|
||||
eventType: 'UNLOAD',
|
||||
occurredAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
// Same event the per-booking unloadBooking() path emits — WarehouseInventoryService
|
||||
// listens for this to auto-create the warehouse_inventory row (import/intercity only,
|
||||
// it filters EXPORT itself). The bulk SQL update above skipped this entirely, so
|
||||
// bookings caught by this fallback never left "awaiting unload".
|
||||
this.events.emit('booking.unloadedAtYard', {
|
||||
bookingId: row.id,
|
||||
tradeDirection: row.trade_direction,
|
||||
});
|
||||
if (row.trade_direction !== 'EXPORT' && (!facility?.hasFacility || facility.hasWarehouse)) {
|
||||
this.events.emit('booking.unloadedAtYard', {
|
||||
bookingId: row.id,
|
||||
tradeDirection: row.trade_direction,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
@@ -435,6 +471,100 @@ export class BookingJourneyService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* INTERCITY ONLY. Intercity cargo does not get its own wagons — it rides the
|
||||
* slots freed by cargo already unloaded along the corridor (e.g. import
|
||||
* containers uncoupled at Dire Dawa). Staff pinning is a pre-dispatch tool,
|
||||
* so a DOMESTIC booking loaded mid-corridor is auto-placed here: greedy over
|
||||
* on-train slots (not DEPARTED) with no active cargo (every allocation
|
||||
* DEPARTED, or none), in consist order, by capacity. Container numbers are
|
||||
* copied onto the first allocation so the marshalling document and its
|
||||
* 40ft/20ft tally stay truthful. When nothing is free the load proceeds
|
||||
* unallocated — the marshalling document then lists the booking as on board
|
||||
* without a recorded wagon.
|
||||
* ponytail: remainder over free capacity is dumped on the last used slot
|
||||
* (paper overload beats missing cargo); upgrade path is a capacity guard in
|
||||
* the intercity accept step.
|
||||
*/
|
||||
private async autoPlaceOnFreedWagons(
|
||||
manager: EntityManager,
|
||||
schedule: TrainSchedule,
|
||||
booking: Booking,
|
||||
): Promise<void> {
|
||||
const existing = await this.allocationsForBooking(manager, schedule.id, booking.id);
|
||||
if (existing.length) return;
|
||||
|
||||
const slots = await manager
|
||||
.getRepository(TrainSetWagon)
|
||||
.createQueryBuilder('slot')
|
||||
.leftJoinAndSelect('slot.allocations', 'alloc')
|
||||
.innerJoin(
|
||||
TrainSchedule,
|
||||
'schedule',
|
||||
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
|
||||
{ scheduleId: schedule.id },
|
||||
)
|
||||
.where(`slot.status != 'DEPARTED'`)
|
||||
.orderBy('slot.sequence_no', 'ASC')
|
||||
.getMany();
|
||||
const freed = slots.filter((slot) =>
|
||||
(slot.allocations ?? []).every((a) => a.status === 'DEPARTED'),
|
||||
);
|
||||
if (!freed.length) {
|
||||
this.logger.warn(
|
||||
`No freed wagon for intercity booking ${booking.reference} on schedule ${schedule.id} — loading without wagon allocation`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let remaining = Number(booking.cargoTotalWeightVgm) || 0;
|
||||
const allocRepo = manager.getRepository(WagonBookingAllocation);
|
||||
const created: WagonBookingAllocation[] = [];
|
||||
for (const slot of freed) {
|
||||
const capacity = Number(slot.capacityTons) || remaining || 1;
|
||||
const take = Math.min(remaining || capacity, capacity);
|
||||
created.push(
|
||||
await allocRepo.save(
|
||||
allocRepo.create({
|
||||
trainSetWagonId: slot.id,
|
||||
bookingId: booking.id,
|
||||
allocatedWeightTons: take,
|
||||
loadType: booking.freightType ?? null,
|
||||
status: 'LOADED',
|
||||
}),
|
||||
),
|
||||
);
|
||||
remaining = Math.max(0, remaining - take);
|
||||
if (remaining <= 0) break;
|
||||
}
|
||||
if (remaining > 0 && created.length) {
|
||||
await allocRepo.update(created[created.length - 1].id, {
|
||||
allocatedWeightTons: () => `allocated_weight_tons + ${remaining}`,
|
||||
} as never);
|
||||
}
|
||||
|
||||
// Container numbers onto the first allocation, from the booking's container
|
||||
// lines (per physical unit when recorded, else per line).
|
||||
const lines = await manager
|
||||
.getRepository(BookingContainer)
|
||||
.find({ where: { bookingId: booking.id }, relations: { units: true } });
|
||||
const itemRepo = manager.getRepository(WagonAllocationContainerItem);
|
||||
const first = created[0];
|
||||
for (const line of lines) {
|
||||
const units = line.units?.length ? line.units : [null];
|
||||
for (const unit of units) {
|
||||
await itemRepo.save(
|
||||
itemRepo.create({
|
||||
wagonBookingAllocationId: first.id,
|
||||
bookingContainerId: line.id,
|
||||
containerNumber: unit?.containerNumber ?? line.containerNumber ?? null,
|
||||
containerTypeId: line.containerTypeId ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async setAllocationStatuses(
|
||||
manager: EntityManager,
|
||||
scheduleId: string,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user