Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Stephanos A
2026-07-14 11:00:54 +03:00
188 changed files with 8175 additions and 2670 deletions

View File

@@ -2,7 +2,6 @@ name: Deploy Stacks
on:
push:
branches:
- main
- dev
- staging
workflow_dispatch:

View File

@@ -43,3 +43,4 @@ EXPOSE 3001
# GT06 GPS tracker TCP listener (raw TCP, not HTTP). Change via GT06_TCP_PORT.
EXPOSE 5023
CMD ["node", "dist/main.js"]

View File

@@ -27,6 +27,7 @@
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
"seed:dropdown-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-dropdown-settings.ts",
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",

View File

@@ -0,0 +1,44 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
/**
* Base query DTO for every paginated list endpoint. Extend it and add the
* module's own filter fields; sort-field whitelists stay in the subclass
* because the allowed columns differ per resource.
*
* All list endpoints built on this return the shared `PaginatedResponse<T>`
* envelope from `@edr/types` (`items` + `meta`), produced by
* `common/utils/pagination.util.ts`.
*/
export class PaginationQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Transform(({ value }) => parseInt(String(value), 10) || 1)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@IsOptional()
@Transform(({ value }) => parseInt(String(value), 10) || 20)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number;
@ApiPropertyOptional({
description: 'Free-text search, applied server-side (resource-specific columns).',
})
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : undefined,
)
search?: string;
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
@IsOptional()
@Transform(({ value }) => String(value).toUpperCase())
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC';
}

View File

@@ -0,0 +1,85 @@
import { PaginatedResponse, PaginationMeta } from '@edr/types';
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
/** Raw page/pageSize as they arrive from a query DTO (both optional). */
export interface PageRequest {
page?: number;
pageSize?: number;
}
export interface PaginationOptions {
defaultPageSize?: number;
maxPageSize?: number;
}
export interface NormalizedPage {
page: number;
pageSize: number;
skip: number;
take: number;
}
const DEFAULT_PAGE_SIZE = 20;
const MAX_PAGE_SIZE = 100;
/** Clamp raw query values into a safe page window (page ≥ 1, pageSize capped). */
export function normalizePagination(
request: PageRequest,
options: PaginationOptions = {},
): NormalizedPage {
const defaultPageSize = options.defaultPageSize ?? DEFAULT_PAGE_SIZE;
const maxPageSize = options.maxPageSize ?? MAX_PAGE_SIZE;
const page = Math.max(1, Math.floor(request.page ?? 1) || 1);
const requested = Math.floor(request.pageSize ?? defaultPageSize) || defaultPageSize;
const pageSize = Math.min(Math.max(1, requested), maxPageSize);
return { page, pageSize, skip: (page - 1) * pageSize, take: pageSize };
}
export function buildPaginationMeta(
total: number,
page: number,
pageSize: number,
): PaginationMeta {
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
return {
page,
pageSize,
total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
};
}
/**
* Apply skip/take to a query builder, run it, and wrap the result in the
* shared `PaginatedResponse` envelope. Ordering and filtering must already be
* applied by the caller.
*/
export async function paginateQuery<T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>,
request: PageRequest,
options?: PaginationOptions,
): Promise<PaginatedResponse<T>> {
const { page, pageSize, skip, take } = normalizePagination(request, options);
const [items, total] = await qb.skip(skip).take(take).getManyAndCount();
return { items, meta: buildPaginationMeta(total, page, pageSize) };
}
/**
* Paginate an already-materialized array. Prefer `paginateQuery` (DB-level
* LIMIT/OFFSET); use this only for lists that are inherently in-memory.
*/
export function paginateArray<T>(
rows: readonly T[],
request: PageRequest,
options?: PaginationOptions,
): PaginatedResponse<T> {
const { page, pageSize, skip } = normalizePagination(request, options);
return {
items: rows.slice(skip, skip + pageSize),
meta: buildPaginationMeta(rows.length, page, pageSize),
};
}

View File

@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Partial-batch splits no longer promote a ONE_TIME contract to GENERAL.
* Instead the reduced booking is flagged is_split, and the booking gate lets
* the customer book exactly the remainder under the still-ONE_TIME contract.
*/
export class AddBookingIsSplit2110000000000 implements MigrationInterface {
name = 'AddBookingIsSplit2110000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS is_split BOOLEAN NOT NULL DEFAULT FALSE
`);
// Quantities the booking carried before the split — the remainder ledger
// for ONE_TIME contracts, which have no quantity cap to derive it from.
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS pre_split_quantities JSONB NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS pre_split_quantities
`);
await queryRunner.query(`
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS is_split
`);
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Proof of delivery for EDR last-mile: recipient name, a captured signature
* (stored as a file), delivery photos (file ids), notes, and the capture time.
* Recorded when the driver completes the delivery.
*/
export class AddLastMileProofOfDelivery2120000000000
implements MigrationInterface
{
name = "AddLastMileProofOfDelivery2120000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile
ADD COLUMN IF NOT EXISTS pod_recipient_name varchar(160),
ADD COLUMN IF NOT EXISTS pod_signature_file_id uuid,
ADD COLUMN IF NOT EXISTS pod_photo_file_ids text[] NOT NULL DEFAULT '{}',
ADD COLUMN IF NOT EXISTS pod_notes text,
ADD COLUMN IF NOT EXISTS pod_captured_at timestamptz
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile
DROP COLUMN IF EXISTS pod_recipient_name,
DROP COLUMN IF EXISTS pod_signature_file_id,
DROP COLUMN IF EXISTS pod_photo_file_ids,
DROP COLUMN IF EXISTS pod_notes,
DROP COLUMN IF EXISTS pod_captured_at
`);
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add a frozen wagon-allocation snapshot to each train schedule.
*
* Once a schedule leaves the editable DRAFT/SCHEDULED phase (dispatch / arrive /
* cancel), the same physical wagons get released and re-pinned onto later trains.
* The live wagon↔slot joins then no longer describe THIS train's plan, so an
* admin viewing a past schedule saw a mangled or "unavailable" allocation.
*
* This jsonb column stores a one-shot frozen copy of the wagon plan (per-slot
* physical wagon + booking allocations) captured at the transition. Non-editable
* schedules render from the snapshot; DRAFT/SCHEDULED still read live. NULL on
* legacy rows and while editable — the read path falls back to the live joins.
*/
export class AddScheduleWagonAllocationSnapshot2120000000000
implements MigrationInterface
{
name = "AddScheduleWagonAllocationSnapshot2120000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS wagon_allocation_snapshot jsonb;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS wagon_allocation_snapshot;
`);
}
}

View File

@@ -605,6 +605,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
async findAllPaginated(options: BookingListFilterOptions & {
page: number;
pageSize: number;
search?: string;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}): Promise<{
@@ -640,6 +641,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
this.applyListFilters(qb, options);
// Free-text search spans joined columns (company, contract) that only this
// list query joins — so it lives here, not in applyListFilters (shared
// with getListSummaryMetrics, whose query builder has no joins).
if (options.search) {
qb.andWhere(
'(booking.reference ILIKE :search OR company.name ILIKE :search OR contract.reference ILIKE :search)',
{ search: `%${options.search}%` },
);
}
if (options.sortBy === 'isGovernment') {
qb.orderBy('booking.isGovernment', 'DESC')
.addOrderBy('booking.priorityScore', 'DESC')
@@ -1258,7 +1269,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
fields: Partial<
Pick<
Booking,
'schedulingStatus' | 'wagonsRequired' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt'
| 'schedulingStatus'
| 'wagonsRequired'
| 'scheduledAt'
| 'holdStartedAt'
| 'holdExpiresAt'
| 'trainScheduleId'
>
>,
manager?: EntityManager,

View File

@@ -31,6 +31,7 @@ import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
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';
import { Contract } from '../contracts/entities/contract.entity';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { VehiclesService } from '../vehicles/vehicles.service';
@@ -1211,6 +1212,13 @@ export class BookingsService {
destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment,
consolidationPaired: filter.consolidationPaired,
// DTO carries 'true'/'false' strings (query params); the repo option is a
// real boolean — convert, preserving "not filtered" when absent.
customsClearingEnabled:
filter.customsClearingEnabled === undefined
? undefined
: filter.customsClearingEnabled === 'true',
search: filter.search,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
@@ -1262,6 +1270,7 @@ export class BookingsService {
// Global Logistics only clears customs bookings; non-customs clearance is
// reviewed by Marketing from the booking detail, not this queue.
customsClearingEnabled: true,
search: filter.search,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
@@ -1286,6 +1295,7 @@ export class BookingsService {
// Company-wide: payables span all of the customer's services.
companyId: company.id,
companyProfileId: filter.companyProfileId,
search: filter.search,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
@@ -1473,6 +1483,18 @@ export class BookingsService {
);
}
// Surface the parent contract's reference for drawdown bookings — the
// portal detail header shows it (the entity has no contract relation, so
// the list attaches it via a raw join and the detail attaches it here).
if (booking.contractId) {
const contract = await this.dataSource.getRepository(Contract).findOne({
where: { id: booking.contractId },
select: { reference: true },
});
(booking as Booking & { contractReference?: string | null }).contractReference =
contract?.reference ?? null;
}
// Surface the assigned train's operational status so the portal stepper
// can show the Arrival stage: the booking status stays IN_TRANSIT from
// dispatch until delivery, so arrival is only knowable from the schedule.

View File

@@ -2,18 +2,24 @@ import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, IsNull } from 'typeorm';
import { NotificationAudience, NotificationType } from '@edr/types';
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
interface BookingGuardRow {
tradeDirection: string | null;
freightType: string | null;
firstMile: string | null;
lastMile: string | null;
paymentStatus: string | null;
@@ -29,9 +35,13 @@ interface BookingGuardRow {
*/
@Injectable()
export class CustomerTruckService {
private readonly logger = new Logger(CustomerTruckService.name);
constructor(
private readonly dataSource: DataSource,
private readonly assignments: CustomerTruckAssignmentsRepository,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
) {}
listTrucks(bookingId: string): Promise<CustomerTruckAssignment[]> {
@@ -41,14 +51,20 @@ export class CustomerTruckService {
async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise<CustomerTruckAssignment[]> {
const booking = await this.loadBookingGuard(bookingId);
this.assertSelfHaulPaid(booking);
this.assertAssignmentWindow(booking);
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
// Bulk bookings have no containers — the truck hauls loose tonnage and is
// weighed out on departure (gross_weight_kg). Container bookings assign the
// 12 specific containers each truck carries.
const isBulk = booking.freightType === 'BULK';
const requested = isBulk
? []
: (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
// Both import and export specify the containers each truck carries. Capacity
// is size-based: a 40ft container fills the truck (max 1); two 20ft containers
// fit (max 2), no size mixing. #trucks <= #containers follows naturally since
// each container is assigned to exactly one truck.
if (requested.length < 1) {
// Container capacity is size-based: a 40ft container fills the truck (max 1);
// two 20ft containers fit (max 2), no size mixing. #trucks <= #containers
// follows naturally since each container is assigned to exactly one truck.
if (!isBulk && requested.length < 1) {
throw new BadRequestException('Select at least one container for this truck');
}
if (requested.length > 2) {
@@ -395,20 +411,74 @@ export class CustomerTruckService {
});
if (!container) return;
const assignment = await m
.getRepository(CustomerTruckAssignment)
.findOne({ where: { id: container.assignmentId } });
const justArrived = Boolean(assignment) && !assignment?.arrivedAt;
await m
.getRepository(CustomerTruckAssignment)
.update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
await this.syncBookingArrival(bookingId, m);
if (justArrived && assignment) {
await this.notifyTruckArrival(bookingId, assignment.plateNumber, m);
}
}
/** Mark every truck on the booking arrived (fallback when no container is known). */
async markAllArrived(bookingId: string, manager?: EntityManager): Promise<void> {
const m = manager ?? this.dataSource.manager;
const justArrived = await m
.getRepository(CustomerTruckAssignment)
.find({ where: { bookingId, arrivedAt: IsNull() } });
await m
.getRepository(CustomerTruckAssignment)
.update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
await this.syncBookingArrival(bookingId, m);
for (const truck of justArrived) {
await this.notifyTruckArrival(bookingId, truck.plateNumber, m);
}
}
/**
* Best-effort truck-arrival notification to the booking's company across every
* channel: in-app (portal inbox) + SMS + email. Never throws — a missing
* provider or contact must not break the arrival flow.
*/
private async notifyTruckArrival(
bookingId: string,
plateNumber: string | null,
m: EntityManager,
): Promise<void> {
try {
const [booking]: Array<{ companyId: string | null; reference: string | null }> =
await m.query(
`SELECT company_id AS "companyId", reference
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!booking?.companyId) return;
const ref = booking.reference ?? bookingId;
const truck = plateNumber ? `Truck ${plateNumber}` : 'A customer truck';
const body = `${truck} has arrived at the terminal for booking ${ref}.`;
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title: 'Truck arrived',
body,
link: `/bookings/${bookingId}`,
data: { bookingId, plateNumber, action: 'TRUCK_ARRIVED' },
});
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
} catch (err) {
this.logger.warn(
`Truck-arrival notify failed for ${bookingId}: ${(err as Error).message}`,
);
}
}
/**
@@ -430,6 +500,7 @@ export class CustomerTruckService {
private async loadBookingGuard(bookingId: string): Promise<BookingGuardRow> {
const [row]: BookingGuardRow[] = await this.dataSource.query(
`SELECT trade_direction AS "tradeDirection",
freight_type AS "freightType",
first_mile_pickup_address AS "firstMile",
last_mile_delivery_address AS "lastMile",
payment_status AS "paymentStatus",
@@ -463,6 +534,30 @@ export class CustomerTruckService {
}
}
/**
* Assignment window by direction:
* - IMPORT: pickup trucks are assigned only AFTER the train has arrived.
* - EXPORT / DOMESTIC: delivery trucks are assigned only BEFORE the cargo is
* loaded onto the train (booking still PAID / TRUCK_ASSIGNED). Once loaded
* (IN_TRANSIT and beyond) assignment is closed.
*/
private assertAssignmentWindow(booking: BookingGuardRow): void {
const status = booking.status ?? '';
if (booking.tradeDirection === 'IMPORT') {
if (status !== 'ARRIVED') {
throw new BadRequestException(
'Import pickup trucks can only be assigned after the train has arrived',
);
}
return;
}
if (!['PAID', 'TRUCK_ASSIGNED'].includes(status)) {
throw new BadRequestException(
'Export delivery trucks can only be assigned before the cargo is loaded onto the train',
);
}
}
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber"

View File

@@ -106,6 +106,14 @@ export class FilterBookingDto {
@IsIn(['true', 'false'])
isGovernment?: 'true' | 'false';
@ApiPropertyOptional({
enum: ['true', 'false'],
description: 'Filter customs vs self-clearance (non-customs) bookings',
})
@IsOptional()
@IsIn(['true', 'false'])
customsClearingEnabled?: 'true' | 'false';
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
@IsOptional()
@IsIn([...TRADE_DIRECTIONS])
@@ -125,6 +133,16 @@ export class FilterBookingDto {
@IsOptional()
consolidationPaired?: string;
@ApiPropertyOptional({
description:
'Free-text search across booking reference, company name, and contract reference.',
})
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : undefined,
)
search?: string;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))

View File

@@ -166,6 +166,24 @@ export class Booking extends BaseEntity {
@Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true })
contractKind?: string | null;
/**
* The customer paid a partial batch offer and this booking was reduced to the
* offered part (see BookingSplitService.applySplit). On a ONE_TIME contract a
* split booking releases the single-active-booking slot for the remainder —
* the contract kind itself is never changed.
*/
@Column({ name: 'is_split', type: 'boolean', default: false })
isSplit!: boolean;
/**
* Quantities this booking carried BEFORE it was reduced by a split — the
* split chain's source of truth for the outstanding remainder (ONE_TIME
* contracts have no quantity cap to derive it from). Bulk: total tons;
* container: units per size. Null until the booking is split.
*/
@Column({ name: 'pre_split_quantities', type: 'jsonb', nullable: true })
preSplitQuantities?: { bulkTons?: number; bySize?: Record<string, number> } | null;
/** Who created this booking: CUSTOMER (Path A), GL_ET (Path B), or STAFF. */
@Column({ name: 'created_by_role', type: 'varchar', length: 20, default: 'CUSTOMER', nullable: true })
createdByRole?: string | null;

View File

@@ -199,6 +199,8 @@ export class BookingRequestService {
reviewedByStaffId: staffId ?? null,
reviewedAt: new Date(),
} as never);
const contract = await this.contractsService.findById(request.contractId);
this.notifier.shipmentRequestRejected(contract, request.reference, note);
return (await this.repo.findById(requestId)) ?? request;
}

View File

@@ -28,6 +28,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, // invoiceService
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
);
return { service, contractsRepository };

View File

@@ -59,6 +59,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
invoiceService as never,
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
);
return {

View File

@@ -24,9 +24,11 @@ import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { hasFreightPermission } from '../../common/freight-permission.util';
@@ -40,6 +42,11 @@ import { CreateBookingUnderContractDto } from './dto/create-booking-under-contra
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
/** Bookings that never shipped release their quantity hold on the contract. */
const RELEASING_BOOKING_STATUSES = ['CANCELLED', 'REJECTED', 'EXPIRED'];
const round3 = (n: number): number => Math.round(n * 1000) / 1000;
export interface CreateBookingUnderContractResult {
booking: Booking;
warnings: string[];
@@ -74,6 +81,8 @@ export class ContractBookingService {
private readonly dataSource: DataSource,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
@Inject(forwardRef(() => BookingTransitionService))
private readonly bookingTransitionService: BookingTransitionService,
) {}
@@ -90,10 +99,22 @@ export class ContractBookingService {
// A contract whose quantity cap was fully booked is completed — no further
// bookings, even while contract validity and a booking window are still
// open. Capacity released after closure (a cancelled/expired booking)
// reopens the contract on the next booking attempt.
// reopens the contract on the next booking attempt. A ONE_TIME contract
// only closes via a finished split chain, so its room is the outstanding
// split remainder rather than a cap line.
if (contract.status === 'CONTRACT_CLOSED') {
const capacity = await this.computeCapacity(contract);
const hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0);
let hasRoom: boolean;
if (contract.contractKind !== 'GENERAL') {
const outstanding = await this.splitOutstanding(contract);
hasRoom = outstanding
? contract.freightType === 'CONTAINER'
? [...outstanding.bySize.values()].some((s) => s.outstanding > 0)
: (outstanding.bulk?.outstanding ?? 0) > 0.001
: false;
} else {
const capacity = await this.computeCapacity(contract);
hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0);
}
if (!hasRoom) {
throw new BadRequestException(
'This contract is completed — the full contracted quantity has been booked.',
@@ -121,12 +142,21 @@ export class ContractBookingService {
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
// booking reached a terminal state (e.g. payment expired without shipping),
// letting the customer re-book within contract validity (doc §10.4).
// EXCEPTION — split chain: a paid partial split (booking.isSplit) releases
// the slot for the leftover, but the next booking must take the WHOLE
// 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.
if (contract.contractKind === 'ONE_TIME') {
const active = await this.countActiveBookings(contractId);
if (active > 0) {
throw new BadRequestException(
'This one-time contract already has an active booking.',
);
if (await this.hasSplitBooking(contractId)) {
await this.assertExactRemainder(contract, dto);
} else {
const active = await this.countActiveBookings(contractId);
if (active > 0) {
throw new BadRequestException(
'This one-time contract already has an active booking.',
);
}
}
} else {
// GENERAL: draw down against the cargo quantity cap until it is full.
@@ -181,6 +211,10 @@ export class ContractBookingService {
scheduledDate: dto.scheduledDate ?? null,
direction: contract.tradeDirection ?? null,
});
// EXPORT rides whole or not at all (no split concept): reject the booking
// up front when no single open train on the day can carry it, telling the
// customer how much space is still bookable.
await this.assertExportTrainSpace(contract, route, dto);
}
// Hard capacity gate: a container line whose total weight exceeds the
@@ -573,6 +607,31 @@ export class ContractBookingService {
Number(booking.cargoTotalWeightVgm) > 0;
const warnings: string[] = [];
// EXPORT rides whole or not at all (no split concept): the chosen day must
// have a single open train that carries the whole booking. First completion
// sizes from the dto's cargo; a changes-requested resubmit (cargo already
// persisted, only the day re-picked) sizes from the booking itself.
if (contract.tradeDirection === 'EXPORT') {
if (hasCargo) {
const probe = Object.assign(
Object.create(Object.getPrototypeOf(booking)),
booking,
{ scheduledDate: new Date(dto.scheduledDate) },
) as Booking;
const report = await this.bookingBatchService.exportSpaceReport(probe);
if (!report.scheduleId) {
throw new BadRequestException(
report.fullMessage ?? 'Not enough train space for this day.',
);
}
} else {
await this.assertExportTrainSpace(contract, null, dto, {
originYardId: booking.originYardId ?? null,
destinationYardId: booking.destinationYardId ?? null,
});
}
}
// First completion persists cargo and draws contract capacity; a resubmit
// after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks
// the shipment day.
@@ -869,6 +928,196 @@ export class ContractBookingService {
.getCount();
}
/**
* Whether the contract is in split-remainder mode: some booking on it was
* reduced by a paid partial batch offer and still holds capacity. A split
* booking that never shipped (CANCELLED / REJECTED / EXPIRED) releases its
* hold and the contract falls back to the plain single-slot rule — the
* customer can rebook the whole quantity again.
*/
private async hasSplitBooking(contractId: string): Promise<boolean> {
const count = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.where('b.contract_id = :contractId', { contractId })
.andWhere('b.is_split = true')
.andWhere('b.status NOT IN (:...releasing)', { releasing: RELEASING_BOOKING_STATUSES })
.getCount();
return count > 0;
}
/**
* Outstanding split remainder of a ONE_TIME contract: what the FIRST split
* booking carried before its reduction (its pre_split_quantities snapshot —
* one-time contracts have no quantity cap to derive this from) minus
* everything currently booked on the contract. Bookings that never shipped
* (CANCELLED / REJECTED / EXPIRED) release their share. Null when the
* contract has no live split booking.
*/
private async splitOutstanding(
contract: Contract,
): Promise<{ bySize: Map<string, { total: number; outstanding: number }>; bulk: { total: number; outstanding: number } | null } | null> {
const first = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.where('b.contract_id = :contractId', { contractId: contract.id })
.andWhere('b.is_split = true')
.andWhere('b.status NOT IN (:...releasing)', { releasing: RELEASING_BOOKING_STATUSES })
.orderBy('b.created_at', 'ASC')
.getOne();
if (!first?.preSplitQuantities) return null;
const booked = await this.bookedQuantities(contract);
if (contract.freightType === 'CONTAINER') {
const bySize = new Map<string, { total: number; outstanding: number }>();
for (const [size, total] of Object.entries(first.preSplitQuantities.bySize ?? {})) {
bySize.set(size, {
total: Number(total),
outstanding: Math.max(0, Number(total) - (booked.bySize.get(size) ?? 0)),
});
}
return { bySize, bulk: null };
}
const total = Number(first.preSplitQuantities.bulkTons ?? 0);
return {
bySize: new Map(),
bulk: { total, outstanding: Math.max(0, round3(total - booked.bulk)) },
};
}
/**
* EXPORT whole-booking single-train gate. Export bookings never split — the
* entire booking must ride ONE open train on the chosen day. When no train
* fits it whole (trying every fillable train on the corridor, earliest
* first), reject BEFORE anything is written, with the largest still-bookable
* space (tons for bulk via the cargo type's wagon type; wagons/containers
* for container freight) so the customer knows what he CAN book.
*/
private async assertExportTrainSpace(
contract: Contract,
route: ContractRoute | null,
dto: CreateBookingUnderContractDto,
yards?: { originYardId: string | null; destinationYardId: string | null },
): Promise<void> {
if (contract.tradeDirection !== 'EXPORT' || !dto.scheduledDate) return;
const probe = await this.buildExportProbe(contract, route, dto, yards);
const report = await this.bookingBatchService.exportSpaceReport(probe);
if (report.scheduleId) return;
throw new BadRequestException(
report.fullMessage ?? 'Not enough train space for this day.',
);
}
/**
* Unsaved booking twin carrying exactly what the batch engine's capacity
* math reads: yards + day for the leg, container lines WITH their container
* type (wagon-type FK) for TEU/wagon sizing, or bulk tons + cargo type
* (wagon-type FK) for tons→wagons conversion.
*/
private async buildExportProbe(
contract: Contract,
route: ContractRoute | null,
dto: CreateBookingUnderContractDto,
yards?: { originYardId: string | null; destinationYardId: string | null },
): Promise<Booking> {
const probe = new Booking();
probe.freightType = contract.freightType;
probe.tradeDirection = contract.tradeDirection;
probe.scheduledDate = dto.scheduledDate ? new Date(dto.scheduledDate) : null;
// Entity types are non-nullable; a missing yard just makes legOf() match no
// train, which surfaces as "no export train for this day" — the right failure.
probe.originYardId = (yards?.originYardId ?? route?.originYardId) as string;
probe.destinationYardId = (yards?.destinationYardId ??
route?.destinationYardId) as string;
if (contract.freightType === 'CONTAINER') {
const lines = await Promise.all(
(dto.containers ?? []).map(async (line) => {
const ct = await this.resolveContainerTypeForSize(
line.containerSize,
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
);
const bc = new BookingContainer();
bc.containerSize = line.containerSize;
bc.quantity = line.quantity;
bc.containerTypeId = ct.id;
bc.containerType = ct;
bc.wagonsRequired = Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1));
bc.totalVgmTons = (line.units ?? []).reduce(
(sum, u) => sum + Number(u.vgmTons ?? 0),
0,
);
return bc;
}),
);
probe.bookingContainers = lines;
probe.cargoTotalWeightVgm = lines.reduce(
(sum, l) => sum + Number(l.totalVgmTons ?? 0),
0,
);
return probe;
}
probe.cargoTotalWeightVgm = this.resolveBulkTons(dto);
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
probe.cargoTypeId = cargoTypeId;
if (cargoTypeId) {
probe.cargoType =
(await this.dataSource
.getRepository(CargoType)
.findOne({ where: { id: cargoTypeId } })) ?? undefined;
}
return probe;
}
/**
* ONE_TIME split chain: the next booking must take the WHOLE outstanding
* remainder — a one-time contract is a single shipment, so the only way it
* fragments is the system splitting it on train capacity, never the customer
* choosing a partial amount.
*/
private async assertExactRemainder(
contract: Contract,
dto: CreateBookingUnderContractDto,
): Promise<void> {
const outstanding = await this.splitOutstanding(contract);
if (!outstanding) return; // no live split booking — nothing to pin the remainder to
if (contract.freightType === 'CONTAINER') {
const sizes = new Set<string>([
...outstanding.bySize.keys(),
...(dto.containers ?? []).map((l) => l.containerSize ?? ''),
]);
for (const size of sizes) {
const remaining = outstanding.bySize.get(size)?.outstanding ?? 0;
const requested = (dto.containers ?? [])
.filter((l) => (l.containerSize ?? '') === size)
.reduce((sum, l) => sum + Number(l.quantity ?? 0), 0);
if (requested !== remaining) {
throw new BadRequestException(
`This one-time contract was split — the next booking must take the whole remainder: ` +
`${remaining} × ${size || 'container'} container(s), got ${requested}.`,
);
}
}
return;
}
const requested =
(dto.bulkLines ?? []).reduce(
(sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
0,
) || this.resolveBulkTons(dto) || 0;
const remaining = outstanding.bulk?.outstanding ?? 0;
// 0.001 t tolerance absorbs the 3-decimal rounding applied to split weights.
if (Math.abs(requested - remaining) > 0.001) {
throw new BadRequestException(
`This one-time contract was split — the next booking must take the whole ` +
`remaining ${remaining} tons, got ${requested}.`,
);
}
}
// ── GENERAL contract quantity cap (draw-down) ──────────────────────────────
/**
@@ -980,34 +1229,96 @@ export class ContractBookingService {
});
}
/**
* Capacity as shown to bookers (the /:id/capacity endpoint): GENERAL cap
* lines as-is, or — for a ONE_TIME contract in split-remainder mode —
* synthesized lines whose cap is the first split booking's pre-split
* snapshot and whose remaining is the outstanding remainder, i.e. the exact
* quantity the next booking must take.
*/
async capacityView(
contract: Contract,
): Promise<
Array<{
containerSize?: string | null;
cargoTypeId?: string | null;
cap: number | null;
booked: number;
remaining: number | null;
}>
> {
const capacity = await this.computeCapacity(contract);
if (capacity.length > 0 || contract.contractKind === 'GENERAL') {
return capacity;
}
const outstanding = await this.splitOutstanding(contract);
if (!outstanding) return capacity;
if (contract.freightType === 'CONTAINER') {
return [...outstanding.bySize.entries()].map(([size, s]) => ({
containerSize: size,
cargoTypeId: null,
cap: s.total,
booked: s.total - s.outstanding,
remaining: s.outstanding,
}));
}
const bulk = outstanding.bulk;
if (!bulk) return [];
return [
{
containerSize: null,
cargoTypeId: null,
cap: bulk.total,
booked: round3(bulk.total - bulk.outstanding),
remaining: bulk.outstanding,
},
];
}
/**
* Complete the contract once its quantity cap is fully consumed. Runs after
* every booking created under a GENERAL contract (including a split remainder
* being rebooked): when no capped scope line has capacity left, the contract
* moves to CONTRACT_CLOSED even though its validity window is still open —
* blocking further bookings and shipment requests, including inside an open
* booking window. Never throws: a status hiccup must not undo the booking
* that was just created.
* every booking created under a GENERAL contract, and under a ONE_TIME
* contract in split-remainder mode (a split remainder being rebooked): when
* no capped scope line has capacity left, the contract moves to
* CONTRACT_CLOSED even though its validity window is still open — blocking
* further bookings and shipment requests, including inside an open booking
* window. Never throws: a status hiccup must not undo the booking that was
* just created.
*/
private async maybeCompleteContract(contract: Contract): Promise<void> {
try {
// ONE_TIME contracts are governed by the single-active-booking slot (and
// are promoted to GENERAL on split), so only GENERAL completes by cap.
if (contract.contractKind !== 'GENERAL') return;
if (!['CONTRACT_ACTIVE', 'FULLY_EXECUTED'].includes(contract.status)) return;
const capacity = await this.computeCapacity(contract);
if (capacity.length === 0) return; // uncapped — completes only by expiry
// 0.001 tolerance absorbs bulk-ton float rounding (split weights round to
// 3 decimals); container caps are integers and unaffected.
const exhausted = capacity.every(
(c) => c.remaining != null && c.remaining <= 0.001,
);
if (!exhausted) return;
// ONE_TIME contracts are governed by the single-active-booking slot, so
// they normally complete by expiry — EXCEPT once a booking was split: the
// remainder chain draws down the split booking's pre-split snapshot, and
// the contract completes when the outstanding remainder hits zero.
// (An unsplit ONE_TIME never completes here, so re-booking after an
// expired unpaid booking keeps working.)
if (contract.contractKind !== 'GENERAL') {
const outstanding = await this.splitOutstanding(contract);
if (!outstanding) return;
// 0.001 tolerance absorbs bulk-ton float rounding (split weights round
// to 3 decimals); container quantities are integers and unaffected.
const exhausted =
contract.freightType === 'CONTAINER'
? [...outstanding.bySize.values()].every((s) => s.outstanding <= 0)
: (outstanding.bulk?.outstanding ?? 0) <= 0.001;
if (!exhausted) return;
} else {
const capacity = await this.computeCapacity(contract);
if (capacity.length === 0) return; // uncapped — completes only by expiry
const exhausted = capacity.every(
(c) => c.remaining != null && c.remaining <= 0.001,
);
if (!exhausted) return;
}
await this.contractsRepository.update(contract.id, {
status: 'CONTRACT_CLOSED',
} as never);
this.logger.log(
`Contract ${contract.reference} quantity cap fully booked — completed; no further bookings within validity.`,
`Contract ${contract.reference} quantity fully booked — completed; no further bookings within validity.`,
);
} catch (err) {
this.logger.error(
@@ -1025,7 +1336,7 @@ export class ContractBookingService {
private async bookedQuantities(
contract: Contract,
): Promise<{ bySize: Map<string, number>; bulk: number }> {
const releasing = ['CANCELLED', 'REJECTED', 'EXPIRED'];
const releasing = RELEASING_BOOKING_STATUSES;
if (contract.freightType === 'CONTAINER') {
const rows = await this.dataSource
.getRepository(BookingContainer)
@@ -1213,6 +1524,8 @@ export class ContractBookingService {
currency: string | null;
pairingErrors: string[];
capacityErrors: string[];
containerClashErrors: string[];
spaceErrors: string[];
lineItems: PriceLineItemDto[];
totalAmount: number;
}> {
@@ -1227,6 +1540,8 @@ export class ContractBookingService {
currency: null,
pairingErrors: [],
capacityErrors: [],
containerClashErrors: [],
spaceErrors: [],
lineItems: [],
totalAmount: 0,
};
@@ -1312,12 +1627,50 @@ export class ContractBookingService {
contract.tradeDirection,
);
// A physical container rides one train only — surface a clash with another
// active booking on the same day + route in the preview, so the form can
// hard-block before the create call rejects with the same rule.
let containerClashErrors: string[] = [];
if (dto.scheduledDate) {
const numbers = lines.flatMap((line) =>
(line.units ?? [])
.map((u) => (u.containerNumber ?? '').trim().toUpperCase())
.filter((n) => n.length > 0),
);
const clashes = await this.findContainerClashesOnTrain(
[...new Set(numbers)],
dto.scheduledDate,
{
originYardId: route?.originYardId,
destinationYardId: route?.destinationYardId,
},
);
containerClashErrors = clashes.map(
(c) =>
`${c.containerNumber} is already booked on ${c.reference} for this shipment day.`,
);
}
// EXPORT rides whole or not at all — surface the single-train space check
// in the preview so the form hard-blocks BEFORE the create call rejects
// with the same message (including how much space is still bookable).
let spaceErrors: string[] = [];
if (contract.tradeDirection === 'EXPORT' && dto.scheduledDate) {
const probe = await this.buildExportProbe(contract, route, dto);
const report = await this.bookingBatchService.exportSpaceReport(probe);
if (!report.scheduleId) {
spaceErrors = [report.fullMessage ?? 'Not enough train space for this day.'];
}
}
return {
overweightLines: computed.overweightLines,
overweightSurchargeAmount,
currency: computed.currency,
pairingErrors,
capacityErrors,
containerClashErrors,
spaceErrors,
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
};
@@ -1445,6 +1798,37 @@ export class ContractBookingService {
route: { originYardId?: string | null; destinationYardId?: string | null },
excludeBookingId?: string,
): Promise<void> {
const clashes = await this.findContainerClashesOnTrain(
numbers,
scheduledDate,
route,
excludeBookingId,
);
if (clashes.length) {
const detail = clashes
.map((c) => `${c.containerNumber} (booking ${c.reference})`)
.join(', ');
throw new ConflictException(
`Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` +
'A container can only be on one booking per train — remove it or pick another shipment day.',
);
}
}
/**
* Container numbers among `numbers` that already sit on another active
* booking of the same train — same day and same route. One row per clashing
* number. Bookings without route yards (legacy rows) match on the day alone
* rather than let through.
*/
private async findContainerClashesOnTrain(
numbers: string[],
scheduledDate: string,
route: { originYardId?: string | null; destinationYardId?: string | null },
excludeBookingId?: string,
): Promise<Array<{ containerNumber: string; reference: string }>> {
if (!numbers.length) return [];
const qb = this.dataSource
.getRepository(BookingContainerUnit)
.createQueryBuilder('unit')
@@ -1474,18 +1858,7 @@ export class ContractBookingService {
}
const clashes: Array<{ containerNumber: string; reference: string }> =
await qb.getRawMany();
if (clashes.length) {
const detail = [
...new Map(clashes.map((c) => [c.containerNumber, c])).values(),
]
.map((c) => `${c.containerNumber} (booking ${c.reference})`)
.join(', ');
throw new ConflictException(
`Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` +
'A container can only be on one booking per train — remove it or pick another shipment day.',
);
}
return [...new Map(clashes.map((c) => [c.containerNumber, c])).values()];
}
private async assert20ftPairableAtCreate(
@@ -1528,8 +1901,8 @@ export class ContractBookingService {
preferReefer: boolean,
): Promise<ContainerType> {
const sizeFt = parseInt(size, 10);
const { data } = await this.containerTypesService.findAll({ pageSize: 200 });
const types = data.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false);
const { items } = await this.containerTypesService.findAll({ pageSize: 100 });
const types = items.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false);
if (!types.length) {
throw new BadRequestException(`No container type configured for size ${size}.`);
}

View File

@@ -848,8 +848,10 @@ export class ContractClearanceService {
}
/**
* GL ET clearance hub: every customs (Path B) contract in phased clearance,
* including after booking is created.
* GL ET clearance hub, Contracts tab: ONE_TIME customs (Path B) contracts in
* phased clearance that already carry at least one uploaded clearance
* document — a contract still waiting for its first document has nothing to
* review, and GENERAL contracts clear per booking, not at contract level.
*/
async queue(filter: FilterContractDto): Promise<PaginatedContracts> {
return this.contractsRepository.findAllPaginated({
@@ -857,6 +859,8 @@ export class ContractClearanceService {
pageSize: filter.pageSize ?? 100,
statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
hasClearanceDocuments: true,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
@@ -866,12 +870,38 @@ export class ContractClearanceService {
* Operations queue: self-clearance (Path A) contracts awaiting Operations
* review of the customer's own clearance documents.
*/
/**
* Statuses a non-customs contract passes through around Operations
* clearance review — the set a caller may narrow {@link opsQueue} to.
*/
private static readonly OPS_CLEARANCE_STATUSES = [
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'FULLY_EXECUTED',
'CONTRACT_ACTIVE',
'ACTIVE_SHIPMENT_IN_PROGRESS',
'CONTRACT_CLOSED',
'CANCELLED',
];
async opsQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
// Callers may narrow to any subset of the ops-clearance lifecycle (the
// hub's status filter sends an explicit list); anything outside the
// whitelist is dropped so this endpoint can't become a general contract
// browser. No statuses given → the original under-review queue.
const requested = (filter.statuses ?? filter.status ?? '')
.split(',')
.map((s) => s.trim())
.filter((s) =>
ContractClearanceService.OPS_CLEARANCE_STATUSES.includes(s),
);
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 100,
statuses: ['CLEARANCE_UNDER_REVIEW'],
statuses: requested.length ? requested : ['CLEARANCE_UNDER_REVIEW'],
customsClearingEnabled: false,
search: filter.search,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
@@ -896,6 +926,7 @@ export class ContractClearanceService {
pageSize: filter.pageSize ?? 50,
statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'],
customsClearingEnabled: false,
search: filter.search,
sortBy: filter.sortBy ?? 'createdAt',
sortOrder: filter.sortOrder ?? 'DESC',
});

View File

@@ -145,6 +145,19 @@ export class ContractNotifierService {
this.inApp(c, 'Contract changes requested', msg);
}
/** GL rejected a shipment request filed under the contract. */
shipmentRequestRejected(c: Contract, requestRef: string, note?: string): void {
const msg =
`Your shipment request ${requestRef} under contract ${c.reference} was rejected.` +
(note ? ` Reason: ${note}.` : '') +
` Please contact us for details.`;
void this.notifyContact(c, msg, 'SHIPMENT REQUEST REJECTED');
this.inApp(c, 'Shipment request rejected', msg, {
type: NotificationType.BOOKING_STATUS,
data: { contractId: c.id, reference: requestRef },
});
}
// ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */

View File

@@ -80,9 +80,9 @@ export class ContractPricingService {
const sizes = (contract.cargoScope ?? [])
.map((c) => c.containerSize)
.filter((s): s is string => !!s);
const { data: containerTypes } = await this.containerTypesService.findAll({
const { items: containerTypes } = await this.containerTypesService.findAll({
isActive: true,
pageSize: 500,
pageSize: 100,
});
for (const size of sizes) {
const sizeFt = size === '40ft' ? 40 : 20;

View File

@@ -852,11 +852,12 @@ export class ContractsController {
@Get(':id/capacity')
@ApiOperation({
summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)',
summary:
'Remaining bookable quantity per cargo line (GENERAL draw-down cap, or the outstanding remainder of a split ONE_TIME contract)',
})
async capacity(@Param('id', ParseUUIDPipe) id: string) {
const contract = await this.contractsService.findById(id);
return this.contractBookingService.computeCapacity(contract);
return this.contractBookingService.capacityView(contract);
}
// ── Clearance milestones (doc §11.3, §12.2) ────────────────────────────────

View File

@@ -26,6 +26,8 @@ export interface ContractListFilterOptions {
tradeDirection?: string;
paymentCurrency?: string;
customsClearingEnabled?: boolean;
/** true → only contracts with at least one uploaded clearance document. */
hasClearanceDocuments?: boolean;
createdFrom?: string;
createdTo?: string;
}
@@ -98,6 +100,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
options: ContractListFilterOptions & {
page: number;
pageSize: number;
search?: string;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
},
@@ -128,6 +131,16 @@ export class ContractsRepository extends BaseRepository<Contract> {
this.applyListFilters(qb, options);
// Free-text search across contract reference and customer (company) name.
// Applied here (not in applyListFilters) because only this query joins the
// `company` alias — the summary-metrics query builder does not.
if (options.search) {
qb.andWhere(
'(contract.reference ILIKE :search OR company.name ILIKE :search)',
{ search: `%${options.search}%` },
);
}
const sortField =
options.sortBy === 'contractValidUntil'
? 'contract.contractValidUntil'
@@ -273,6 +286,12 @@ export class ContractsRepository extends BaseRepository<Contract> {
customsClearingEnabled: options.customsClearingEnabled,
});
}
if (options.hasClearanceDocuments) {
qb.andWhere(
'EXISTS (SELECT 1 FROM freight.contract_document_review cdr ' +
'WHERE cdr.contract_id = contract.id AND cdr.deleted_at IS NULL)',
);
}
if (options.serviceTypeId) {
qb.andWhere('contract.service_type_id = :serviceTypeId', {
serviceTypeId: options.serviceTypeId,

View File

@@ -579,6 +579,7 @@ export class ContractsService {
paymentCurrency: filter.paymentCurrency,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
search: filter.search,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});

View File

@@ -71,6 +71,15 @@ export class FilterContractDto {
@IsDateString()
createdTo?: string;
@ApiPropertyOptional({
description: 'Free-text search across contract reference and company name.',
})
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : undefined,
)
search?: string;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))

View File

@@ -10,12 +10,14 @@ import {
Patch,
Post,
Put,
Query,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto";
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
import { DropdownSettingsService } from "./dropdown-settings.service";
@@ -34,6 +36,15 @@ export class DropdownSettingsController {
return this.service.list();
}
// Must be declared before @Get(":id") so "paged" isn't captured as an id.
@Get("paged")
@ApiOperation({
summary: "Paged admin listing of dropdown settings (server-side search)",
})
listPaged(@Query() query: ListDropdownSettingsQueryDto) {
return this.service.listPaged(query);
}
@Get(":id")
@ApiOperation({ summary: "Get a dropdown setting by ID" })
getById(@Param("id", ParseUUIDPipe) id: string) {

View File

@@ -1,8 +1,11 @@
import { BaseRepository } from "@edr/api-common";
import { PaginatedResponse } from "@edr/types";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { paginateQuery } from "../../common/utils/pagination.util";
import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto";
import { DropdownOption } from "./entities/dropdown-option.entity";
import { DropdownSetting } from "./entities/dropdown-setting.entity";
import type { IDropdownSettingsRepository } from "./interfaces/dropdown-settings.repository.interface";
@@ -44,6 +47,27 @@ export class DropdownSettingsRepository
});
}
findPaged(
query: ListDropdownSettingsQueryDto,
): Promise<PaginatedResponse<DropdownSetting>> {
// Soft-deleted rows are excluded automatically by the query builder
// (BaseEntity's deletedAt column). Ordering mirrors findAll (label ASC).
const qb = this.repository
.createQueryBuilder("setting")
.leftJoinAndSelect("setting.children", "option")
.orderBy("setting.label", query.sortOrder ?? "ASC")
.addOrderBy("option.order", "ASC");
if (query.search) {
qb.andWhere(
"(setting.code ILIKE :search OR setting.label ILIKE :search OR setting.description ILIKE :search)",
{ search: `%${query.search}%` },
);
}
return paginateQuery(qb, query);
}
async replaceOptions(
settingId: string,
options: Array<Partial<DropdownOption>>,

View File

@@ -5,8 +5,11 @@ import {
NotFoundException,
} from "@nestjs/common";
import { PaginatedResponse } from "@edr/types";
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto";
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
import { DropdownOption } from "./entities/dropdown-option.entity";
@@ -16,71 +19,6 @@ import {
IDropdownSettingsRepository,
} from "./interfaces/dropdown-settings.repository.interface";
const STATIONS_TER_CODE = "stations_ter";
const DEFAULT_STATION_OPTIONS: CreateDropdownOptionDto[] = [
{
value: "inside_addis_ababa",
label: "Addis Ababa",
note: "Inside country",
order: 1,
},
{
value: "inside_adama",
label: "Adama",
note: "Inside country",
order: 2,
},
{
value: "inside_mojo",
label: "Mojo",
note: "Inside country",
order: 3,
},
{
value: "inside_awash",
label: "Awash",
note: "Inside country",
order: 4,
},
{
value: "inside_mieso",
label: "Mieso",
note: "Inside country",
order: 5,
},
{
value: "inside_dire_dawa",
label: "Dire Dawa",
note: "Inside country",
order: 6,
},
{
value: "outside_ali_sabieh",
label: "Ali Sabieh",
note: "Outside country",
order: 7,
},
{
value: "outside_holhol",
label: "Holhol",
note: "Outside country",
order: 8,
},
{
value: "outside_djibouti_city",
label: "Djibouti City",
note: "Outside country",
order: 9,
},
{
value: "outside_doraleh_terminal",
label: "Doraleh Terminal",
note: "Outside country",
order: 10,
},
];
@Injectable()
export class DropdownSettingsService {
constructor(
@@ -92,6 +30,12 @@ export class DropdownSettingsService {
return this.repository.findAll();
}
listPaged(
query: ListDropdownSettingsQueryDto,
): Promise<PaginatedResponse<DropdownSetting>> {
return this.repository.findPaged(query);
}
async getById(id: string): Promise<DropdownSetting> {
const setting = await this.repository.findById(id);
if (!setting) throw new NotFoundException(`Setting ${id} not found`);
@@ -127,34 +71,6 @@ export class DropdownSettingsService {
return this.getById(setting.id);
}
async seedDefaultStations(): Promise<void> {
const existing = await this.repository.findByCode(STATIONS_TER_CODE);
if (!existing) {
await this.create({
code: STATIONS_TER_CODE,
label: "Stations TER",
description:
"Temporary freight station list used by booking origin and destination yards.",
multiple: false,
meta: {
searchable: true,
clearable: true,
version: "temporary",
},
children: DEFAULT_STATION_OPTIONS,
});
return;
}
if ((existing.children?.length ?? 0) === 0) {
await this.repository.replaceOptions(
existing.id,
DEFAULT_STATION_OPTIONS,
);
}
}
async update(
id: string,
dto: UpdateDropdownSettingDto,

View File

@@ -0,0 +1,8 @@
import { PaginationQueryDto } from "../../../common/dto/pagination-query.dto";
/**
* Query params for the paged admin listing (`GET /dropdown-settings/paged`).
* `search` matches code, label and description server-side. The entity has no
* status/isActive flag, so the base pagination fields are all that's needed.
*/
export class ListDropdownSettingsQueryDto extends PaginationQueryDto {}

View File

@@ -1,3 +1,6 @@
import { PaginatedResponse } from "@edr/types";
import { ListDropdownSettingsQueryDto } from "../dto/list-dropdown-settings-query.dto";
import { DropdownOption } from "../entities/dropdown-option.entity";
import { DropdownSetting } from "../entities/dropdown-setting.entity";
@@ -11,6 +14,9 @@ export const DROPDOWN_SETTINGS_REPOSITORY = Symbol(
export interface IDropdownSettingsRepository {
findAll(): Promise<DropdownSetting[]>;
findPaged(
query: ListDropdownSettingsQueryDto,
): Promise<PaginatedResponse<DropdownSetting>>;
findById(id: string): Promise<DropdownSetting | null>;
findByCode(code: string): Promise<DropdownSetting | null>;

View File

@@ -0,0 +1,22 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
/**
* Proof of delivery captured by the EDR driver when a last-mile leg is
* completed. Sent as multipart/form-data — the recipient's signature (field
* `signature`) and proof photos (field `photos`) are uploaded alongside these
* text fields.
*/
export class RecordProofOfDeliveryDto {
@ApiProperty({ description: 'Name of the person who received the cargo.' })
@IsString()
@IsNotEmpty()
@MaxLength(160)
recipientName!: string;
@ApiPropertyOptional({ description: 'Optional delivery notes.' })
@IsOptional()
@IsString()
@MaxLength(1000)
notes?: string;
}

View File

@@ -70,4 +70,22 @@ export class LastMile extends BaseEntity {
@OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile)
vehicleAssignments?: LastMileVehicleAssignment[];
// ── Proof of delivery (captured by the EDR driver on completion) ──────────
@Column({ name: 'pod_recipient_name', type: 'varchar', length: 160, nullable: true })
podRecipientName?: string | null;
/** File id of the recipient's captured signature (PNG). */
@Column({ name: 'pod_signature_file_id', type: 'uuid', nullable: true })
podSignatureFileId?: string | null;
/** File ids of the delivery proof photos. */
@Column({ name: 'pod_photo_file_ids', type: 'text', array: true, default: '{}' })
podPhotoFileIds!: string[];
@Column({ name: 'pod_notes', type: 'text', nullable: true })
podNotes?: string | null;
@Column({ name: 'pod_captured_at', type: 'timestamptz', nullable: true })
podCapturedAt?: Date | null;
}

View File

@@ -11,8 +11,11 @@ import {
Patch,
Post,
Query,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
@@ -21,6 +24,7 @@ import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { SetVehiclesDto } from './dto/set-vehicles.dto';
import { SetDistancesDto } from './dto/set-distances.dto';
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
import { LastMileStatus } from './entities/last-mile.entity';
import { LastMileService } from './last-mile.service';
import { LastMileInvoiceService } from './last-mile-invoice.service';
@@ -115,6 +119,19 @@ export class LastMileController {
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
}
@Post(':id/proof-of-delivery')
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Record proof of delivery (signature + photos) and complete the leg' })
async recordProofOfDelivery(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RecordProofOfDeliveryDto,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.lastMileService.recordProofOfDelivery(id, dto, files ?? []);
}
@Post(':id/invoice')
@BookingStaff(FREIGHT_PERMS.lastMile.generateInvoice)
@ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' })

View File

@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module';
import { FilesModule } from '../files/files.module';
import { DriversModule } from '../drivers/drivers.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
@@ -22,6 +23,7 @@ import { LastMileService } from './last-mile.service';
VehiclesModule,
DriversModule,
NotificationsModule,
FilesModule,
],
controllers: [LastMileController],
providers: [LastMileRepository, LastMileService, LastMileInvoiceService],

View File

@@ -8,10 +8,12 @@ import { VehiclesService } from '../vehicles/vehicles.service';
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
import { LastMileRepository } from './last-mile.repository';
import { FilesService } from '../files/files.service';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
import { OnEvent } from '@nestjs/event-emitter';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@@ -47,6 +49,7 @@ export class LastMileService {
private readonly dataSource: DataSource,
private readonly history: FleetHistoryService,
private readonly billing: BillingService,
private readonly filesService: FilesService,
) {}
/** Attach real invoice info (number/status) to records so the UI can show an
@@ -210,6 +213,49 @@ export class LastMileService {
return record;
}
/**
* Record proof of delivery (recipient signature + photos + notes) and complete
* the leg. Marking DELIVERED reuses {@link update}'s side effects (deliveredAt,
* vehicle release, history).
*/
async recordProofOfDelivery(
id: string,
dto: RecordProofOfDeliveryDto,
files: Express.Multer.File[],
): Promise<LastMile> {
const existing = await this.findById(id);
const signature = files.find((f) => f.fieldname === 'signature');
const photos = files.filter((f) => f.fieldname === 'photos');
const signatureFileId = signature
? (
await this.filesService.upload({
resourceId: id,
resource: 'last-mile',
code: 'pod-signature',
file: signature,
})
).id
: null;
const photoFileIds = photos.length
? (await this.filesService.uploadMany(id, 'last-mile', photos)).map((r) => r.id)
: [];
await this.lastMileRepository.update(id, {
podRecipientName: dto.recipientName.trim(),
podSignatureFileId: signatureFileId,
podPhotoFileIds: photoFileIds,
podNotes: dto.notes?.trim() || null,
podCapturedAt: new Date(),
} as never);
if (existing.status !== 'DELIVERED') {
return this.update(id, { status: 'DELIVERED' } as UpdateLastMileDto);
}
return this.findById(id);
}
async create(dto: CreateLastMileDto): Promise<LastMile> {
const record = await this.lastMileRepository.create({
bookingId: dto.bookingId,

View File

@@ -5,6 +5,7 @@ import {
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
@@ -19,15 +20,8 @@ export class ApprovalRulesController {
@Get()
@RuleEngineView('approval-rules')
@ApiOperation({ summary: 'List approval rules' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
requiresDirectorApproval:
query['requiresDirectorApproval'] !== undefined
? query['requiresDirectorApproval'] === 'true'
: undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
findAll(@Query() query: ListApprovalRulesQueryDto) {
return this.service.findAll(query);
}
@Get('chain')

View File

@@ -5,6 +5,7 @@ import {
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
@@ -19,19 +20,8 @@ export class CargoTypesController {
@Get()
@RuleEngineView('cargo-types')
@ApiOperation({ summary: 'List cargo types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
requiresDirectorApproval: query['requiresDirectorApproval'] !== undefined
? query['requiresDirectorApproval'] === 'true'
: undefined,
parentGroupId: query['parentGroupId'],
search: query['search'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
sortBy: query['sortBy'],
sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC',
});
findAll(@Query() query: ListCargoTypesQueryDto) {
return this.service.findAll(query);
}
@Post('reorder')

View File

@@ -5,6 +5,7 @@ import {
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
@@ -19,12 +20,8 @@ export class ContainerTypesController {
@Get()
@RuleEngineView('container-types')
@ApiOperation({ summary: 'List container types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
findAll(@Query() query: ListContainerTypesQueryDto) {
return this.service.findAll(query);
}
@Post('reorder')

View File

@@ -5,6 +5,7 @@ import {
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
@@ -19,13 +20,8 @@ export class PriorityConfigsController {
@Get()
@RuleEngineView('priority-configs')
@ApiOperation({ summary: 'List priority configs' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
type: (query['type'] as 'WAGON' | 'CURRENCY' | 'CUSTOMS') || undefined,
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
findAll(@Query() query: ListPriorityConfigsQueryDto) {
return this.service.findAll(query);
}
@Get(':id')

View File

@@ -6,6 +6,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { CreateRateDto } from '../dto/create-rate.dto';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import {
type AuthUserPayload,
resolveAuthUserId,
@@ -22,13 +23,8 @@ export class RatesController {
@Get()
@RuleEngineView('rates')
@ApiOperation({ summary: 'List rates' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
status: query['status'],
rateType: query['rateType'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
findAll(@Query() query: ListRatesQueryDto) {
return this.service.findAll(query);
}
@Get('live')

View File

@@ -5,6 +5,7 @@ import {
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
@@ -19,16 +20,8 @@ export class ServiceTypesController {
@Get()
@RuleEngineView('service-types')
@ApiOperation({ summary: 'List service types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
canBeBookedAlone: query['canBeBookedAlone'] !== undefined ? query['canBeBookedAlone'] === 'true' : undefined,
search: query['search'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
sortBy: query['sortBy'],
sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC',
});
findAll(@Query() query: ListServiceTypesQueryDto) {
return this.service.findAll(query);
}
@Post('reorder')

View File

@@ -5,6 +5,7 @@ import {
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto';
import { ShippingLinesService } from '../services/shipping-lines.service';
@@ -17,12 +18,8 @@ export class ShippingLinesController {
@Get()
@RuleEngineView('shipping-lines')
@ApiOperation({ summary: 'List shipping lines' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
findAll(@Query() query: ListRuleEngineQueryDto) {
return this.service.findAll(query);
}
@Get(':id')

View File

@@ -5,6 +5,7 @@ import {
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
import { WeightLimitRulesService } from '../services/weight-limit-rules.service';
@@ -17,13 +18,8 @@ export class WeightLimitRulesController {
@Get()
@RuleEngineView('weight-limit-rules')
@ApiOperation({ summary: 'List weight limit rules' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
tradeDirection: query['tradeDirection'],
containerTypeId: query['containerTypeId'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
findAll(@Query() query: ListWeightLimitRulesQueryDto) {
return this.service.findAll(query);
}
@Get(':id')

View File

@@ -5,6 +5,7 @@ import {
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateYardDto } from '../dto/create-yard.dto';
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
import { MoveOrderDto } from '../dto/move-order.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateYardDto } from '../dto/update-yard.dto';
@@ -19,13 +20,8 @@ export class YardsController {
@Get()
@RuleEngineView('yards')
@ApiOperation({ summary: 'List yards' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
country: query['country'],
page: query['page'] ? parseInt(query['page'], 10) : undefined,
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
});
findAll(@Query() query: ListYardsQueryDto) {
return this.service.findAll(query);
}
@Post('reorder')

View File

@@ -0,0 +1,129 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsBoolean, IsIn, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
/**
* Query-string booleans arrive as strings; implicit conversion is disabled
* app-wide, so coerce explicitly. Mirrors the previous controller behaviour
* (`query['flag'] === 'true'`): only the literal "true" is truthy.
*/
const toOptionalBoolean = ({ value }: TransformFnParams): boolean | undefined =>
value === undefined || value === null || value === '' ? undefined : value === true || value === 'true';
/**
* Shared list query for rule-engine resources. Every rule-engine list endpoint
* returns the standard `PaginatedResponse` envelope (`items` + `meta`) built by
* `common/utils/pagination.util.ts`; `search` is applied server-side against
* each resource's human-readable columns (see the repository `findPaged`).
*/
export class ListRuleEngineQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Filter by active flag.' })
@IsOptional()
@Transform(toOptionalBoolean)
@IsBoolean()
isActive?: boolean;
}
export class ListCargoTypesQueryDto extends ListRuleEngineQueryDto {
@ApiPropertyOptional({ description: 'Filter by director-approval requirement.' })
@IsOptional()
@Transform(toOptionalBoolean)
@IsBoolean()
requiresDirectorApproval?: boolean;
@ApiPropertyOptional({ description: 'Filter by parent cargo-type group.' })
@IsOptional()
@IsUUID()
parentGroupId?: string;
@ApiPropertyOptional({ enum: ['displayOrder', 'cargoTypeName', 'code', 'createdAt'], default: 'displayOrder' })
@IsOptional()
@IsIn(['displayOrder', 'cargoTypeName', 'code', 'createdAt'])
sortBy?: string;
}
export class ListContainerTypesQueryDto extends ListRuleEngineQueryDto {
@ApiPropertyOptional({ enum: ['displayOrder'], default: 'displayOrder' })
@IsOptional()
@IsIn(['displayOrder'])
sortBy?: string;
}
export class ListPriorityConfigsQueryDto extends ListRuleEngineQueryDto {
@ApiPropertyOptional({ enum: ['WAGON', 'CURRENCY', 'CUSTOMS'] })
@IsOptional()
@IsIn(['WAGON', 'CURRENCY', 'CUSTOMS'])
type?: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
@ApiPropertyOptional({ enum: ['displayOrder'], default: 'displayOrder' })
@IsOptional()
@IsIn(['displayOrder'])
sortBy?: string;
}
export class ListServiceTypesQueryDto extends ListRuleEngineQueryDto {
@ApiPropertyOptional({ description: 'Filter by standalone-bookable flag.' })
@IsOptional()
@Transform(toOptionalBoolean)
@IsBoolean()
canBeBookedAlone?: boolean;
@ApiPropertyOptional({ enum: ['displayOrder', 'serviceName', 'code', 'createdAt'], default: 'displayOrder' })
@IsOptional()
@IsIn(['displayOrder', 'serviceName', 'code', 'createdAt'])
sortBy?: string;
}
export class ListYardsQueryDto extends ListRuleEngineQueryDto {
@ApiPropertyOptional({ description: 'Filter by yard country.' })
@IsOptional()
@IsString()
@MaxLength(50)
country?: string;
@ApiPropertyOptional({ enum: ['displayOrder'], default: 'displayOrder' })
@IsOptional()
@IsIn(['displayOrder'])
sortBy?: string;
}
export class ListApprovalRulesQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Filter by approval chain (director vs standard).' })
@IsOptional()
@Transform(toOptionalBoolean)
@IsBoolean()
requiresDirectorApproval?: boolean;
@ApiPropertyOptional({ enum: ['stepOrder'], default: 'stepOrder' })
@IsOptional()
@IsIn(['stepOrder'])
sortBy?: string;
}
export class ListRatesQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Filter by rate status (DRAFT, PENDING_APPROVAL, LIVE...).' })
@IsOptional()
@IsString()
@MaxLength(20)
status?: string;
@ApiPropertyOptional({ description: 'Filter by derived rate type.' })
@IsOptional()
@IsString()
@MaxLength(50)
rateType?: string;
}
export class ListWeightLimitRulesQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ description: 'Filter by container type.' })
@IsOptional()
@IsUUID()
containerTypeId?: string;
@ApiPropertyOptional({ description: 'Filter by trade direction (IMPORT/EXPORT/BOTH).' })
@IsOptional()
@IsString()
@MaxLength(10)
tradeDirection?: string;
}

View File

@@ -1,4 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { FindManyOptions } from 'typeorm';
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ApprovalRule } from '../entities/approval-rule.entity';
export interface IApprovalRulesRepository {
@@ -6,6 +8,7 @@ export interface IApprovalRulesRepository {
findChainForCargo(requiresDirectorApproval: boolean): Promise<ApprovalRule[]>;
findAll(options?: FindManyOptions<ApprovalRule>): Promise<ApprovalRule[]>;
findAndCount(options?: FindManyOptions<ApprovalRule>): Promise<[ApprovalRule[], number]>;
findPaged(query: ListApprovalRulesQueryDto): Promise<PaginatedResponse<ApprovalRule>>;
create(data: Partial<ApprovalRule>): Promise<ApprovalRule>;
update(id: string, data: Partial<ApprovalRule>): Promise<ApprovalRule | null>;
softDelete(id: string): Promise<void>;

View File

@@ -1,4 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { FindManyOptions } from 'typeorm';
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { CargoType } from '../entities/cargo-type.entity';
export interface ICargoTypesRepository {
@@ -6,6 +8,7 @@ export interface ICargoTypesRepository {
findByCode(code: string): Promise<CargoType | null>;
findAll(options?: FindManyOptions<CargoType>): Promise<CargoType[]>;
findAndCount(options?: FindManyOptions<CargoType>): Promise<[CargoType[], number]>;
findPaged(query: ListCargoTypesQueryDto): Promise<PaginatedResponse<CargoType>>;
create(data: Partial<CargoType>): Promise<CargoType>;
update(id: string, data: Partial<CargoType>): Promise<CargoType | null>;
softDelete(id: string): Promise<void>;

View File

@@ -1,4 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { FindManyOptions } from 'typeorm';
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ContainerType } from '../entities/container-type.entity';
export interface IContainerTypesRepository {
@@ -6,6 +8,7 @@ export interface IContainerTypesRepository {
findByCode(code: string): Promise<ContainerType | null>;
findAll(options?: FindManyOptions<ContainerType>): Promise<ContainerType[]>;
findAndCount(options?: FindManyOptions<ContainerType>): Promise<[ContainerType[], number]>;
findPaged(query: ListContainerTypesQueryDto): Promise<PaginatedResponse<ContainerType>>;
create(data: Partial<ContainerType>): Promise<ContainerType>;
update(id: string, data: Partial<ContainerType>): Promise<ContainerType | null>;
softDelete(id: string): Promise<void>;

View File

@@ -1,4 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { FindManyOptions } from 'typeorm';
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
import { PriorityConfig } from '../entities/priority-config.entity';
export const PRIORITY_CONFIGS_REPOSITORY = Symbol('PRIORITY_CONFIGS_REPOSITORY');
@@ -7,6 +9,7 @@ export interface IPriorityConfigsRepository {
findById(id: string): Promise<PriorityConfig | null>;
findAll(options?: FindManyOptions<PriorityConfig>): Promise<PriorityConfig[]>;
findAndCount(options?: FindManyOptions<PriorityConfig>): Promise<[PriorityConfig[], number]>;
findPaged(query: ListPriorityConfigsQueryDto): Promise<PaginatedResponse<PriorityConfig>>;
findAllActive(): Promise<PriorityConfig[]>;
create(data: Partial<PriorityConfig>): Promise<PriorityConfig>;
update(id: string, data: Partial<PriorityConfig>): Promise<PriorityConfig | null>;

View File

@@ -1,4 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { FindManyOptions } from 'typeorm';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import { Rate } from '../entities/rate.entity';
export interface IRatesRepository {
@@ -13,6 +15,7 @@ export interface IRatesRepository {
}): Promise<Rate | null>;
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
findPaged(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>>;
create(data: Partial<Rate>): Promise<Rate>;
update(id: string, data: Partial<Rate>): Promise<Rate | null>;
softDelete(id: string): Promise<void>;

View File

@@ -1,4 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { FindManyOptions } from 'typeorm';
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ServiceType } from '../entities/service-type.entity';
export interface IServiceTypesRepository {
@@ -6,6 +8,7 @@ export interface IServiceTypesRepository {
findByCode(code: string): Promise<ServiceType | null>;
findAll(options?: FindManyOptions<ServiceType>): Promise<ServiceType[]>;
findAndCount(options?: FindManyOptions<ServiceType>): Promise<[ServiceType[], number]>;
findPaged(query: ListServiceTypesQueryDto): Promise<PaginatedResponse<ServiceType>>;
create(data: Partial<ServiceType>): Promise<ServiceType>;
update(id: string, data: Partial<ServiceType>): Promise<ServiceType | null>;
softDelete(id: string): Promise<void>;

View File

@@ -1,4 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { FindManyOptions } from 'typeorm';
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
import { ShippingLine } from '../entities/shipping-line.entity';
export interface IShippingLinesRepository {
@@ -6,6 +8,7 @@ export interface IShippingLinesRepository {
findByCode(code: string): Promise<ShippingLine | null>;
findAll(options?: FindManyOptions<ShippingLine>): Promise<ShippingLine[]>;
findAndCount(options?: FindManyOptions<ShippingLine>): Promise<[ShippingLine[], number]>;
findPaged(query: ListRuleEngineQueryDto): Promise<PaginatedResponse<ShippingLine>>;
create(data: Partial<ShippingLine>): Promise<ShippingLine>;
update(id: string, data: Partial<ShippingLine>): Promise<ShippingLine | null>;
softDelete(id: string): Promise<void>;

View File

@@ -1,4 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { FindManyOptions } from 'typeorm';
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
export interface IWeightLimitRulesRepository {
@@ -14,6 +16,7 @@ export interface IWeightLimitRulesRepository {
): Promise<WeightLimitRule | null>;
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]>;
findAndCount(options?: FindManyOptions<WeightLimitRule>): Promise<[WeightLimitRule[], number]>;
findPaged(query: ListWeightLimitRulesQueryDto): Promise<PaginatedResponse<WeightLimitRule>>;
create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule>;
update(id: string, data: Partial<WeightLimitRule>): Promise<WeightLimitRule | null>;
softDelete(id: string): Promise<void>;

View File

@@ -1,4 +1,6 @@
import { PaginatedResponse } from '@edr/types';
import { FindManyOptions } from 'typeorm';
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
import { Yard } from '../entities/yard.entity';
export interface IYardsRepository {
@@ -6,6 +8,7 @@ export interface IYardsRepository {
findByCode(code: string): Promise<Yard | null>;
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]>;
findAndCount(options?: FindManyOptions<Yard>): Promise<[Yard[], number]>;
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>>;
create(data: Partial<Yard>): Promise<Yard>;
update(id: string, data: Partial<Yard>): Promise<Yard | null>;
softDelete(id: string): Promise<void>;

View File

@@ -1,5 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ApprovalRule } from '../entities/approval-rule.entity';
import { IApprovalRulesRepository } from '../interfaces/approval-rules.repository.interface';
@@ -30,6 +33,31 @@ export class ApprovalRulesRepository implements IApprovalRulesRepository {
return this.repo.findAndCount(options);
}
/**
* Paged list in the standard envelope. Chain grouping is preserved: rows are
* grouped by chain (requiresDirectorApproval) first, then step order.
*/
findPaged(query: ListApprovalRulesQueryDto): Promise<PaginatedResponse<ApprovalRule>> {
const qb = this.repo
.createQueryBuilder('rule')
.orderBy('rule.requiresDirectorApproval', 'ASC')
.addOrderBy('rule.stepOrder', query.sortOrder ?? 'ASC');
if (query.requiresDirectorApproval !== undefined) {
qb.andWhere('rule.requiresDirectorApproval = :requiresDirectorApproval', {
requiresDirectorApproval: query.requiresDirectorApproval,
});
}
if (query.search) {
qb.andWhere(
'(rule.actionLabel ILIKE :search OR rule.requiredRole ILIKE :search OR rule.blocksRole ILIKE :search)',
{ search: `%${query.search}%` },
);
}
return paginateQuery(qb, query);
}
async create(data: Partial<ApprovalRule>): Promise<ApprovalRule> {
const entity = this.repo.create(data);
return this.repo.save(entity);

View File

@@ -1,5 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { CargoType } from '../entities/cargo-type.entity';
import { ICargoTypesRepository } from '../interfaces/cargo-types.repository.interface';
@@ -27,6 +30,33 @@ export class CargoTypesRepository implements ICargoTypesRepository {
return this.repo.findAndCount(options);
}
/** Paged list with server-side search (name/code) in the standard envelope. */
findPaged(query: ListCargoTypesQueryDto): Promise<PaginatedResponse<CargoType>> {
const qb = this.repo
.createQueryBuilder('cargoType')
.leftJoinAndSelect('cargoType.parent', 'parent')
.orderBy(`cargoType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
if (query.isActive !== undefined) {
qb.andWhere('cargoType.isActive = :isActive', { isActive: query.isActive });
}
if (query.requiresDirectorApproval !== undefined) {
qb.andWhere('cargoType.requiresDirectorApproval = :requiresDirectorApproval', {
requiresDirectorApproval: query.requiresDirectorApproval,
});
}
if (query.parentGroupId !== undefined) {
qb.andWhere('cargoType.parentGroupId = :parentGroupId', { parentGroupId: query.parentGroupId });
}
if (query.search) {
qb.andWhere('(cargoType.cargoTypeName ILIKE :search OR cargoType.code ILIKE :search)', {
search: `%${query.search}%`,
});
}
return paginateQuery(qb, query);
}
async create(data: Partial<CargoType>): Promise<CargoType> {
const entity = this.repo.create(data);
return this.repo.save(entity);

View File

@@ -1,5 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ContainerType } from '../entities/container-type.entity';
import { IContainerTypesRepository } from '../interfaces/container-types.repository.interface';
@@ -27,6 +30,24 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
return this.repo.findAndCount(options);
}
/** Paged list with server-side search (label/code) in the standard envelope. */
findPaged(query: ListContainerTypesQueryDto): Promise<PaginatedResponse<ContainerType>> {
const qb = this.repo
.createQueryBuilder('containerType')
.orderBy(`containerType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
if (query.isActive !== undefined) {
qb.andWhere('containerType.isActive = :isActive', { isActive: query.isActive });
}
if (query.search) {
qb.andWhere('(containerType.label ILIKE :search OR containerType.code ILIKE :search)', {
search: `%${query.search}%`,
});
}
return paginateQuery(qb, query);
}
async create(data: Partial<ContainerType>): Promise<ContainerType> {
const entity = this.repo.create(data);
return this.repo.save(entity);

View File

@@ -1,5 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
import { PriorityConfig } from '../entities/priority-config.entity';
import { IPriorityConfigsRepository } from '../interfaces/priority-configs.repository.interface';
@@ -23,6 +26,28 @@ export class PriorityConfigsRepository implements IPriorityConfigsRepository {
return this.repo.findAndCount(options);
}
/** Paged list with server-side search (label/type/currency) in the standard envelope. */
findPaged(query: ListPriorityConfigsQueryDto): Promise<PaginatedResponse<PriorityConfig>> {
const qb = this.repo
.createQueryBuilder('config')
.orderBy(`config.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
if (query.type !== undefined) {
qb.andWhere('config.type = :type', { type: query.type });
}
if (query.isActive !== undefined) {
qb.andWhere('config.isActive = :isActive', { isActive: query.isActive });
}
if (query.search) {
qb.andWhere(
'(config.label ILIKE :search OR config.type ILIKE :search OR config.currency ILIKE :search)',
{ search: `%${query.search}%` },
);
}
return paginateQuery(qb, query);
}
async findAllActive(): Promise<PriorityConfig[]> {
return this.repo.find({
where: { isActive: true },

View File

@@ -1,5 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import { Rate } from '../entities/rate.entity';
import { IRatesRepository } from '../interfaces/rates.repository.interface';
@@ -68,6 +71,28 @@ export class RatesRepository implements IRatesRepository {
return this.repo.findAndCount(options);
}
/** Paged list with server-side search (type/status/unit/currency), newest first. */
findPaged(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>> {
const qb = this.repo
.createQueryBuilder('rate')
.orderBy('rate.createdAt', query.sortOrder ?? 'DESC');
if (query.status) {
qb.andWhere('rate.status = :status', { status: query.status });
}
if (query.rateType) {
qb.andWhere('rate.rateType = :rateType', { rateType: query.rateType });
}
if (query.search) {
qb.andWhere(
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)',
{ search: `%${query.search}%` },
);
}
return paginateQuery(qb, query);
}
async create(data: Partial<Rate>): Promise<Rate> {
const entity = this.repo.create(data);
return this.repo.save(entity);

View File

@@ -1,5 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ServiceType } from '../entities/service-type.entity';
import { IServiceTypesRepository } from '../interfaces/service-types.repository.interface';
@@ -27,6 +30,30 @@ export class ServiceTypesRepository implements IServiceTypesRepository {
return this.repo.findAndCount(options);
}
/** Paged list with server-side search (name/code/description) in the standard envelope. */
findPaged(query: ListServiceTypesQueryDto): Promise<PaginatedResponse<ServiceType>> {
const qb = this.repo
.createQueryBuilder('serviceType')
.orderBy(`serviceType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
if (query.isActive !== undefined) {
qb.andWhere('serviceType.isActive = :isActive', { isActive: query.isActive });
}
if (query.canBeBookedAlone !== undefined) {
qb.andWhere('serviceType.canBeBookedAlone = :canBeBookedAlone', {
canBeBookedAlone: query.canBeBookedAlone,
});
}
if (query.search) {
qb.andWhere(
'(serviceType.serviceName ILIKE :search OR serviceType.code ILIKE :search OR serviceType.description ILIKE :search)',
{ search: `%${query.search}%` },
);
}
return paginateQuery(qb, query);
}
async create(data: Partial<ServiceType>): Promise<ServiceType> {
const entity = this.repo.create(data);
return this.repo.save(entity);

View File

@@ -1,5 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
import { ShippingLine } from '../entities/shipping-line.entity';
import { IShippingLinesRepository } from '../interfaces/shipping-lines.repository.interface';
@@ -27,6 +30,25 @@ export class ShippingLinesRepository implements IShippingLinesRepository {
return this.repo.findAndCount(options);
}
/** Paged list with server-side search (label/code/mappedToCode), ordered by code. */
findPaged(query: ListRuleEngineQueryDto): Promise<PaginatedResponse<ShippingLine>> {
const qb = this.repo
.createQueryBuilder('line')
.orderBy('line.code', query.sortOrder ?? 'ASC');
if (query.isActive !== undefined) {
qb.andWhere('line.isActive = :isActive', { isActive: query.isActive });
}
if (query.search) {
qb.andWhere(
'(line.label ILIKE :search OR line.code ILIKE :search OR line.mappedToCode ILIKE :search)',
{ search: `%${query.search}%` },
);
}
return paginateQuery(qb, query);
}
async create(data: Partial<ShippingLine>): Promise<ShippingLine> {
const entity = this.repo.create(data);
return this.repo.save(entity);

View File

@@ -1,5 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
import { IWeightLimitRulesRepository } from '../interfaces/weight-limit-rules.repository.interface';
@@ -59,6 +62,34 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
return this.repo.findAndCount(options);
}
/**
* Paged list with the container relation loaded, newest first. `search`
* matches the trade direction and the joined container type's label/code.
*/
findPaged(query: ListWeightLimitRulesQueryDto): Promise<PaginatedResponse<WeightLimitRule>> {
const qb = this.repo
.createQueryBuilder('rule')
.leftJoinAndSelect('rule.containerType', 'containerType')
.orderBy('rule.createdAt', query.sortOrder ?? 'DESC');
if (query.containerTypeId) {
qb.andWhere('rule.containerTypeId = :containerTypeId', {
containerTypeId: query.containerTypeId,
});
}
if (query.tradeDirection) {
qb.andWhere('rule.tradeDirection = :tradeDirection', { tradeDirection: query.tradeDirection });
}
if (query.search) {
qb.andWhere(
'(rule.tradeDirection ILIKE :search OR containerType.label ILIKE :search OR containerType.code ILIKE :search)',
{ search: `%${query.search}%` },
);
}
return paginateQuery(qb, query);
}
async create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule> {
const entity = this.repo.create(data);
return this.repo.save(entity);

View File

@@ -1,5 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { Injectable } from '@nestjs/common';
import { DataSource, FindManyOptions, Repository } from 'typeorm';
import { paginateQuery } from '../../../common/utils/pagination.util';
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
import { Yard } from '../entities/yard.entity';
import { IYardsRepository } from '../interfaces/yards.repository.interface';
@@ -27,6 +30,29 @@ export class YardsRepository implements IYardsRepository {
return this.repo.findAndCount(options);
}
/** Paged list with server-side search (label/code/country) in the standard envelope. */
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>> {
const qb = this.repo
.createQueryBuilder('yard')
.orderBy(`yard.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC')
.addOrderBy('yard.label', 'ASC');
if (query.isActive !== undefined) {
qb.andWhere('yard.isActive = :isActive', { isActive: query.isActive });
}
if (query.country) {
qb.andWhere('yard.country = :country', { country: query.country });
}
if (query.search) {
qb.andWhere(
'(yard.label ILIKE :search OR yard.code ILIKE :search OR yard.country ILIKE :search)',
{ search: `%${query.search}%` },
);
}
return paginateQuery(qb, query);
}
async create(data: Partial<Yard>): Promise<Yard> {
const entity = this.repo.create(data);
return this.repo.save(entity);

View File

@@ -1,5 +1,7 @@
import { PaginatedResponse } from '@edr/types';
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
import { ApprovalRule } from '../entities/approval-rule.entity';
@@ -17,26 +19,9 @@ export class ApprovalRulesService {
private readonly displayOrder: DisplayOrderService,
) {}
/** List approval rules. */
async findAll(filter: {
requiresDirectorApproval?: boolean;
page?: number;
pageSize?: number;
}): Promise<{ data: ApprovalRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.requiresDirectorApproval !== undefined) {
where.requiresDirectorApproval = filter.requiresDirectorApproval;
}
const [data, total] = await this.repository.findAndCount({
where,
order: { requiresDirectorApproval: 'ASC', stepOrder: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
/** List approval rules — standard paginated envelope with server-side search. */
async findAll(query: ListApprovalRulesQueryDto): Promise<PaginatedResponse<ApprovalRule>> {
return this.repository.findPaged(query);
}
/** Get approval chain for a cargo type flag. */

View File

@@ -1,7 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { ILike } 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';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
import { CargoType } from '../entities/cargo-type.entity';
@@ -19,33 +20,9 @@ export class CargoTypesService {
private readonly displayOrder: DisplayOrderService,
) {}
/** List cargo types with pagination and optional filtering. */
async findAll(filter: {
isActive?: boolean;
requiresDirectorApproval?: boolean;
parentGroupId?: string;
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}): Promise<{ data: CargoType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.requiresDirectorApproval !== undefined) where.requiresDirectorApproval = filter.requiresDirectorApproval;
if (filter.parentGroupId !== undefined) where.parentGroupId = filter.parentGroupId;
if (filter.search) where.cargoTypeName = ILike(`%${filter.search}%`);
const [data, total] = await this.repository.findAndCount({
where,
order: { [filter.sortBy ?? 'displayOrder']: filter.sortOrder ?? 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
relations: { parent: true },
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
/** List cargo types — standard paginated envelope with server-side search. */
async findAll(query: ListCargoTypesQueryDto): Promise<PaginatedResponse<CargoType>> {
return this.repository.findPaged(query);
}
/** Get a single cargo type by ID. */

View File

@@ -1,6 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
import { ContainerType } from '../entities/container-type.entity';
@@ -18,24 +20,9 @@ export class ContainerTypesService {
private readonly displayOrder: DisplayOrderService,
) {}
/** List container types with pagination. */
async findAll(filter: {
isActive?: boolean;
page?: number;
pageSize?: number;
}): Promise<{ data: ContainerType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
const [data, total] = await this.repository.findAndCount({
where,
order: { displayOrder: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
/** List container types — standard paginated envelope with server-side search. */
async findAll(query: ListContainerTypesQueryDto): Promise<PaginatedResponse<ContainerType>> {
return this.repository.findPaged(query);
}
/** Get a single container type by ID. */

View File

@@ -1,5 +1,7 @@
import { PaginatedResponse } from '@edr/types';
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
import { PriorityConfig } from '../entities/priority-config.entity';
import {
@@ -16,25 +18,9 @@ export class PriorityConfigsService {
private readonly displayOrder: DisplayOrderService,
) {}
async findAll(filter: {
type?: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
isActive?: boolean;
page?: number;
pageSize?: number;
}): Promise<{ data: PriorityConfig[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.type !== undefined) where.type = filter.type;
if (filter.isActive !== undefined) where.isActive = filter.isActive;
const [data, total] = await this.repository.findAndCount({
where,
order: { displayOrder: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
/** List priority configs — standard paginated envelope with server-side search. */
async findAll(query: ListPriorityConfigsQueryDto): Promise<PaginatedResponse<PriorityConfig>> {
return this.repository.findPaged(query);
}
async findById(id: string): Promise<PriorityConfig> {

View File

@@ -5,7 +5,9 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PaginatedResponse } from '@edr/types';
import { CreateRateDto } from '../dto/create-rate.dto';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
import { Rate } from '../entities/rate.entity';
import { deriveRateType } from '../entities/rate-type.util';
@@ -19,26 +21,9 @@ export class RatesService {
private readonly repository: IRatesRepository,
) {}
/** List rates with pagination. */
async findAll(filter: {
status?: string;
rateType?: string;
page?: number;
pageSize?: number;
}): Promise<{ data: Rate[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.status) where.status = filter.status;
if (filter.rateType) where.rateType = filter.rateType;
const [data, total] = await this.repository.findAndCount({
where,
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) } };
/** List rates — standard paginated envelope with server-side search. */
async findAll(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>> {
return this.repository.findPaged(query);
}
/** Return all currently LIVE rates. */

View File

@@ -1,7 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { ILike } from 'typeorm';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
import { ServiceType } from '../entities/service-type.entity';
@@ -19,30 +20,9 @@ export class ServiceTypesService {
private readonly displayOrder: DisplayOrderService,
) {}
/** List service types with pagination and optional filtering. */
async findAll(filter: {
isActive?: boolean;
canBeBookedAlone?: boolean;
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}): Promise<{ data: ServiceType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.canBeBookedAlone !== undefined) where.canBeBookedAlone = filter.canBeBookedAlone;
if (filter.search) where.serviceName = ILike(`%${filter.search}%`);
const [data, total] = await this.repository.findAndCount({
where,
order: { [filter.sortBy ?? 'displayOrder']: filter.sortOrder ?? 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
/** List service types — standard paginated envelope with server-side search. */
async findAll(query: ListServiceTypesQueryDto): Promise<PaginatedResponse<ServiceType>> {
return this.repository.findPaged(query);
}
/** Get a single service type by ID. */

View File

@@ -1,5 +1,7 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto';
import { ShippingLine } from '../entities/shipping-line.entity';
import {
@@ -14,24 +16,9 @@ export class ShippingLinesService {
private readonly repository: IShippingLinesRepository,
) {}
/** List shipping lines with pagination. */
async findAll(filter: {
isActive?: boolean;
page?: number;
pageSize?: number;
}): Promise<{ data: ShippingLine[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
const [data, total] = await this.repository.findAndCount({
where,
order: { code: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
/** List shipping lines — standard paginated envelope with server-side search. */
async findAll(query: ListRuleEngineQueryDto): Promise<PaginatedResponse<ShippingLine>> {
return this.repository.findPaged(query);
}
/** Get a shipping line by ID. */

View File

@@ -5,7 +5,9 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PaginatedResponse } from '@edr/types';
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
import {
@@ -20,27 +22,9 @@ export class WeightLimitRulesService {
private readonly repository: IWeightLimitRulesRepository,
) {}
/** List weight limit rules with pagination. */
async findAll(filter: {
containerTypeId?: string;
tradeDirection?: string;
page?: number;
pageSize?: number;
}): Promise<{ data: WeightLimitRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.containerTypeId) where.containerTypeId = filter.containerTypeId;
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
const [data, total] = await this.repository.findAndCount({
where,
relations: { containerType: true },
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
/** List weight limit rules — standard paginated envelope with server-side search. */
async findAll(query: ListWeightLimitRulesQueryDto): Promise<PaginatedResponse<WeightLimitRule>> {
return this.repository.findPaged(query);
}
/** Get a single weight limit rule by ID. */

View File

@@ -1,6 +1,8 @@
import { PaginatedResponse } from '@edr/types';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { generateCode } from '../../../common/utils/generate-code.util';
import { CreateYardDto } from '../dto/create-yard.dto';
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
import { ReorderItemsDto } from '../dto/reorder-items.dto';
import { UpdateYardDto } from '../dto/update-yard.dto';
import { Yard } from '../entities/yard.entity';
@@ -15,26 +17,9 @@ export class YardsService {
private readonly displayOrder: DisplayOrderService,
) {}
/** List yards with pagination. */
async findAll(filter: {
isActive?: boolean;
country?: string;
page?: number;
pageSize?: number;
}): Promise<{ data: Yard[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 10;
const where: Record<string, unknown> = {};
if (filter.isActive !== undefined) where.isActive = filter.isActive;
if (filter.country) where.country = filter.country;
const [data, total] = await this.repository.findAndCount({
where,
order: { displayOrder: 'ASC', label: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
/** List yards — standard paginated envelope with server-side search. */
async findAll(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>> {
return this.repository.findPaged(query);
}
/** Get a yard by ID. */

View File

@@ -1,5 +1,8 @@
import { BaseEntity } from '@edr/api-common';
import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
import {
TrainScheduleStatus as TrainScheduleStatusEnum,
WagonAllocationSnapshot,
} from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
@@ -146,6 +149,14 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'rule_export_booking_lead_hours', type: 'int', nullable: true })
ruleExportBookingLeadHours?: number | null;
// Frozen wagon plan captured once when the schedule leaves the editable
// DRAFT/SCHEDULED phase (dispatch / arrive / cancel). Admin views of a
// non-editable schedule read THIS instead of the live wagon↔slot joins, so the
// historical allocation survives the same physical wagons being re-pinned onto
// later trains. NULL while DRAFT/SCHEDULED (read live) and on legacy rows.
@Column({ name: 'wagon_allocation_snapshot', type: 'jsonb', nullable: true })
wagonAllocationSnapshot?: WagonAllocationSnapshot | null;
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
scheduleBookings?: TrainScheduleBooking[];
}

View File

@@ -53,6 +53,18 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
});
}
/**
* Light fetch for human-facing labels (notifications): reference, train
* number, departure and the two station names — none of the composition
* graph {@link findByIdWithFullGraph} drags in.
*/
findByIdWithStations(id: string): Promise<TrainSchedule | null> {
return this.repository.findOne({
where: { id },
relations: { originStation: true, destinationStation: true },
});
}
async updateStatus(
id: string,
status: TrainScheduleStatus,

View File

@@ -0,0 +1,140 @@
import { ConflictException } from '@nestjs/common';
import { BookingBatchService } from './booking-batch.service';
import { Booking } from '../bookings/entities/booking.entity';
/**
* Export whole-booking single-train gate: an export booking never splits — it
* rides one train whole or is rejected. The report must try every fillable
* train on the day (first full → use the second), and when none fits, say how
* much space is still bookable so the customer knows what he CAN book.
*/
describe('BookingBatchService — exportSpaceReport (whole-booking, single train)', () => {
const DAY = new Date('2026-07-20T10:00:00Z');
const schedule = (id: string) => ({
id,
status: 'SCHEDULED',
direction: 'EXPORT',
scheduledDepartureDate: DAY,
bookingWindowStatus: 'OPEN',
windowPhase: null, // legacy gate: OPEN alone makes it fillable
});
const fullGraph = (id: string) => ({
id,
originStationId: 'yard-a',
destinationStationId: 'yard-b',
routeId: null, // legacy two-stop pseudo-route — no milestone query
scheduleBookings: [],
trainSet: {
locomotive: {
maxPullWeightTons: 500,
maxTrainLengthMeters: 140,
overageToleranceTons: 0,
overageToleranceMeters: 0,
},
},
});
const exportBooking = (cargoTons: number) =>
({
id: 'bk-exp',
freightType: 'BULK',
tradeDirection: 'EXPORT',
scheduledDate: DAY,
originYardId: 'yard-a',
destinationYardId: 'yard-b',
cargoTotalWeightVgm: cargoTons,
bookingContainers: [],
}) as unknown as Booking;
// A reserved bulk booking heavy enough to exhaust the 500t pull budget.
const heavyReserved = {
id: 'bk-heavy',
freightType: 'BULK',
originYardId: 'yard-a',
destinationYardId: 'yard-b',
cargoTotalWeightVgm: 476,
bookingContainers: [],
} as unknown as Booking;
let service: BookingBatchService;
let trainSchedulesRepository: {
findAll: jest.Mock;
findByIdWithFullGraph: jest.Mock;
findById: jest.Mock;
};
let bookingsRepository: { findReservedForSchedule: jest.Mock };
beforeEach(() => {
trainSchedulesRepository = {
findAll: jest.fn().mockResolvedValue([]),
findByIdWithFullGraph: jest
.fn()
.mockImplementation(async (id: string) => fullGraph(id)),
findById: jest.fn(),
};
bookingsRepository = { findReservedForSchedule: jest.fn().mockResolvedValue([]) };
// WagonType.find() → [] so representative default dims apply (bulk 60t
// payload / 23.4t tare / 14m); RouteMilestone is never queried (routeId null).
const genericRepo = { find: jest.fn().mockResolvedValue([]) };
const dataSource = { getRepository: jest.fn().mockReturnValue(genericRepo) };
service = new BookingBatchService(
dataSource as never,
bookingsRepository as never,
trainSchedulesRepository as never,
{} as never, // trainScheduleBookingsRepository
{} as never, // notifier
{} as never, // scheduler
{} as never, // trainSchedulingService
{} as never, // billing
{} as never, // bookingWindowGateway
{} as never, // pricingService
);
});
it('uses the other train when the first one is full', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([
schedule('train-1'),
schedule('train-2'),
]);
bookingsRepository.findReservedForSchedule.mockImplementation(
async (id: string) => (id === 'train-1' ? [heavyReserved] : []),
);
const report = await service.exportSpaceReport(exportBooking(60));
expect(report.scheduleId).toBe('train-2');
});
it('rejects a booking no single train fits and reports the bookable space', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([schedule('train-1')]);
const report = await service.exportSpaceReport(exportBooking(900));
expect(report.scheduleId).toBeNull();
expect(report.bestAvailable).not.toBeNull();
expect(report.bestAvailable!.cargoTons).toBeGreaterThan(0);
expect(report.bestAvailable!.cargoTons).toBeLessThan(900);
expect(report.fullMessage).toMatch(/largest remaining space is about .* tons/);
expect(report.fullMessage).toMatch(/single train whole/);
await expect(service.pickExportSchedule(exportBooking(900))).rejects.toThrow(
ConflictException,
);
});
it('says no train is accepting bookings when the day has none', async () => {
trainSchedulesRepository.findAll.mockResolvedValue([]);
const report = await service.exportSpaceReport(exportBooking(60));
expect(report.scheduleId).toBeNull();
expect(report.fullMessage).toBe(
'No export train is accepting bookings for this day',
);
});
});

View File

@@ -38,8 +38,16 @@ import {
BATCH_BOARD_STATUSES,
BatchBoardQueryDto,
} from './dto/batch-board-query.dto';
import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types";
import {
Freight,
PaginatedResponse,
TrainScheduleStatus as TrainScheduleStatusEnum,
} from "@edr/types";
import { BillingService } from "../billing/billing.service";
import {
buildPaginationMeta,
normalizePagination,
} from '../../common/utils/pagination.util';
import {
@@ -83,6 +91,21 @@ export type { Capacity } from './corridor-capacity.util';
*/
type TrainLimits = { base: Capacity; tolerance: OverageTolerance };
/**
* Result of the export whole-booking single-train space check. `scheduleId`
* is the earliest fillable train that carries the whole booking, or null when
* none can — then `bestAvailable` reports the largest single-train leftover
* in the booking's own units and `fullMessage` is the customer-facing copy.
*/
export interface ExportSpaceReport {
scheduleId: string | null;
trainsForDay: boolean;
corridorMatched: boolean;
need: Capacity;
bestAvailable: { wagons: number; cargoTons: number } | null;
fullMessage: string | null;
}
/** A day-level pool key: all trains on this route departing on this EAT day. */
interface RouteDayGroup {
originYardId: string;
@@ -241,15 +264,10 @@ export interface BatchBoardSchedule {
bookings: BatchBoardBooking[];
}
/** Paginated batch-board list. `items` (not `data`) — the API response wrapper
* already uses `data`, and the frontend's unwrap() strips one `data` level. */
export interface BatchBoardListResponse {
items: BatchBoardSchedule[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/** Paginated batch-board list in the shared `{items, meta}` envelope — the API
* response wrapper already uses `data`, and the frontend's unwrap() strips one
* `data` level. */
export type BatchBoardListResponse = PaginatedResponse<BatchBoardSchedule>;
/**
* Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool
@@ -514,6 +532,7 @@ export class BookingBatchService implements OnModuleInit {
`Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`,
);
}
this.notifyBoardChanged(booking.trainScheduleId, "booking_paid_allocated");
}
/** Customer paid — delegate to ensurePaidBookingAllocated. */
@@ -542,12 +561,18 @@ export class BookingBatchService implements OnModuleInit {
// ---- export FCFS -----------------------------------------------------------
/**
* Export is first-come-first-serve: no window cycle, no priority, no batch.
* Pick the earliest open export train on the booking's corridor/day that still
* fits the booking. Throws ConflictException when every train is full — the
* staff accept fails and no more export bookings are taken.
* Whole-booking single-train space report for an EXPORT booking. Export
* bookings never split — the entire booking must ride ONE train, so the
* report scans every fillable export train on the booking's corridor/day
* (earliest first) for one whose remaining budget fits the whole need. When
* none fits, `bestAvailable` carries the largest single-train leftover
* converted into the booking's own units (base caps, no overage tolerance)
* so the customer can be told exactly how much he COULD book on that day.
*/
async pickExportSchedule(booking: Booking, need?: Capacity): Promise<string> {
async exportSpaceReport(
booking: Booking,
need?: Capacity,
): Promise<ExportSpaceReport> {
if (!booking.scheduledDate) {
throw new BadRequestException('Booking has no scheduled date');
}
@@ -573,15 +598,19 @@ export class BookingBatchService implements OnModuleInit {
(a, b) =>
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
);
if (!candidates.length) {
throw new ConflictException(
'No export train is accepting bookings for this day',
);
}
const wagonDims = await this.loadWagonDims();
const required = need ?? this.needFor(booking, wagonDims);
let corridorMatched = false;
const dims = this.dimsFor(booking, wagonDims);
const report: ExportSpaceReport = {
scheduleId: null,
trainsForDay: candidates.length > 0,
corridorMatched: false,
need: required,
bestAvailable: null,
fullMessage: null,
};
for (const candidate of candidates) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id,
@@ -592,15 +621,101 @@ export class BookingBatchService implements OnModuleInit {
const budget = await this.remainingBudget(schedule, limits, wagonDims);
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
if (!leg) continue; // this train's route doesn't carry the booking's leg
corridorMatched = true;
if (budget.fits(required, leg)) return schedule.id;
report.corridorMatched = true;
if (budget.fits(required, leg)) {
// Earliest fitting train wins — no need to keep sizing leftovers.
report.scheduleId = schedule.id;
return report;
}
const available = this.bookableWithin(budget.remainingFor(leg), dims);
if (
!report.bestAvailable ||
available.cargoTons > report.bestAvailable.cargoTons ||
(available.cargoTons === report.bestAvailable.cargoTons &&
available.wagons > report.bestAvailable.wagons)
) {
report.bestAvailable = available;
}
}
if (!corridorMatched) {
throw new ConflictException(
'No export train is accepting bookings for this day',
report.fullMessage = this.exportFullMessage(booking, report);
return report;
}
/**
* Largest booking (in the requester's own wagon-type units) that a single
* train's leftover base capacity could still admit: bounded by free wagon
* slots, free train length, and the locomotive's remaining pull weight
* (gross — each wagon's tare eats into it before any cargo does).
*/
private bookableWithin(
remaining: Capacity,
dims: PerWagonDims,
): { wagons: number; cargoTons: number } {
const byLength =
dims.lengthMeters > 0
? Math.floor(Math.max(0, remaining.lengthMeters) / dims.lengthMeters)
: Math.floor(Math.max(0, remaining.wagons));
const maxWagons = Math.max(
0,
Math.min(Math.floor(Math.max(0, remaining.wagons)), byLength),
);
let bestTons = 0;
let usableWagons = 0;
for (let w = 1; w <= maxWagons; w++) {
if (w * dims.tareWeightTons > remaining.weightTons) break;
usableWagons = w;
const tons = Math.min(
w * dims.capacityTons,
remaining.weightTons - w * dims.tareWeightTons,
);
if (tons > bestTons) bestTons = tons;
}
return {
wagons: usableWagons,
cargoTons: Math.max(0, Math.floor(bestTons * 1000) / 1000),
};
}
/** Customer-facing "train is full" copy carrying the bookable leftover. */
private exportFullMessage(booking: Booking, report: ExportSpaceReport): string {
if (!report.trainsForDay || !report.corridorMatched) {
return 'No export train is accepting bookings for this day';
}
const best = report.bestAvailable;
const base =
'Not enough train space — an export booking must ride a single train whole, ' +
'and no open train on this day can carry it. ';
if (!best || best.wagons <= 0) {
return base + 'No capacity is left on this day — pick another shipment day.';
}
if (booking.freightType === 'BULK') {
return (
base +
`The largest remaining space is about ${best.cargoTons} tons ` +
`(${best.wagons} wagon${best.wagons === 1 ? '' : 's'}) — book up to that amount or pick another day.`
);
}
throw new ConflictException('Train is full — no export capacity left for this day');
return (
base +
`The largest remaining space is ${best.wagons} wagon${best.wagons === 1 ? '' : 's'} ` +
`(up to ${best.wagons * 2} × 20ft or ${best.wagons} × 40ft, weight permitting) — ` +
'reduce the booking or pick another day.'
);
}
/**
* Export is first-come-first-serve: no window cycle, no priority, no batch.
* Pick the earliest open export train on the booking's corridor/day that still
* fits the booking. Throws ConflictException when every train is full — the
* staff accept fails and no more export bookings are taken.
*/
async pickExportSchedule(booking: Booking, need?: Capacity): Promise<string> {
const report = await this.exportSpaceReport(booking, need);
if (report.scheduleId) return report.scheduleId;
throw new ConflictException(
report.fullMessage ?? 'Train is full — no export capacity left for this day',
);
}
/**
@@ -652,6 +767,7 @@ export class BookingBatchService implements OnModuleInit {
if (schedule && (await this.isTrainFull(schedule))) {
await this.setWindow(scheduleId, 'FULL');
}
this.notifyBoardChanged(scheduleId, 'export_booking_accepted');
}
/** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
@@ -664,6 +780,9 @@ export class BookingBatchService implements OnModuleInit {
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
);
}
if (unlinked.length > 0) {
this.notifyBoardChanged(scheduleId, "paid_reconciled");
}
}
// ---- legacy fill entry point ----------------------------------------------
@@ -700,8 +819,11 @@ export class BookingBatchService implements OnModuleInit {
async getBatchBoard(
query: BatchBoardQueryDto = {},
): Promise<BatchBoardListResponse> {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 12;
// Board cards are heavy (per-schedule booking summaries), so the default
// page is smaller than the toolkit-wide 20.
const { page, pageSize, skip, take } = normalizePagination(query, {
defaultPageSize: 12,
});
// Status filter: any subset of the lifecycle. Omitted = all statuses, so
// arrived / cancelled / dispatched schedules stay visible as history.
@@ -763,8 +885,8 @@ export class BookingBatchService implements OnModuleInit {
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
},
order: { [sortBy]: sortOrder } as never,
skip: (page - 1) * pageSize,
take: pageSize,
skip,
take,
});
const wagonDims = await this.loadWagonDims();
@@ -800,13 +922,7 @@ export class BookingBatchService implements OnModuleInit {
board.push(this.buildScheduleSummary(s, items));
}
return {
items: board,
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
};
return { items: board, meta: buildPaginationMeta(total, page, pageSize) };
}
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
@@ -1048,7 +1164,12 @@ export class BookingBatchService implements OnModuleInit {
/** Run wagon-level allocation for all eligible linked bookings on a schedule. */
async runWagonAllocation(scheduleId: string) {
return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
const result =
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
if (result.assignedBookingIds.length > 0) {
this.notifyBoardChanged(scheduleId, "wagon_allocation_run");
}
return result;
}
/**
@@ -1070,9 +1191,11 @@ export class BookingBatchService implements OnModuleInit {
maxWagons: number | null,
): BatchBoardSchedule["capacity"] {
const allocated = items.filter((i) => i.state === "ALLOCATED");
const committed = items.filter(
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
);
// Every booking still targeting this train holds gross weight — including
// PAID ones waiting for wagon allocation (WAITING) and post-dispatch
// catch-all states. Counting only ALLOCATED + SELECTED_FOR_BATCH zeroed the
// board's weight the moment customers paid. Only EXPIRED released its hold.
const committed = items.filter((i) => i.state !== "EXPIRED");
const caps = loco
? trainHardCaps({
maxPullWeightTons: Number(loco.maxPullWeightTons),
@@ -1218,6 +1341,7 @@ export class BookingBatchService implements OnModuleInit {
this.resortPoolByPriority(pool);
const units = this.groupConsolidatedPool(pool);
let armed = false;
let preempted = false;
let reservedThisPass = 0;
let commercialReserved = 0;
@@ -1256,6 +1380,7 @@ export class BookingBatchService implements OnModuleInit {
budget,
wagonDims,
);
preempted = true;
if (!freed) continue; // still doesn't fit even after preempt
} else {
// Doesn't fit whole. A split-eligible import booking is offered the part
@@ -1304,6 +1429,10 @@ export class BookingBatchService implements OnModuleInit {
);
if (budget.isExhausted(minPerWagon)) await this.setWindow(scheduleId, "FULL");
if (armed) this.armSettle(scheduleId);
// One push per fill pass (never per booking) — only when rows changed.
if (reservedThisPass > 0 || armed || preempted) {
this.notifyBoardChanged(scheduleId, "batch_fill");
}
void this.triggerWagonAllocation(scheduleId);
return commercialReserved;
}
@@ -1404,8 +1533,13 @@ export class BookingBatchService implements OnModuleInit {
const wagonDims = await this.loadWagonDims();
// Live per-schedule corridor budget + arm flag, in departure order.
const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = [];
// Live per-schedule corridor budget + arm/changed flags, in departure order.
const trains: Array<{
id: string;
budget: CorridorBudget;
armed: boolean;
changed: boolean;
}> = [];
for (const id of scheduleIds) {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
@@ -1419,7 +1553,7 @@ export class BookingBatchService implements OnModuleInit {
const limits = await this.capacityLimits(locomotive);
await this.syncScheduleMaxWagons(schedule, locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
trains.push({ id, budget, armed: false });
trains.push({ id, budget, armed: false, changed: false });
}
if (trains.length === 0) return { scheduleIds, commercialReserved: 0 };
@@ -1492,6 +1626,9 @@ export class BookingBatchService implements OnModuleInit {
t.budget,
wagonDims,
);
// Preempt may have displaced (expired) victims even when the need
// still doesn't fit — the board must refresh either way.
t.changed = true;
if (freed) {
target = t;
break;
@@ -1536,6 +1673,7 @@ export class BookingBatchService implements OnModuleInit {
commercialReserved += 1;
}
target.budget.subtract(need, legOn(target)!);
target.changed = true;
reservedThisPass += 1;
} catch (err) {
this.logger.error(
@@ -1553,6 +1691,10 @@ export class BookingBatchService implements OnModuleInit {
for (const t of trains) {
if (t.budget.isExhausted(minPerWagon)) await this.setWindow(t.id, "FULL");
if (t.armed) this.armSettle(t.id);
// One push per touched train per pass (never per booking). `armed` covers
// commercial reserves + partial offers; `changed` covers gov allocations
// and preemption.
if (t.armed || t.changed) this.notifyBoardChanged(t.id, "batch_fill");
void this.triggerWagonAllocation(t.id);
}
@@ -1799,6 +1941,10 @@ export class BookingBatchService implements OnModuleInit {
`— payment phase extended for them`,
);
}
// Emitted here (not in settleDueReservations/settleBatch, which both wrap
// this) so one settle produces one push, after every allocation/expiry/
// top-up extension for this schedule has been persisted.
this.notifyBoardChanged(scheduleId, "reservations_settled");
return true;
}
@@ -1851,6 +1997,21 @@ export class BookingBatchService implements OnModuleInit {
);
}
/**
* Announce that a schedule's batch-board data changed so open boards refetch.
* Called AFTER the state change is persisted; a push failure only logs — it
* must never break the business transaction that triggered it.
*/
private notifyBoardChanged(scheduleId: string, reason: string): void {
try {
this.bookingWindowGateway.emitBatchChanged(scheduleId, reason);
} catch (err) {
this.logger.warn(
`Batch-board push (${reason}) failed for ${scheduleId}: ${(err as Error).message}`,
);
}
}
// ---- staff override actions ----------------------------------------------
/** Staff "mark paid" override → set PAID and allocate immediately (don't wait for settle). */
@@ -1876,6 +2037,7 @@ export class BookingBatchService implements OnModuleInit {
await this.setWindow(booking.trainScheduleId, "FULL");
}
void this.triggerWagonAllocation(booking.trainScheduleId!);
this.notifyBoardChanged(booking.trainScheduleId, "booking_marked_paid");
}
/**
@@ -1910,6 +2072,7 @@ export class BookingBatchService implements OnModuleInit {
);
}
const sourceScheduleId = booking.trainScheduleId ?? null;
await this.dataSource.transaction(async (manager) => {
if (booking.trainScheduleId) {
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
@@ -1932,6 +2095,11 @@ export class BookingBatchService implements OnModuleInit {
selectedForBatchAt: null,
} as never);
});
// Both boards changed: the booking left the source train and joined the target.
if (sourceScheduleId && sourceScheduleId !== newScheduleId) {
this.notifyBoardChanged(sourceScheduleId, "booking_moved");
}
this.notifyBoardChanged(newScheduleId, "booking_moved");
}
/** Staff "expire" override → free a reservation now (booking becomes EXPIRED). */
@@ -1949,6 +2117,8 @@ export class BookingBatchService implements OnModuleInit {
if (topUpReserved > 0) {
await this.extendPaymentPhaseForTopUp(freedScheduleId);
}
// After the top-up + phase extension so one push carries the final state.
this.notifyBoardChanged(freedScheduleId, "reservation_expired");
}
}
@@ -1988,10 +2158,12 @@ export class BookingBatchService implements OnModuleInit {
.update(booking.id, { trainScheduleId: scheduleId });
booking.trainScheduleId = scheduleId;
await this.allocate(scheduleId, booking, 'gov');
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
return;
}
await this.reserve(booking, scheduleId);
this.armSettle(scheduleId);
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
}
// ---- mutations ------------------------------------------------------------
@@ -2088,7 +2260,7 @@ export class BookingBatchService implements OnModuleInit {
this.logger.log(
`[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`,
);
this.notifier.secured(booking, reason);
this.notifier.secured(booking, reason, scheduleId);
void this.triggerWagonAllocation(scheduleId);
void this.markWagonAllocatedMilestone(booking.id);
// Customer tracking: freight payment settled (commercial pay-window path).
@@ -2254,7 +2426,12 @@ export class BookingBatchService implements OnModuleInit {
day,
);
const leftovers = pool.filter((b) => !b.isGovernment);
// Capture pinned schedules BEFORE expire() clears trainScheduleId, so each
// touched board gets exactly one push at the end of the sweep.
const touchedScheduleIds = new Set<string>();
if (leftovers.length) touchedScheduleIds.add(scheduleId);
for (const booking of leftovers) {
if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId);
await this.expire(booking, "no-capacity");
}
if (leftovers.length) {
@@ -2263,6 +2440,9 @@ export class BookingBatchService implements OnModuleInit {
`expired ${leftovers.length} waiting booking(s)`,
);
}
for (const id of touchedScheduleIds) {
this.notifyBoardChanged(id, "day_pool_expired");
}
return leftovers.length;
}
@@ -2325,7 +2505,12 @@ export class BookingBatchService implements OnModuleInit {
`on ${group.originYardId}->${group.destinationYardId} ${group.day}`,
);
}
// Only bookings pinned to a train show on a board — collect their schedules
// and push once per schedule after the sweep (most unaccepted rows are
// unpinned under day-level pooling, so this usually emits nothing).
const touchedScheduleIds = new Set<string>();
for (const booking of unaccepted) {
if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId);
await this.bookingsRepository.update(booking.id, {
status: "EXPIRED",
schedulingStatus: "ELIGIBLE",
@@ -2342,6 +2527,9 @@ export class BookingBatchService implements OnModuleInit {
`[BATCH] EXPIRED (unaccepted) ${booking.reference}:${booking.id} at doc-review end`,
);
}
for (const id of touchedScheduleIds) {
this.notifyBoardChanged(id, "unaccepted_expired");
}
}
/**

View File

@@ -9,6 +9,7 @@ import {
import { Booking } from '../bookings/entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { BATCH_TIMEZONE } from './booking-batch.constants';
@Injectable()
@@ -18,8 +19,37 @@ export class BookingNotifierService {
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
private readonly trainSchedules: TrainSchedulesRepository,
) {}
/**
* Human-readable description of a train schedule for customer messages:
* reference (or train number) + route + departure date. Never leaks a UUID —
* falls back to a generic phrase when the schedule can't be loaded.
*/
private async scheduleLabel(scheduleId?: string | null): Promise<string> {
const fallback = 'your selected train';
if (!scheduleId) return fallback;
try {
const s = await this.trainSchedules.findByIdWithStations(scheduleId);
if (!s) return fallback;
const ref = s.reference ?? s.trainNumber ?? null;
const route =
s.originStation?.label && s.destinationStation?.label
? ` (${s.originStation.label}${s.destinationStation.label})`
: '';
const departure = s.scheduledDepartureDate
? `, departing ${new Date(s.scheduledDepartureDate).toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE })}`
: '';
return ref ? `train ${ref}${route}${departure}` : `${fallback}${route}${departure}`;
} catch (err) {
this.logger.warn(
`scheduleLabel(${scheduleId}) failed: ${(err as Error).message}`,
);
return fallback;
}
}
private ref(b: Booking): string {
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
}
@@ -127,12 +157,15 @@ export class BookingNotifierService {
});
}
secured(b: Booking, reason: 'paid' | 'gov'): void {
const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${
reason === 'gov' ? ' (government)' : ''
}.`;
void this.notifyContact(b, msg, 'ALLOCATED');
this.inApp(b, 'Wagon allocated', msg);
secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void {
void (async () => {
const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId);
const msg = `Booking ${b.reference ?? b.id} allocated on ${label}${
reason === 'gov' ? ' (government)' : ''
}.`;
void this.notifyContact(b, msg, 'ALLOCATED');
this.inApp(b, 'Wagon allocated', msg);
})();
}
expired(b: Booking): void {

View File

@@ -6,11 +6,12 @@ import { Contract } from '../contracts/entities/contract.entity';
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
/**
* applySplit promotion behaviour: a ONE_TIME contract must be flipped to GENERAL
* (both the parent contract row and the booking's denormalized copy) so the split
* remainder can be rebooked. A GENERAL booking is left untouched.
* applySplit split-marking behaviour: the reduced booking is flagged is_split
* and keeps a pre_split_quantities snapshot (the remainder ledger for ONE_TIME
* contracts). The contract kind is NEVER changed — a ONE_TIME contract stays
* ONE_TIME through the split chain.
*/
describe('BookingSplitService — applySplit ONE_TIME promotion', () => {
describe('BookingSplitService — applySplit split marking', () => {
const bookingId = 'bk-1';
const contractId = 'ct-1';
const offerId = 'of-1';
@@ -34,6 +35,7 @@ describe('BookingSplitService — applySplit ONE_TIME promotion', () => {
id: bookingId,
contractId,
contractKind: bookingContractKind,
cargoTotalWeightVgm: 50,
}),
find: jest.fn().mockResolvedValue([]),
softDelete: jest.fn().mockResolvedValue(undefined),
@@ -76,22 +78,35 @@ describe('BookingSplitService — applySplit ONE_TIME promotion', () => {
return { service, bookingRepo, contractRepo };
};
it('promotes a ONE_TIME booking + parent contract to GENERAL', async () => {
const { service, bookingRepo, contractRepo } = buildService('ONE_TIME');
it('flags the reduced booking is_split with a pre-split bulk snapshot', async () => {
const { service, bookingRepo } = buildService('ONE_TIME');
await service.applySplit(bookingId);
expect(bookingRepo.update).toHaveBeenCalledWith(
bookingId,
expect.objectContaining({ contractKind: 'GENERAL' }),
);
expect(contractRepo.update).toHaveBeenCalledWith(
contractId,
expect.objectContaining({ contractKind: 'GENERAL' }),
expect.objectContaining({
isSplit: true,
preSplitQuantities: { bulkTons: 50 },
cargoTotalWeightVgm: 30,
wagonsRequired: 3,
}),
);
});
it('leaves a GENERAL booking untouched (no contract promotion)', async () => {
it('never changes the contract kind — ONE_TIME stays ONE_TIME', async () => {
const { service, bookingRepo, contractRepo } = buildService('ONE_TIME');
await service.applySplit(bookingId);
expect(contractRepo.update).not.toHaveBeenCalled();
expect(bookingRepo.update).not.toHaveBeenCalledWith(
bookingId,
expect.objectContaining({ contractKind: expect.anything() }),
);
});
it('leaves a GENERAL contract untouched too', async () => {
const { service, contractRepo } = buildService('GENERAL');
await service.applySplit(bookingId);

View File

@@ -9,7 +9,6 @@ import { BillingService } from '../billing/billing.service';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { Contract } from '../contracts/entities/contract.entity';
import {
BookingBatchOffer,
OfferedLine,
@@ -34,11 +33,14 @@ export interface SizedOffer {
* GENERAL and ONE_TIME commercial bookings are offered partials: the remainder
* returns to the contract's quantity cap (derived live from booking_container
* rows, so reducing the lines releases it automatically) and can be rebooked in
* any later window within contract validity. A ONE_TIME contract is promoted to
* GENERAL on split (see applySplit) so its remainder is actually rebookable.
* Once the remainder is rebooked and the cap hits zero, ContractBookingService
* completes the contract (CONTRACT_CLOSED): no further bookings or shipment
* requests, even while validity and a booking window are still open.
* any later window within contract validity. The reduced booking is flagged
* is_split (see applySplit); the contract kind never changes. On a ONE_TIME
* contract a split booking releases the single-active-booking slot, but the
* next booking must take the WHOLE remainder — the split chain is the only way
* a ONE_TIME contract produces multiple bookings. Once the remainder is
* rebooked and the cap hits zero, ContractBookingService completes the
* contract (CONTRACT_CLOSED): no further bookings or shipment requests, even
* while validity and a booking window are still open.
*/
@Injectable()
export class BookingSplitService {
@@ -215,11 +217,26 @@ export class BookingSplitService {
if (!offer) return;
await this.dataSource.transaction(async (manager) => {
// Snapshot what the booking carried BEFORE the reduction: on a ONE_TIME
// contract this is the ledger the outstanding remainder is derived from
// (there is no contract quantity cap to fall back on).
const preSplit = await manager.getRepository(Booking).findOne({
where: { id: bookingId },
select: { id: true, cargoTotalWeightVgm: true },
});
const preSplitQuantities: { bulkTons?: number; bySize?: Record<string, number> } = {};
if (offer.offeredLines?.length) {
const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l]));
const lines = await manager.getRepository(BookingContainer).find({
where: { bookingId },
});
const bySize: Record<string, number> = {};
for (const line of lines) {
const size = line.containerSize ?? '';
bySize[size] = (bySize[size] ?? 0) + Number(line.quantity ?? 0);
}
preSplitQuantities.bySize = bySize;
for (const line of lines) {
const kept = keptByLine.get(line.id);
if (!kept) {
@@ -251,34 +268,24 @@ export class BookingSplitService {
}
}
}
} else {
preSplitQuantities.bulkTons = Number(preSplit?.cargoTotalWeightVgm ?? 0);
}
// is_split releases the ONE_TIME single-active-booking slot for the
// remainder (whole-remainder-only, enforced at booking creation) and
// switches the contract into remainder-based completion. The contract
// kind is NOT changed: a ONE_TIME contract stays ONE_TIME through the
// split chain.
await manager.getRepository(Booking).update(bookingId, {
wagonsRequired: offer.offeredWagons,
cargoTotalWeightVgm: offer.offeredWeightTons,
totalAmount: offer.offeredAmount,
pricingBreakdown: offer.offeredPricingBreakdown,
isSplit: true,
preSplitQuantities,
} as never);
// A ONE_TIME contract permits a single active booking, which would block the
// split remainder from ever being rebooked. Promote the parent contract (and
// the booking's denormalized copy) to GENERAL so the leftover quantity draws
// down against the cap like any general contract, within the same validity.
const booking = await manager.getRepository(Booking).findOne({
where: { id: bookingId },
select: { id: true, contractId: true, contractKind: true },
});
if (booking?.contractKind === 'ONE_TIME') {
await manager
.getRepository(Booking)
.update(bookingId, { contractKind: 'GENERAL' } as never);
if (booking.contractId) {
await manager
.getRepository(Contract)
.update(booking.contractId, { contractKind: 'GENERAL' } as never);
}
}
await manager
.getRepository(BookingBatchOffer)
.update(offer.id, { status: 'APPLIED' });

View File

@@ -1,6 +1,7 @@
import {
BOOKING_WINDOW_WS_EVENTS,
BOOKING_WINDOW_WS_NAMESPACE,
type BatchBoardChangedEvent,
type BookingWindowPhaseEvent,
} from '@edr/types';
import { Logger } from '@nestjs/common';
@@ -65,6 +66,20 @@ export class BookingWindowGateway implements OnGatewayConnection {
this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload);
}
/**
* Announce that a schedule's batch-board data changed (payment, allocation,
* fill, expiry, …). Broadcast namespace-wide like PHASE — the payload carries
* no board data, only the scheduleId; clients holding that board refetch.
*/
emitBatchChanged(scheduleId: string, reason: string): void {
const payload: BatchBoardChangedEvent = {
scheduleId,
reason,
at: new Date().toISOString(),
};
this.server.emit(BOOKING_WINDOW_WS_EVENTS.BATCH_CHANGED, payload);
}
private extractToken(socket: Socket): string | undefined {
const authToken = socket.handshake.auth?.token as string | undefined;
if (authToken) return authToken;

View File

@@ -153,6 +153,16 @@ export class BookingWindowService implements OnModuleInit {
.update(s.id, { docReviewCompletedAt: now });
s.docReviewCompletedAt = now;
await this.advanceSchedule(s, effectiveWindowConfig(s, liveCfg), now);
// The batch fill emits only for trains it actually reserved onto — this
// covers the empty-pool case so every open board still refetches. Push
// failures only log; the doc-review completion itself already persisted.
try {
this.gateway.emitBatchChanged(s.id, 'doc_review_completed');
} catch (err) {
this.logger.warn(
`Batch-board push failed for ${s.id}: ${(err as Error).message}`,
);
}
}
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
return fresh ?? schedule;

View File

@@ -1,15 +1,7 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsIn,
IsInt,
IsISO8601,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
import { IsIn, IsISO8601, IsOptional, IsString } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
export const BATCH_BOARD_STATUSES = [
'DRAFT',
@@ -27,23 +19,13 @@ export const BATCH_BOARD_SORT_FIELDS = [
] as const;
export type BatchBoardSortField = (typeof BATCH_BOARD_SORT_FIELDS)[number];
/** Filters for the batch monitoring board list (import schedules, all statuses). */
export class BatchBoardQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ default: 12, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number;
/**
* Filters for the batch monitoring board list (import schedules, all statuses).
* `page`/`pageSize`/`search`/`sortOrder` come from the shared
* {@link PaginationQueryDto}; search matches train number, route yards,
* stations, or locomotive code (case-insensitive).
*/
export class BatchBoardQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({
description:
'Comma-separated schedule statuses (DRAFT,SCHEDULED,DISPATCHED,ARRIVED,CANCELLED). Omit for all.',
@@ -58,15 +40,6 @@ export class BatchBoardQueryDto {
@IsIn(['OPEN', 'FULL', 'CLOSED'])
bookingWindowStatus?: 'OPEN' | 'FULL' | 'CLOSED';
@ApiPropertyOptional({
description:
'Case-insensitive match on train number, route yards, stations, or locomotive code.',
})
@IsOptional()
@IsString()
@MaxLength(120)
search?: string;
@ApiPropertyOptional({ description: 'Departure date lower bound (ISO 8601).' })
@IsOptional()
@IsISO8601()
@@ -91,9 +64,4 @@ export class BatchBoardQueryDto {
@IsOptional()
@IsIn(BATCH_BOARD_SORT_FIELDS as unknown as string[])
sortBy?: BatchBoardSortField;
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
@IsOptional()
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC';
}

View File

@@ -0,0 +1,64 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
import {
TRAIN_SCHEDULE_STATUSES,
TrainScheduleStatus,
} from '../../train-schedules/entities/train-schedule.entity';
export const TRAIN_SCHEDULE_SORT_FIELDS = [
'createdAt',
'scheduledDepartureDate',
'reference',
'trainNumber',
'status',
] as const;
export type TrainScheduleSortField = (typeof TRAIN_SCHEDULE_SORT_FIELDS)[number];
/**
* Schedules have no freight-type column — the type is DERIVED from the
* bookings aboard (see `resolveScheduleFreightType`): a single kind yields
* CONTAINER or BULK, both kinds yield MIXED, no bookings yield null (never
* matched by this filter).
*/
export const TRAIN_SCHEDULE_FREIGHT_TYPES = ['CONTAINER', 'BULK', 'MIXED'] as const;
export type TrainScheduleFreightType = (typeof TRAIN_SCHEDULE_FREIGHT_TYPES)[number];
/**
* Query for the train-schedule list (container/bulk boards). Pagination and
* free-text `search` come from the shared {@link PaginationQueryDto}; search
* matches schedule reference, train number, route yards, stations, or
* locomotive code (case-insensitive). The remaining fields are exact-match
* filters that the search never widens.
*/
export class ListTrainSchedulesQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: TRAIN_SCHEDULE_SORT_FIELDS, default: 'createdAt' })
@IsOptional()
@IsIn(TRAIN_SCHEDULE_SORT_FIELDS as unknown as string[])
sortBy?: TrainScheduleSortField;
/** Lifecycle status (exact match). */
@ApiPropertyOptional({ enum: TRAIN_SCHEDULE_STATUSES })
@IsOptional()
@IsIn(TRAIN_SCHEDULE_STATUSES as unknown as string[])
status?: TrainScheduleStatus;
/** Derived freight type of the bookings aboard (exact match). */
@ApiPropertyOptional({ enum: TRAIN_SCHEDULE_FREIGHT_TYPES })
@IsOptional()
@IsIn(TRAIN_SCHEDULE_FREIGHT_TYPES as unknown as string[])
freightType?: TrainScheduleFreightType;
/** Origin station/yard id (exact match). */
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
originStationId?: string;
/** Destination station/yard id (exact match). */
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
destinationStationId?: string;
}

View File

@@ -41,6 +41,7 @@ import {
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
import { ListTrainSchedulesQueryDto } from "./dto/list-train-schedules-query.dto";
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto";
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
@@ -743,16 +744,16 @@ export class TrainSchedulingController {
@Get("container/schedules")
@TrainSchedulingView()
@ApiOperation({ summary: "List container train schedules" })
getContainerTrainSchedules() {
return this.trainSchedulingService.getContainerTrainSchedules();
@ApiOperation({ summary: "List container train schedules (paginated)" })
getContainerTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) {
return this.trainSchedulingService.getContainerTrainSchedules(query);
}
@Get("bulk/schedules")
@TrainSchedulingView()
@ApiOperation({ summary: "List bulk train schedules" })
getBulkTrainSchedules() {
return this.trainSchedulingService.getContainerTrainSchedules();
@ApiOperation({ summary: "List bulk train schedules (paginated)" })
getBulkTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) {
return this.trainSchedulingService.getContainerTrainSchedules(query);
}
@Get("container/schedules/:id")

View File

@@ -4,6 +4,7 @@
SchedulingStatus,
TrainCheckpointKind,
TrainScheduleStatus as TrainScheduleStatusEnum,
WagonAllocationSnapshot,
WagonMovementKind,
WagonStatus,
} from '@edr/types';
@@ -17,8 +18,21 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In, Not, QueryFailedError } from 'typeorm';
import {
DataSource,
EntityManager,
FindOptionsWhere,
ILike,
In,
Not,
QueryFailedError,
Raw,
} from 'typeorm';
import {
buildPaginationMeta,
normalizePagination,
} from '../../common/utils/pagination.util';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
@@ -50,6 +64,10 @@ import { CreateContainerTrainScheduleDto } from './dto/create-container-train-sc
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
import {
ListTrainSchedulesQueryDto,
TrainScheduleFreightType,
} from './dto/list-train-schedules-query.dto';
import { PinWagonsDto } from './dto/pin-wagons.dto';
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
@@ -115,6 +133,8 @@ import {
computeImportWindowTimes,
earliestSchedulableDeparture,
eatDay,
eatDayToUtc,
shiftEatDay,
} from './batch-window.util';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { BookingJourneyService } from './booking-journey.service';
@@ -378,6 +398,142 @@ export class TrainSchedulingService {
}
}
/**
* A schedule GROUP is every schedule sharing an origin, destination, and EAT
* departure day — regardless of intermediate stops (ADD→DJ and ADD→DIRE→DJ
* group together, since the route entity keys only origin + destination). All
* schedules in a group must run ONE shared booking-window timeline so a
* customer booking on a later-created train is never expired by a sibling
* train's payment window closing on a different clock.
*
* Grouping keys off the columns the schedule already carries — no new schema.
* Callers pass a live `manager` so both the create (inside its transaction) and
* the update paths see uncommitted siblings.
*/
private async findGroupSiblings(
manager: EntityManager,
originStationId: string,
destinationStationId: string,
departure: Date,
excludeScheduleId?: string,
): Promise<TrainSchedule[]> {
const day = eatDay(departure);
const dayStart = eatDayToUtc(day, 0);
const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0);
const qb = manager
.getRepository(TrainSchedule)
.createQueryBuilder('s')
.where('s.originStationId = :originStationId', { originStationId })
.andWhere('s.destinationStationId = :destinationStationId', { destinationStationId })
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart });
if (excludeScheduleId) {
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
}
return qb.getMany();
}
/**
* The window timeline a brand-new schedule must adopt to join its route+day
* group. Returns the canonical open/close times + rule snapshot copied from an
* existing sibling, or null when this is the first schedule in the group (the
* caller then computes its own times as before — nothing changes for the
* single-schedule case).
*
* The anchor is the sibling that best represents where the GROUP currently is
* on its shared clock, so a train created mid-cycle joins the group AT its
* current phase (with the group's exact doc-review / payment deadlines) instead
* of restarting the whole cycle on its own `now`. When the group has advanced
* past PRE_WINDOW we pick the MOST-ADVANCED live (non-DONE) sibling — that is
* the phase the joiner must adopt to see the same "N minutes left" the group
* already shows. When every sibling is still PRE_WINDOW we pick the
* earliest-opening one (the group's frozen open clock).
*/
private async findGroupWindowAnchor(
manager: EntityManager,
originStationId: string,
destinationStationId: string,
departure: Date,
): Promise<TrainSchedule | null> {
const siblings = await this.findGroupSiblings(
manager,
originStationId,
destinationStationId,
departure,
);
if (siblings.length === 0) return null;
const withWindow = siblings.filter((s) => s.windowOpensAt != null);
if (withWindow.length === 0) return null;
// A group whose window is live (some sibling has moved past PRE_WINDOW but is
// not yet DONE) — the joiner must land in that same phase on the same clock.
const PHASE_ORDER = ['PRE_WINDOW', 'OPEN', 'DOC_REVIEW', 'PAYMENT'];
const live = withWindow.filter(
(s) => s.windowPhase != null && PHASE_ORDER.includes(s.windowPhase) &&
s.windowPhase !== 'PRE_WINDOW',
);
if (live.length > 0) {
// Most-advanced phase leads; ties broken by earliest open for determinism.
return live.reduce((best, s) => {
const a = PHASE_ORDER.indexOf(s.windowPhase!);
const b = PHASE_ORDER.indexOf(best.windowPhase!);
if (a !== b) return a > b ? s : best;
return s.windowOpensAt!.getTime() < best.windowOpensAt!.getTime() ? s : best;
});
}
// Otherwise the whole group is still PRE_WINDOW — earliest-opening sibling
// defines the group clock (the one a customer would have seen first).
const pending = withWindow.filter((s) => s.windowPhase === 'PRE_WINDOW');
const pool = pending.length > 0 ? pending : withWindow;
return pool.reduce((earliest, s) =>
s.windowOpensAt!.getTime() < earliest.windowOpensAt!.getTime() ? s : earliest,
);
}
/**
* The full window state an anchor sibling hands down to a schedule JOINING its
* group. Copies not just the open/close times + frozen rule snapshot but the
* anchor's LIVE PHASE and every phase-end timestamp (docReviewEndsAt,
* paymentPhaseEndsAt, bookingCycleNo, bookingWindowStatus). This is what makes a
* train created mid-cycle show the EXACT SAME "N minutes left" as the rest of
* the group: it enters directly at the group's current phase with the group's
* shared payment deadline, instead of restarting PRE_WINDOW→…→PAYMENT on its own
* `now` and stamping its own later `paymentPhaseEndsAt`.
*
* `targetDeparture` is the JOINING schedule's own departure: every time is
* clamped to it so a group whose trains depart at different times of the same
* day never hands an earlier-departing train a deadline that outlives its
* departure (computeImport/ExportWindowTimes clamp to departure at source; this
* preserves that invariant when the anchor departed later).
*/
private groupWindowFieldsFrom(anchor: TrainSchedule, targetDeparture: Date) {
const cap = targetDeparture.getTime();
const clamp = (d: Date | null | undefined): Date | null =>
d == null ? null : d.getTime() > cap ? targetDeparture : d;
return {
// Live phase + its deadlines — a mid-cycle joiner lands here directly.
windowPhase: anchor.windowPhase,
bookingWindowStatus:
anchor.bookingWindowStatus === 'FULL'
? 'OPEN'
: anchor.bookingWindowStatus,
bookingCycleNo: anchor.bookingCycleNo,
windowOpensAt: clamp(anchor.windowOpensAt),
windowClosesAt: clamp(anchor.windowClosesAt),
docReviewEndsAt: clamp(anchor.docReviewEndsAt),
docReviewCompletedAt: clamp(anchor.docReviewCompletedAt),
paymentPhaseEndsAt: clamp(anchor.paymentPhaseEndsAt),
// Frozen rule snapshot.
ruleWindowOpenHour: anchor.ruleWindowOpenHour,
ruleWindowCloseHour: anchor.ruleWindowCloseHour,
ruleWindowDurationHours: anchor.ruleWindowDurationHours,
ruleReopenDelayMinutes: anchor.ruleReopenDelayMinutes,
ruleImportWindowLeadDays: anchor.ruleImportWindowLeadDays,
ruleExportBookingLeadHours: anchor.ruleExportBookingLeadHours,
};
}
async getEligibleBookings(query: GetEligibleBookingsDto) {
// Day-level pooling: when the wizard targets a schedule, surface the whole
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
@@ -544,15 +700,53 @@ export class TrainSchedulingService {
);
}
await this.dataSource.getRepository(TrainSchedule).update(id, {
windowOpensAt: times.windowOpensAt,
windowClosesAt: times.windowClosesAt,
...windowRuleSnapshot(merged),
});
// Route+day grouping (IMPORT/DOMESTIC only): the override applies to the
// WHOLE group — every schedule sharing this origin + destination + EAT
// departure day. They all adopt the SAME rule snapshot and share the SAME
// window open/close timeline (the whole point of grouping). The shared times
// are clamped to each train's OWN departure so a group whose trains depart at
// different times of the same day never hands an earlier-departing sibling a
// window that outlives its departure. Only still-PRE_WINDOW siblings are
// touched — a sibling that has already opened, finalized, or dispatched stays
// frozen on the times its customers were shown and simply drops out of the
// group; the remaining pending trains stay in sync. EXPORT is excluded
// (departure-anchored FCFS window, no cross-expiry), so an export override
// only touches its own schedule.
const ruleFields = windowRuleSnapshot(merged);
const repo = this.dataSource.getRepository(TrainSchedule);
const cap = (d: Date, departure: Date): Date =>
d.getTime() > departure.getTime() ? departure : d;
const targets: Array<{ id: string; departure: Date }> = [
{ id, departure: schedule.scheduledDepartureDate },
];
if (schedule.direction !== 'EXPORT') {
const siblings = await this.findGroupSiblings(
this.dataSource.manager,
schedule.originStationId,
schedule.destinationStationId,
schedule.scheduledDepartureDate,
id,
);
for (const sib of siblings) {
if (sib.windowPhase === 'PRE_WINDOW' && sib.scheduledDepartureDate) {
targets.push({ id: sib.id, departure: sib.scheduledDepartureDate });
}
}
}
for (const t of targets) {
await repo.update(t.id, {
windowOpensAt: cap(times.windowOpensAt, t.departure),
windowClosesAt: cap(times.windowClosesAt, t.departure),
...ruleFields,
});
}
this.logger.log(
`Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`,
`Booking-window rule overridden for schedule ${id} and ${targets.length - 1} ` +
`route+day sibling(s) — reopens ${times.windowOpensAt.toISOString()}`,
);
void this.emitWindowState(id);
for (const t of targets) void this.emitWindowState(t.id);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
@@ -617,14 +811,34 @@ export class TrainSchedulingService {
? computeExportWindowTimes(departure, merged)
: computeImportWindowTimes(departure, merged, now);
// Moving the departure moves this train between route+day GROUPS. If the
// destination day already has a group (a sibling on the same origin +
// destination + new EAT day), adopt that group's shared timeline instead of
// the times just derived, so the rescheduled train lines up with the group
// it lands in rather than drifting onto its own clock. Otherwise it keeps its
// own re-derived times and becomes the anchor for that day. EXPORT is
// excluded — its window is anchored to its own departure, not shared.
const anchor =
schedule.direction === 'EXPORT'
? null
: await this.findGroupWindowAnchor(
this.dataSource.manager,
schedule.originStationId,
schedule.destinationStationId,
departure,
);
const windowFields = anchor
? this.groupWindowFieldsFrom(anchor, departure)
: { windowOpensAt: times.windowOpensAt, windowClosesAt: times.windowClosesAt };
await this.dataSource.getRepository(TrainSchedule).update(id, {
scheduledDepartureDate: departure,
windowOpensAt: times.windowOpensAt,
windowClosesAt: times.windowClosesAt,
...windowFields,
});
this.logger.log(
`Departure date changed for schedule ${id}${departure.toISOString()} ` +
`(window reopens ${times.windowOpensAt.toISOString()})`,
`(window reopens ${windowFields.windowOpensAt?.toISOString() ?? 'n/a'}` +
`${anchor ? `, joined route+day group anchor ${anchor.id}` : ''})`,
);
void this.emitWindowState(id);
@@ -837,21 +1051,44 @@ export class TrainSchedulingService {
// 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 windowFields =
// 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 +
// frozen rule) verbatim — it does NOT compute its own `now`-based times.
// That keeps every train on the day advancing through the same open/
// doc-review/payment/close instants, so a booking on one train is never
// expired by a sibling train's payment window closing on a different clock.
// First train in the group falls through to the normal computation.
//
// EXPORT is excluded: an export window is a single FCFS window anchored to
// each train's OWN departure (windowClosesAt = departure) with no
// doc-review/payment phase — so there is no cross-expiry to fix, and two
// export trains departing the same day at different times must keep their
// own departure-anchored windows.
const groupAnchor =
direction === 'EXPORT'
? {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...ruleSnapshot,
...computeExportWindowTimes(departure, windowCfg),
}
? null
: await this.findGroupWindowAnchor(
manager,
route.originYardId,
route.destinationYardId,
departure,
);
const computedTimes =
direction === 'EXPORT'
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
: {
// IMPORT and DOMESTIC share the import booking-day window cycle.
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...ruleSnapshot,
...computeImportWindowTimes(departure, windowCfg, new Date()),
};
const windowFields = {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...(groupAnchor
? this.groupWindowFieldsFrom(groupAnchor, departure)
: computedTimes),
};
const maxWagons = (await this.resolveTrainLimitConfig(dto, limitLoco))
.maxWagonsPerTrain;
// Retry past a concurrent insert that grabbed the same S-<year> sequence
@@ -899,14 +1136,35 @@ export class TrainSchedulingService {
throw new BadRequestException('Schedule has no train set');
}
// Batch parity: a schedule may only allocate bookings that targeted it. This mirrors
// the automatic fill, which only pulls bookings whose train_schedule_id is this schedule.
// Batch parity: a schedule may only allocate bookings from its route-day POOL.
// Under day-level pooling (see fillRouteDayInternal) an unreserved booking has
// a NULL train_schedule_id and is only pinned by reserve(); a reserved one is
// pinned to whichever train in the day's group first held it. Every train
// sharing this origin + destination + EAT departure day draws from ONE shared
// pool (one shared booking window), so a booking is allocatable here when it is
// either unpinned (NULL) or pinned to THIS train or a GROUP SIBLING. A booking
// pinned to a train on a DIFFERENT route/day is a real stray. Genuine route/
// day/capacity fit is enforced downstream by validateBookingsForScheduling.
// EXPORT never groups, so its pool is this schedule alone (plus NULL pool).
if (dto.bookingIds.length) {
const groupScheduleIds = new Set<string>([scheduleId]);
if (schedule.direction !== 'EXPORT') {
const siblings = await this.findGroupSiblings(
this.dataSource.manager,
schedule.originStationId,
schedule.destinationStationId,
schedule.scheduledDepartureDate,
scheduleId,
);
for (const sib of siblings) groupScheduleIds.add(sib.id);
}
const targeted = await this.bookingsRepository.findByIdsForScheduling(dto.bookingIds);
const stray = targeted.filter((b) => b.trainScheduleId !== scheduleId);
const stray = targeted.filter(
(b) => b.trainScheduleId != null && !groupScheduleIds.has(b.trainScheduleId),
);
if (stray.length) {
throw new BadRequestException(
`These bookings are not assigned to this schedule: ${stray
`These bookings are pinned to a train on a different route or day: ${stray
.map((b) => b.reference ?? b.id)
.join(', ')}`,
);
@@ -1150,9 +1408,13 @@ export class TrainSchedulingService {
const booking = await this.bookingsRepository.findById(bookingId);
const schedulingStatus = this.resolvePostUnassignStatus(booking);
// Clear the schedule pointer too: unassign fully detaches the booking from
// this train. Leaving trainScheduleId set glued the booking to a schedule
// that may then be dispatched/cancelled/deleted, orphaning it — the
// assign-bookings parity guard would reject it from every OTHER schedule.
await this.bookingsRepository.updateSchedulingFields(
bookingId,
{ schedulingStatus, wagonsRequired: null },
{ schedulingStatus, wagonsRequired: null, trainScheduleId: null },
manager,
);
@@ -1505,12 +1767,34 @@ export class TrainSchedulingService {
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Dispatched,
{ actualDepartureAt: now, trainNumber },
{
actualDepartureAt: now,
trainNumber,
// Freeze the wagon plan the moment the train leaves the editable phase.
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
schedule,
TrainScheduleStatusEnum.Dispatched,
now,
),
},
manager,
);
if (schedule.trainSetId) {
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' });
}
// The train is out — every pinned wagon is ASSIGNED to this schedule and
// stays pinned so no other schedule can pick it while it's rolling.
const dispatchedPhysicalIds = (schedule.trainSet?.wagons ?? [])
.map((slot) => slot.physicalWagonId)
.filter((id): id is string => Boolean(id));
if (dispatchedPhysicalIds.length) {
await manager
.getRepository(Wagon)
.update(
{ id: In(dispatchedPhysicalIds) },
{ status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId },
);
}
for (const sb of schedule.scheduleBookings ?? []) {
await this.bookingsRepository.updateSchedulingFields(
sb.bookingId,
@@ -1587,7 +1871,24 @@ export class TrainSchedulingService {
);
}
void this.notifyScheduleBookings(schedule, 'dispatched');
return this.getTrainScheduleById(scheduleId);
const detail = await this.getTrainScheduleById(scheduleId);
// Surface a compact dispatch confirmation so the caller can toast the "train
// is out" info (train number, departure, wagons committed) without re-deriving it.
const dispatchedWagonCount = (schedule.trainSet?.wagons ?? []).filter(
(slot) => slot.physicalWagonId,
).length;
return Object.assign(detail, {
dispatchInfo: {
// The real train number was assigned inside the txn — read it back off
// the persisted detail (schedule.trainNumber is the pre-dispatch value).
trainNumber: detail.trainNumber ?? schedule.trainNumber ?? null,
departedAt: now.toISOString(),
wagonsDispatched: dispatchedWagonCount,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
},
});
}
async getImportDjiboutiOperation(scheduleId: string) {
@@ -2544,7 +2845,15 @@ export class TrainSchedulingService {
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Arrived,
{ actualArrivalAt: now },
{
actualArrivalAt: now,
// Freeze the plan before the wagons below are released to their yards.
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
schedule,
TrainScheduleStatusEnum.Arrived,
now,
),
},
manager,
);
@@ -2641,8 +2950,42 @@ export class TrainSchedulingService {
return Object.assign(detail, { warehouseAutomation });
}
async getContainerTrainSchedules() {
const schedules = await this.trainSchedulesRepository.findAll({
async getContainerTrainSchedules(query: ListTrainSchedulesQueryDto = {}) {
const { page, pageSize, skip, take } = normalizePagination(query);
// 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 (query.status) base.status = query.status;
if (query.originStationId) base.originStationId = query.originStationId;
if (query.destinationStationId) base.destinationStationId = query.destinationStationId;
if (query.freightType) base.id = this.scheduleFreightTypeFilter(query.freightType) as never;
// Search fans out across every human-recognizable label; each OR variant
// repeats the base filters so the search never widens them.
const term = query.search?.trim();
let where: FindOptionsWhere<TrainSchedule> | FindOptionsWhere<TrainSchedule>[] =
base;
if (term) {
const like = ILike(`%${term}%`);
where = [
{ ...base, reference: like as never },
{ ...base, trainNumber: like as never },
{ ...base, originStation: { label: like } },
{ ...base, destinationStation: { label: like } },
{ ...base, route: { originYard: { label: like } } },
{ ...base, route: { destinationYard: { label: like } } },
{ ...base, trainSet: { locomotive: { code: like } } },
] as FindOptionsWhere<TrainSchedule>[];
}
// Newest-created first (the client can re-sort; this is the default order).
const sortBy = query.sortBy ?? 'createdAt';
const sortOrder = query.sortOrder ?? 'DESC';
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
where,
relations: {
trainSet: { locomotive: true, locomotives: { locomotive: true } },
// Yards carry the route's display name used by mapScheduleListItem;
@@ -2652,10 +2995,14 @@ export class TrainSchedulingService {
destinationStation: true,
scheduleBookings: { booking: true },
},
// Newest-created first (the client can re-sort; this is the default order).
order: { createdAt: 'DESC', scheduledDepartureDate: 'DESC' },
order: { [sortBy]: sortOrder } as never,
skip,
take,
});
return schedules.map((s) => this.mapScheduleListItem(s));
return {
items: schedules.map((s) => this.mapScheduleListItem(s)),
meta: buildPaginationMeta(total, page, pageSize),
};
}
async getContainerTrainScheduleById(id: string) {
@@ -2668,13 +3015,24 @@ export class TrainSchedulingService {
throw new NotFoundException(`Train schedule ${id} not found`);
}
const now = new Date();
await this.dataSource.transaction(async (manager) => {
await this.trainSchedulesRepository.updateStatus(
id,
TrainScheduleStatusEnum.Cancelled,
// Retire the booking window so a canceled schedule never lingers as an
// "open window" in booking-window lists or the legacy batch fill.
{ bookingWindowStatus: 'CLOSED', windowPhase: 'DONE' },
{
// Retire the booking window so a canceled schedule never lingers as an
// "open window" in booking-window lists or the legacy batch fill.
bookingWindowStatus: 'CLOSED',
windowPhase: 'DONE',
// Freeze the plan before the wagons below are released back to the yard.
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
schedule,
TrainScheduleStatusEnum.Cancelled,
now,
),
},
manager,
);
if (schedule.trainSetId) {
@@ -2702,14 +3060,23 @@ export class TrainSchedulingService {
currentTrainScheduleId: null,
trainSetWagonId: null,
status: WagonStatus.Available,
// A cancelled train never left — its wagons stay/return at the origin
// yard, free to be re-pinned onto another schedule from there.
currentYardId: schedule.originStationId,
});
}
}
for (const sb of schedule.scheduleBookings ?? []) {
const booking = await this.bookingsRepository.findById(sb.bookingId);
// Detach from the cancelled schedule — clear the pointer so the freed
// booking can be assigned to another train. Leaving it set orphans the
// booking against a schedule that is about to be gone.
await this.bookingsRepository.updateSchedulingFields(
sb.bookingId,
{ schedulingStatus: this.resolvePostUnassignStatus(booking) },
{
schedulingStatus: this.resolvePostUnassignStatus(booking),
trainScheduleId: null,
},
manager,
);
}
@@ -3241,6 +3608,47 @@ export class TrainSchedulingService {
}));
}
/**
* Freeze the schedule's live wagon plan into a snapshot. Built from the fully
* hydrated graph (findByIdWithFullGraph) BEFORE the transition releases the
* physical wagons, so the historical allocation survives those wagons being
* re-pinned onto later trains. `capturedStatus` is the status being applied.
*/
private buildWagonAllocationSnapshot(
schedule: TrainSchedule,
capturedStatus: TrainScheduleStatusEnum,
capturedAt: Date,
): WagonAllocationSnapshot {
const slots = [...(schedule.trainSet?.wagons ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((wagon) => ({
sequenceNo: wagon.sequenceNo,
trainSetWagonId: wagon.id,
physicalWagonId: wagon.physicalWagonId ?? null,
physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
wagonTypeId: wagon.wagonTypeId ?? null,
wagonTypeCode: wagon.wagonType?.code ?? null,
slotStatus: wagon.status ?? null,
boardYardId: wagon.boardYardId ?? null,
alightYardId: wagon.alightYardId ?? null,
allocations: (wagon.allocations ?? []).map((allocation) => ({
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
loadType: allocation.loadType ?? null,
containerNumbers: (allocation.containerItems ?? [])
.map((item) => item.containerNumber)
.filter((n): n is string => Boolean(n)),
})),
}));
return {
capturedStatus,
capturedAt: capturedAt.toISOString(),
slots,
};
}
private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) {
const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } });
for (const slot of slots) {
@@ -3869,6 +4277,33 @@ export class TrainSchedulingService {
};
}
/**
* WHERE fragment matching the DERIVED schedule freight type — the SQL mirror
* of {@link resolveScheduleFreightType} (keep the two in sync). CONTAINER /
* BULK = has bookings and every one is that kind; MIXED = both kinds aboard.
* Schedules with no bookings (type null) match nothing. Applied to `id` so
* the list query stays on findAndCount instead of a query-builder rewrite.
*/
private scheduleFreightTypeFilter(freightType: TrainScheduleFreightType) {
const hasBookingOfType = (alias: string, cmp: '=' | '<>', param: string) =>
'EXISTS (SELECT 1 FROM freight.train_schedule_bookings tsb ' +
'JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL ' +
`WHERE tsb.train_schedule_id = ${alias} AND tsb.deleted_at IS NULL ` +
`AND b.freight_type ${cmp} :${param})`;
if (freightType === 'MIXED') {
return Raw(
(alias) =>
`${hasBookingOfType(alias, '=', 'ftContainer')} AND ${hasBookingOfType(alias, '=', 'ftBulk')}`,
{ ftContainer: 'CONTAINER', ftBulk: 'BULK' },
);
}
return Raw(
(alias) =>
`${hasBookingOfType(alias, '=', 'ftIs')} AND NOT ${hasBookingOfType(alias, '<>', 'ftNot')}`,
{ ftIs: freightType, ftNot: freightType },
);
}
private resolveScheduleFreightType(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
): 'CONTAINER' | 'BULK' | 'MIXED' | null {
@@ -4441,6 +4876,20 @@ export class TrainSchedulingService {
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
);
// Once a schedule leaves DRAFT/SCHEDULED, its physical wagons are released
// and re-pinned onto later trains — the live wagon↔slot joins no longer
// describe THIS train. If a frozen snapshot was captured at the transition,
// the per-slot wagon number + booking allocations are read from it instead.
const snapshot = schedule.wagonAllocationSnapshot ?? null;
const isWagonAllocationFrozen = Boolean(
snapshot &&
schedule.status !== TrainScheduleStatusEnum.Draft &&
schedule.status !== TrainScheduleStatusEnum.Scheduled,
);
const snapshotSlotByTrainSetWagonId = new Map(
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
);
return {
id: schedule.id,
reference: schedule.reference ?? null,
@@ -4523,52 +4972,84 @@ export class TrainSchedulingService {
})),
wagons: [...(schedule.trainSet.wagons ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((wagon) => ({
id: wagon.id,
sequenceNo: wagon.sequenceNo,
capacityTons: roundTons(Number(wagon.capacityTons)),
lengthMeters: roundTons(Number(wagon.lengthMeters)),
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
// Empty-wagon weight — the pull limit hauls tare + cargo, so the
// frontend needs it to show the gross train weight.
tareWeightTons: wagon.wagonType
? roundTons(Number(wagon.wagonType.tareWeightTons))
: null,
status: wagon.status,
physicalWagonId: wagon.physicalWagonId ?? null,
physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
wagonType: wagon.wagonType
? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name }
: null,
allocations:
wagon.allocations?.map((allocation) => ({
id: allocation.id,
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)),
loadType: allocation.loadType ?? null,
status: allocation.status,
containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map(
(item) => ({
id: item.id,
containerNumber: item.containerNumber ?? null,
containerTypeId: item.containerTypeId,
grossWeightTons: item.grossWeightTons ?? null,
containerId: item.containerId ?? null,
positionOnWagon: item.positionOnWagon ?? null,
bookingContainerId: item.bookingContainerId ?? null,
}),
),
bulkLoad: bulkLoadsByAllocation.get(allocation.id)
? {
id: bulkLoadsByAllocation.get(allocation.id)!.id,
weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons,
cargoDescription:
bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null,
}
: null,
})) ?? [],
})),
.map((wagon) => {
// Frozen schedules read the wagon number + allocations from the
// snapshot slot; the immutable slot geometry (capacity/type) still
// comes live. Falls back to live if a slot is missing from the snap.
const frozenSlot = isWagonAllocationFrozen
? snapshotSlotByTrainSetWagonId.get(wagon.id)
: undefined;
return {
id: wagon.id,
sequenceNo: wagon.sequenceNo,
capacityTons: roundTons(Number(wagon.capacityTons)),
lengthMeters: roundTons(Number(wagon.lengthMeters)),
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
// Empty-wagon weight — the pull limit hauls tare + cargo, so the
// frontend needs it to show the gross train weight.
tareWeightTons: wagon.wagonType
? roundTons(Number(wagon.wagonType.tareWeightTons))
: null,
status: wagon.status,
physicalWagonId: frozenSlot
? frozenSlot.physicalWagonId
: wagon.physicalWagonId ?? null,
physicalWagonNumber: frozenSlot
? frozenSlot.physicalWagonNumber
: wagon.physicalWagon?.wagonNumber ?? null,
wagonType: wagon.wagonType
? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name }
: null,
allocations: frozenSlot
? frozenSlot.allocations.map((allocation) => ({
id: null,
bookingId: allocation.bookingId,
bookingReference: allocation.bookingReference,
allocatedWeightTons: roundTons(allocation.allocatedWeightTons),
loadType: allocation.loadType,
status: null,
// Frozen: container detail collapses to the captured numbers;
// per-container geometry isn't re-derivable post-release.
containerItems: allocation.containerNumbers.map((containerNumber) => ({
id: null,
containerNumber,
containerTypeId: null,
grossWeightTons: null,
containerId: null,
positionOnWagon: null,
bookingContainerId: null,
})),
bulkLoad: null,
}))
: wagon.allocations?.map((allocation) => ({
id: allocation.id,
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)),
loadType: allocation.loadType ?? null,
status: allocation.status,
containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map(
(item) => ({
id: item.id,
containerNumber: item.containerNumber ?? null,
containerTypeId: item.containerTypeId,
grossWeightTons: item.grossWeightTons ?? null,
containerId: item.containerId ?? null,
positionOnWagon: item.positionOnWagon ?? null,
bookingContainerId: item.bookingContainerId ?? null,
}),
),
bulkLoad: bulkLoadsByAllocation.get(allocation.id)
? {
id: bulkLoadsByAllocation.get(allocation.id)!.id,
weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons,
cargoDescription:
bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null,
}
: null,
})) ?? [],
};
}),
}
: null,
bookings:
@@ -4586,6 +5067,11 @@ export class TrainSchedulingService {
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
})) ?? [],
// True when the wagon plan above is served from the frozen snapshot (schedule
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
// it "historical" and skip re-pin affordances.
isWagonAllocationFrozen,
wagonAllocationSnapshot: snapshot,
};
}

View File

@@ -52,6 +52,12 @@ export class WarehouseInventoryController {
return this.inventoryService.arrivalQueue();
}
@Get('zone-occupancy')
@ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' })
zoneOccupancy(@Query('yardId') yardId?: string) {
return this.inventoryService.zoneOccupancy(yardId);
}
@Post('auto-unload-arrived')
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
autoUnloadArrived() {

View File

@@ -1,4 +1,5 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
@@ -396,6 +397,135 @@ export class WarehouseInventoryService {
* but has no customer truck assigned yet, nudge the customer to assign one — with
* a deep-link to the booking's truck-assignment card. Fire-and-forget.
*/
/**
* Live occupancy per zone: rated capacity vs the weight/items currently held
* (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard
* occupancy heatmap. Optionally scoped to one yard.
*/
async zoneOccupancy(yardId?: string): Promise<
Array<{
id: string;
name: string;
code: string;
type: string;
yardId: string;
capacityWeight: number | null;
capacityContainers: number | null;
usedWeight: number;
usedItems: number;
occupancyPct: number | null;
}>
> {
const rows: Array<{
id: string;
name: string;
code: string;
type: string;
yardId: string;
capacityWeight: string | null;
capacityContainers: number | null;
usedWeight: number;
usedItems: number;
}> = await this.dataSource.query(
`SELECT z.id,
z.name,
z.code,
z.type,
z.yard_id AS "yardId",
z.capacity_weight AS "capacityWeight",
z.capacity_containers AS "capacityContainers",
COALESCE(SUM(inv.weight) FILTER (
WHERE inv.deleted_at IS NULL
AND inv.status NOT IN ('DELIVERED', 'DISPATCHED')
), 0)::float8 AS "usedWeight",
COALESCE(COUNT(inv.id) FILTER (
WHERE inv.deleted_at IS NULL
AND inv.status NOT IN ('DELIVERED', 'DISPATCHED')
), 0)::int AS "usedItems"
FROM freight.warehouse_zones z
LEFT JOIN freight.warehouse_inventory inv ON inv.zone_id = z.id
WHERE z.is_active = true
AND z.deleted_at IS NULL
AND ($1::uuid IS NULL OR z.yard_id = $1)
GROUP BY z.id
ORDER BY z.name`,
[yardId ?? null],
);
return rows.map((r) => {
const capWeight = r.capacityWeight != null ? Number(r.capacityWeight) : null;
const byWeight =
capWeight && capWeight > 0 ? (r.usedWeight / capWeight) * 100 : null;
const byItems =
r.capacityContainers && r.capacityContainers > 0
? (r.usedItems / r.capacityContainers) * 100
: null;
// Prefer container-count occupancy (unit-consistent). Weight capacity is
// tonnes while inventory weight is kg, so weight% is only a rough fallback.
const pct = byItems ?? byWeight;
return {
id: r.id,
name: r.name,
code: r.code,
type: r.type,
yardId: r.yardId,
capacityWeight: capWeight,
capacityContainers: r.capacityContainers,
usedWeight: r.usedWeight,
usedItems: r.usedItems,
occupancyPct: pct == null ? null : Math.round(pct * 10) / 10,
};
});
}
/**
* Recurring nudge: keep reminding self-haul IMPORT customers to assign a
* collection truck while their goods are still in the warehouse
* (READY_FOR_PICKUP) and no truck has been assigned yet. Stops once a truck is
* assigned (customer_truck_assigned_at set) or the goods leave (DELIVERED).
*/
@Cron(CronExpression.EVERY_30_MINUTES, { name: 'import-truck-assignment-reminder' })
async remindImportTruckAssignment(): Promise<void> {
try {
const rows: Array<{
bookingId: string;
companyId: string | null;
reference: string | null;
}> = await this.dataSource.query(
`SELECT DISTINCT b.id AS "bookingId",
b.company_id AS "companyId",
b.reference
FROM freight.warehouse_inventory inv
JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
WHERE inv.deleted_at IS NULL
AND inv.status = 'READY_FOR_PICKUP'
AND b.trade_direction = 'IMPORT'
AND b.customer_truck_assigned_at IS NULL
AND COALESCE(NULLIF(TRIM(b.last_mile_delivery_address), ''), '') = ''`,
);
if (!rows.length) return;
this.logger.log(
`Import truck-assignment reminder: ${rows.length} booking(s) awaiting a collection truck`,
);
for (const row of rows) {
await this.notifyTruckAssignmentNeeded(
{
companyId: row.companyId,
reference: row.reference,
hasFirstMile: false,
hasLastMile: false,
customerTruckAssignedAt: null,
},
row.bookingId,
);
}
} catch (err) {
this.logger.warn(
`Import truck-assignment reminder tick failed: ${(err as Error).message}`,
);
}
}
private async notifyTruckAssignmentNeeded(booking: {
companyId?: string | null;
reference?: string | null;

View File

@@ -0,0 +1,23 @@
import { AppDataSource } from "../data-source";
import { DropdownSettingsSeeder } from "../seed/dropdown-settings.seeder";
/**
* Seed the dropdown settings catalog (codes/labels only, no options) into an
* EMPTY dropdown_settings table; skips entirely if any rows exist. Run on
* demand:
* pnpm --filter @edr/freight-api seed:dropdown-settings
*/
async function run() {
await AppDataSource.initialize();
try {
const seeder = new DropdownSettingsSeeder(AppDataSource);
await seeder.run();
} finally {
await AppDataSource.destroy();
}
}
run().catch((error) => {
console.error("Failed to seed dropdown settings:", error);
process.exit(1);
});

View File

@@ -0,0 +1,89 @@
import { Injectable, Logger } from "@nestjs/common";
import { DataSource } from "typeorm";
import {
DropdownSetting,
DropdownSettingMeta,
} from "../modules/dropdown-settings/entities/dropdown-setting.entity";
interface DefaultDropdownSetting {
code: string;
label: string;
description: string;
multiple: boolean;
meta?: DropdownSettingMeta | null;
}
/**
* Known dropdown settings, seeded as empty catalogs (no options). The options
* are managed by the admin from the backoffice Dropdown Settings editor.
*/
const DEFAULT_DROPDOWN_SETTINGS: DefaultDropdownSetting[] = [
{
code: "stations_ter",
label: "Stations TER",
description:
"Temporary freight station list used by booking origin and destination yards.",
multiple: false,
meta: { searchable: true, clearable: true, version: "temporary" },
},
{
code: "general_contract_period",
label: "General Contract Period (months)",
description:
"How many months a general contract stays open for ordering after activation.",
multiple: false,
},
{
code: "contract_validity_periods",
label: "Contract Validity Periods (days)",
description:
"Validity durations (in days) a staff can choose when accepting a submitted contract.",
multiple: false,
},
{
code: "ro_vessel_min_days",
label: "RO vessel minimum lead time (days)",
description:
"Minimum days between today and the vessel departure date on an export Release Order.",
multiple: false,
},
];
@Injectable()
export class DropdownSettingsSeeder {
private readonly logger = new Logger(DropdownSettingsSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run() {
const settingRepository = this.dataSource.getRepository(DropdownSetting);
// Seed only into an empty table: any existing rows (including
// soft-deleted ones, which would still conflict on the unique `code`)
// mean the data is admin-managed, so leave it untouched.
const existing = await settingRepository.count({ withDeleted: true });
if (existing > 0) {
this.logger.log(
`dropdown_settings already has ${existing} rows — skipping seed`,
);
return;
}
// Insert setting rows only — no DropdownOption rows. Options start empty
// and are configured by the admin from the backoffice editor.
await settingRepository.insert(
DEFAULT_DROPDOWN_SETTINGS.map((setting) => ({
code: setting.code,
label: setting.label,
description: setting.description,
multiple: setting.multiple,
meta: setting.meta ?? null,
})),
);
this.logger.log(
`Seeded ${DEFAULT_DROPDOWN_SETTINGS.length} dropdown settings with empty options`,
);
}
}

View File

@@ -1,7 +1,6 @@
import { Injectable, Logger } from "@nestjs/common";
import { DataSource } from "typeorm";
import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity";
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
interface OnboardingField {
@@ -576,88 +575,66 @@ export class FileUploadSettingsSeeder {
constructor(private readonly dataSource: DataSource) { }
async run() {
await this.dataSource.transaction(async (manager) => {
const settingRepository = manager.getRepository(FileUploadSetting);
const fieldRepository = manager.getRepository(FileUploadField);
const settingRepository = this.dataSource.getRepository(FileUploadSetting);
const allSettings: Array<
OnboardingDocumentSetting & { description: string }
> = [
...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
...s,
description: COMPANY_ONBOARDING_DESCRIPTION,
})),
...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
...s,
description: CLEARANCE_DESCRIPTION,
})),
...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({
...s,
description:
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
})),
...SELF_CLEARANCE_SETTINGS.map((s) => ({
...s,
description:
"Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
})),
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
...s,
description:
"Commercial/framework documents attached at contract submission.",
})),
...DRIVER_DOCUMENT_SETTINGS.map((s) => ({
...s,
description:
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
})),
];
// Seed only into an empty table: any existing rows (including
// soft-deleted ones, which would still conflict on the unique `code`)
// mean the data is admin-managed, so leave it untouched.
const existing = await settingRepository.count({ withDeleted: true });
if (existing > 0) {
this.logger.log(
`file_upload_settings already has ${existing} rows — skipping seed`,
);
return;
}
for (const documentSetting of allSettings) {
await settingRepository.upsert(
{
code: documentSetting.code,
label: documentSetting.label,
description: documentSetting.description,
entity: documentSetting.entity,
},
{
conflictPaths: { code: true },
},
);
const allSettings: Array<
OnboardingDocumentSetting & { description: string }
> = [
...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
...s,
description: COMPANY_ONBOARDING_DESCRIPTION,
})),
...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
...s,
description: CLEARANCE_DESCRIPTION,
})),
...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({
...s,
description:
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
})),
...SELF_CLEARANCE_SETTINGS.map((s) => ({
...s,
description:
"Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
})),
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
...s,
description:
"Commercial/framework documents attached at contract submission.",
})),
...DRIVER_DOCUMENT_SETTINGS.map((s) => ({
...s,
description:
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
})),
];
const setting = await settingRepository.findOne({
where: { code: documentSetting.code },
select: { id: true, code: true },
});
if (!setting) {
throw new Error(
`file_upload_setting_seed_failed:${documentSetting.code}`,
);
}
await fieldRepository.delete({ settingId: setting.id });
await fieldRepository.insert(
documentSetting.fields.map((field, index) => ({
settingId: setting.id,
fileKey: field.fileKey,
fileLabel: field.fileLabel,
helpText: field.helpText,
isRequired: field.isRequired,
isMultiple: field.isMultiple,
maxFiles: field.maxFiles,
allowedExtensions: [...field.allowedExtensions],
maxSizeMb: field.maxSizeMb,
displayOrder: field.displayOrder ?? index + 1,
})),
);
}
});
// Insert setting rows only — no FileUploadField rows. Fields start empty
// and are configured from the backoffice file-settings editor; the field
// definitions above are kept as reference defaults.
await settingRepository.insert(
allSettings.map((documentSetting) => ({
code: documentSetting.code,
label: documentSetting.label,
description: documentSetting.description,
entity: documentSetting.entity,
})),
);
this.logger.log(
"Ensured company onboarding + booking clearance file upload settings",
`Seeded ${allSettings.length} file upload settings with empty fields`,
);
}
}

View File

@@ -52,6 +52,7 @@ import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPa
import ContractViewPage from "./pages/contracts/ContractViewPage";
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
import ClearanceDocumentsPage from "./pages/contracts/ClearanceDocumentsPage";
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
@@ -126,6 +127,7 @@ import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
import { HealthCheck } from "./features/health/HealthCheck";
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
import { UserManagementRoutes } from "./user-management/route";
import SetPassword from "./shared/components/SetPassword";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
@@ -152,6 +154,14 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <FileSignature />,
permission: FREIGHT_PERMS.contracts.view,
},
// Operations hub: clearance-document review for contracts WITHOUT
// customs clearing (contract-level for one-time, per-booking for general).
{
label: "Clearance Documents",
href: "/dashboard/contracts/clearance-documents",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
{
label: "Customers",
href: "/dashboard/customers",
@@ -655,6 +665,10 @@ const App = () => {
<Routes>
<Route path="/auth" element={<LoginPage />} />
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
<Route
path="um/set-password"
element={<SetPassword />}
/>
<Route path="/callback" element={<FaydaCallbackPage />} />
<Route path="*" element={<Navigate to="/auth" replace />} />
</Routes>
@@ -788,6 +802,18 @@ const App = () => {
</RequirePermission>
}
/>
{/* Operations hub: clearance documents for non-customs contracts —
Contracts tab (contract-level) + General tab (per-booking). */}
<Route
path="contracts/clearance-documents"
element={
<RequirePermission
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
>
<ClearanceDocumentsPage />
</RequirePermission>
}
/>
{/* GL (Path B) contract clearance review hub */}
<Route
path="contracts/clearance"

View File

@@ -149,8 +149,12 @@ export function ExportClearanceStepper({
const entityId = contractId ?? bookingId ?? "";
// The booking that carries the post-booking steps (gate pass, T1, invoice).
const actionBookingId = clearance.linkedBookingId ?? bookingId ?? null;
// Per-booking GENERAL clearance runs on a bare instance that only becomes a
// real booking once GL completes it — the caller's bookingCreated prop carries
// that signal, so a booking-keyed view must NOT count as "created" by itself
// (it would lock GL Ethiopia out of the declaration step right after the RO).
const effectiveBookingCreated =
bookingCreated || Boolean(clearance.linkedBookingId) || isBooking;
bookingCreated || Boolean(clearance.linkedBookingId);
const activeStep = useMemo(
() => computeExportActiveStep(clearance, bookingMilestones, effectiveBookingCreated),

View File

@@ -24,9 +24,10 @@ export interface GlShipmentQuantities {
hazardousQuantity: number;
reeferQuantity: number;
}>;
/** Bulk: tons (or item count) + hazardous qty. */
/** Bulk: tons (or item count) + hazardous/reefer qty. */
bulkQuantity: number;
bulkHazardousQuantity: number;
bulkReeferQuantity: number;
}
/**
@@ -113,6 +114,30 @@ export function computeGlShipmentTotal(
amount: rate.unitPrice * qty,
});
}
if (contract.isHazardous && q.bulkHazardousQuantity > 0) {
const hz = rateFor((i) => i.conditionalOn === "is_hazardous");
if (hz) {
lines.push({
label: hz.label,
unitPrice: hz.unitPrice,
unit: hz.unit,
quantity: q.bulkHazardousQuantity,
amount: hz.unitPrice * q.bulkHazardousQuantity,
});
}
}
if (contract.isReefer && q.bulkReeferQuantity > 0) {
const rf = rateFor((i) => i.conditionalOn === "is_reefer");
if (rf) {
lines.push({
label: rf.label,
unitPrice: rf.unitPrice,
unit: rf.unit,
quantity: q.bulkReeferQuantity,
amount: rf.unitPrice * q.bulkReeferQuantity,
});
}
}
}
const total = lines.reduce((s, l) => s + l.amount, 0);

View File

@@ -0,0 +1,157 @@
import { useState } from "react";
import {
Button,
FileInput,
Group,
Modal,
Stack,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { Camera, PenLine } from "lucide-react";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { lastMileService } from "@/services/last-mile.service";
import { useToast } from "@/hooks/use-toast";
interface ProofOfDeliveryModalProps {
opened: boolean;
onClose: () => void;
lastMileId: string | null;
reference?: string | null;
/** Called after a successful capture so the caller can refetch. */
onDone: () => void;
}
/**
* Proof of delivery capture for an EDR last-mile leg: recipient name, a drawn
* signature, and proof photos. On confirm it uploads everything and completes
* the delivery (marks the leg DELIVERED).
*/
export function ProofOfDeliveryModal({
opened,
onClose,
lastMileId,
reference,
onDone,
}: ProofOfDeliveryModalProps) {
const { toast } = useToast();
const [recipient, setRecipient] = useState("");
const [notes, setNotes] = useState("");
const [signatureUrl, setSignatureUrl] = useState<string | null>(null);
const [photos, setPhotos] = useState<File[]>([]);
const reset = () => {
setRecipient("");
setNotes("");
setSignatureUrl(null);
setPhotos([]);
};
const close = () => {
reset();
onClose();
};
const submit = useMutation({
mutationFn: async () => {
if (!lastMileId) throw new Error("No delivery selected");
const signature = signatureUrl
? await (await fetch(signatureUrl)).blob()
: null;
return lastMileService.recordProofOfDelivery(lastMileId, {
recipientName: recipient.trim(),
notes: notes.trim() || undefined,
signature,
photos,
});
},
onSuccess: () => {
toast({
title: "Proof of delivery recorded",
description: "The delivery has been completed.",
});
reset();
onDone();
onClose();
},
onError: (e) =>
toast({
variant: "destructive",
title: "Could not record delivery",
description: e instanceof Error ? e.message : undefined,
}),
});
// Require a recipient plus at least one form of proof (signature or a photo).
const canSubmit =
recipient.trim().length > 0 && (Boolean(signatureUrl) || photos.length > 0);
return (
<Modal
opened={opened}
onClose={close}
title={`Record delivery${reference ? `${reference}` : ""}`}
size="lg"
centered
>
<Stack gap="md">
<TextInput
label="Received by"
placeholder="Recipient's name"
required
value={recipient}
onChange={(e) => setRecipient(e.currentTarget.value)}
/>
<div>
<Text size="sm" fw={500} mb={4}>
Recipient signature
</Text>
<ContractSignaturePad onChange={setSignatureUrl} />
</div>
<FileInput
label="Proof photos"
placeholder="Attach delivery photo(s)"
leftSection={<Camera size={16} />}
accept="image/*"
multiple
clearable
value={photos}
onChange={setPhotos}
/>
<Textarea
label="Notes"
placeholder="Optional delivery notes"
autosize
minRows={2}
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
/>
<Text size="xs" c="dimmed">
Provide a signature or at least one photo. Confirming completes the delivery.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={close} disabled={submit.isPending}>
Cancel
</Button>
<Button
color="green"
leftSection={<PenLine size={16} />}
loading={submit.isPending}
disabled={!canSubmit}
onClick={() => submit.mutate()}
>
Confirm delivery
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -137,8 +137,12 @@ export function AllocateBookingWizard({
enabled: opened,
}),
);
// Paginated {items, meta} list; the newest 100 schedules comfortably cover
// every DRAFT schedule the wizard can attach to.
const schedulesQuery = useQuery(
api.trainScheduling.scheduleList.queryOptions({ input: {} }),
api.trainScheduling.scheduleList.queryOptions({
input: { filters: { pageSize: 100 } },
}),
);
const routesQuery = useQuery(
api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }),
@@ -163,7 +167,7 @@ export function AllocateBookingWizard({
const matchingSchedules = useMemo(
() =>
(schedulesQuery.data ?? []).filter(
(schedulesQuery.data?.items ?? []).filter(
(s: TrainScheduleListItem) =>
s.status === "DRAFT" &&
(!s.freightType || s.freightType === "MIXED" || s.freightType === bookingFreightType),

View File

@@ -232,6 +232,15 @@ export default function BookingWindowSettingsModal({
</Alert>
) : (
<Stack gap="lg">
{!isExport ? (
<Alert variant="light" color="orange" icon={<Info size={16} />}>
These settings apply to every train on this route (same origin and
destination) departing the same day they all share one booking
window, so it opens, moves to document review, opens for payment,
and closes at the same time for all of them.
</Alert>
) : null}
{isExport ? (
<Alert variant="light" color="blue" icon={<Info size={16} />}>
Export schedules use a single first-come-first-served window: it

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { memo, useMemo, useState } from "react";
import {
Box,
Group,
@@ -267,7 +267,13 @@ function CapacityDivider({ used, max }: { used: number; max: number | null }) {
);
}
export function PriorityTrackingTab({ data, bookings }: Props) {
// Memoized: mounted in a keep-mounted Tabs panel, so it re-renders with every
// page render; both props keep their identity across unrelated page state
// (React Query structural sharing + the page's useMemo'd bookings).
export const PriorityTrackingTab = memo(function PriorityTrackingTab({
data,
bookings,
}: Props) {
const phase = data.windowPhase;
const isPayPhase = phase === "PAYMENT";
@@ -558,7 +564,7 @@ export function PriorityTrackingTab({ data, bookings }: Props) {
) : null}
</Stack>
);
}
});
/** Contextual banner describing the current window phase in plain language. */
function PhaseBanner({ phase }: { phase: string | null }) {

View File

@@ -0,0 +1,96 @@
import { Badge, Card, Group, Loader, Progress, SimpleGrid, Stack, Text } from '@mantine/core';
import { LayoutGrid } from 'lucide-react';
import { useZoneOccupancy } from '@/hooks/useWarehouses';
import type { ZoneOccupancy } from '@/types/warehouse';
/** Green < 60%, amber 6085%, red > 85%. */
function tone(pct: number | null): { color: string; label: string } {
if (pct == null) return { color: 'gray', label: 'No capacity set' };
if (pct > 85) return { color: 'red', label: 'Full' };
if (pct >= 60) return { color: 'orange', label: 'Filling' };
return { color: 'teal', label: 'Space' };
}
function capacityLabel(z: ZoneOccupancy): string {
if (z.capacityContainers && z.capacityContainers > 0) {
return `${z.usedItems} / ${z.capacityContainers} items`;
}
if (z.capacityWeight && z.capacityWeight > 0) {
return `${z.usedItems} item(s) · ${z.usedWeight.toLocaleString()} kg`;
}
return `${z.usedItems} item(s)`;
}
interface ZoneOccupancyHeatmapProps {
/** Scope to one yard; omit for all zones. */
yardId?: string;
}
/**
* Occupancy heatmap: one tile per zone, coloured by how full it is. Occupancy is
* container-count based (unit-consistent); weight is shown as context only.
*/
export function ZoneOccupancyHeatmap({ yardId }: ZoneOccupancyHeatmapProps) {
const { data: zones = [], isLoading } = useZoneOccupancy(yardId);
if (isLoading) {
return (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
);
}
if (zones.length === 0) {
return (
<Text c="dimmed" ta="center" py="lg" size="sm">
No active zones to show occupancy for.
</Text>
);
}
return (
<Stack gap="sm">
<Group gap="xs">
<LayoutGrid size={16} />
<Text fw={600} size="sm">
Zone occupancy
</Text>
<Text size="xs" c="dimmed">
({zones.length} zone{zones.length !== 1 ? 's' : ''})
</Text>
</Group>
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3, lg: 4 }} spacing="sm">
{zones.map((z) => {
const t = tone(z.occupancyPct);
const pct = z.occupancyPct ?? 0;
return (
<Card key={z.id} withBorder radius="md" padding="sm">
<Stack gap={6}>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Text fw={600} size="sm" truncate title={z.name}>
{z.name}
</Text>
<Badge color={t.color} variant="light" size="sm">
{z.occupancyPct == null ? '—' : `${Math.round(pct)}%`}
</Badge>
</Group>
<Progress value={Math.min(pct, 100)} color={t.color} size="lg" radius="sm" />
<Group justify="space-between" gap="xs">
<Text size="xs" c="dimmed">
{capacityLabel(z)}
</Text>
<Text size="xs" c={t.color === 'gray' ? 'dimmed' : t.color}>
{t.label}
</Text>
</Group>
</Stack>
</Card>
);
})}
</SimpleGrid>
</Stack>
);
}

View File

@@ -29,3 +29,4 @@ export { VisualEmptyState } from './VisualEmptyState';
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
export { InspectionReportModal } from './InspectionReportModal';
export { FeePreviewModal } from './FeePreviewModal';
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';

View File

@@ -100,7 +100,8 @@ export const QUERY_KEYS = {
locomotives: (routeId?: string) =>
["train-scheduling", "locomotives", routeId ?? "all"] as const,
stations: () => ["train-scheduling", "stations"] as const,
schedules: () => ["train-scheduling", "schedules"] as const,
schedules: (filters?: unknown) =>
["train-scheduling", "schedules", filters ?? {}] as const,
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
track: (id: string) => ["train-scheduling", "track", id] as const,
batchBoard: (filters?: unknown) =>

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